تصور کنید کاربر وسط یک تراکنش اتصالش را قطع میکند یا مهلت درخواست تمام میشود و شما Task را لغو میکنید. اما درست در همین لحظه، کد در حال نوشتن نهایی در پایگاهداده بود. اگر این نوشتن نیمهکاره قطع شود، دادهٔ خراب میماند. سؤال این است: چطور یک Task بیرونی را لغو کنیم ولی یک عملیات حساس درونی از این لغو در امان بماند؟ پاسخ، تابع asyncio.shield() است.
مشکل: لغوی که عملیات حساس را قطع میکند
به این نسخهٔ ساده نگاه کنید. یک Task پردازش داریم که درونش یک نوشتن حساس را await میکند. وقتی Task بیرونی را لغو کنیم، لغو تا عمق نفوذ میکند:
import asyncio
async def write_to_db():
print('db: writing...')
await asyncio.sleep(3) # the critical write in progress
print('db: commit done') # never printed if cancelled midway
async def worker():
await write_to_db() # no protection: cancel reaches the write
async def main():
task = asyncio.create_task(worker())
await asyncio.sleep(1)
task.cancel() # user disconnected / timeout
try:
await task
except asyncio.CancelledError:
print('main: worker cancelled')
await asyncio.sleep(3)
asyncio.run(main())
چرا این خراب است؟ خروجی خط db: commit done را هرگز نمیبینید؛ لغو دقیقاً وسط await asyncio.sleep(3) نوشتن را قطع کرد و نوشتن نیمهکاره ماند. ما میخواهیم worker لغو شود، اما نوشتن دیتابیس به پایان برسد.
راهحل با asyncio.shield()
عملیات حساس را در یک Task جداگانه بگذارید و آن را داخل asyncio.shield() بپوشانید. اگر کدی که منتظر shield است لغو شود، خطای CancelledError فقط به نقطهٔ await میرسد، اما خود Task داخلی در پسزمینه زنده میماند:
import asyncio
async def write_to_db():
print('db: writing...')
await asyncio.sleep(3)
print('db: commit done') # now protected, still runs to the end
async def worker():
db_task = asyncio.create_task(write_to_db())
try:
await asyncio.shield(db_task) # cancel hits here, NOT db_task itself
except asyncio.CancelledError:
print('worker: cancelled, but db_task keeps running')
raise
async def main():
task = asyncio.create_task(worker())
await asyncio.sleep(1)
task.cancel()
try:
await task
except asyncio.CancelledError:
print('main: worker cancelled')
await asyncio.sleep(3) # give db_task time to finish
asyncio.run(main())
حالا worker لغو میشود، اما db_task که shield شده بود به کار ادامه میدهد و در نهایت db: commit done را چاپ میکند. دقیقاً همان چیزی که میخواستیم. توجه کنید که shield از انتشار لغو از بیرون به Task داخلی جلوگیری میکند، نه از لغو شدنِ خود نقطهٔ await.
یک مثال واقعی: خاموشی ایمن هنگام پرداخت
فرض کنید سرویسی دارید که هنگام خاموشی همهٔ درخواستهای درحالاجرا را لغو میکند. اما یک تراکنش پرداخت که از درگاه پاسخ گرفته، نباید نیمهکاره رها شود:
import asyncio
async def finalize_payment(order):
await asyncio.sleep(2) # confirm with the payment gateway
print(f'order {order}: payment finalized')
async def handle_request(order):
pay = asyncio.create_task(finalize_payment(order))
await asyncio.shield(pay) # survives a shutdown-triggered cancel
async def main():
req = asyncio.create_task(handle_request(42))
await asyncio.sleep(0.5)
req.cancel() # graceful shutdown signal
try:
await req
except asyncio.CancelledError:
print('request cancelled during shutdown')
await asyncio.sleep(2) # let the shielded payment complete
asyncio.run(main())
پیتفال: shield نتیجه را نگه نمیدارد
مهمترین دامی که باید بشناسید این است: shield کردن یک Task به این معنا نیست که نتیجهاش حتماً مصرف میشود. اگر پس از دریافت CancelledError دیگر منتظر db_task نمانید، نتیجهٔ نهایی نادیده گرفته میشود. بدتر اینکه اگر حلقهٔ رویداد پیش از اتمام آن بسته شود، خودِ Task محافظتشده هم لغو میشود. دلیلش این است که shield فقط انتشار لغو را متوقف میکند، نه اینکه Task را تا ابد زنده نگه دارد. پس بهتر است مرجعی به db_task نگه دارید و در جایی امن منتظرش بمانید.
کی از shield استفاده کنیم؟
از asyncio.shield() زمانی استفاده کنید که یک عملیات باید حتی در صورت لغو شدن Task بیرونی کامل شود: نوشتن در فایل یا دیتابیس، تراکنش پرداخت، یا آزاد کردن منابع. اما آن را بیدریغ دور همهچیز نپیچید؛ اگر فقط میخواهید یک تایماوت بگذارید، asyncio.wait_for مناسبتر است، و برای پاکسازی هنگام لغو، بلوک finally یا مدیر زمینه راه سادهتری است.
جمعبندی
asyncio.shield()یک Task داخلی را از انتشار لغوی بیرونی در امان نگه میدارد؛ لغو فقط به نقطهٔ await میرسد.- در کدی که shield را await میکند، هنوز باید
CancelledErrorرا مدیریت کنید. - shield نتیجه را نگه نمیدارد؛ مرجع Task را نگه دارید و در جایی امن منتظرش بمانید، وگرنه با بسته شدن حلقه لغو میشود.
- برای تایماوت سراغ
wait_forبروید؛ shield مخصوص محافظت از عملیات حساس در برابر لغو است.