Temeller

Python'da asyncio: async/await, TaskGroup, iş parçacıkları, multiprocessing ve GIL

Güncellendi: · Skillaria.Top

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 korutin await event.wait() ile bekler, diğeri event.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örevAraçNeden
200 sayfa indirmek, çok sayıda API isteğiasyncio (httpx/aiohttp) ya da ThreadPoolExecutorağ beklemesi, işlemci boşta
Zaten asenkron bir programda birkaç istekTaskGroupkod zaten async
Async sürümü olmayan senkron bir kütüphaneiş parçacıkları, asyncio.to_threadkodu yeniden yazmak gerekmez
Saf Python'da ağır hesaplamaProcessPoolExecutorGIL'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 konuyu videoyla öğrenin

Bu konu “Python 3.15: от новичка до профи” kursunda uygulamalı olarak ayrıntılı işleniyor.

Kursa git