Batayan

Asyncio sa Python: async/await, TaskGroup, thread, multiprocessing, at GIL

Na-update: · Skillaria.Top

Ang asyncio ay built-in na library ng Python para sa asynchronous na code: tumitigil sandali ang mga function na idineklara sa async def (coroutine) sa await, at habang naghihintay, pinapatakbo ng event loop ang ibang gawain, kaya sabay-sabay na natatapos sa iisang thread ang daan-daang network request. Para sa paghihintay sa network at disk, angkop ang asyncio o threading; para sa mabibigat na kalkulasyon, multiprocessing, dahil pinipigilan ng GIL ang mga thread ng karaniwang CPython na magkalkula nang sabay-sabay.

Ang asyncio ay built-in na library ng Python para sa asynchronous na code. Ang mga function na idineklara gamit ang async def ay kayang huminto sandali sa await, at habang naghihintay ang isa sa sagot ng network, pinapatakbo ng event loop ang iba. Kaya sabay-sabay na umaandar ang daan-daang request sa iisang thread. Pero isa lang ang asyncio sa tatlong kasangkapan: may mga thread (threading) at proseso (multiprocessing) din. Tatalakayin natin kung paano gumagana ang asyncio sa Python, alin ang pipiliin para sa iyong gawain, at ano ang kinalaman ng GIL dito.

Paghihintay at pagkalkula — magkaibang gawain

Kailangan itong maunawaan bago ang iba. Mabagal ang programa dahil sa isa sa dalawang dahilan:

  • I/O-bound — naghihintay sa input-output: sagot ng API, database, o pagbasa ng disk. Nakatiwangwang ang processor.
  • CPU-bound — nagkakalkula: nagpoproseso ng larawan, umiikot sa milyun-milyong tala. Puno ang processor.

Para sa paghihintay, angkop ang asyncio o thread: habang naghihintay ang isang operasyon, may ibang gumagana. Para sa kalkulasyon, kailangan ang totoong parallel na pagkalkula sa maraming core — mga proseso. Ang pagkalito sa dalawa ang pinakamalaking pagkakamali: hindi bibilis ang kalkulasyon sa purong Python gamit ang thread, at ang paggamit ng proseso para sa tatlong HTTP request ay sobrang laki ng kasangkapan para sa maliit na trabaho.

Ano ang asyncio: coroutine at event loop

Nakabatay ang asynchronous na code sa dalawang salita. Ang async def ay nagdedeklara ng coroutine — function na kayang huminto. Ang pagtawag dito ay hindi nagpapatakbo ng katawan nito, kundi gumagawa ng coroutine object. Ang await sa loob ay nagsasabing: "dito, maghihintay ako, at ikaw muna ang bahala sa iba." Ang buong bagay ay kinokontrol ng event loop, na sinisimulan sa pamamagitan ng asyncio.run().

import asyncio

async def fetch_rate(currency):
    await asyncio.sleep(1)          # panggagaya sa request sa API
    return {"USD": 92.5, "EUR": 100.1, "CNY": 12.7}[currency]

async def main():
    rate = await fetch_rate("USD")
    print(rate)

asyncio.run(main())  # 92.5

Ang asyncio.sleep() ay asynchronous na paghinto: ibinabalik nito ang kontrol sa event loop. Kapag ginamit ang karaniwang time.sleep() sa loob ng coroutine, haharangin nito ang buong loop, at titigil ang lahat ng ibang gawain.

Sunod-sunod o sabay-sabay

Kung isusulat ang await sa loob ng loop, pasunod-sunod ang mga request — tatlong pera ay aabutin ng tatlong segundo. Para sabay-sabay ang mga ito, dapat ilunsad bilang mga task.

import asyncio, time

async def main():
    start = time.perf_counter()
    for cur in ("USD", "EUR", "CNY"):
        await fetch_rate(cur)                       # isa-isa
    print(f"{time.perf_counter() - start:.1f} s")   # 3.0 s

asyncio.run(main())

