Concurrency ve Parallelism: Scraping Hızını Ne Belirler?

Yayın tarihi:

12 dk okuma

Acar Diveroli
Yazar: Acar Diveroli
Saat simgeli tek bir async sütunu ile yan yana duran üç paralel işlemci çipi sütununun karşılaştırıldığı çizim

Bir scraper'ı hızlandırmak isteyen geliştiricinin ilk aklına gelen genellikle "daha çok çekirdek" olur: işi süreçlere bölmek, sunucuyu büyütmek. Oysa tipik bir scraping işinde işlemci zamanının çoğu boşta geçer. Program saniyelerce hedef sunucunun yanıtını, TLS el sıkışmasını, proxy'nin tüneli açmasını bekler. Bu bekleme süresini değerlendirmenin yolu concurrency (eşzamanlılık), işlemci gücünü değerlendirmenin yolu parallelism (paralellik) olarak adlandırılır ve ikisi farklı sorunları çözer.

Bu yazıda iki kavramın farkını, scraping'in neden çoğunlukla girdi/çıktı (I/O) ağırlıklı bir iş olduğunu ve Python'da asyncio, thread ve process seçeneklerinin hangi durumda işe yaradığını anlatıyoruz. Node.js'in event loop yapısına, hızı gerçekte neyin sınırladığına ve eşzamanlılık değerinin nasıl belirleneceğine de bakıyoruz. Örnek kodları yapay gecikmeli yerel bir sunucu üzerinde çalıştırdık; kendi işinizde ölçüm yapabilmeniz için ölçüm iskeletini de paylaşıyoruz.

Concurrency (eşzamanlılık) nedir?

Concurrency, birden fazla görevin aynı zaman diliminde ilerleyebilmesidir. Görevlerin aynı anda çalışması gerekmez; biri beklerken diğeri çalışır. Bir aşçının makarna suyunun kaynamasını beklerken salata doğraması eşzamanlılıktır: tek bir kişi, iki işi aynı sürede bitirir.

Programlamada bu, bir görevin bir yanıt beklediği anda kontrolü başka bir göreve bırakmasıyla sağlanır. Python'da asyncio, Node.js'te event loop bu mantıkla çalışır. Tek bir işlemci çekirdeği ve tek bir thread yüzlerce ağ isteğini aynı anda "açık" tutabilir, çünkü bu isteklerin hiçbiri beklerken işlemciyi kullanmaz.

Parallelism (paralellik) nedir?

Parallelism, birden fazla görevin fiziksel olarak aynı anda, farklı işlemci çekirdeklerinde yürütülmesidir. Mutfak örneğinde bu, iki aşçının aynı anda iki farklı yemeği hazırlamasıdır. İşin hızı çalışan sayısıyla doğrudan artar, ama her çalışanın kendi tezgâhı ve malzemesi olmalıdır.

Programlamada paralellik genellikle birden fazla süreç (process) veya gerçekten aynı anda çalışabilen thread'lerle sağlanır. Faydası, işin işlemciyi yoğun kullandığı durumlarda ortaya çıkar: büyük bir HTML belgesini ayrıştırmak, görsel işlemek, veri sıkıştırmak.

ÖlçütConcurrency (eşzamanlılık)Parallelism (paralellik)
Temel fikirBeklemeleri üst üste bindirmekİşleri çekirdeklere bölmek
Çekirdek ihtiyacıTek çekirdek yeterliBirden fazla çekirdek
Uygun olduğu işI/O ağırlıklı: ağ, disk, veritabanıCPU ağırlıklı: ayrıştırma, hesaplama
Python araçlarıasyncio, threadmultiprocessing, ProcessPoolExecutor
Node.js araçlarıEvent loop, Promiseworker_threads, birden fazla süreç
Bellek maliyetiDüşük, görev başına küçükYüksek, süreç başına ayrı bellek
Scraping'deki rolüİstekleri göndermek ve yanıtları almakSayfaları ayrıştırmak, headless tarayıcı çalıştırmak
Tipik hataSınırsız eşzamanlılıkla hedefi yormakI/O işini süreçlere bölüp kaynak harcamak

