المولدات والاشتمالات غير المتزامنة
درس 3 من 5
3المولدات والاشتمالات غير المتزامنة (Async Comprehension)
الصورة الكبيرةحين تصل البيانات قطعة قطعة مع انتظار بين كل قطعتين: تكتب مولّدًا يجمع yield وawait في جسم واحد، وتستهلكه بـasync for ثم تختصره في سطر واحد باشتمال غير متزامن، وتعلّق عليه النوع الصحيح، وتفهم أخيرًا لماذا أربعة اشتمالات متوازية تكلّف زمن واحد.
ماذا تشاهد في الرسم: المولّد ينتج 1 ثم ينتظر، ثم 2 ثم 3، وحلقة async for تستلم كل قيمة حين تجهز دون أن توقف بقية البرنامج.
خارطة هذا الجزء
المفاهيم بالترتيب، اضغط أيًّا منها للقفز إليه:
- تذكير سريع: المولّد العادي
- المولّد غير المتزامن: yield وawait في جسم واحد
- الأشكال الأربعة المتقاربة (الجدول الذي يحسم الالتباس)
- async for: الحلقة التي تنتظر بين الدورات
- الاشتمال غير المتزامن
- await داخل الاشتمال: حالة مختلفة
- التعليقات النوعية للمولّدات
- إغلاق المولّد والتنظيف
- لماذا أربعة اشتمالات متوازية ≈ زمن واحد؟
طريقان إلى نفس الفكرة
المولّد مثل حنفية: لا يعطيك خزان الماء كله دفعة واحدة، بل كوبًا كلما طلبت. والمولّد غير المتزامن حنفية في بيت بعيد: تطلب كوبًا فتنتظر قليلًا حتى يصل، وفي أثناء انتظارك تستطيع أن تفعل شيئًا آخر.
المولّد العادي يستخدم yield ليعيد قيمة ويحفظ حالته. المولّد غير المتزامن async def فيه yield وawait معًا، ويُستهلك بـasync for. والاشتمال غير المتزامن [x async for x in gen()] يختصر الحلقة، ويُعلّق عليه بـAsyncIterator[int]، وaclose() يغلقه وينظّف موارده.
الأساسيات المطلوبة
درس البرمجة غير المتزامنة: async وawait
هذا الجزء امتداد مباشر له. إن لم تكن حلقة الأحداث وgather واضحة عندك، ارجع لدرس البرمجة غير المتزامنة أولًا.
الاشتمالات (درس هياكل البيانات)
[x for x in items] هو الصيغة التي سنضيف عليها كلمة واحدة فقط: async.
الدوال وreturn (درس الدوال)
الفرق بين دالة تُرجع قائمة كاملة ودالة تُنتج القيم واحدة واحدة هو كل الفرق بين return وyield.
الاستثناءات (درس الاستثناءات)
نهاية المولّد غير المتزامن تصل كاستثناء StopAsyncIteration تلتقطه حلقة async for نيابة عنك.
المفاهيم الأساسية
في الجزء السابق كانت النتيجة تصل دفعة واحدة في نهاية الانتظار. لكن كثيرًا من البيانات الحقيقية لا تأتي هكذا: صفوف تُقرأ من قاعدة بيانات، صفحات نتائج تأتي واحدة بعد واحدة، رسائل تصل على اتصال مفتوح. هنا تحتاج شيئًا يُنتج القيم تباعًا مع انتظار بينها، وهذا بالضبط هو المولّد غير المتزامن.
1تذكير سريع: المولّد العادي
الدالة التي تحتوي yield لا تُرجع قيمة، بل تُرجع مولّدًا: كائنًا يُنتج قيمة عند كل طلب ثم يتجمّد عند ذلك السطر حتى الطلب التالي.
def counter():
for i in range(3):
yield i
g = counter() # لم يُنفَّذ شيء بعد
print(next(g), next(g)) # 0 1 كل استدعاء يُشغّل حتى yield التاليالفائدة الكبرى: الكسل. مولّد يمرّ على ملف بحجم جيجابايت يحتفظ بسطر واحد في الذاكرة، بينما قائمة كاملة تحتفظ بالملف كله.
2المولّد غير المتزامن: yield وawait في جسم واحد
قبل بايثون 3.6 كان الجمع بينهما ممنوعًا. رفع PEP 525 هذا المنع، فصار بإمكانك كتابة:
async def menu_stream():
for i in range(5):
await asyncio.sleep(0.2) # انتظار يعيد الدور للحلقة
yield round(random.uniform(0, 10), 2) # ثم أنتج قيمةالدالة الآن ليست كوروتينًا رغم async def: وجود yield غيّر نوعها بالكامل. استدعاؤها يعطيك كائنًا من نوع async_generator، ولا يصحّ أن تكتب await أمامه، بل async for.
3الأشكال الأربعة المتقاربة (الجدول الذي يحسم الالتباس)
| الشكل | فيه yield؟ | معرّف بـasync؟ | الاستدعاء يعطيك | كيف تستهلكه |
|---|---|---|---|---|
| دالة عادية | لا | لا | القيمة نفسها | تستعملها مباشرة |
| مولّد | نعم | لا | generator | for |
| كوروتين | لا | نعم | coroutine | await |
| مولّد غير متزامن | نعم | نعم | async_generator | async for |
inspect.isasyncgenfunction(f) يعطيك True للمولّد غير المتزامن، بينما inspect.iscoroutinefunction(f) يعطيك False له. جرّبناها في الخطوة الثالثة والنتيجة في الدليل.
4async for: الحلقة التي تنتظر بين الدورات
الحلقة العادية for تطلب العنصر التالي بـ__next__ وتستلمه فورًا. أما async for فتطلبه بـ__anext__ الذي يُرجع شيئًا يُنتظر، فتعيد الحلقة الدور لحلقة الأحداث بين كل عنصرين. ونهاية التكرار تصل كاستثناء StopAsyncIteration تلتقطه الحلقة نيابة عنك.
وasync for، مثل await، ممنوعة خارج دالة async.
5الاشتمال غير المتزامن
أضاف PEP 530 في بايثون 3.6 السماح بـasync for داخل الاشتمالات، فصار هذا:
result = []
async for v in menu_stream():
result.append(v)يُكتب هكذا:
result = [v async for v in menu_stream()]
وتعمل الفكرة في اشتمالات القوائم والمجموعات والقواميس والمولّدات، ومعها شرط اختياري:
big = [v async for v in menu_stream() if v > 5]
as_set = {v async for v in menu_stream()}
as_dict = {i: v async for i, v in numbered_stream()}async تسبق for، لا الاشتمال
الصيغة الصحيحة [v async for v in src]، لا async [v for v in src]. والاشتمال كله يبقى داخل دالة async، وإلا فـSyntaxError.
6await داخل الاشتمال: حالة مختلفة
انتبه للفرق بين وجهين يسهل الخلط بينهما:
| الصيغة | المصدر | ماذا تفعل |
|---|---|---|
[v async for v in agen()] | مولّد غير متزامن | تنتظر بين كل عنصرين أثناء التكرار |
[await f() for f in funcs] | قائمة عادية من الدوال | تنتظر نتيجة كل استدعاء، بالتتابع |
await asyncio.gather(*(f() for f in funcs)) | قائمة عادية | تشغّلها كلها معًا |
الصيغة الوسطى فخ أداء حقيقي: تبدو «غير متزامنة» لكنها متتابعة تمامًا. إن أردت التزامن فالجواب gather لا الاشتمال.
7التعليقات النوعية للمولّدات
| ما تكتبه | التعليق | معنى الوسائط |
|---|---|---|
| مولّد عادي | Generator[Y, S, R] | نوع المُنتَج، نوع المُرسَل بـsend، نوع return النهائي |
| مولّد غير متزامن | AsyncGenerator[Y, S] | وسيطان فقط: لا يوجد نوع إرجاع لأن المولّد غير المتزامن لا يُرجع قيمة |
| كوروتين | Coroutine[Any, Any, R] | نادرًا ما تحتاجه: علّق على نوع القيمة النهائية مباشرة |
from typing import AsyncGenerator
async def count_slowly() -> AsyncGenerator[float, None]:
...typing: from typing import AsyncGenerator، لأن collections.abc.AsyncGenerator لا يقبل الأقواس المربعة قبل 3.9. التوثيق الحديث يوجّه إلى collections.abc لأنه يفترض نسخة أحدث، فاكتب ما يناسب نسختك لا ما يناسب التوثيق.
8إغلاق المولّد والتنظيف
المولّد غير المتزامن قد يكون ممسكًا باتصال أو ملف. إن لم تستهلكه حتى النهاية فأغلقه بنفسك بـawait g.aclose()، وضع تنظيفك في finally داخل المولّد ليعمل في الحالتين. وحلقة async for التي تنتهي طبيعيًا تتولى هذا عنك.
9لماذا أربعة اشتمالات متوازية ≈ زمن واحد؟
هذا أهم سؤال في هذا الجزء، وجوابه في سطرين:
- داخل الاشتمال الواحد: عشر قيم، كل واحدة بعد انتظار ثانية، والانتظارات متتابعة لأن المولّد لا يُنتج القيمة الثانية قبل الأولى. المجموع ≈ 10 ثوانٍ، ولا سبيل لاختصاره.
- بين الاشتمالات الأربعة: لا علاقة بينها، فحين تمرّرها لـ
gatherتتداخل انتظاراتها الأربعة تمامًا. المجموع ≈ 10 ثوانٍ أيضًا، لا 40.
أي أن asyncio يختصر الانتظارات المستقلة ولا يختصر الانتظارات المتسلسلة. وسنقيس هذا بالأرقام في الخطوة السادسة.
كيف يعمل هذا فعليًا، خطوة بخطوة
ثلاث صور: كيف تميّز الشكل الذي كتبته، وماذا يجري داخل async for، ولماذا لا يتضاعف الزمن مع عدد الاشتمالات.
الرسم 40: كيف تعرف ما الذي كتبته. سؤالان فقط يحدّدان النوع وطريقة الاستهلاك، ومعظم أخطاء «object is not iterable» سببها الخلط بين فرعين من هذه الشجرة.
الرسم 41: ما يجري بين كل عنصرين. الفرق عن for العادية أن فترة الانتظار هذه ليست ضائعة: الحلقة تشغّل غيرك خلالها.
الرسم 42: الانتظارات العشرة داخل كل اشتمال متسلسلة فلا تُختصر، لكن الاشتمالات الأربعة مستقلة فتتداخل تمامًا. الزمن الكلي هو زمن واحد منها.
- اكتب المصدر مولّدًا غير متزامن إذا كانت القيم تصل تباعًا مع انتظار بينها.
- استهلكه بـ
async forإن احتجت منطقًا داخل الحلقة، أو باشتمال إن كان الهدف جمع قائمة. - علّق النوع بـ
AsyncGenerator[Y, None]المستورد منtypingعلى 3.8. - لتشغيل عدة اشتمالات مستقلة معًا، مرّرها لـ
gather؛ الاشتمال وحده لا يوازي شيئًا. - قِس بـ
perf_counterوتأكّد أن الرقم يطابق توقّعك، وإلا فافهم لماذا قبل أن تمضي.
مثال محلول: شاشة الطلبات التي تتدفّق
في المقهى شاشة تعرض الطلبات كلما وصلت من الكاشير، لا دفعة واحدة في آخر اليوم. كل كود ومخرَج تحته نُفِّذ فعليًا.
الخطوة 1: المصدر، مولّد غير متزامن
import asyncio, random, time
random.seed(11)
async def menu_stream():
"""يُنتج 5 طلبات، بينها 0.2 ثانية انتظار"""
for i in range(5):
await asyncio.sleep(0.2)
yield round(random.uniform(0, 10), 2)الخطوة 2: استهلاكه بـasync for
async def main():
t0 = time.perf_counter()
seen = []
async for v in menu_stream():
seen.append(v)
print("async for ->", seen, "in", round(time.perf_counter()-t0, 2), "s")
asyncio.run(main())
# async for -> [4.52, 5.6, 9.24, 4.66, 5.08] in 1.0 sخمس قيم × 0.2 ثانية = ثانية واحدة. الانتظارات داخل المولّد متسلسلة بطبيعتها.
الخطوة 3: ما نوع هذا الكائن أصلًا؟
import inspect
async def inspect_it(): # داخل دالة async بسبب aclose
g = menu_stream()
print(type(g).__name__) # async_generator
print(inspect.isasyncgenfunction(menu_stream)) # True
print(inspect.iscoroutinefunction(menu_stream)) # False
await g.aclose() # إغلاق يدوي لأننا لم نستهلكه
asyncio.run(inspect_it())النتيجة الثالثة هي المفاجأة المفيدة: المولّد غير المتزامن ليس كوروتينًا، ولهذا لا ينفع معه await ولا asyncio.run مباشرة.
الخطوة 4: نفس العمل باشتمال من سطر واحد
vals = [v async for v in menu_stream()]
print("async comp ->", vals)
# async comp -> [5.87, 1.85, 5.12, 6.3, 7.93] in 1.0 sنفس الزمن ونفس النتيجة، بثلاثة أسطر أقل. الاشتمال اختصار للحلقة، لا تسريع لها.
الخطوة 5: مع شرط
big = [v async for v in menu_stream() if v > 5]
print("filtered ->", big)
# filtered -> [8.1, 6.93]الشرط يُطبَّق بعد وصول كل قيمة، تمامًا كالاشتمال العادي.
الخطوة 6: أربعة اشتمالات، بالتتابع ثم بالتوازي
async def gen(): # 10 قيم، بينها 0.1 ث -> ثانية لكل تشغيل
for _ in range(10):
await asyncio.sleep(0.1)
yield random.uniform(0, 10)
async def comprehension():
return [v async for v in gen()]
async def four_sequential():
t0 = time.perf_counter()
for _ in range(4):
await comprehension() # ننتظر كل واحد قبل التالي
return round(time.perf_counter() - t0, 2)
async def four_parallel():
t0 = time.perf_counter()
await asyncio.gather(*(comprehension() for _ in range(4)))
return round(time.perf_counter() - t0, 2)
print("4 x sequential:", asyncio.run(four_sequential()), "s") # 4.02 s
print("4 x gather :", asyncio.run(four_parallel()), "s") # 1.0 sgather تساوي زمن واحد. وبمقياس مشروعك: 40 ثانية مقابل 10.
الخطوة 7: فخ «اشتمال يبدو متزامنًا»
jobs = [comprehension for _ in range(4)] # داخل دالة async slow = [await f() for f in jobs] # 4.02 ث، نفس رقم «بالتتابع» أعلاه fast = await asyncio.gather(*(f() for f in jobs)) # 1.0 ث، نفس رقم gather أعلاه
السطران يبدوان متقاربين والفرق أربعة أضعاف، وهو نفس الفرق الذي قسناه في الخطوة السادسة لأن الاشتمال هنا ليس إلا كتابة أخرى للحلقة المتتابعة. كلمة await داخل اشتمال تعني «انتظر هذه ثم انتقل»، وهذا تتابع لا تزامن.
الخطوة 8: قياس الزمن داخل كوروتين
async def time_four_runs():
t0 = time.perf_counter()
await asyncio.gather(*(comprehension() for _ in range(4)))
return time.perf_counter() - t0لاحظ الفرق عن درس البرمجة غير المتزامنة: هنا الدالة كوروتين لأنها تنتظر gather داخلها، بينما كانت هناك دالة عادية لأنها تستدعي asyncio.run. القاعدة: من ينتظر شيئًا يكون كوروتينًا، ومن يفتح الحلقة يكون دالة عادية.
أسطورة وحقيقة
| الأسطورة | الحقيقة |
|---|---|
«أي دالة فيها async def هي كوروتين.» | وجود yield في جسمها يحوّلها إلى مولّد غير متزامن، ونوعها async_generator لا coroutine. أثبتناها بـinspect في الخطوة الثالثة. |
| «الاشتمال غير المتزامن يجلب القيم بالتوازي.» | يجلبها تباعًا تمامًا كالحلقة. هو اختصار صياغة، والتوازي يأتي من gather فقط. |
«[await f() for f in funcs] كود غير متزامن إذًا هو سريع.» | متتابع بالكامل: ينتظر كل استدعاء قبل التالي. التزامن يحتاج gather، والفرق قِسناه: أربعة أضعاف. |
«أستطيع كتابة await أمام المولّد غير المتزامن.» | لا. هو ليس قابلًا للانتظار. تستهلكه بـasync for أو باشتمال غير متزامن. |
«AsyncGenerator يأخذ ثلاثة وسائط مثل Generator.» | وسيطان فقط [YieldType, SendType]، لأن المولّد غير المتزامن لا يُرجع قيمة نهائية. |
| «المولّد غير المتزامن يعمل في أي نسخة بايثون.» | من 3.6 فقط (PEP 525)، والاشتمالات غير المتزامنة من 3.6 أيضًا (PEP 530)، بعد أن جاءت async/await في 3.5 (PEP 492). |
أين يُستخدم هذا عمليًا؟
نتائج مقسّمة لصفحات
واجهة تُرجع 100 نتيجة في كل طلب: مولّد غير متزامن يجلب صفحة ويُنتج عناصرها، فيتعامل كودك مع تدفّق واحد بدل إدارة أرقام الصفحات.
صفوف قاعدة البيانات (درس بايثون وقواعد البيانات)
برامج التشغيل غير المتزامنة تعرض النتائج كتدفّق، فتعالج مليون صف بذاكرة ثابتة بدل تحميلها كلها.
الاتصالات المفتوحة
WebSocket أو Server-Sent Events: الرسائل تصل متى وصلت، وasync for هي الصياغة الطبيعية لاستقبالها.
الردود المتدفّقة من النماذج
نماذج اللغة تُرسل الرد كلمة كلمة؛ تستهلكه بـasync for وتعرضه فور وصوله بدل انتظار اكتماله.
الملفات الضخمة
قراءة غير متزامنة سطرًا سطرًا تُبقي الذاكرة ثابتة وتترك الحلقة حرّة لخدمة طلبات أخرى بين السطور.
الاختبارات
مولّد غير متزامن مزيّف يحاكي مصدرًا بطيئًا بلا شبكة حقيقية، فتختبر منطق الاستهلاك وحده.
جرّب بنفسك
ما الذي يقرأ القيم من دالة async def فيها yield؟
المولّد غير المتزامن يُقرأ بـasync for أو باشتمال غير متزامن. for العادية وawait أمامه كلاهما خطأ.
بطاقات مراجعة سريعة
تحقّق من فهمك
1. كتبت async def f() فيها yield، ثم استدعيتها بـawait f() فجاءك خطأ. لماذا؟
لأن وجود yield جعلها مولّدًا غير متزامن لا كوروتينًا، والمولّد ليس قابلًا للانتظار. استهلكها بـasync for v in f() أو باشتمال [v async for v in f()].
2. ما الفرق في الزمن بين [v async for v in gen()] و[await f() for f in funcs]؟
الأول يمرّ على مصدر واحد تصل قيمه تباعًا، فزمنه مجموع انتظاراته وهي متسلسلة بطبيعتها. والثاني يستدعي دوالَّ مستقلة واحدة بعد الأخرى، فزمنه مجموع أزمنتها رغم أنه كان يمكن تشغيلها معًا بـgather. الأول لا حيلة فيه، والثاني فرصة ضائعة.
3. (اكتشف الخطأ) مولّد غير متزامن يفتح اتصالًا ثم yield للصفوف. الكود يكسر الحلقة بـbreak بعد عشرة صفوف، والاتصالات تتراكم مفتوحة. ما العلاج؟
الخروج بـbreak يترك المولّد متوقفًا عند yield فلا يصل إلى كود الإغلاق. ضع الإغلاق في finally داخل المولّد، واستدعِ await g.aclose() صراحة إن كنت تمسك المولّد بمتغيّر. الاستهلاك حتى النهاية يغلقه تلقائيًا، لكن break ليس نهاية.
4. لماذا AsyncGenerator وسيطان بينما Generator ثلاثة؟
الوسيط الثالث في Generator هو نوع قيمة return النهائية التي تصل في StopIteration. والمولّد غير المتزامن لا يُسمح له بإرجاع قيمة مع return، فلا وجود لهذا الوسيط أصلًا.
5. (قرار) تريد أول عشرة عناصر فقط من تدفّق طويل. اشتمال أم حلقة؟
حلقة مع break بعد العاشر، لأن الاشتمال يستهلك التدفّق حتى نهايته ولا يملك شرط توقّف. وإن أصررت على الاشتمال فقيّد المصدر نفسه. والأهم: أغلق المولّد بعد break كما في السؤال الثالث.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
توثيق رسمي3
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.