Ana içeriğe geç
Kesit Python
İleri · Eşzamanlılık ve performans

threading, concurrent.futures ve GIL

Thread ve havuz (ThreadPoolExecutor), Lock ile paylaşılan durumu koruma, queue ile üretici-tüketici, GIL'in etkisi ve ProcessPoolExecutor kavramı; hangi iş için hangi araç.

Uygulamada aç, 18 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

Bu derste örnekler tarayıcıda farklı davranırTarayıcıdaki Python gerçek iş parçacığı başlatamaz: thread'ler sırayla çalışır. Bu yüzden thread örneklerinde hız kazancı ya da yarış durumu burada görünmez. Her örneğin altında kendi bilgisayarında beklenen çıktı yazıyor; bu dersi bilgisayarında da çalıştırman iyi olur.

Üç araç, üç farklı iş

Anadolu Market'in gece işi: 200 tedarikçi dosyasını indir (ağ beklemesi), her birini ayrıştır (hafif), sonra 2 milyon satırda fiyat optimizasyonu hesapla (ağır hesap). Üçü de "aynı anda daha çok iş" ister ama farklı araçlarla: ağ için thread ya da asyncio, ağır hesap için süreç (process). Bu dersin amacı doğru aracı seçmeyi ve paylaşılan veriyi güvenle korumayı öğretmek.

İş parçacığı (thread) ve havuz

Thread, aynı süreç içinde paralel akan bir çalışma hattıdır; belleği paylaşır. Elle threading.Thread başlatabilirsin ama modern kalıp concurrent.futures.ThreadPoolExecutor: sabit sayıda işçi, submit ile iş ver, Future ile sonucu al; ya da map ile liste gibi kullan.

import time
from concurrent.futures import ThreadPoolExecutor, as_completed

def indir(tedarikci: str) -> str:
    time.sleep(0.1)                       # sahte ağ beklemesi
    return f"{tedarikci}.csv"

tedarikciler = ["Ege", "Marmara", "Karadeniz", "Akdeniz", "Anadolu", "Trakya"]

t0 = time.perf_counter()
sirali = [indir(t) for t in tedarikciler]
print(f"sıralı: {time.perf_counter() - t0:.2f} s")

t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=3) as havuz:
    sonuc = list(havuz.map(indir, tedarikciler))      # sıra korunur
print(sonuc, f"havuz(3): {time.perf_counter() - t0:.2f} s")

with ThreadPoolExecutor(max_workers=6) as havuz:
    gelecekler = {havuz.submit(indir, t): t for t in tedarikciler}
    for f in as_completed(gelecekler):                  # bitiş sırasıyla
        print("bitti:", f.result())
Kendi bilgisayarında beklenen çıktı (süre değişebilir, "bitti" sırası her seferinde farklı olabilir):
sıralı: 0.60 s
['Ege.csv', 'Marmara.csv', 'Karadeniz.csv', 'Akdeniz.csv', 'Anadolu.csv', 'Trakya.csv'] havuz(3): 0.20 s
bitti: Marmara.csv
bitti: Ege.csv
…
Tarayıcıda iki süre de 0.60 s görünür, çünkü thread'ler sırayla çalışır.
map mi submit mi?map girdi sırasında sonuç verir ve kısa yazılır. submit + as_completed biten işi hemen işlemene, hataları tek tek yakalamana izin verir; f.result() işçideki istisnayı burada yeniden fırlatır.

GIL: tek anda tek Python

CPython'da GIL (Global Interpreter Lock) aynı anda yalnızca bir thread'in Python bytecode çalıştırmasına izin verir. Thread'ler beklerken (ağ, disk, sleep) GIL'i bırakır; o yüzden I/O işinde havuz gerçek hız sağlar. Ama saf Python hesabında iki thread sırayla çalışır, toplam süre düşmez, hatta kilit alışverişi yüzünden artabilir.

import time
from concurrent.futures import ThreadPoolExecutor

def hesap(n: int) -> int:
    return sum(i * i for i in range(n))         # saf Python, CPU işi