İki kavram birbirinin alternatifi değildir. Büyük bir scraping sistemi genellikle ikisini birlikte kullanır: her süreç kendi içinde yüzlerce isteği eşzamanlı yönetir, süreçler de farklı çekirdeklerde paralel çalışır.

Scraping neden I/O ağırlıklıdır?

Tek bir sayfa isteğinin geçtiği aşamalara bakınca işlemcinin ne kadar az çalıştığı görülür:

  1. DNS çözümlemesi. Alan adının IP adresine çevrilmesi. Ağ üzerinden bir sorgu ve yanıt beklenir.
  2. Proxy'ye bağlantı ve tünel. Proxy'ye TCP bağlantısı açılır, CONNECT isteği gönderilir, proxy hedefe bağlanana kadar beklenir.
  3. TLS el sıkışması. Hedef sunucuyla şifreli bağlantı kurulur. Birkaç gidiş dönüş gerektirir.
  4. İsteğin gönderilmesi ve yanıtın beklenmesi. Hedef sunucu sayfayı hazırlar; veritabanı sorgusu içeren sayfalarda bu süre uzar.
  5. Yanıtın indirilmesi. Sayfa boyutu ve bant genişliğine bağlıdır.
  6. Ayrıştırma. HTML'den verinin çıkarılması. Bu adım işlemciyi kullanan tek adımdır.

İlk beş adımda program ağdan gelecek baytları bekler. Sayfaları sırayla indiren bir betikte ayrıştırma, toplam sürenin küçük bir kısmıdır; betik zamanının büyük bölümünü bekleyerek geçirir ve işlemci bu sırada boştadır.

Ancak bu oran sabit değildir. Her yanıtı yapay olarak 300 milisaniye geciktiren yerel bir sunucuda, her biri 400 ürün kartı içeren 40 sayfayı indirip ayrıştırdık. Eşzamanlılığı 1'den 5'e ve 20'ye çıkarmak indirme süresini on kattan fazla kısalttı. Eşzamanlılık 20'deyken ise aynı sayfaları tek çekirdekte ayrıştırmak, indirmekten uzun sürdü; süreç havuzuna geçmek bu adımı yaklaşık yarıya indirdi. Yani eşzamanlılık arttıkça darboğaz ağdan işlemciye kayabilir. Kendi hedeflerinizde rakamlar farklı olacaktır; aşağıdaki ölçüm iskeletiyle kendi işinizi ölçmenizi öneririz.

Python'da asyncio, thread ve process farkı nedir?

Python'da üç araç vardır ve seçimi anlamanın anahtarı GIL (Global Interpreter Lock) kavramıdır. Standart CPython yorumlayıcısında GIL, aynı anda yalnızca bir thread'in Python kodu çalıştırmasına izin verir. Ancak bir thread ağdan veri beklerken GIL'i bırakır. Sonuç:

  • asyncio: Tek thread içinde çalışan bir event loop. Görevler await noktalarında kontrolü birbirine bırakır. Yüzlerce hatta binlerce eşzamanlı ağ isteği için en hafif seçenektir. asyncio belgesi bu modelin temel yapı taşlarını anlatır. Kütüphanenin de asenkron olması gerekir: HTTPX'in AsyncClient'ı veya AIOHTTP.
  • Thread (ThreadPoolExecutor): Ağ beklerken GIL bırakıldığı için I/O işlerinde gerçek bir hızlanma sağlar. Requests gibi senkron kütüphanelerle eşzamanlılık kurmanın en kolay yoludur. Thread başına bellek maliyeti asyncio görevinden yüksektir, bu yüzden birkaç yüz eşzamanlı istekten sonra verimi düşer.
  • Process (ProcessPoolExecutor, multiprocessing): Her süreç kendi yorumlayıcısı ve kendi GIL'iyle çalışır; bu yüzden işlemci ağırlıklı işlerde gerçek paralellik sağlar. Süreç başlatmak ve veriyi süreçler arasında taşımak maliyetlidir. concurrent.futures belgesi thread ve process havuzlarının ortak arayüzünü tanımlar.

Python 3.13 ile GIL'siz çalışabilen deneysel bir derleme de sunuldu; ancak kütüphane uyumluluğu henüz sınırlı olduğu için üretim scraping işlerinde standart derleme ve yukarıdaki üçlü yaygın kullanılmaya devam ediyor.

