Async Python: async/await ও Event Loop

একসাথে হাজার request সামলানোর কৌশল

রোডম্যাপের মাস ২পড়তে প্রায় ১৪ মিনিট

Flutter এ তুমি প্রতিদিন async/await লেখো: final data = await api.getOrders();। Python এও একই শব্দ, একই ধারণা। কিন্তু backend এ এর গুরুত্ব আরও বেশি: একটা FastAPI server একসাথে হাজার user কে সামলাতে পারে মূলত async এর কারণে। এই অধ্যায়ে দেখবো async আসলে কীভাবে কাজ করে, কখন সাহায্য করে, আর কখন উল্টো ক্ষতি করে।

#সমস্যা: অপেক্ষা

একটা API request সামলাতে server এর সময় আসলে কোথায় যায়?

  • Database এর উত্তরের জন্য অপেক্ষা: ২০ ms
  • অন্য API (payment gateway) এর উত্তরের জন্য অপেক্ষা: ৩০০ ms
  • আসল হিসাব (CPU এর কাজ): ১ ms

বেশিরভাগ সময় CPU কিছুই করছে না, শুধু অপেক্ষা করছে। একে বলে I/O-bound কাজ।

#রেস্টুরেন্টের ওয়েটার

একজন ওয়েটার দুইভাবে কাজ করতে পারে:

  • Sync ওয়েটার: টেবিল ১ এর order নিলো, রান্নাঘরে দিলো, খাবার না হওয়া পর্যন্ত রান্নাঘরের সামনে দাঁড়িয়ে থাকলো। তারপর টেবিল ২ এ গেল। বাকি সবাই বসে আছে।
  • Async ওয়েটার: টেবিল ১ এর order রান্নাঘরে দিলো, অপেক্ষা না করে টেবিল ২ এ গেল, তারপর ৩ এ। রান্নাঘর থেকে ডাক এলে খাবার নিয়ে টেবিল ১ এ দিলো।

একই একজন ওয়েটার, কিন্তু দ্বিতীয়জন অনেক বেশি টেবিল সামলায়। Python এর event loop হলো এই async ওয়েটার।

Sync: মোট ৯০০ ms request ১: অপেক্ষা request ২: অপেক্ষা request ৩: অপেক্ষা Async: মোট প্রায় ৩০০ ms request ১: অপেক্ষা request ২: অপেক্ষা request ৩: অপেক্ষা রঙিন ছোট অংশ = CPU এর আসল কাজ ধূসর লম্বা অংশ = database/network এর জন্য অপেক্ষা async এ অপেক্ষাগুলো একসাথে চলে
একটা thread, একটা CPU। async এ এক request এর অপেক্ষার সময় event loop অন্য request এর কাজ শুরু করে দেয়।

#async/await এর মূল কথা

Python
import asyncio
import time


async def fetch_user(user_id: int) -> dict:
    await asyncio.sleep(1)          # database এর অপেক্ষার নকল
    return {"id": user_id, "name": f"User {user_id}"}


async def main() -> None:
    start = time.perf_counter()

    # একের পর এক: ৩ সেকেন্ড
    a = await fetch_user(1)
    b = await fetch_user(2)
    c = await fetch_user(3)
    print(f"একের পর এক: {time.perf_counter() - start:.1f}s")

    # একসাথে: ১ সেকেন্ড
    start = time.perf_counter()
    a, b, c = await asyncio.gather(fetch_user(1), fetch_user(2), fetch_user(3))
    print(f"একসাথে: {time.perf_counter() - start:.1f}s")


asyncio.run(main())
  • async def দিয়ে বানানো function কে বলে coroutine function। ডাকলে সাথে সাথে চলে না, একটা coroutine object দেয়।
  • await মানে "এটা শেষ না হওয়া পর্যন্ত আমি থামলাম, এই ফাঁকে event loop অন্য কাজ করুক"।
  • asyncio.gather কয়েকটা কাজ একসাথে চালায় (Dart এর Future.wait এর মতো)।
  • asyncio.run() event loop চালু করে। FastAPI তে এটা লাগে না, server নিজেই চালায়।

#বাস্তব উদাহরণ: অনেক API একসাথে

Python
import asyncio
import httpx

CITIES = {
    "ঢাকা": (23.81, 90.41),
    "চট্টগ্রাম": (22.36, 91.78),
    "সিলেট": (24.89, 91.87),
    "রাজশাহী": (24.37, 88.60),
    "খুলনা": (22.85, 89.54),
}


async def get_temp(client: httpx.AsyncClient, city: str, lat: float, lng: float) -> tuple[str, float]:
    r = await client.get(
        "https://api.open-meteo.com/v1/forecast",
        params={"latitude": lat, "longitude": lng, "current": "temperature_2m"},
    )
    r.raise_for_status()
    return city, r.json()["current"]["temperature_2m"]