TaskGroup: sabay na ilunsad at hintayin lahat

Ang modernong paraan (Python 3.11+) ay ang task group. Sa loob ng block na async with, agad na nagsisimula ang bawat task sa pamamagitan ng tg.create_task(), at natatapos ang block kapag tapos na ang lahat.

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} s")   # ... 1.0 s

asyncio.run(main())

Mahigpit ang TaskGroup: kapag bumagsak ang isang task, kakanselahin ang iba, at lahat ng error ay titipunin sa ExceptionGroup. Hinuhuli ang mga ito gamit ang except*:

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("hindi kilalang pera:", eg.exceptions)

asyncio.gather at create_task

Bago ang TaskGroup, ang pamantayan ay ang asyncio.gather(): sabay nitong pinapatakbo ang mga coroutine at ibinabalik ang mga resulta sa parehong pagkakasunod. Marami pa ring ganitong code sa umiiral na proyekto.

async def main():
    usd, eur, cny = await asyncio.gather(
        fetch_rate("USD"), fetch_rate("EUR"), fetch_rate("CNY")
    )
    # sa return_exceptions=True, ibabalik ang mga error sa listahan at hindi titigil ang lahat

Pinapatakbo ng asyncio.create_task() ang coroutine sa background nang walang grupo. Dapat i-save ang reference sa task, kung hindi, maaari itong burahin ng garbage collector, at walang makakakita sa mga error nito. Kaya para sa mga bagong proyekto, mas maaasahan ang TaskGroup.

Pagkansela ng task at timeout

async def main():
    task = asyncio.create_task(fetch_rate("USD"))
    task.cancel()                     # sa loob ng task, lalabas ang CancelledError
    try:
        await task
    except asyncio.CancelledError:
        print("nakansela")

    try:
        async with asyncio.timeout(0.5):   # Python 3.11+
            await fetch_rate("EUR")
    except TimeoutError:
        print("masyadong matagal")

Sa mas lumang bersyon, sa halip na asyncio.timeout(), ginagamit ang asyncio.wait_for(coro, timeout=0.5). Mayroon ding asyncio.wait() — ibinabalik nito ang mga set ng natapos at hindi pa natatapos na task, at kayang maghintay ng una (return_when=asyncio.FIRST_COMPLETED).

Sa Python 3.15, ayon sa kurso, may method na tg.cancel() ang TaskGroup: kinakansela nito ang lahat ng task sa grupo nang walang exception. Sinasagot nito ang senaryong "sino ang unang": magtanong sa tatlong mirror service, kunin ang unang sagot, at kanselahin ang iba. Dati, kailangang magtapon ng sariling exception bilang signal o manu-manong kanselahin ang mga task.

Synchronization: Semaphore, Lock, Event, Queue

May sariling primitives ang asyncio na gumagana sa await. Pinakakapaki-pakinabang ang Semaphore: nililimita nito ang bilang ng sabay-sabay na operasyon, para hindi mapabagsak ang API ng iba sa libu-libong request.

sem = asyncio.Semaphore(10)

async def limited(url):
    async with sem:              # hindi lalampas sa 10 sabay-sabay
        await asyncio.sleep(0.1) # dito sana ang request
        return url
  • asyncio.Lock — isang coroutine lang ang nasa critical section.
  • asyncio.Event — naghihintay ang isang coroutine sa await event.wait(), at nagbibigay ng signal ang isa gamit ang event.set().
  • asyncio.Queue — pila ng "producer — consumer": await q.put(), await q.get(), q.task_done().

Bitag: nakalimutan ang await

Pinakakaraniwang pagkakamali ang pagtawag sa coroutine nang walang await:

async def main():
    rate = fetch_rate("USD")    # nakalimutan ang await
    print(rate * 2)             # TypeError
# RuntimeWarning: coroutine 'fetch_rate' was never awaited

