Python'da asyncio: async/await, TaskGroup, iş parçacıkları, multiprocessing ve GIL
Python'da asyncio, asenkron kod yazmak için yerleşik kütüphanedir: async def ile tanımlanan korutinler await'te duraklar ve olay döngüsü o sırada diğer görevleri çalıştırır; böylece yüzlerce ağ isteği tek bir iş parçacığında aynı anda ilerler. Ağ ve disk beklemek için asyncio ya da iş parçacıkları (threading), ağır hesaplar için süreçler (multiprocessing) uygundur; çünkü klasik CPython'da GIL, iş parçacıklarının hesabı paralel yürütmesini engeller.
Python'da asyncio, asenkron kod için yerleşik bir kütüphanedir. async def ile tanımlanan fonksiyonlar await noktasında duraklayabilir; bu sırada olay döngüsü diğer işleri çalıştırır. Böylece yüzlerce ağ isteği tek bir iş parçacığında aynı anda ilerler. Ama asyncio üç araçtan yalnızca biri: bunların yanında iş parçacıkları (threading) ve süreçler (multiprocessing) da vardır. Bu yazıda asyncio'nun nasıl çalıştığını, hangi aracın hangi işe uyduğunu ve GIL'in bu tabloda neden önemli olduğunu anlatıyoruz.
Beklemek ve hesaplamak: farklı işler
Bu ayrımı bilmeden gerisini anlamak zordur. Bir program iki nedenden biriyle yavaşlar:
- I/O-bound — giriş-çıkış beklemek: API yanıtı, veritabanı, disk okuma. İşlemci bu sırada boştadır.
- CPU-bound — hesaplamak: görüntü işlemek, milyonlarca kayıt üzerinde döngü çevirmek. İşlemci doludur.
Beklemek için asyncio ya da iş parçacıkları uygundur: bir işlem beklerken diğeri çalışır. Hesaplamak için gerçek paralel işlem, yani birden çok çekirdekte süreçler gerekir. Bu ikisini karıştırmak en büyük hatadır: saf Python'da iş parçacıkları hesabı hızlandırmaz, öte yandan üç HTTP isteği için süreç başlatmak ufak bir işe büyük bir araç kullanmak demektir.
asyncio nedir: korutinler ve olay döngüsü
Asenkron kod iki kelimeye dayanır. async def bir korutin tanımlar: duraklatılabilen bir fonksiyon. Onu çağırmak gövdeyi çalıştırmaz, bir korutin nesnesi oluşturur. Gövde içindeki await ise şunu söyler: "burada bekleyeceğim, bu arada başka görevlere bak." Her şeyi yöneten olay döngüsüdür (event loop); onu asyncio.run() başlatır.
import asyncio
async def fetch_rate(currency):
await asyncio.sleep(1) # API isteğini taklit eder
return {"USD": 37.5, "EUR": 40.1, "CNY": 5.2}[currency]
async def main():
rate = await fetch_rate("USD")
print(rate)
asyncio.run(main()) # 37.5
asyncio.sleep() asenkron bir beklemedir: kontrolü olay döngüsüne geri verir. Korutin içinde time.sleep() kullanırsanız, döngü tamamen tıkanır ve diğer görevler beklemek zorunda kalır.
Sırayla mı, aynı anda mı
Bir döngü içinde await yazarsanız istekler sırayla gider: üç kur üç saniye sürer. Aynı anda gitmeleri için onları görev olarak başlatmanız gerekir.
import asyncio, time
async def main():
start = time.perf_counter()
for cur in ("USD", "EUR", "CNY"):
await fetch_rate(cur) # sırayla
print(f"{time.perf_counter() - start:.1f} sn") # 3.0 sn
asyncio.run(main())
TaskGroup: birlikte başlat ve hepsini bekle
Modern yöntem (Python 3.11+) görev grubudur. async with bloğunun içinde her görev tg.create_task() ile hemen başlar ve bloktan, hepsi bitince çıkılır.
async def main():
start = time.perf_counter()
async with asyncio.TaskGroup() as tg:
tasks = {cur: tg.create_task(fetch_rate(cur)) for cur in ("USD", "EUR", "CNY")}
rates = {cur: t.result() for cur, t in tasks.items()}
print(rates, f"{time.perf_counter() - start:.1f} sn") # ... 1.0 sn
asyncio.run(main())
TaskGroup katı davranır: bir görev hata verirse diğerleri iptal edilir ve tüm hatalar bir ExceptionGroup içinde toplanır. Bunları except* ile yakalarsınız:
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch_rate("USD"))
tg.create_task(fetch_rate("XXX")) # KeyError
except* KeyError as eg:
print("bilinmeyen para birimi:", eg.exceptions)
asyncio.gather ve create_task
TaskGroup'tan önce standart yöntem asyncio.gather() idi: korutinleri aynı anda çalıştırır ve sonuçları verilen sırayla döndürür. Mevcut kodların çoğunda hâlâ görülür.
async def main():
usd, eur, cny = await asyncio.gather(
fetch_rate("USD"), fetch_rate("EUR"), fetch_rate("CNY")
)
# return_exceptions=True ile hatalar listede döner, her şeyi durdurmaz
asyncio.create_task() bir korutini bir grup olmadan arka planda başlatır. Görevin referansını saklamanız gerekir; aksi halde çöp toplayıcı onu silebilir ve hatalar kimsenin dikkatine gelmez. Bu yüzden yeni projelerde TaskGroup daha güvenlidir.
Görev iptali ve zaman aşımı
async def main():
task = asyncio.create_task(fetch_rate("USD"))
task.cancel() # görevin içinde CancelledError oluşur
try:
await task
except asyncio.CancelledError:
print("iptal edildi")
try:
async with asyncio.timeout(0.5): # Python 3.11+
await fetch_rate("EUR")
except TimeoutError:
print("çok uzun sürdü")
Eski sürümlerde asyncio.timeout() yerine asyncio.wait_for(coro, timeout=0.5) kullanılır. Ayrıca asyncio.wait() vardır: tamamlanmış ve tamamlanmamış görevlerin kümelerini döndürür; return_when=asyncio.FIRST_COMPLETED ile ilk biteni beklemek de mümkündür.
Kursta anlatıldığı üzere Python 3.15'te TaskGroup'a tg.cancel() metodu eklendi: grubun tüm görevlerini istisnasız iptal eder. Bu, "kim önce cevap verirse" senaryosunu kolaylaştırır: üç yedek servise sorup ilk yanıtı alır, diğerlerini iptal edersiniz. Önceden bunun için ya kendi istisnanızı fırlatıp yakalamak ya da görevleri elle iptal etmek gerekiyordu.
Senkronizasyon: Semaphore, Lock, Event, Queue
asyncio'nun await ile çalışan kendi senkronizasyon araçları vardır. En kullanışlısı Semaphore: aynı anda yapılacak işlem sayısını sınırlar, böylece bir API'yi binlerce istekle boğmazsınız.
sem = asyncio.Semaphore(10)
async def limited(url):
async with sem: # aynı anda en fazla 10 tane
await asyncio.sleep(0.1) # burada istek olurdu
return url
asyncio.Lock— kritik bölgeye aynı anda yalnızca bir korutin girer.asyncio.Event— bir korutinawait event.wait()ile bekler, diğerievent.set()ile haber verir.asyncio.Queue— üretici-tüketici kuyruğu:await q.put(),await q.get(),q.task_done().
Tuzak: await unutmak
En sık yapılan hata, bir korutini await olmadan çağırmaktır:
async def main():
rate = fetch_rate("USD") # await unutulmuş
print(rate * 2) # TypeError
# RuntimeWarning: coroutine 'fetch_rate' was never awaited
Python fonksiyonu çalıştırmadı, yalnızca bir korutin nesnesi oluşturdu. Günlükte "was never awaited" görürseniz eksik await'i arayın. İkinci tipik hata, korutin içinde bloke eden bir çağrı yapmaktır (time.sleep, requests.get). HTTP için httpx ya da aiohttp gibi asenkron kütüphaneleri kullanın; kaçınılmaz senkron kodu ise await asyncio.to_thread(func, arg) ile bir iş parçacığına taşıyın.
Python'da iş parçacıkları: threading modülü
İş parçacığı (thread), aynı süreç içinde bağımsız bir yürütme akışıdır; iş parçacıkları belleği paylaşır. Ağ beklemek için çok iyi çalışırlar ve kodu async'e çevirmeyi gerektirmezler.
import threading, time
def download(name):
time.sleep(1) # ağ beklemesi
print(name, "tamam")
threads = [threading.Thread(target=download, args=(n,)) for n in ("a", "b", "c")]
for t in threads:
t.start()
for t in threads:
t.join() # hepsi yaklaşık 1 saniyede biter
Ortak bellek hem avantaj hem de hata kaynağıdır: iki iş parçacığı aynı değişkeni değiştirirse threading.Lock gerekir. Genellikle iş parçacıklarını elle yönetmek yerine bir havuz kullanmak daha iyidir.
ThreadPoolExecutor
concurrent.futures.ThreadPoolExecutor bir iş parçacığı havuzu oluşturur ve işleri dağıtır. map sonuçları verilen sırayla döndürür.
from concurrent.futures import ThreadPoolExecutor
import time
def fetch(url):
time.sleep(1)
return f"{url}: 200"
urls = [f"https://example.com/{i}" for i in range(5)]
with ThreadPoolExecutor(max_workers=5) as pool:
for result in pool.map(fetch, urls):
print(result) # hepsi yaklaşık 1 saniyede
GIL nedir ve iş parçacıkları neden hesapları hızlandırmaz
GIL (Global Interpreter Lock), CPython'un global yorumlayıcı kilididir. Herhangi bir anda Python bayt kodunu yalnızca bir iş parçacığı çalıştırır. Bir iş parçacığı giriş-çıkış beklerken kilidi bırakır, bu yüzden ağ işlerinde iş parçacıkları faydalıdır. Ama saf hesaplamada dört iş parçacığı tek birinden daha hızlı çalışmaz: kilidi sırayla birbirlerine devrederler.
from concurrent.futures import ThreadPoolExecutor
def heavy(n):
return sum(i * i for i in range(n))
with ThreadPoolExecutor(4) as pool:
list(pool.map(heavy, [5_000_000] * 4)) # yaklaşık olarak sırayla çalışmakla aynı süre
Ağır işlemleri C ile yazılmış kütüphaneler (NumPy ve benzerleri) çoğu zaman GIL'i bırakarak çalışır; bu nedenle onlarda iş parçacıkları hızlandırma sağlar.
multiprocessing: gerçek paralellik
Her sürecin kendi yorumlayıcısı ve kendi GIL'i vardır; dolayısıyla süreçler farklı çekirdeklerde aynı anda hesap yapabilir. Bedeli, ayrı bellek: süreçler arasında veri serileştirme (pickle) ile taşınır ve bir süreç başlatmak bir iş parçacığından daha pahalıdır.
from concurrent.futures import ProcessPoolExecutor
def heavy(n):
return sum(i * i for i in range(n))
if __name__ == "__main__": # Windows ve macOS'ta zorunlu
with ProcessPoolExecutor() as pool:
print(list(pool.map(heavy, [5_000_000] * 4)))
if __name__ == "__main__" koruması olmadan, içe aktarma sırasında alt süreçler kendi yeni süreçlerini başlatmaya çalışır. multiprocessing modülünde Process, Pool, Queue gibi düşük seviyeli araçlar da vardır; ama ProcessPoolExecutor daha basittir ve iş parçacığı havuzuyla aynı arayüze sahiptir.
Hangisini seçmeli: asyncio, threading veya multiprocessing
| Görev | Araç | Neden |
|---|---|---|
| 200 sayfa indirmek, çok sayıda API isteği | asyncio (httpx/aiohttp) ya da ThreadPoolExecutor | ağ beklemesi, işlemci boşta |
| Zaten asenkron bir programda birkaç istek | TaskGroup | kod zaten async |
| Async sürümü olmayan senkron bir kütüphane | iş parçacıkları, asyncio.to_thread | kodu yeniden yazmak gerekmez |
| Saf Python'da ağır hesaplama | ProcessPoolExecutor | GIL'i aşar, tüm çekirdekler |
GIL'siz Python ve alt yorumlayıcılar: yenilikler
Python 3.13'ten beri GIL'siz bir derleme vardır (free-threading, PEP 703); adında t harfi bulunur, örneğin python3.15t. 3.13'te deneyseldi, 3.14'ten itibaren (PEP 779) resmen desteklenir. Bu derlemede iş parçacıkları farklı çekirdeklerde gerçekten paralel hesap yapar. Ama varsayılan olarak hâlâ GIL'li normal Python'u kullanırsınız: tek iş parçacıklı kod GIL'siz derlemede biraz yavaş çalışır ve tüm kütüphaneler buna hazır değildir.
import sys
print(sys._is_gil_enabled()) # GIL'siz derlemede False
Paralel hesap için ikinci yol Python 3.14'te (PEP 734) geldi: concurrent.interpreters modülündeki alt yorumlayıcılar. Aynı süreçte birbirinden yalıtılmış birkaç yorumlayıcı çalışır ve her birinin kendi GIL'i vardır. Bunları en kolay, iş parçacığı havuzuna benzeyen InterpreterPoolExecutor ile kullanırsınız. Kursun ölçümünde dört ağır görev bir iş parçacığında 1.03 sn, alt yorumlayıcı havuzunda 0.39 sn sürdü.
Özet
Python asyncio, beklemeye dayalı işler için bir araçtır: async def ile korutinler, await ile duraklamalar, TaskGroup ya da gather ile eşzamanlı başlatma. İş parçacıkları aynı işi async'e geçmeden yapar; hesaplamalar için süreçler gerekir, çünkü GIL klasik CPython'da iş parçacıklarının paralel hesap yapmasını engeller. GIL'siz derleme ve alt yorumlayıcılar bu tabloyu değiştirmeye başlıyor, ama temel kural hâlâ geçerli: beklersek asyncio ya da iş parçacıkları, hesaplarsak süreçler. Kursta bunlar uygulamalı işlenir: harcama takipçisi kurları üç saniye yerine bir saniyede paralel olarak alır.
Sık sorulan sorular
Senkron bir fonksiyonu asenkron kodun içinden nasıl çağırırım?
Asenkron kodda engellemeden çalıştırmak için await asyncio.to_thread(func, arg) kullanın. Hesap yoğunsa loop.run_in_executor ile ProcessPoolExecutor vermek daha uygundur.
Normal bir koddan async fonksiyonu nasıl çalıştırırım?
asyncio.run(coro()) ile: bu bir olay döngüsü oluşturur, korutini çalıştırır ve döngüyü kapatır. Zaten çalışan bir döngünün içinde asyncio.run çağrılamaz; orada await kullanılır.
asyncio birden çok iş parçacığında çalışır mı?
Hayır, olay döngüsü tüm korutinleri tek iş parçacığında yürütür ve yalnızca await noktalarında geçiş yapar. Bu yüzden iş parçacıklarına göre veri yarışı daha azdır, ama tek bir bloke eden işlem her şeyi durdurur.
asyncio'yu pip ile kurmak gerekir mi?
Hayır, asyncio Python 3'ün standart kütüphanesinde bulunur; import asyncio yazmak yeterlidir. PyPI'deki asyncio paketi eskidir ve kurulmamalıdır.
Korutin ile görev (Task) arasındaki fark nedir?
Korutin, await edilmedikçe hiçbir şey yapmayan bir nesnedir. Görev ise korutini sarar ve hemen olay döngüsünün zamanlamasına alır; böylece diğer kodla paralel ilerler.
Bu konu “Python 3.15: от новичка до профи” kursunda uygulamalı olarak ayrıntılı işleniyor.
Kursa git