async def main() -> None:
    async with httpx.AsyncClient(timeout=10) as client:
        tasks = [get_temp(client, c, *loc) for c, loc in CITIES.items()]
        results = await asyncio.gather(*tasks, return_exceptions=True)

    for res in results:
        if isinstance(res, Exception):
            print("ব্যর্থ:", res)
        else:
            city, temp = res
            print(f"{city}: {temp}°C")


asyncio.run(main())

৫টা request একের পর এক করলে ৫ গুণ সময়, gather দিয়ে প্রায় একটার সময়ে। return_exceptions=True দিলে একটা ব্যর্থ হলেও বাকিগুলোর ফলাফল পাওয়া যায়।

#একসাথে কতগুলো: সীমা দাও

১০,০০০টা request একসাথে পাঠালে অন্য server তোমাকে block করবে, বা তোমার নিজের connection শেষ হয়ে যাবে। Semaphore দিয়ে সীমা দাও:

Python
sem = asyncio.Semaphore(10)        # একসাথে সর্বোচ্চ ১০টা

async def limited_fetch(client, url):
    async with sem:
        r = await client.get(url)
        return r.status_code

#Timeout

Python
async def main():
    try:
        async with asyncio.timeout(2):         # Python 3.11+
            await slow_operation()
    except TimeoutError:
        print("২ সেকেন্ডে শেষ হয়নি")

#সবচেয়ে বড় ভুল: async এর ভেতরে blocking কাজ

Event loop একটা thread এ চলে। কোনো coroutine যদি await ছাড়া অনেকক্ষণ আটকে থাকে, তাহলে পুরো server আটকে যায়, সব user এর জন্য।

Python
import time
import requests            # sync library

async def bad_handler():
    time.sleep(2)                          # ❌ পুরো event loop ২ সেকেন্ড আটকে
    r = requests.get("https://...")        # ❌ sync HTTP, event loop আটকে
    return r.json()

async def good_handler():
    await asyncio.sleep(2)                 # ✓
    async with httpx.AsyncClient() as c:   # ✓ async HTTP
        r = await c.get("https://...")
    return r.json()

#Sync library ব্যবহার করতেই হলে

কিছু library এর async version নেই। তখন সেটাকে আলাদা thread এ পাঠাও:

Python
import asyncio

def generate_pdf_sync(data) -> bytes:      # ধীর sync কাজ
    ...

async def handler(data):
    pdf = await asyncio.to_thread(generate_pdf_sync, data)   # event loop আটকায় না
    return pdf

FastAPI তে আরেকটা সহজ উপায়: route কে async def না লিখে সাধারণ def লেখো। তখন FastAPI নিজেই সেটাকে আলাদা thread pool এ চালায়।

#কখন async, কখন না

কাজের ধরন উদাহরণ কী ব্যবহার করবে
I/O-bound, অনেকগুলো একসাথে API server, অনেক HTTP call, database query async
CPU-bound ছবি resize, PDF বানানো, বড় হিসাব, ML multiprocessing বা background worker (Celery)
সাধারণ script, একটা একটা কাজ ছোট CLI tool সাধারণ sync code ই সহজ

Async CPU এর কাজ দ্রুত করে না। একজন ওয়েটার যত চালাকই হোক, রান্না নিজে করলে অন্য টেবিলে যেতে পারবে না। ভারী হিসাবের জন্য আলাদা "রাঁধুনি" (process বা worker) লাগে।

#অনুশীলন

#নিজেকে যাচাই করো

Async কি CPU এর ভারী হিসাব দ্রুত করে?

না। Async শুধু অপেক্ষার সময়টা কাজে লাগায় (I/O-bound কাজে)। CPU-bound কাজের জন্য আলাদা process বা background worker লাগে।

`async def` route এর ভেতরে `time.sleep(2)` দিলে কী হবে?

পুরো event loop ২ সেকেন্ড আটকে থাকবে, ফলে ওই সময় server অন্য কোনো user এর request ও সামলাতে পারবে না। await asyncio.sleep(2) লিখতে হবে।

`fetch_user(1)` লিখে `await` ভুলে গেলে কী হয়?

Function টা চলেই না, শুধু একটা coroutine object তৈরি হয়। Python warning দেয় "coroutine was never awaited"। Dart এর Future এর মতো নিজে থেকে চলা শুরু করে না।

`asyncio.gather` এ `return_exceptions=True` কেন দেবে?

না দিলে একটা task ব্যর্থ হলেই gather exception দিয়ে থেমে যায়, বাকি সফল ফলাফলও হারায়। দিলে ব্যর্থ task এর জায়গায় exception object ফেরত আসে, আর সফলগুলোর ফলাফল পাওয়া যায়।

একটা sync library (যেমন PDF বানানো) async code এ কীভাবে নিরাপদে ব্যবহার করবে?

await asyncio.to_thread(func, args) দিয়ে আলাদা thread এ চালাবে, যাতে event loop আটকে না যায়। FastAPI তে route কে সাধারণ def লিখলেও FastAPI নিজে thread pool এ চালায়।

চিহ্ন দিলে sidebar আর রোডম্যাপে এই অধ্যায়ের station ভরে যাবে।