Hindi tumakbo ang function; gumawa lang ang Python ng coroutine object. Kapag nakita mo sa log ang "was never awaited", hanapin ang nawawalang await. Ang pangalawang karaniwang pagkakamali ay ang blocking na tawag (time.sleep, requests.get) sa loob ng coroutine. Para sa HTTP, gumamit ng asynchronous na library (httpx, aiohttp), at ang hindi maiiwasang sync na code ay ilipat sa thread gamit ang await asyncio.to_thread(func, arg).

Multithreading sa Python: module na threading

Ang thread ay independiyenteng linya ng pagpapatakbo sa loob ng iisang proseso; nagbabahagi ng memorya ang mga thread. Mahusay silang gumagana sa paghihintay ng network at hindi nangangailangan ng pagsusulat muli ng code bilang async.

import threading, time

def download(name):
    time.sleep(1)               # paghihintay sa network
    print(name, "tapos na")

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()                    # lahat sa loob ng ~1 segundo

Ang nakabahaging memorya ay kapwa bentahe at pinagmumulan ng error: kapag dalawang thread ang nagbabago ng iisang variable, kailangan ng threading.Lock. Kadalasan, mas mainam na huwag manu-manong galawin ang mga thread, at kumuha na lang ng pool.

ThreadPoolExecutor

Gumagawa ang concurrent.futures.ThreadPoolExecutor ng pool ng mga thread at ipinamamahagi ang mga gawain dito. Ibinabalik ng map ang mga resulta sa orihinal na pagkakasunod.

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)           # lahat sa loob ng ~1 segundo

Ano ang GIL at bakit hindi napapabilis ng thread ang kalkulasyon

Ang GIL (Global Interpreter Lock) ay pandaigdigang lock ng interpreter ng CPython. Sa bawat sandali, iisang thread lang ang nagpapatakbo ng Python bytecode. Habang naghihintay ng input-output ang isang thread, binibitawan nito ang GIL, kaya kapaki-pakinabang ang thread para sa network. Pero sa purong kalkulasyon, hindi mas mabilis ang apat na thread kaysa sa isa: pasa-pasa lang nila ang lock.

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))   # halos kapareho ng sunod-sunod

Ang mga library na nakasulat sa C (NumPy at katulad) ay madalas nagbibitaw ng GIL habang tumatakbo ang mabibigat na operasyon, kaya para sa mga ito, napapabilis ng thread ang trabaho.

multiprocessing: totoong parallelism

May kanya-kanyang interpreter at sariling GIL ang bawat proseso, kaya sabay na nagkakalkula sa iba't ibang core ang mga proseso. Ang kapalit ay magkahiwalay na memorya: ipinapasa ang datos sa pagitan ng mga proseso sa pamamagitan ng serialization (pickle), at mas mahal ang pagsisimula ng proseso kaysa sa thread.

from concurrent.futures import ProcessPoolExecutor

def heavy(n):
    return sum(i * i for i in range(n))

if __name__ == "__main__":      # kailangan sa Windows at macOS
    with ProcessPoolExecutor() as pool:
        print(list(pool.map(heavy, [5_000_000] * 4)))

Kapag walang tseke na if __name__ == "__main__", ang mga child process ay magsisimulang gumawa ng sariling proseso habang ini-import ang module. Mayroon ding mababang-antas na module na multiprocessing na may Process, Pool, Queue, pero mas simple ang ProcessPoolExecutor at pareho ang interface nito sa thread pool.

Alin ang pipiliin: asyncio, threading, o multiprocessing

GawainKasangkapanBakit
Mag-download ng 200 pahina, maraming request sa APIasyncio (httpx/aiohttp) o ThreadPoolExecutorpaghihintay sa network, libre ang processor
Maraming request sa loob na ng asynchronous na programaTaskGroupasync na ang code
Sync na library na walang async na bersyonthread, asyncio.to_threadhindi kailangang isulat muli ang code
Mabigat na kalkulasyon sa purong PythonProcessPoolExecutornilalampasan ang GIL, lahat ng core

Python na walang GIL at mga sub-interpreter: ano ang bago