AraçParalellik sağlar mı?Uygun işScraping'de kullanımı
asyncio + HTTPX/AIOHTTPHayır, tek threadÇok sayıda ağ isteğiSayfaları indirmek
ThreadPoolExecutor + RequestsI/O sırasında fiilen evetOrta sayıda ağ isteği, senkron kodMevcut Requests kodunu hızlandırmak
ProcessPoolExecutorEvetİşlemci ağırlıklı işlerBüyük sayfaları ayrıştırmak

Python kütüphanelerinin eşzamanlılık destekleri arasındaki farkları HTTPX, Requests ve AIOHTTP yazımızda karşılaştırdık.

Örnek 1: asyncio.Semaphore ile sınırlı eşzamanlılık

Aşağıdaki kod sayfaları eşzamanlı indirir ama aynı anda açık istek sayısını limit değeriyle sınırlar. Semaphore olmadan asyncio.gather bütün istekleri tek seferde başlatır; bu hem kendi bağlantı havuzunuzu hem hedef siteyi zorlar.

python
import asyncio

import httpx


async def fetch(client, sem, url):
    async with sem:
        response = await client.get(url)
        response.raise_for_status()
        return response.text


async def fetch_all(urls, limit=10, proxy=None):
    sem = asyncio.Semaphore(limit)
    async with httpx.AsyncClient(proxy=proxy, timeout=30) as client:
        return await asyncio.gather(*(fetch(client, sem, u) for u in urls), return_exceptions=True)


urls = [f"https://ornek.com/urun/{i}" for i in range(100)]
pages = asyncio.run(fetch_all(urls, limit=10, proxy="http://kullanici:parola@pr.proxynet.io:8000"))

Tek bir AsyncClient bütün istekler için paylaşılır. Bu, bağlantıların yeniden kullanılmasını sağlar ve her istekte yeni TLS el sıkışması yapılmasını önler.

Örnek 2: indirme eşzamanlı, ayrıştırma paralel

Sayfalar büyükse ve ayrıştırma belirgin bir süre alıyorsa iki modeli birleştirmek mantıklıdır: indirme asyncio ile, ayrıştırma süreç havuzunda.

python
import asyncio
from concurrent.futures import ProcessPoolExecutor

from bs4 import BeautifulSoup


def parse(html):
    soup = BeautifulSoup(html, "html.parser")
    return [(u.h2.get_text(strip=True), u.select_one(".fiyat").get_text(strip=True)) for u in soup.select(".urun")]


async def scrape(urls, limit=10, proxy=None):
    pages = await fetch_all(urls, limit=limit, proxy=proxy)
    html_pages = [p for p in pages if isinstance(p, str)]
    loop = asyncio.get_running_loop()
    with ProcessPoolExecutor() as pool:
        return await asyncio.gather(*(loop.run_in_executor(pool, parse, html) for html in html_pages))


if __name__ == "__main__":
    results = asyncio.run(scrape(urls, limit=10))

if __name__ == "__main__": satırı Windows ve macOS'ta zorunludur: süreç havuzu yeni süreçleri modülü baştan yükleyerek başlatır ve bu koruma olmadan kod kendini tekrar tekrar çalıştırır.

Bu yapının her zaman hızlandırmadığını bilmek önemlidir. Süreç başlatmanın ve HTML'i süreçler arasında taşımanın bir maliyeti vardır; sayfalar küçük ve az sayıdaysa bu maliyet ayrıştırmanın kendisinden büyük olabilir. Yukarıdaki testte 400 kartlık sayfalarda süreç havuzu ayrıştırmayı belirgin biçimde kısalttı, ama indirme ve ayrıştırmayı birleştiren toplam süreye süreç başlatma maliyeti de eklendi. Karar vermeden önce kendi sayfalarınızla ölçün.

Node.js event loop nasıl çalışır?

Node.js, JavaScript kodunu tek bir thread'de çalıştırır ve ağ işlemlerini bir event loop üzerinden yönetir. Bir fetch çağrısı yapıldığında Node.js isteği işletim sistemine bırakır ve JavaScript kodu başka işlere devam eder; yanıt geldiğinde ilgili geri çağırma sıraya alınır. Node.js'in event loop rehberi bu döngünün aşamalarını ayrıntılı anlatır.

