این دو تکه کد را مقایسه کنید. هر دو دو کوروتین را اجرا میکنند که هر کدام ۳ ثانیه میخوابند، اما یکی ۶ ثانیه طول میکشد و دیگری حدود ۳ ثانیه. تفاوت در یک جمله است: اینکه کجا await میکنید. همین تفاوت ریز ما را به قلب موضوع این درس میبرد: شیء Task و متد create_task، که کلید واقعی همزمانی در asyncio هستند.
اشتباه رایج: await پشت سر هم
خیلیها تصور میکنند هر await یعنی «همزمانی»، اما اینطور نیست. وقتی یک کوروتین را مستقیم await کنید، دقیقاً همانجا میایستید تا تمام شود، بعد سراغ خط بعدی میروید. پس کد زیر کاملاً ترتیبی است:
import asyncio
async def fetch_data(name, delay):
print(f'{name}: started')
await asyncio.sleep(delay)
print(f'{name}: finished')
async def main():
# each await blocks here until the coroutine finishes -> sequential
await fetch_data('Task 1', 3)
await fetch_data('Task 2', 3)
asyncio.run(main()) # takes about 6 seconds
این کد حدود ۶ ثانیه طول میکشد، که هیچ بهتر از کد سینکرون نیست. در درس run_in_executor هم دیدیم که فقط داشتن async کافی نیست؛ مهم این است که حلقهٔ رویداد فرصت سوئیچ داشته باشد. برای همین به Task نیاز داریم.
Task چیست؟ نقش create_task
وقتی یک کوروتین را داخل asyncio.create_task() میپیچید، آن کوروتین بلافاصله روی حلقهٔ رویداد زمانبندی میشود تا اجرا شود، بدون اینکه لازم باشد همان لحظه await کنید. خروجی آن یک شیء Task است که میتوانید بعداً منتظرش بمانید یا وضعیتش را بررسی کنید. نکتهٔ کلیدی همین جداشدن «شروع کردن» از «منتظر ماندن» است.
async def main():
# both tasks are scheduled and start running right away
task1 = asyncio.create_task(fetch_data('Task 1', 3))
task2 = asyncio.create_task(fetch_data('Task 2', 3))
# now we just wait for the already-running tasks
await task1
await task2
asyncio.run(main()) # takes about 3 seconds, not 6
این بار هر دو Task قبل از رسیدن به خط await شروع شدهاند، پس همزمان میخوابند و کل برنامه تقریباً به اندازهٔ طولانیترین Task طول میکشد، نه مجموع آنها. این دقیقاً همان مزیتی است که Task به ما میدهد.
مثال واقعی: گرفتن چند API همزمان
تصور کنید یک داشبورد دارید که باید قیمت ارز، آبوهوا و آخرین اخبار را از سه سرویس مختلف بگیرد. اگر پشت سر هم await کنید، کاربر باید مجموع زمان هر سه درخواست را صبر کند. با create_task هر سه را همزمان میفرستیم. در عمل برای این الگو معمولاً asyncio.gather راحتتر است، که درون خود همین Taskها را میسازد:
async def main():
# gather schedules all three concurrently and collects their results
prices, weather, news = await asyncio.gather(
fetch_data('prices', 2),
fetch_data('weather', 1),
fetch_data('news', 3),
)
متدهای مهم شیء Task
done(): مشخص میکند Task به پایان رسیده یا نه.cancel(): درخواست لغو Task را میفرستد.result(): مقدار بازگشتی Task را برمیگرداند (اگر تمام شده باشد).exception(): اگر Task با خطا متوقف شده باشد، آن exception را میدهد.
لغو کردن یک Task
گاهی لازم است یک Task را قبل از پایان طبیعیاش متوقف کنیم؛ مثلاً وقتی کاربر صفحه را میبندد یا یک timeout میگذاریم. متد cancel() در اولین نقطهٔ await داخل کوروتین، خطای asyncio.CancelledError را پرتاب میکند.
async def long_task():
print('long_task: started')
await asyncio.sleep(10)
print('long_task: finished') # never reached
async def main():
task = asyncio.create_task(long_task())
await asyncio.sleep(1)
task.cancel() # ask the task to stop
try:
await task # CancelledError surfaces here
except asyncio.CancelledError:
print('long_task: cancelled')
print(f'task.cancelled(): {task.cancelled()}')
asyncio.run(main())
یک تلهٔ رایج اینجاست: اگر کوروتین CancelledError را بگیرد و دوباره پرتابش نکند (یعنی جلوی لغو را بگیرد)، عملاً درخواست لغو را خنثی کردهاید؛ چون این خطا همان سازوکاری است که لغو را به کوروتین اطلاع میدهد. اگر برای پاکسازی منابع آن را میگیرید، حتماً در انتها دوباره raise کنید.
جمعبندی
- await پشت سر هم روی کوروتینها، اجرا را ترتیبی میکند و هیچ همزمانی به شما نمیدهد.
create_taskکوروتین را فوراً روی حلقهٔ رویداد زمانبندی میکند؛ اول همه را بسازید، بعد await کنید.- با
done()،result()وcancel()وضعیت Task را مدیریت کنید؛ وCancelledErrorرا بیدلیل خنثی نکنید. - برای اجرای گروهی و جمعآوری نتیجهها،
asyncio.gatherراحتتر از مدیریت دستی چند Task است.