تغییر از JavaScript به Python مانند نقل مکان به شهری با تابلوهای راهنمایی یکسان اما قوانین راهنمایی و رانندگی متفاوت است. نحو (syntax) آن دوستانه و آشنا به نظر میرسد. شما async و await را دقیقاً در دستور زبان میبینید، بنابراین تصور میکنید که مدل ذهنی شما به راحتی منتقل میشود. اما اینطور نیست. یک الگوی عادتگونه از JavaScript، عملکرد Python شما را بدون ایجاد خطا (crash)، بدون ثبت لاگ خطا و بدون اینکه در یک بررسی کد (code review) سریع مشخص شود، به شدت کاهش میدهد.
چگونه JavaScript به شما یاد میدهد که شروع کنید و رها کنید
در JavaScript، فراخوانی یک تابع async به صورت eager است. به محض اینکه آن را فراخوانی میکنید، موتور یک Promise ایجاد میکند و کار بلافاصله شروع میشود. حلقه رویداد (event loop) از همان لحظه به کار میافتد. به همین دلیل است که توسعهدهندگان JavaScript به طور طبیعی کدی شبیه به این مینویسند:
const userPromise = fetchUser(id);
const ordersPromise = fetchOrders(id);
const user = await userPromise;
const orders = await ordersPromise;
هر دو درخواست شبکه قبل از رسیدن به اولین await در حال اجرا هستند. اولین await تابع فعلی را تا زمانی که fetchUser حل (resolve) شود متوقف میکند، اما fetchOrders از خط قبلی در پسزمینه در حال اجرا بوده است. زمانی که به متغیر orders نیاز پیدا میکنید، ممکن است درخواست دوم از قبل تمام شده باشد. این الگو در JavaScript بسیار طبیعی به نظر میرسد، به طوری که بسیاری از توسعهدهندگان حتی آن را به عنوان یک ترفند همزمانی (concurrency) در نظر نمیگیرند. این فقط روش کار async است.
غافلگیری Python: یک Coroutine سرد
Python از قرارداد متفاوتی استفاده میکند. وقتی یک تابع async def را در Python فراخوانی میکنید، هیچ کاری شروع نمیشود. شما یک شیء coroutine دریافت میکنید. آن را مانند دستور پختی تصور کنید که روی کاغذ نوشته شده است. مواد اولیه لیست شدهاند و مراحل مشخص هستند، اما چیزی در فر نیست. تا زمانی که چیزی به طور صریح آن coroutine را از طریق event loop پیش نبرد، بیاثر باقی میماند.
تله اینجاست. یک مهندس JavaScript که به یک کاربر و سفارشهای او نیاز دارد، ممکن است این کد را در Python بنویسد:
user_coro = fetch_user(id)
orders_coro = fetch_orders(id)
user = await user_coro
orders = await orders_coro
به نظر همزمان میرسد. حس همزمانی میدهد. اما کاملاً ترتیبی (sequential) است.
خط اول یک coroutine غیرفعال را به user_coro اختصاص میدهد. خط دوم یک coroutine غیرفعال دیگر را به orders_coro اختصاص میدهد. وقتی اجرا به await user_coro میرسد، Python بالاخره اولین وظیفه (task) را شروع کرده و آن را تا پایان اجرا میکند. تنها پس از اتمام fetch_user است که مفسر به await orders_coro میرسد و وظیفه دوم را شروع میکند. زمان اجرای کل شما مجموع هر دو عملیات I/O است، نه طولانیترین آنها. شما آنها را به صورت موازی اجرا نکردید؛ بلکه آنها را یکی پس از دیگری با مراحل اضافی اجرا کردید.
چرا این باگ نامرئی است
این از آن دسته افتهای عملکردی (performance regression) است که ماهها دوام میآورند. کد از نظر Python معتبر است. از بررسیکنندههای نوع (type checkers) عبور میکند. نتایج صحیح را برمیگرداند. فقط با نصف سرعت، یا حتی کمتر، اجرا میشود. از آنجایی که هیچ stack trace یا هشداری وجود ندارد، تیمهای مهندسی اغلب ابتدا به دنبال جای دیگری میگردند. آنها کشهای Redis را اضافه میکنند، سطوح پایگاه داده را ارتقا میدهند یا مناطق میزبانی (hosting regions) را تغییر میدهند. مقصر اصلی، عدم تطابق ظریف در انتظارات درباره عملکرد واقعی await است.
سه راه برای اینکه Python واقعاً موارد را به صورت همزمان اجرا کند
برای رفع این مشکل، باید به event loop در Python بگویید که کار را بلافاصله زمانبندی (schedule) کند. شما به چیزی فعالتر از یک coroutine خام نیاز دارید. شما به یک Task نیاز دارید.
1. asyncio.create_task
مستقیمترین معادل الگوی JavaScript، قرار دادن coroutine شما در یک Task است. یک Task به محض اینکه آن را ایجاد میکنید، در event loop زمانبندی میشود. این نزدیکترین معادل Python به یک Promise در حال حرکت در JavaScript است.
user_task = asyncio.create_task(fetch_user(id))
orders_task = asyncio.create_task(fetch_orders(id))
user = await user_task
orders = await_orders_task
اکنون هر دو fetch_user و fetch_orders قبل از اولین await در حال اجرا هستند. وقتی به await user_task میرسید، فقط تا زمانی که آن Task خاص تکمیل شود متوقف میشوید، اما Task دیگر به اجرا ادامه میدهد. اگر fetch_orders زودتر تمام شود، نتیجه آن به سادگی داخل orders_task منتظر میماند تا زمانی که شما آن را درخواست کنید.
با این حال، مراقب باشید. اگر یک Task ایجاد کنید و هرگز آن را await نکنید، Python خطایی مبنی بر از بین رفتن یک task معلق (pending task) صادر میکند. شما همچنان باید نتایج خود را جمعآوری کنید.
2. asyncio.gather
اگر چندین coroutine دارید که همگی باید قبل از ادامه کار تمام شوند، asyncio.gather کارهای تکراری (boilerplate) را برای شما انجام میدهد. این متد هر coroutine را در داخل به عنوان یک Task زمانبندی کرده و آنها را با هم await میکند.
user, orders = await asyncio.gather(fetch_user(id), fetch_orders(id))
این روش مختصر و خوانا است. زمانی میدرخشد که عملیاتها مستقل هستند و شما یک خط واحد میخواهید که بیان کند: "همه اینها را اجرا کن، سپس تمام نتایج را به من بده." همچنین ترتیب آرگومانها را در لیست یا تاپل بازگشتی حفظ میکند، حتی اگر وظایف زیربنایی با ترتیب متفاوتی تکمیل شوند.
3. asyncio.TaskGroup
Python 3.11 قابلیت TaskGroup را معرفی کرد که همروندی ساختاریافته (structured concurrency) را به کتابخانه استاندارد میآورد. به جای ایجاد دستی تسکها، از یک مدیریتکننده بافت (context manager) استفاده میکنید که تضمین میکند هر تسکِ ایجادشده بهدرستی به پایان برسد. اگر یک تسک استثنایی (exception) را پرتاب کند، بقیه بهطور خودکار لغو میشوند.
async with asyncio.TaskGroup() as tg:
user_task = tg.create_task(fetch_user(id))
orders_task = tg.create_task(fetch_orders(id))
user = user_task.result()
orders = orders_task.result()
این الگو برای جریانهای کاری پیچیده عالی است. این الگو خطر یتیم شدن (orphaning) یک Task را از بین میبرد و چرخه حیات عملیاتهای مرتبط را زیر یک چتر منطقی واحد گروهبندی میکند. اگر کد شما روی پایتون ۳.۱۱ یا نسخههای جدیدتر اجرا میشود، این اغلب تمیزترین معماری برای همروندی از نوع fan-out است.
مدل ذهنی: await یعنی «همین الان این را اجرا کن»
درس اصلی، زبانی است. در جاوااسکریپت، میتوانید await را به معنای «در این میان» (meanwhile) بخوانید. شما کار را شروع میکنید، کارهای دیگر را انجام میدهید و تنها زمانی که به مقدار (value) نیاز دارید، مکث میکنید. در پایتون، await یعنی «این کوروتین (coroutine) را تا نقطه تعلیق بعدی یا تا پایان اجرا کن». اگر کوروتین هنوز زمانبندی (scheduled) نشده باشد، await همان چیزی است که آن را زمانبندی میکند. به همین دلیل است که نمیتوانید دو کوروتین خام را شروع کنید و بعداً آنها را await کنید. شما در این فاصله، هیچ کاری برای انجام دادن به حلقه رویداد (event loop) ندادهاید.
کوروتینهای پایتون را مانند توابع مولد (generator functions) در نظر بگیرید. فراخوانی مولد باعث پیمایش (iterate) آن نمیشود. شما باید روی آن حلقه بزنید، next() را صدا بزنید یا آن را به یک مصرفکننده (consumer) پاس دهید. برنامهنویسی ناهمگام (Async) نیز به همین صورت عمل میکند. دستور asyncio.create_task همان مصرفکنندهای است که میگوید «همین الان این را در حلقه رویداد قرار بده». دستور await بعدی صرفاً منتظر سیگنال پایان میماند.
یک عادت عملی که کمک میکند: هر زمان که فراخوانی یک تابع async را بدون await به یک متغیر اختصاص میدهید، از خود بپرسید که آیا آن را زمانبندی کردهاید یا خیر. اگر سمت راست عبارت در create_task ، gather یا TaskGroup قرار نگرفته باشد، در حال اجرا نیست. آن فقط مانند دستور پختی است که روی میز رها شده است.
نکته کلیدی
زماناجرای (runtime) ناهمگام پایتون قدرتمند است، اما نیازمند قصد و نیت صریح است. زبان پایتون صرفاً به این دلیل که تابعی را فراخوانی کردهاید، کار پسزمینه را شروع نمیکند. اگر از دنیای جاوااسکریپت میآیید، هر جایی را که در آن یک کوروتین را در یک متغیر ذخیره کرده و بعداً آن را await میکنید، بازبینی کنید. مگر اینکه ابتدا آن را به یک Task ارتقا داده باشید، در غیر این صورت شما کدی ترتیبی (sequential) نوشتهاید که فقط لباس async به تن دارد. کار را با یک Task شروع کنید، سپس منتظر نتایج بمانید. اینگونه است که میتوانید async پایتون را از یک گلوگاه خاموش به یک ابزار همروندی واقعی تبدیل کنید.