Scraping açısından bunun üç sonucu vardır:

  • Ağ istekleri doğal olarak eşzamanlıdır. Promise tabanlı her istek, beklerken diğerlerini engellemez. Python'daki asyncio modeline benzer.
  • İşlemciyi yoran kod bütün döngüyü durdurur. Büyük bir HTML'i ayrıştıran senkron bir fonksiyon çalışırken başka hiçbir yanıt işlenemez. Bu tür işler için worker_threads modülüyle ayrı thread'ler veya ayrı süreçler kullanılır.
  • Alan adı çözümlemesi gizli bir darboğaz olabilir. Node.js'in varsayılan dns.lookup fonksiyonu işletim sisteminin çözümleyicisini libuv'un küçük bir thread havuzunda çalıştırır. Çok sayıda farklı alan adına aynı anda bağlanan bir betikte bu havuz dolabilir. Proxy kullanırken hedef alan adları proxy tarafında çözüldüğü için bu etki azalır.

Eşzamanlılığı sınırlamak için ek kütüphane gerekmez; basit bir sınırlayıcı yeterlidir:

javascript
function limiter(max) {
  let active = 0;
  const queue = [];
  const next = () => {
    if (active >= max || queue.length === 0) return;
    active++;
    const { task, resolve, reject } = queue.shift();
    task().then(resolve, reject).finally(() => {
      active--;
      next();
    });
  };
  return (task) => new Promise((resolve, reject) => {
    queue.push({ task, resolve, reject });
    next();
  });
}

const limit = limiter(10);
const results = await Promise.allSettled(
  urls.map((url) => limit(() => fetch(url).then((r) => r.text()))),
);

Node.js'te proxy'nin nasıl verileceğini, rotasyonlu ve yeniden denemeli bir örnekle birlikte Node.js'te Proxy Kullanımı yazımızda anlattık.

Hızı gerçekte ne sınırlar?

Eşzamanlılığı artırmak bir noktaya kadar hızı artırır, sonra ya etkisiz kalır ya da işi yavaşlatır. Scraping işlerinde üst sınırı genellikle işlemci değil, şu etkenler belirler:

  • Hedef sitenin hız sınırı. Site aynı kaynaktan dakikada belirli sayıda isteğe izin veriyorsa, eşzamanlılığı artırmak yalnızca daha fazla 429 yanıtı üretir. Bu kodların nasıl ele alınacağını Scraping'de HTTP Hata Kodları yazımızda anlattık.
  • Hedef sunucunun kapasitesi. Küçük bir sitenin sunucusu yavaşladığında sizin eşzamanlılığınız o sitenin yanıt süresini uzatır; hem sizin işiniz hem sitenin gerçek ziyaretçileri yavaşlar.
  • Proxy kapasitesi. Proxy paketinin eşzamanlı bağlantı sınırı, havuzdaki IP sayısı ve çıkış noktalarının yanıt süresi. Tek adres üzerinden çok sayıda çıkış IP'si sunan Rotating Proxy ile istekler farklı adreslere dağılır, ama pakete tanımlı bağlantı sınırı yine geçerlidir.
  • Bant genişliği. Büyük sayfalar ve görsel içeren yanıtlar indirilirken kendi bağlantınızın ya da proxy trafiğinizin sınırı devreye girer.
  • Bağlantı kurma maliyeti. Her istekte yeni bağlantı ve yeni TLS el sıkışması yapılıyorsa süre artar. İstemciyi paylaşmak ve bağlantıları yeniden kullanmak bu maliyeti düşürür.
  • Mesafe. Proxy çıkış noktası ile hedef sunucu arasındaki gecikme, her gidiş dönüşe eklenir. Gerçek ev bağlantılarından çıkan Residential Proxy adreslerinde bu süre konuma ve hatta göre değişir.
  • İşlemci. Yalnızca headless tarayıcı çalıştırırken, çok büyük belgeleri ayrıştırırken veya aynı makinede yoğun veri işleme yaparken belirleyici olur.

