Kodokon kodokon.com

البرمجة غير المتزامنة مع asyncio: الكوروتينات، await، gather

افهم التزامن التعاوني في asyncio: الكوروتينات الكسولة، ونقاط التعليق، وgather، وTaskGroup، وفخاخ الاستدعاءات الحاجبة.

10 دقيقة · 3 أسئلة

افتح هذا الدرس في Kodokon

تُطبّق asyncio التزامنًا تعاونيًا على خيط واحد: إذ تنظّم حلقة أحداث الكوروتينات (coroutines) التي تتخلّى طوعًا عن التحكم عند كل await. ونقطة جوهرية كثيرًا ما يُساء فهمها: استدعاء دالة async def لا ينفّذ شيئًا. فهو يبني كائن كوروتين خاملًا لا يتقدّم جسمه إلا عندما يُنتظَر بـ await أو يُجدوَل كمهمة على الحلقة.

PYTHON
import asyncio

async def greet() -> str:
    return "hello"

coro = greet()
print(type(coro).__name__)

async def main() -> None:
    print(await coro)

asyncio.run(main())
استدعاء greet() ينشئ كائن كوروتين؛ ولا يُنفَّذ أيّ شيء قبل await.

تشير await إلى نقطة تعليق: يتجمّد الكوروتين عندها، وتستعيد الحلقة التحكم فتُقدّم مهمة أخرى جاهزة. وبين كل await والتالية، يعمل كودك دون انقطاع - فلا حاجة إلى أقفال لمعظم البنى. ولإطلاق عدة كوروتينات على التوازي (بمعنى الإدخال/الإخراج)، لُفّها في asyncio.gather: فثلاث انتظارات مدة كل منها ثانية تنتهي في ثانية واحدة إجمالًا، لا في ثلاث.

PYTHON
import asyncio
import time

async def fetch(name: str, delay: float) -> str:
    await asyncio.sleep(delay)
    return f"{name} done"

async def main() -> None:
    start = time.perf_counter()
    results = await asyncio.gather(
        fetch("a", 1.0),
        fetch("b", 1.0),
        fetch("c", 1.0),
    )
    elapsed = time.perf_counter() - start
    print(results)
    print(f"total: {elapsed:.1f} s")

asyncio.run(main())
ثلاث انتظارات متزامنة: نحو ثانية واحدة إجمالًا.

افتراضيًا، تنشر gather أول استثناء يُطلَق، لكنها تترك المهام الأخرى تواصل العمل في الخلفية - وهذا نادرًا ما تريده. ومع return_exceptions=True، تُرجَع الاستثناءات كنتائج، لتفرزها بعد ذلك. ومنذ Python 3.11، فضّل asyncio.TaskGroup: فعند أول خطأ، تُلغى كل المهام الشقيقة بشكل نظيف، وتصعد الاستثناءات المتعددة مجمَّعة في ExceptionGroup، لتلتقطها بـ except*.

PYTHON
import asyncio
import time

def blocking_io() -> str:
    time.sleep(1)
    return "report written"

async def main() -> None:
    results = await asyncio.gather(
        asyncio.to_thread(blocking_io),
        asyncio.sleep(1, result="tick"),
    )
    print(results)

asyncio.run(main())
تعزل to_thread الكود الحاجب: فتبقى الحلقة مستجيبة.

اختبار المعرفة

تأكّد من أنك تذكّرت النقاط الأساسية في هذا الدرس.

  1. ماذا يُنتِج الاستدعاء fetch('a', 1.0) مكتوبًا وحده، دون await ولا create_task؟
    • كائن كوروتين خامل: فلا يُنفَّذ سطر واحد من جسم الدالة
    • التنفيذ الفوري والمتزامن للكوروتين
    • مهمة مُجدوَلة تلقائيًا على حلقة الأحداث
    • خطأ SyntaxError، لأن await إلزامية
  2. ثلاثة استدعاءات لـ asyncio.sleep(1) أُطلقت معًا عبر asyncio.gather: ما المدة الإجمالية؟
    • نحو 3 ثوانٍ، ما دام خيط واحد ينفّذ الكود
    • من المستحيل التنبؤ بها دون معرفة عدد الأنوية
    • نحو ثانية واحدة: تتداخل الانتظارات، وتنتقل الحلقة من مهمة إلى أخرى
  3. لماذا يُمنع استخدام time.sleep(2) داخل كوروتين؟
    • لأنها تُطلق استثناءً في سياق غير متزامن
    • لأنها تحجب خيط حلقة الأحداث: فلا تتقدّم أيّ مهمة أخرى طوال ثانيتين
    • لأنها ببساطة أقل دقة من asyncio.sleep
    • لأنها تستهلك ضِعف الذاكرة