Python'da Paralel İşlem: threading, multiprocessing ve GIL
Beceri rehberi

Python'da Paralel İşlem: threading, multiprocessing ve GIL

Hazırlayan: Mindset Institute EditörlüğüGüncellendi: 6 dk okuma

Giriş-çıkış beklemesi yoğun işlerde iş parçacıklarını, işlemci yoğun işlerde süreçleri seçmeyi; GIL'in bunu neden belirlediğini örneklerle öğrenirsiniz.

Bir bakışta

  1. GIL, aynı anda yalnız bir iş parçacığının Python kodu çalıştırmasına izin verir. CPython'un bu kısıtı, işlemci yoğun işlerde iş parçacıklarının hızlanma sağlamamasının nedenidir.
  2. Ağ ve disk beklemesi için threading uygundur. Bekleyen iş parçacığı diğerine yer açar; birçok istek aynı anda ilerler.
  3. İşlemci yoğun işler için süreç kullanın. multiprocessing her süreçte ayrı yorumlayıcı çalıştırarak GIL'i dolanır.
  4. Ortak veriyi Lock ile koruyun. Birden çok iş parçacığı aynı değişkeni değiştiriyorsa kilit olmadan sonuç güvenilmez.
  5. Süreçlere veri pickle ile gider. Argümanların ve dönen değerlerin serileştirilebilir olması gerekir.

Eşzamanlılık, paralellik ve GIL

Aynı anda birden çok iş yapmanın Python'da iki ayrı yolu vardır ve ikisi aynı sorunu çözmez. İş parçacıkları (threading) aynı süreç içinde, belleği paylaşan hafif akışlardır. Süreçler (multiprocessing) ise ayrı yorumlayıcılar, ayrı belleklerdir. Hangisini seçeceğinizi, işinizin zamanını nereye harcadığı belirler.

Belirleyici kısıt GIL'dir. Python belgelerine göre CPython'da Global Interpreter Lock yüzünden aynı anda yalnız bir iş parçacığı Python kodu çalıştırabilir. Bu yüzden saf Python hesabı yapan birden çok iş parçacığı, tek bir iş parçacığından hızlı değildir. Ancak bir iş parçacığı ağdan yanıt beklerken ya da diskten okurken kilidi bırakır ve diğeri çalışır. Belgeler bu nedenle threading'in giriş-çıkış bağımlı birden çok işi aynı anda çalıştırmak için hâlâ uygun bir model olduğunu söyler. Çok çekirdekli makineden yararlanmak isteyen işlemci yoğun işler için ise multiprocessing ya da concurrent.futures.ProcessPoolExecutor önerilir.

GIL'in kalkıp kalkmayacağı da sık sorulur. Python'un yeni sürümlerinde GIL'i devre dışı bırakabilen serbest iş parçacıklı derlemeler bulunur; ancak belgelere göre bu varsayılan olarak mevcut değildir. Bugün yazdığınız kodun çoğu için GIL'li model geçerli kabul edilmelidir.

threading ile ilk örnek: birkaç adresten aynı anda veri çekmek

Tipik bir giriş-çıkış işi, bir adres listesinden sayfa indirmektir. Sırayla yapıldığında her istek bir öncekinin bitmesini bekler. İş parçacıklarıyla beklemeler üst üste biner:

import threading import urllib.request

sonuclar = {} kilit = threading.Lock()

def indir(adres): with urllib.request.urlopen(adres) as yanit: boyut = len(yanit.read()) with kilit: sonuclar[adres] = boyut

adresler = adresleri_yukle() is_parcaciklari = [threading.Thread(target=indir, args=(a,)) for a in adresler]

for parca in is_parcaciklari: parca.start() for parca in is_parcaciklari: parca.join()

Thread nesnesi iki ana argüman alır: çalıştırılacak fonksiyon (target) ve ona geçirilecek argümanlar (args, demet olarak; kwargs, sözlük olarak). start() iş parçacığını başlatır ve belgelere göre bir nesne için en fazla bir kez çağrılabilir; ikinci çağrı RuntimeError verir. join() ise çağıran iş parçacığını, ilgili iş parçacığı bitene kadar bekletir. İki ayrı döngü kullanmak önemlidir: start ve join'i aynı döngüde yaparsanız her iş parçacığı bitmeden diğeri başlamaz ve eşzamanlılığı kaybedersiniz.

Ortak veriyi korumak: Lock ve daemon iş parçacıkları

İş parçacıkları belleği paylaşır; bu hem avantaj hem tehlikedir. İki iş parçacığı aynı değişkeni aynı anda değiştirirse sonuç, işlemlerin hangi sırayla araya girdiğine bağlıdır ve her çalıştırmada farklı çıkabilir. Bu tür hatalar çoğu zaman test ortamında görünmez, üretimde ortaya çıkar.