Engellenmeden, siteye zarar vermeden hızı ayarlamanın diğer yollarını Web Scraping'de Engellenmeden Veri Toplama Yöntemleri yazımızda anlattık.

Eşzamanlılık kaça ayarlanmalı?

Her iş için geçerli tek bir sayı yoktur; doğru değer ölçülerek bulunur. Önerdiğimiz yöntem:

  1. Düşük bir değerle başlayın. Tek bir hedef site için birkaç eşzamanlı istek.
  2. Üç şeyi ölçün. Dakikada tamamlanan sayfa sayısı, ortalama yanıt süresi ve hata oranı (429, 503, zaman aşımı).
  3. Değeri kademeli artırın. Her adımda aynı ölçümü tekrarlayın.
  4. Dönüm noktasını bulun. Tamamlanan sayfa sayısı artmayı bırakıp yanıt süresi veya hata oranı yükselmeye başladığında bir önceki değere dönün.
  5. Site başına sınır koyun. Birden fazla siteyi aynı anda tarıyorsanız toplam eşzamanlılık yüksek olabilir, ama her siteye düşen pay küçük kalmalıdır.
  6. Periyodik olarak tekrar ölçün. Hedef sitenin altyapısı ve hız sınırları zamanla değişir.

Ölçüm için basit bir iskelet:

python
import asyncio
import time


def measure(label, fn):
    start = time.perf_counter()
    result = fn()
    elapsed = time.perf_counter() - start
    print(f"{label}: {elapsed:.2f} s")
    return result


for limit in (1, 5, 10, 20):
    pages = measure(f"limit={limit}", lambda: asyncio.run(fetch_all(urls, limit=limit)))
    errors = sum(isinstance(p, Exception) for p in pages)
    print(f"  hata: {errors} / {len(pages)}")

time.perf_counter(), sistem saatindeki değişikliklerden etkilenmeyen yüksek çözünürlüklü bir sayaçtır ve süre ölçümü için time.time()'dan daha uygundur. Ölçümü hedef siteye yük bindirmeyecek küçük bir URL listesiyle ve mümkünse sitenin yoğun olmadığı saatlerde yapın.

Kullanım senaryoları

  • Tek siteden sınırlı sayıda sayfa: Senkron Requests ve istekler arasında bekleme. Eşzamanlılık gerekmez.
  • Çok sayıda siteden fiyat ve katalog toplama: asyncio ile site başına küçük limitli eşzamanlılık, dağıtım için rotasyonlu proxy. Genel kurgu veri kazıma çözümü sayfamızda.
  • Büyük ölçekli tarama (crawl): Birden fazla süreç, her birinde asyncio; alan adı başına kuyruk. Ölçekleme tarafı web crawler çözümü sayfamızda.
  • Mevcut Requests betiğini hızlandırmak: Kodu yeniden yazmadan ThreadPoolExecutor ile. Rotasyonlu bir Requests kurulumu için Python ile Rotating Proxy yazımıza bakabilirsiniz.
  • JavaScript ile yüklenen sayfalar: Headless tarayıcı işlemciyi ve belleği yoğun kullandığı için eşzamanlı tarayıcı sayısı çekirdek ve bellekle sınırlanır.

Sık yapılan hatalar

  • I/O ağırlıklı işi süreçlere bölmek. Süreç başlatma ve bellek maliyetini ödersiniz, ama darboğaz ağ olduğu için hız artmaz.
  • asyncio içinde senkron kütüphane kullanmak. async def içindeki requests.get() çağrısı event loop'u durdurur ve bütün görevler sırayla çalışır.
  • Sınırsız gather veya Promise.all. Binlerce isteği tek seferde başlatmak bağlantı hatalarına ve hedef sitede hız sınırına yol açar.
  • Her istekte yeni istemci oluşturmak. Bağlantılar yeniden kullanılmaz, her istek yeni TLS el sıkışması yapar.
  • Toplam süreye bakıp hata oranına bakmamak. Hızlanan ama 429 oranı artan bir iş, daha az veri toplar.
  • Node.js'te ayrıştırmayı ana thread'de yapmak. Büyük belgelerde event loop durur ve bekleyen yanıtlar zaman aşımına uğrayabilir.

Karar rehberi

