الوحدة 6 — الطلبات غير المتزامنة ومهامّ الخلفيّة
async كلمة سحريّة كثيرًا ما تُستعمل بلا فهم عواقبها. في خدمة تنبّؤ، إضافة async أمام كلّ دالّة تعتقد أنّها ستُسرّع الأمور، وقد تُبطئها أضعافًا. هذه الوحدة تفرّق بين الحالة التي تستفيد فيها فعلًا من async والحالة التي يجب فيها تجنّبها، ثمّ تُقدّم مهامّ الخلفيّة للأعمال الطويلة (تسجيل ملفّ كامل) التي لا يمكن للعميل انتظارها.
async في جملتَين
FastAPI يعتمد على حلقة أحداث واحدة (asyncio event loop) لكلّ عامل. async def يُتيح تعليق الدالّة عند عمليّة I/O (استعلام قاعدة بيانات، استدعاء HTTP لخدمة أخرى، قراءة قرص) لتُعالج الحلقة طلبًا آخر. بدون async، الدالّة تشغل الخيط حتّى تنتهي.
القاعدة: async يُفيد عندما تكون الدالّة في انتظار I/O بلا حساب، ولا يُفيد (بل يضرّ) عندما تكون في حساب متواصل يستهلك CPU (كتنبّؤ نموذج).
المسار المتزامن العاديّ
FastAPI يُميّز تلقائيًّا بين مسار def عاديّ ومسار async def. المسار العاديّ يُنفَّذ في مجمع خيوط (thread pool) مُنفصل عن حلقة الأحداث، فلا يعطّلها:
@app.post("/predict", response_model=ChurnResponse)
def predire(req: ChurnRequest) -> ChurnResponse:
# يُنفَّذ في خيط منفصل ; الحلقة تظلّ تعالج طلبات أخرى
df = pd.DataFrame([req.model_dump()])
proba = float(app.state.model.predict_proba(df)[0][1])
...
هذا هو الاختيار الافتراضيّ الصحيح لكلّ مسار تنبّؤ. النموذج يستهلك CPU (وربّما GIL)، والخيط المنفصل يمنعه من تجميد الخدمة كاملةً.
الفخّ: async def مع نموذج حاجب
الشيفرة التالية تبدو أنيقة لكنّها تدمّر الخدمة:
@app.post("/predict", response_model=ChurnResponse)
async def predire(req: ChurnRequest) -> ChurnResponse:
# كارثة : يعطّل الحلقة كاملةً
df = pd.DataFrame([req.model_dump()])
proba = float(app.state.model.predict_proba(df)[0][1])
...
المشكلة: predict_proba استدعاء متزامن حاجب يشغّل CPU لعشرات الميلّي ثواني. داخل async def يعمل مباشرة في حلقة الأحداث الوحيدة للعامل. ما دام يعمل، كلّ طلبات العميل الأخرى معلّقة. تحت حمل، الإنتاجيّة تنخفض ×10 مقارنةً بالمسار المتزامن العاديّ الذي يعمل في مجمع خيوط.
القاعدة القطعيّة: لا تُضِف async أمام مسار يستدعي نموذجًا حاجبًا. إمّا أن تُبقيه def عاديًّا، أو تُغلّف الاستدعاء بـasyncio.to_thread:
import asyncio
@app.post("/predict", response_model=ChurnResponse)
async def predire(req: ChurnRequest) -> ChurnResponse:
df = pd.DataFrame([req.model_dump()])
proba = await asyncio.to_thread(
lambda: float(app.state.model.predict_proba(df)[0][1])
)
...
هذا يُعطي نفس فائدة الخيط المنفصل، ويسمح باستعمال async لأشياء أخرى في نفس الدالّة (استعلامات I/O مثلًا).
متى async يفيد فعلًا
مسار يستعلم قاعدة بيانات ثمّ يستدعي خدمة خارجيّة، بلا حساب مركّز:
import httpx
@app.get("/predict/enriched/{cid}")
async def predire_enrichi(cid: str) -> dict:
async with httpx.AsyncClient() as client:
req = await client.get(f"https://crm.example/customer/{cid}", timeout=2.0)
donnees = req.json()
df = pd.DataFrame([donnees])
proba = await asyncio.to_thread(
lambda: float(app.state.model.predict_proba(df)[0][1])
)
return {"customer_id": cid, "churn_probability": proba}
الاستعلام الخارجيّ ينتظر شبكةً لعدّة ميلّي ثوانٍ. async يسمح للعامل بمعالجة طلبات أخرى أثناء الانتظار. تحت حمل، الإنتاجيّة قد تتضاعف أربع مرّات.
مهامّ الخلفيّة: التسجيل الفوريّ لملفّ كامل
سيناريو حقيقيّ: العميل يرفع ملفّ CSV بخمسين ألف مشترك ويطلب تنبّؤًا للكلّ. المعالجة تستغرق دقيقتَين. لا يُعقل أن ينتظر HTTP client دقيقتَين مفتوحًا. الحلّ: مهمّة خلفيّة تُعيد فورًا معرّف تتبّع، والعميل يستطلع الحالة لاحقًا.
FastAPI يوفّر BackgroundTasks للأعمال القصيرة (ثوانٍ)، ونحتاج طابور خارجيّ (Celery، RQ، Dramatiq) للأعمال الطويلة (دقائق).