Çözüm Lock'tur. Belgelere göre bir iş parçacığı kilidi aldığında diğerleri kilit bırakılana kadar bekler. Kilidi with ifadesiyle kullanmak en güvenli yoldur; blok hata verse bile kilit serbest bırakılır:

with kilit: sayac += artis

Kilit sayısını ve süresini kısa tutun; her şeyi bir kilitle sarmak programı fiilen tek iş parçacıklı hâle getirir ve karşılıklı bekleme (deadlock) riskini artırır.

Bir de daemon iş parçacıkları vardır. Belgelere göre yalnız daemon iş parçacıkları kaldığında program sonlanır ve bu iş parçacıkları aniden durdurulur; açık dosyalar, veritabanı işlemleri gibi kaynaklar düzgün bırakılmayabilir. Düzgün kapanma gereken işlerde daemon kullanmayın; iş parçacığını normal bırakın ve durdurmak için Event gibi bir sinyal mekanizması kullanın.

ThreadPoolExecutor: iş havuzuyla daha düz kod

Gerçek projelerde iş parçacığı elle yönetilmez; concurrent.futures.ThreadPoolExecutor kullanılır. Belgelere göre bu, arka plan iş parçacıklarına iş vermenin daha üst düzey bir arayüzüdür ve sonuçlara gerektiğinde ulaşabilirsiniz:

from concurrent.futures import ThreadPoolExecutor, as_completed

with ThreadPoolExecutor() as havuz: gelecekler = {havuz.submit(indir, a): a for a in adresler} for gelecek in as_completed(gelecekler): adres = gelecekler[gelecek] try: gelecek.result() except Exception as hata: print(adres, hata)

submit bir işi planlar ve bir Future nesnesi döndürür. as_completed, tamamlandıkça Future'ları verir; sıra, gönderim sırası değil, bitiş sırasıdır. Bir işte hata olduysa istisna, result() çağrıldığında fırlatılır; bu yüzden try ile sarmak gerekir. map de benzer işi sıralı sonuçla yapar. with bloğu çıkışta havuzu kapatır ve bekler.

Bu yaklaşım Thread nesneleri, listeler ve kilitlerle uğraşmaktan çok daha az kod gerektirir. Önce havuzu deneyin; kontrolün gerektiği özel durumlarda ham Thread'e inin.

multiprocessing ve ProcessPoolExecutor: işlemci yoğun işler

Hesaplama yoğun bir işte iş parçacığı yardımcı olmaz. Belgelere göre multiprocessing paketi, iş parçacığı yerine alt süreçler kullanarak Global Interpreter Lock'u dolanır ve makinedeki tüm işlemcilerden yararlanmanızı sağlar. En kolay giriş yine bir havuzdur:

from concurrent.futures import ProcessPoolExecutor

def agir_hesap(sayi): return sum(i * i for i in range(sayi))

if __name__ == '__main__': girdiler = girdileri_yukle() with ProcessPoolExecutor() as havuz: sonuclar = list(havuz.map(agir_hesap, girdiler))

Üç ayrıntı sık hata kaynağıdır. Birincisi, if __name__ == '__main__' koruması zorunludur: belgelere göre ana modül çocuk süreçlerce içe aktarılabilir olmalıdır; korumasız kod her çocukta yeniden çalışır. İkincisi, serileştirme: işleve giden argümanlar ve dönen değerler pickle ile aktarılır, bu yüzden lambda ya da etkileşimli kabukta tanımlı fonksiyonlar çalışmaz. Üçüncüsü, bellek: her süreç kendi kopyasıyla çalışır, bir süreçte değiştirdiğiniz global değişken diğerlerinde görünmez. Süreçler arası veri akışı için Queue gibi araçlar kullanılır ve aktarılan nesneler yine serileştirilir.

Süreç başlatma yöntemi (spawn, fork, forkserver) platforma ve Python sürümüne göre değişir; Windows ve macOS'ta varsayılan spawn'dır ve yeni bir yorumlayıcı başlatır, bu da fork'tan yavaştır. Çok kısa görevler için süreç oluşturma maliyeti kazancı silebilir; görevleri toplu verin.

Hangisini seçmeli

Karar, işin zamanını nereye harcadığına bağlıdır. Emin değilseniz önce ölçün: işin ne kadarının hesaplama, ne kadarının bekleme olduğunu görmeden model seçmek tahmindir.

  • Çok sayıda ağ isteği, dosya okuma, veritabanı sorgusu: iş parçacığı havuzu
  • Büyük hesaplamalar, veri dönüştürme, görüntü işleme: süreç havuzu
  • Çok sayıda bağlantıyı tek akışta yönetmek ve kütüphaneleriniz destekliyorsa: asyncio, ayrı bir modeldir
  • İş zaten hızlıysa: hiçbiri; eşzamanlılık karmaşıklık getirir