DurumunuzÖneri
Çok sayıda sayfa, küçük HTMLasyncio + HTTPX/AIOHTTP, Semaphore ile limit
Mevcut senkron Requests koduThreadPoolExecutor
Büyük sayfalar, ağır ayrıştırmaİndirme asyncio, ayrıştırma ProcessPoolExecutor
Node.js ile sayfa indirmePromise + eşzamanlılık sınırlayıcı
Node.js'te ağır ayrıştırmaworker_threads
Headless tarayıcıÇekirdek ve bellek sayısına göre az sayıda eşzamanlı tarayıcı
429 oranı artıyorEşzamanlılığı düşürün, site başına sınır koyun
Hız artmıyor, hata da yokDarboğazı ölçün: proxy, bant genişliği, hedef sunucu

Sık sorulan sorular

Concurrency ile parallelism aynı şey mi?

Hayır. Concurrency, görevlerin aynı zaman diliminde ilerlemesidir ve tek çekirdekte de gerçekleşebilir. Parallelism, görevlerin birden fazla çekirdekte gerçekten aynı anda çalışmasıdır. Paralel çalışan her sistem eşzamanlıdır, ama eşzamanlı her sistem paralel değildir.

Scraping için asyncio mu thread mi?

Sıfırdan yazıyorsanız ve çok sayıda istek göndereceksiniz asyncio daha az kaynakla daha fazla eşzamanlı istek yönetir. Mevcut kodunuz Requests ile yazılmışsa ve eşzamanlı istek sayısı birkaç yüzü geçmeyecekse ThreadPoolExecutor kodu yeniden yazmadan hızlandırmanın en kolay yoludur.

GIL scraping'i yavaşlatır mı?

Ağ istekleri sırasında GIL bırakıldığı için sayfa indirmeyi pratikte yavaşlatmaz. Ayrıştırma gibi işlemci ağırlıklı adımlarda ise thread'ler aynı anda Python kodu çalıştıramaz; bu adımlar için süreç havuzu kullanılır.

Daha fazla proxy IP'si hızı artırır mı?

Hedef sitenin hız sınırı IP başına sayılıyorsa, yükü dağıtarak aynı sürede daha fazla sayfaya ulaşmanızı sağlayabilir. Ancak site bu sınırı başka ölçütlere göre uyguluyorsa ya da darboğaz bant genişliği veya hedef sunucunun kapasitesiyse IP sayısı sonucu değiştirmez. Siteye zarar vermemek, IP sayısından bağımsız bir sorumluluktur.

Eşzamanlılık değerini çok yüksek tutarsam ne olur?

Kendi tarafınızda bağlantı havuzu ve dosya tanıtıcısı sınırlarına, hedef tarafta 429 yanıtlarına, zaman aşımlarına ve sitenin yavaşlamasına yol açar. Bir noktadan sonra tamamlanan sayfa sayısı artmaz, hata sayısı artar.

Headless tarayıcılarda eşzamanlılık nasıl ayarlanır?

Her tarayıcı örneği belirgin miktarda bellek ve işlemci kullanır; bu yüzden eşzamanlı sayfa sayısı ağ değil makine kaynaklarıyla sınırlanır. Az sayıda tarayıcı örneğiyle başlayıp bellek ve işlemci kullanımını izleyerek artırın. Headless tarayıcının ne zaman gerçekten gerektiğini Statik ve Dinamik Sayfalar yazımızda anlattık.

Özetle

Concurrency beklemeleri değerlendirir, parallelism işlemci çekirdeklerini. Scraping'de zamanın büyük kısmı ağ yanıtı beklemekle geçtiği için hızı çoğunlukla asyncio, thread veya Node.js event loop ile kurulan sınırlı eşzamanlılık artırır; süreç havuzu yalnızca ağır ayrıştırma ve headless tarayıcı gibi işlemci yoğun adımlarda gerekir. Üst sınırı ise genellikle hedef sitenin hız sınırı, proxy kapasitesi ve bant genişliği belirler. Eşzamanlılık değerini tahminle değil ölçümle belirleyin ve site başına sınır koyun. Ölçekli veri toplama işleri için proxy hizmetlerimize göz atabilirsiniz.

ChatGPT'ye sorClaude'a sor