Mula Python 3.13, may hiwalay na build na walang GIL (free-threading, PEP 703) — minamarkahan ito ng letrang t sa pangalan, halimbawa python3.15t. Eksperimental ito sa 3.13, at opisyal nang sinusuportahan simula 3.14 (PEP 779). Sa build na ito, sabay-sabay na nagkakalkula ang mga thread sa iba't ibang core. Pero bilang default, karaniwang Python na may GIL pa rin ang makukuha mo: bahagyang mas mabagal ang single-threaded na code sa free-threaded build, at hindi pa handa ang lahat ng library rito.

import sys
print(sys._is_gil_enabled())   # False sa free-threaded build

Ang ikalawang daan patungo sa parallel na kalkulasyon ay lumitaw sa 3.14 (PEP 734): ang mga sub-interpreter sa module na concurrent.interpreters — maraming nakahiwalay na interpreter sa iisang proseso, na may kanya-kanyang GIL. Pinakamadaling gamitin ang mga ito sa pamamagitan ng InterpreterPoolExecutor, na kamukha ng thread pool. Sa sukat ng kurso, natapos ang apat na mabigat na gawain sa 0.39 s, kumpara sa 1.03 s sa isang thread.

Buod

Ang asyncio sa Python ay kasangkapan para sa mga gawaing paghihintay: coroutine gamit ang async def, paghinto gamit ang await, at sabay-sabay na paglulunsad sa pamamagitan ng TaskGroup o gather. Nilulutas ng mga thread ang parehong problema nang hindi isinusulat muli ang code bilang async, at para sa mabibigat na kalkulasyon, kailangan ang mga proseso dahil pinipigilan ng GIL ang mga thread ng karaniwang CPython na magkalkula nang sabay. Unti-unting nagbabago ang larawang ito dahil sa free-threading at mga sub-interpreter, pero nananatiling gumagana ang pangunahing tuntunin: "naghihintay — asyncio o thread; nagkakalkula — proseso". Tinalakay ang lahat ng ito nang praktikal sa kurso: kumukuha ng mga rate ng pera nang sabay-sabay ang tagasubaybay ng gastos sa loob ng isang segundo, sa halip na tatlo.

Mga madalas itanong

Maaari bang tawagin ang async function mula sa ordinaryong code?

Oo, sa pamamagitan ng asyncio.run(coro()) — gumagawa ito ng event loop, pinapatakbo ang coroutine, at isinasara ang loop. Sa loob ng tumatakbo nang loop, hindi maaaring tawagin ang asyncio.run; doon ginagamit ang await.

Tumatakbo ba ang asyncio sa maraming thread?

Hindi, pinapatakbo ng event loop ang lahat ng coroutine sa iisang thread at lumilipat lamang sa mga punto ng await. Kaya mas kaunti ang data race kumpara sa thread, pero isang blocking na operasyon ay nakapagpapahinto sa lahat.

Kailangan bang i-install ang asyncio gamit ang pip?

Hindi, kasama ang asyncio sa standard library ng Python 3; sapat na ang import asyncio. Luma na ang package na asyncio sa PyPI at hindi na kailangang i-install ito.

Paano patakbuhin ang mabigat na function mula sa asyncio nang hindi hinaharangan ang loop?

Gamitin ang loop.run_in_executor kasama ang ProcessPoolExecutor para sa kalkulasyon, o asyncio.to_thread para sa blocking na input-output; magpapatuloy ang event loop sa pag-asikaso ng ibang gawain.

Ano ang pagkakaiba ng coroutine at task?

Ang coroutine ay object na walang ginagawa hangga't hindi ito hinihintay gamit ang await. Binabalot ng task ang coroutine at agad itong inilalagay sa iskedyul ng event loop, kaya tumatakbo ito nang parallel sa ibang code.

Pag-aralan ito sa video

Detalyadong tinatalakay ang paksang ito, may praktis, sa kursong “Python 3.15: от новичка до профи”.

Pumunta sa kurso