فرض کنید میخواهید ۵۰۰ تصویر را از یک API دانلود کنید و برای سرعت، همه را با asyncio.gather یکجا اجرا میکنید. چند ثانیه بعد، سرور خطای 429 Too Many Requests میدهد یا اتصالهایتان تایماوت میشوند. چرا؟ چون ۵۰۰ اتصال همزمان را هیچ سروری دوست ندارد. اینجا به ابزاری نیاز دارید که تعداد Taskهای همزمان را محدود کند؛ asyncio Semaphore دقیقاً همین کار را میکند.
asyncio Semaphore چگونه کار میکند؟
Semaphore یک شمارندهٔ داخلی دارد که با یک مقدار اولیه ساخته میشود. هر بار که یک Task آن را acquire میکند، شمارنده یک واحد کم میشود، و با release یک واحد زیاد میشود. اگر شمارنده به صفر برسد، هر Task جدیدی که بخواهد آن را تصاحب کند معلق میماند تا یکی از Taskهای قبلی آن را آزاد کند. در واقع Semaphore میگوید: «حداکثر N نفر همزمان داخل اتاق، بقیه دم در صبر کنید.»
راه درست: async with
راحتترین و امنترین روش، استفاده از Semaphore بهصورت یک Context Manager با async with است. در این حالت، ابتدای بلوک acquire و پایان آن release بهصورت خودکار انجام میشود، حتی اگر داخل بلوک خطایی رخ دهد. بیایید همان مثال دانلود را با سقف دو درخواست همزمان پیاده کنیم:
import asyncio
async def fetch(semaphore, name, delay):
async with semaphore: # acquire on enter, release on exit
print(f'{name}: شروع')
await asyncio.sleep(delay) # pretend this is a network call
print(f'{name}: پایان')
async def main():
semaphore = asyncio.Semaphore(2) # at most 2 downloads at once
tasks = [
asyncio.create_task(fetch(semaphore, f'دانلود {i}', 2))
for i in range(5)
]
await asyncio.gather(*tasks)
asyncio.run(main())
با اینکه ۵ Task ساخته شده، چون مقدار Semaphore برابر ۲ است، در هر لحظه حداکثر ۲ دانلود همزمان اجرا میشوند و بقیه صبر میکنند تا یکی از آن دو، semaphore را آزاد کند. همین الگو دقیقاً راهحل مشکل rate-limiting در APIها است.
روش دستی و دام رایج آن
میتوان acquire و release را دستی هم صدا زد، اما اینجا یک دام جدی وجود دارد: اگر داخل بلوک خطایی رخ دهد و شما release را فراموش کنید، آن واحد از شمارنده برای همیشه قفل میشود و کمکم کل برنامه گیر میکند. به همین دلیل، اگر مجبور به روش دستی شدید، حتماً از try/finally استفاده کنید:
await semaphore.acquire()
try:
... # your work here
finally:
semaphore.release() # ALWAYS runs, even on error
توجه کنید که نسخهٔ async with دقیقاً همین الگوی try/finally را در دل خود دارد؛ پس بهجز موارد خاص، همیشه نسخهٔ Context Manager را ترجیح دهید.
Semaphore یا BoundedSemaphore؟
کلاس asyncio.BoundedSemaphore دقیقاً مثل Semaphore عمل میکند، با یک تفاوت: اگر تعداد release بیشتر از acquire شود، خطای ValueError میدهد. این دقیقاً همان باگی است که در کدهای بزرگ به سختی پیدا میشود: یک release اضافه که سقف همزمانی را بیسروصدا بالا میبرد. بهتر است در کد حساس، BoundedSemaphore را ترجیح دهید تا چنین باگی زود خودش را نشان دهد.
جمعبندی
- Semaphore تعداد Taskهای همزمان فعال را محدود میکند؛ ایدهآل برای rate-limiting درخواستها به API یا پایگاهداده.
- همیشه نسخهٔ
async withرا ترجیح دهید تاreleaseفراموش نشود. - در روش دستی، فراموش کردن
releaseدر مسیر خطا برنامه را قفل میکند؛try/finallyاجباری است. - برای گرفتن باگهای
releaseاضافی، ازBoundedSemaphoreاستفاده کنید.