N = 300_000
t0 = time.perf_counter()
[hesap(N) for _ in range(4)]
tek = time.perf_counter() - t0

t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as havuz:
    list(havuz.map(hesap, [N] * 4))
coklu = time.perf_counter() - t0
print(f"sıralı {tek:.2f} s, 4 thread {coklu:.2f} s -> GIL yüzünden kazanç yok")
Çıkış yoluCPU işi için ProcessPoolExecutor: her işçi ayrı bir süreçtir, kendi GIL'i vardır, çekirdekler gerçekten paralel çalışır. Bedeli: veri süreçler arasında kopyalanır (pickle), başlatma maliyeti vardır. Kullanımı ThreadPoolExecutor ile aynı arayüzdür; yalnızca sınıf adı değişir ve fonksiyon modül düzeyinde tanımlı olmalıdır. Tarayıcıdaki Python'da süreç oluşturulamadığı için bu derste yalnızca anlatıyoruz; kendi makinende dene. Python 3.13 ile deneysel gelen, 3.14'te resmî destek kazanan "free-threaded" yapı GIL'siz çalışabiliyor ama varsayılan değil ve henüz varsayılan değil.

Yarış durumu ve Lock

İki thread aynı sayacı artırırsa ne olur? sayac += 1 aslında oku-artır-yaz üç adımdır; GIL adımların arasında thread değiştirebilir ve bir artış kaybolur. Buna yarış durumu (race condition) denir. Çözüm: paylaşılan veriye erişimi threading.Lock ile tek seferde bir thread'e vermek.

import threading, time
from concurrent.futures import ThreadPoolExecutor

bakiye = 0
kilit = threading.Lock()

def yatir_guvensiz(n):
    global bakiye
    for _ in range(n):
        tmp = bakiye            # oku
        time.sleep(0)           # başka thread'e sıra ver: yarışı görünür kılar
        bakiye = tmp + 1        # yaz

def yatir_guvenli(n):
    global bakiye
    for _ in range(n):
        with kilit:             # kritik bölge
            bakiye += 1

for fn in (yatir_guvensiz, yatir_guvenli):
    bakiye = 0
    with ThreadPoolExecutor(max_workers=4) as havuz:
        list(havuz.map(fn, [2_000] * 4))
    print(fn.__name__, "beklenen 8000, gelen", bakiye)
