چند کروتین دارید که همه آمادهٔ کارند، اما هیچکدام نباید یک لحظه زودتر از «چراغ سبز» شروع کند؛ مثلاً تا وقتی تنظیمات اولیه بار نشده یا اتصال به دیتابیس برقرار نشده، هیچ کارگری نباید درخواستی را پردازش کند. اولین وسوسه این است که یک پرچم ساده بگذارید و مدام چکش کنید. بیایید ببینیم چرا این کار اشتباه است و چطور کلاس Event در asyncio دقیقاً همین مسئله را تمیز حل میکند.
مشکل: انتظار با حلقهٔ چک کردن
به این نسخهٔ ساده و ناقص نگاه کنید. کارگر تا آماده شدن سیستم منتظر میماند، اما با چک کردن مکرر یک متغیر:
import asyncio
ready = False # a plain flag shared between coroutines
async def worker(name):
while not ready: # busy-wait: wakes up again and again for nothing
await asyncio.sleep(0.01)
print(f'{name}: started')
async def setup():
await asyncio.sleep(2) # loading config / warming up
global ready
ready = True
async def main():
await asyncio.gather(worker('A'), worker('B'), setup())
asyncio.run(main())
چرا این بد است؟ کارگر هر ۱۰ میلیثانیه بیدلیل بیدار میشود، حلقه را میچرخاند و دوباره میخوابد. این هم CPU را هدر میدهد و هم واکنش را کند میکند؛ اگر بازهٔ خواب را کم کنید حریصتر میشود و اگر زیاد کنید دیرتر پاسخ میدهد. آنچه واقعاً میخواهیم این است: کارگر بخوابد و فقط در لحظهٔ رسیدن سیگنال بیدار شود، نه یک لحظه زودتر و نه با هدر دادن چرخه.
راهحل با کلاس Event در asyncio
یک asyncio.Event() بسازید. این شیء در ابتدا «تنظیمنشده» است و is_set() مقدار False برمیگرداند. هر کروتینی که await event.wait() را صدا بزند همانجا معلق میماند تا کسی set() را فراخوانی کند؛ آنگاه همهٔ منتظرها با هم آزاد میشوند:
import asyncio
async def worker(event, name):
print(f'{name}: waiting for green light...')
await event.wait() # suspends here with zero CPU until the event is set
print(f'{name}: started')
async def setup(event):
await asyncio.sleep(2) # loading config / warming up
print('setup: ready, sending signal')
event.set() # wakes every coroutine parked on wait() at once
async def main():
event = asyncio.Event()
await asyncio.gather(
worker(event, 'A'),
worker(event, 'B'),
setup(event),
)
asyncio.run(main())
حالا کارگرها تا لحظهٔ set() هیچ چرخهای مصرف نمیکنند و دقیقاً همزمان بیدار میشوند. توجه کنید که Event داده منتقل نمیکند؛ فقط یک سیگنالِ «رخ داد / رخ نداد» است. اگر نیاز به عبور دادن مقدار دارید، asyncio.Queue ابزار درست است، نه Event.
یک مثال واقعی: دروازهٔ آمادهبهکار سرویس
فرض کنید یک سرویس وب دارید که چند هندلر همزمان بالا میآیند، اما هیچکدام نباید تا گرم شدن کش پاسخ بدهند. یک Event نقش «دروازه» را بازی میکند:
import asyncio
async def handler(cache_ready, req):
await cache_ready.wait() # every request parks here until cache is warm
print(f'request {req}: served')
async def warm_cache(cache_ready):
await asyncio.sleep(1.5) # fill the cache from a slow source
cache_ready.set()
async def main():
cache_ready = asyncio.Event()
await asyncio.gather(
warm_cache(cache_ready),
*[handler(cache_ready, i) for i in range(5)],
)
asyncio.run(main())
نکتهٔ ظریف: اگر یک درخواست بعد از set() برسد، wait() فوراً برمیگردد چون Event هنوز در حالت تنظیمشده است. یعنی نیازی نیست نگران «از دست رفتن سیگنال» برای تازهواردها باشید.
پیتفال: چرخهٔ set() و بلافاصله clear()
مهمترین دامی که باید بشناسید این است: اگر برای «یکبار مصرف» کردن سیگنال، بلافاصله بعد از set() متد clear() را صدا بزنید، ممکن است کروتینهایی که هنوز نوبت اجرایشان نرسیده اصلاً سیگنال را نبینند. دلیلش این است که wait() تنها زمانی از خواب بیدار میشود که حلقهٔ رویداد کنترل را به آن بازگرداند؛ اگر پیش از آن Event را پاک کنید، آنها یک Event تنظیمنشده میبینند و برای همیشه معطل میمانند. بهیاد داشته باشید: Event برای الگوی «یکبار روشن، همیشه روشن» عالی است؛ برای سیگنالهای تکرارشونده و شرطدار بهتر است سراغ Condition بروید، درست مثل نسخهٔ threading آن.
جمعبندی
- کلاس Event در asyncio یک سیگنال ساده بین کروتینهاست؛ بهجای حلقهٔ چک کردن، کروتین را بدون مصرف CPU معلق نگه میدارد.
set()همهٔ منتظرهایwait()را همزمان آزاد میکند؛ اگر Event از قبل تنظیم شده باشد،wait()فوراً برمیگردد.- Event داده منتقل نمیکند؛ برای انتقال مقدار از
Queueو برای شرطهای پیچیده ازConditionاستفاده کنید. - از
set()و بلافاصلهclear()پرهیز کنید؛ خطر از دست رفتن سیگنال برای کروتینهایی که هنوز اجرا نشدهاند وجود دارد.