İki modeli birleştirmek de mümkündür: örneğin süreç havuzundaki her sürecin içinde ağ isteklerini iş parçacıklarıyla yapmak. Ancak her ek katman hata ayıklamayı zorlaştırır; önce tek modelle başlayıp ölçümle gerekçelendirmek en sağlıklısıdır.

Şekil 1

Önce işin neyi beklediğine bakın

Hangi eşzamanlılık modelini seçmelisiniz?
  1. İş ağ ya da diskten yanıt bekliyorThreadPoolExecutorBeklemeler üst üste biner.
  2. İş saf hesaplama yapıyorProcessPoolExecutorGIL'i dolanır; veri pickle ile gider.
  3. Çok bağlantı, tek akışta yönetilecekasyncioAyrı bir model; kütüphaneler desteklemeli.
  4. İş zaten hızlıEşzamanlılık gerekmezKarmaşıklığa değmez.
Kaynak: Kaynak: Python belgeleri, threading ve concurrent.futuresMindset Institute

Sık yapılan hatalar

Eşzamanlı kodda hatalar sessiz ve tekrarlanması zordur. Sık görülenler:

  • İşlemci yoğun işi iş parçacıklarıyla çalıştırıp hızlanma beklemek
  • Paylaşılan değişkeni kilitsiz değiştirmek
  • Çok sayıda ufak görev için süreç oluşturup maliyeti hesaba katmamak
  • Süreç havuzunu if __name__ == '__main__' koruması olmadan kullanmak
  • Etkileşimli kabukta tanımlı fonksiyonu süreç havuzuna vermek
  • Daemon iş parçacığıyla dosya ya da veritabanı yazmak
  • Hata yönetimini unutmak; iş parçacığındaki istisna ana programa kendiliğinden ulaşmaz
  • Kilit içinde başka bir kilit beklemek ve deadlock oluşturmak

Bir iş parçacığında ya da süreçte oluşan hatayı sonuç nesnesi üzerinden toplamak (result çağrısı) ve havuzu with bloğuyla kullanmak bu sorunların çoğunu azaltır.

Bu beceriyi Mindset programlarında nerede çalışırsınız

Python İleri Seviye: Eşzamanlılık ve Performans programı, Yürütme Modeli ve GIL, Threading ve Senkronizasyon, Multiprocessing ve Paralel Hesaplama modüllerini ayrı ayrı adlandırır; bu sayfadaki konuları tek bir yol üzerinde çalışmak isteyenler için doğrudan karşılığıdır. Python ile Programlama Eğitimi ise bu konuya gelmeden önce gereken fonksiyonlar, hatalar ve modüller temelini kurar.

İlgili Mindset programı

Python ile Programlama Eğitimi

Sık sorulanlar

Sık sorulan sorular

GIL yüzünden Python tek çekirdekte mi çalışır?

Tek bir iş parçacığı için evet; CPython'da aynı anda yalnız bir iş parçacığı Python kodu çalıştırır. Ama çok çekirdekten yararlanmanın yolları vardır: multiprocessing ayrı süreçler başlatarak GIL'i dolanır. Ayrıca bazı performans odaklı kütüphaneler, GIL'i hesaplama sırasında bırakarak bu sınırı aşabilir.

threading mi multiprocessing mi daha hızlı?

İşe bağlıdır. Ağ ya da disk beklemesi yoğunsa iş parçacıkları hafif ve hızlıdır. Saf Python hesaplamasında süreçler çoklu çekirdeği kullandığı için daha hızlı olur, ama süreç başlatma ve veri serileştirme maliyeti vardır. Kısa görevlerde bu maliyet kazancı silebilir.

Neden if __name__ == '__main__' yazmam gerekiyor?

Süreç başlatma yöntemine bağlı olarak çocuk süreç ana modülü yeniden içe aktarır. Koruma olmazsa modül düzeyindeki kod her çocukta çalışır ve süreç havuzu tekrar tekrar kurulmaya çalışılır. Koruma, süreç oluşturan kodu yalnız ana süreçte çalıştırır.

İş parçacığındaki hata neden programı durdurmuyor?

İş parçacığı kendi akışında çalışır; içinde fırlayan istisna ana iş parçacığına kendiliğinden taşınmaz ve yalnız o iş parçacığını sonlandırır. ThreadPoolExecutor'da Future üzerinde result çağırırsanız istisna o noktada yeniden fırlatılır. Bu yüzden havuz kullanmak hata yönetimini kolaylaştırır.

asyncio ile iş parçacığı arasında nasıl seçim yaparım?

İkisi de bekleme ağırlıklı işler içindir. İş parçacıkları mevcut senkron kütüphanelerle çalışır ve geçiş kolaydır. asyncio tek iş parçacığında çok sayıda bağlantıyı yönetir ama asenkron kütüphane gerektirir. Ayrıntısı ayrı bir konudur.

Python threading ve multiprocessing: GIL ve Seçim Rehberi