Kendi bilgisayarında beklenen çıktı (ilk sayı her çalıştırmada farklı olur, 8000'den küçüktür):
yatir_guvensiz beklenen 8000, gelen 2004
yatir_guvenli beklenen 8000, gelen 8000
Tarayıcıda ikisi de 8000 görünür, çünkü thread'ler sırayla çalışır: yarış burada oluşmaz.
Kilit her yerde değilKilit tutulan süre kısa olsun; kilit içinde I/O yapma. Birden çok kilit varsa hep aynı sırada al, yoksa karşılıklı bekleme (deadlock) oluşur. Mümkünse paylaşılan durumdan kaçın: her işçi kendi sonucunu döndürsün, ana thread toplasın.

queue ile üretici-tüketici

queue.Queue thread-güvenli bir kuyruktur: üreticiler put, tüketiciler get yapar, kilitle uğraşmazsın. None gibi bir "zehirli hap" (sentinel) ile tüketicilere bitişi bildir; task_done/join ile tüm işlerin bittiğini bekle.

import queue, threading, time

kuyruk: queue.Queue = queue.Queue()
islenen = []
kilit = threading.Lock()

def tuketici(ad):
    while True:
        siparis = kuyruk.get()
        if siparis is None:          # bitiş sinyali
            kuyruk.task_done()
            break
        time.sleep(0.01)             # sahte işleme
        with kilit:
            islenen.append((ad, siparis))
        kuyruk.task_done()

isciler = [threading.Thread(target=tuketici, args=(f"isci{i}",)) for i in range(3)]
for t in isciler:
    t.start()
for sid in range(1001, 1011):
    kuyruk.put(sid)
for _ in isciler:
    kuyruk.put(None)
kuyruk.join()
print("işlenen sipariş:", sorted(s for _, s in islenen))
print("işçi başına:", {ad: sum(1 for a, _ in islenen if a == ad) for ad in ("isci0", "isci1", "isci2")})
Kendi bilgisayarında beklenen çıktı (dağılım her seferinde değişir):
işlenen sipariş: [1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010]
işçi başına: {'isci0': 3, 'isci1': 3, 'isci2': 4}
Tarayıcıda işin tamamını ilk işçi yapar: {'isci0': 10, 'isci1': 0, 'isci2': 0}.

Hangi iş için hangisi?

İş türüAraçNeden
Çok sayıda ağ/disk beklemesi, kütüphane async destekliyorasyncioTek thread, binlerce bağlantı, düşük bellek
Ağ/disk beklemesi, kütüphane senkron (requests, sqlite)ThreadPoolExecutorKodu değiştirmeden paralel; GIL beklerken bırakılır
Saf Python CPU hesabıProcessPoolExecutorGIL'i aşar, çekirdekleri kullanır
Sayısal hesapnumpy/pandas (önce bunu dene)Vektörel işlem C'de çalışır, çoğu zaman tek çekirdekte bile en hızlısıdır; numpy birçok döngüde GIL'i bırakır, pandas'ta bu işleme göre değişir
Basit betikHiçbiriKarmaşıklık maliyeti kazancı geçer
Derinleş

Derinleş: süreç havuzu, Amdahl, kilit disiplini ve Future arayüzü

Thread havuzu, GIL, Lock ve queue temellerini gördün. Bu bölüm gerçek projelerde karşına çıkan eksik parçaları tamamlıyor: ProcessPoolExecutor'ı Windows ve macOS'ta çalıştırmanın kuralları, işi süreçlere doğru bölmek, paralelliğin ne kadar hız getirebileceğini önceden hesaplamak (Amdahl yasası), kilitlerde kilitlenme (deadlock) önleme ve Future nesnesinin tam arayüzü.

Bu ortamdaTarayıcıdaki Python (Pyodide) yeni thread ya da süreç başlatamaz. Bu yüzden thread/süreç başlatan örnekleri çalıştırılamaz kod kutusunda veriyoruz; çalıştırılabilir örnekler ve alıştırmalar ise Lock, RLock, Event, Future gibi nesneleri ve iş bölme mantığını thread başlatmadan kullanıyor. Aynı kod kendi bilgisayarında gerçek thread'lerle birebir çalışır.

ProcessPoolExecutor'ı doğru kurmak

# fiyat_opt.py — kendi bilgisayarında: python fiyat_opt.py
import os
from concurrent.futures import ProcessPoolExecutor

def optimize(urun_id: int) -> tuple[int, float]:      # modül düzeyinde (pickle edilebilir)
    toplam = sum((urun_id * i) % 97 for i in range(200_000))
    return urun_id, toplam / 200_000

def main():
    urunler = range(1, 401)
    with ProcessPoolExecutor(max_workers=os.cpu_count()) as havuz:
        sonuc = dict(havuz.map(optimize, urunler, chunksize=25))
    print(len(sonuc), "ürün hesaplandı")

if __name__ == "__main__":       # Windows ve macOS'ta ZORUNLU
    main()

Windows ve macOS (Python 3.8+) yeni süreci spawn yöntemiyle başlatır: çocuk süreç betiğini baştan içe aktarır. if __name__ == "__main__": koruması yoksa her çocuk yeniden havuz kurmaya çalışır. chunksize, işleri süreçlere tek tek değil paketler halinde gönderir; çok sayıda küçük işte pickle ve iletişim maliyetini belirgin azaltır.

BelirtiNedenÇözüm
RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase__main__ koruması yok (spawn)Havuzu if __name__ == "__main__": altında kur
PicklingError: Can't pickle <function <lambda>>lambda, iç fonksiyon ya da yerel sınıf gönderildiFonksiyonu modül düzeyinde tanımla; parametre için functools.partial
AttributeError: Can't get attribute 'f' on <module '__main__'>Jupyter'de tanımlanan fonksiyon süreçte bulunamıyorFonksiyonu bir .py modülüne taşı ve içe aktar
BrokenProcessPoolÇocuk süreç çöktü (bellek yetmedi, sistem sonlandırdı)Daha küçük parçalar, daha az işçi; logları incele
Süreçlerle daha yavaşİşler çok küçük, kopyalama maliyeti hesabı aşıyorchunksize büyüt ya da işi birleştir; önce numpy/pandas dene

Ne kadar hızlanır? Amdahl yasası

Programın yalnızca bir kısmı paralelleştirilebilir: dosyayı okumak, sonuçları birleştirmek sıralı kalır. Paralel oran p, işçi sayısı n ise en iyi hızlanma 1 / ((1 - p) + p / n) olur. İşin %90'ı paralel olsa bile sınır 10 kattır; 64 çekirdek almadan önce bu hesabı yapmak para kazandırır.

def hizlanma(p: float, n: int) -> float:
    return 1 / ((1 - p) + p / n)

for n in (1, 2, 4, 8, 16, 64):
    print(f"{n:>3} işçi: %90 paralel -> {hizlanma(0.90, n):5.2f}x   %50 paralel -> {hizlanma(0.50, n):4.2f}x")
print("üst sınır (%90):", round(1 / (1 - 0.90), 1))

İşi parçalara bölmek ve birleştirmek

Süreç havuzunda en verimli kalıp böl-hesapla-birleştir (map-reduce) kalıbıdır: veriyi yaklaşık eşit parçalara böl, her parça kendi kısmi özetini döndürsün, ana süreç özetleri birleştirsin. Paylaşılan değişken yok, kilit yok. Kısmi özet fonksiyonunu ayrı yazmak bir avantaj daha getirir: aynı kodu önce sıralı map ile test eder, sonra havuz.map ile paralel çalıştırırsın.

from collections import Counter

siparisler = [("Ankara", 120.0), ("İzmir", 80.0), ("Ankara", 45.5), ("Bursa", 300.0),
              ("İzmir", 19.9), ("Ankara", 10.0), ("Bursa", 5.0)]

def parcala(veri, n):
    boyut, artan = divmod(len(veri), n)
    parcalar, bas = [], 0
    for i in range(n):
        son = bas + boyut + (1 if i < artan else 0)
        parcalar.append(veri[bas:son])
        bas = son
    return parcalar

def kismi_ciro(parca):
    c = Counter()
    for sehir, tutar in parca:
        c[sehir] += tutar
    return c

parcalar = parcala(siparisler, 3)
print([len(p) for p in parcalar])
toplam = sum(map(kismi_ciro, parcalar), Counter())    # havuz.map ile aynı arayüz
print(dict(sorted(toplam.items())))

Senkronizasyon araçları

AraçNe işe yararÖrnek
LockKritik bölgeye tek seferde tek threadBakiye güncelleme
RLockAynı thread kilidi iç içe tekrar alabilirKilitli bir metot başka kilitli metodu çağırıyor
Semaphore(n)Aynı anda en çok n threadVeritabanına en çok 5 bağlantı
EventBir thread'in diğerlerine "başla/dur" sinyaliKapanış bayrağı: while not dur.is_set()
ConditionBir koşul gerçekleşene kadar bekle-uyandırKendi kuyruğunu yazarken (çoğu zaman queue yeter)
import threading

kilit = threading.Lock()
kilit.acquire()
print("Lock ikinci kez alınabilir mi?", kilit.acquire(blocking=False))   # kendini kilitler
kilit.release()

rk = threading.RLock()
with rk:
    with rk:                       # aynı thread: sorun yok
        print("RLock iç içe alındı")

dur = threading.Event()
print("dur?", dur.is_set())
dur.set()                          # işçi döngüleri bunu görüp çıkar
print("dur?", dur.is_set(), "| bekleme sonucu:", dur.wait(timeout=0.01))

Kilitlenme (deadlock) ve kilit sırası

Thread A önce hesap 1'in, sonra hesap 2'nin kilidini; thread B aynı anda önce hesap 2'nin, sonra hesap 1'in kilidini almaya çalışırsa ikisi de sonsuza kadar bekler. En basit ve etkili kural: birden çok kilit gerektiğinde onları her zaman aynı sırada al (ör. hesap numarasına göre artan). Ek savunma olarak kilit.acquire(timeout=1) False dönerse işlemi geri al ve hata raporla.

Future: sonucun sözü

submit bir Future döndürür. done() bitti mi, result(timeout=...) sonucu (işçideki istisnayı yeniden fırlatarak) verir, exception() istisnayı döndürür, cancel() henüz başlamamış işi iptal eder, add_done_callback bitince çalışacak fonksiyonu kaydeder. Havuzu kapatırken shutdown(cancel_futures=True) kuyrukta bekleyen işleri iptal eder.

from concurrent.futures import Future

f = Future()                      # havuzun içeride yaptığını elle yapıyoruz
f.add_done_callback(lambda g: print("geri çağrı: bitti =", g.done()))
f.set_result({"USD": 32.5})
print(f.result())

hata = Future()
hata.set_exception(TimeoutError("kur servisi cevap vermedi"))
try:
    hata.result()
except TimeoutError as e:
    print("result() istisnayı yeniden fırlattı:", e)

bekleyen = Future()
print("iptal edildi mi?", bekleyen.cancel(), bekleyen.cancelled())
Yürütücüyü parametre yapFonksiyonların havuz nesnesini dışarıdan alırsa (bağımlılık enjeksiyonu), testte sıralı çalışan sahte bir yürütücü verip deterministik test yazabilir, üretimde gerçek ThreadPoolExecutor ya da ProcessPoolExecutor kullanırsın.
GIL'siz PythonPython 3.13 ile deneysel gelen "free-threaded" (GIL'siz) derleme 3.14'te resmî destek kazandı; saf Python CPU işinde thread'ler gerçekten paralel çalışabiliyor. Varsayılan değil, ayrıca kurulur ve bazı paketler henüz desteklemiyor. Kilit disiplini GIL olsa da olmasa da gerekir: GIL hiçbir zaman sayac += 1 gibi işlemleri atomik yapma sözü vermedi.
Gerçek işteVeri ekiplerinde paralellik çoğunlukla gece çalışan toplu işlerde görülür: dosyalar thread havuzuyla indirilir, ağır hesap süreç havuzunda parçalara bölünerek yapılır, sonuçlar ana süreçte birleştirilir. Bu düzende en pahalı hatalar kilitlenen ve sabaha kadar asılı kalan işler ile Windows sunucuda unutulan __main__ korumasıdır. Her bekleme noktasına zaman aşımı koymak ve işi paylaşılan durum yerine "parça → özet → birleştir" biçiminde tasarlamak bu riskleri büyük ölçüde ortadan kaldırır.
Uzmanlaş

İşçi sayısı

I/O işinde işçi sayısı çekirdek sayısından bağımsızdır; karşı sistemin kaldırabileceği kadar (10-50) seçilir. CPU işinde os.cpu_count() kadar süreç yeterlidir; fazlası bağlam değişimine harcanır. ThreadPoolExecutor'ın varsayılanı min(32, cpu_count + 4)'tür.

Hata ve zaman aşımı

future.result(timeout=5) bekleme süresini sınırlar; concurrent.futures.wait(..., return_when=FIRST_EXCEPTION) ilk hatada durmanı sağlar. Thread içindeki yakalanmamış istisna kaybolmaz, result() çağrısında yeniden fırlatılır; ama threading.Thread ile elle başlattığın thread'de yalnızca stderr'e yazılır, bu yüzden havuz tercih edilir.

asyncio ile köprü

Async kodda senkron bir kütüphane çağırman gerekiyorsa await asyncio.to_thread(func) arka planda bir ThreadPoolExecutor kullanır. Tersine, senkron koddan async kütüphane çağırmak için ayrı bir thread'de olay döngüsü çalıştırılır. İki dünya birbirini dışlamaz; sınırı bilinçli çiz.

Alıştırmalara geç (18)