
مقدمه
اگر دیدید یک برنامهی چندتردی اجرا نمیشود و انگار «هنگ» کرده و مرده است، به احتمال زیاد دچار مشکل Deadlock (بنبست) شده است. در این درس میبینیم بنبست چیست، از چه الگوهایی بهوجود میآید و چطور جلوی هرکدام را بگیریم.
مثال: سرویس بهداشتی
فرض کنید وارد یک سرویس بهداشتی عمومی میشوید و میبینید همهی درها بستهاند، پس منتظر میمانید تا کسی خارج شود. کمی بعد فرد دیگری هم میآید و چون شما را در حال انتظار میبیند، پشت سر شما میایستد. بعد از مدتی نگهبان برای نظافت درها را باز میکند و تازه میفهمید همهی سرویسها خالی بودهاند و بیهوده منتظر ماندهاید!
افرادی که منتظر خالیشدن سرویسِ در واقع خالی هستند، مثل تردهاییاند که منتظر آزادشدن منبعی میمانند که آزاد است. با این تفاوت مهم که در برنامهنویسی، نگهبانی نمیآید که در را باز کند؛ تردها تا پایان اجرای برنامه منتظر میمانند و برنامه برای همیشه معلق میماند.
الگوهایی که به Deadlock منجر میشوند
در برنامهنویسی چندتردی چند الگوی رایج به بنبست منجر میشوند. مهمترینها را با راهحلشان بررسی میکنیم.
سناریو اول: قفلکردن دوبارهی یک قفل (Self-Deadlock)
کد زیر مشکل بنبست دارد: تابع func1 قفل را میگیرد و بعد func2 را صدا میزند که میخواهد همان قفل را دوباره بگیرد. چون قفل هنوز آزاد نشده، acquire منتظر آزادشدن قفلی میماند که هرگز آزاد نمیشود:
import threading
lock = threading.Lock()
def func1():
lock.acquire()
print("Function 1 acquired the lock")
func2() # called while the lock is still held
lock.release()
def func2():
lock.acquire() # waits forever for a lock that never frees
print("Function 2 acquired the lock")
lock.release()
thread1 = threading.Thread(target=func1)
thread1.start()
thread1.join()
print("Finished")راهحل اول: اگر این دو تابع واقعاً منابع متفاوتی را محافظت میکنند، از دو قفل مجزا استفاده کنید:
import threading
lock1 = threading.Lock()
lock2 = threading.Lock()
def func1():
lock1.acquire()
print("Function 1 acquired lock 1")
func2()
lock1.release()
def func2():
lock2.acquire()
print("Function 2 acquired lock 2")
lock2.release()
thread1 = threading.Thread(target=func1)
thread1.start()
thread1.join()
print("Finished")راهحل دوم: اگر واقعاً باید یک منبع مشترک باشد و همان ترد قرار است چند بار قفل بگیرد، از RLock (قفل بازگشتی) بهجای Lock استفاده کنید؛ RLock به همان ترد اجازه میدهد قفل خودش را دوباره بگیرد:
import threading
lock = threading.RLock() # reentrant lock
def func1():
with lock:
print("Function 1 acquired the lock")
func2()
def func2():
with lock: # same thread can re-acquire an RLock
print("Function 2 acquired the lock")
threading.Thread(target=func1).start()سناریو دوم: ترتیب اشتباه قفلها (Lock Ordering)
یک تشبیه ساده: در یک روز تعطیل، زوجی تصمیم میگیرند شیشهها را تمیز کنند. دستمال در یک اتاق و شیشهشوی در اتاق دیگر است. هر دو بیآنکه به هم بگویند، همزمان دستبهکار میشوند: آقا دستمال را برمیدارد و خانم شیشهشوی را. حالا آقا برای شیشهشوی به اتاق دیگر میرود و پیدایش نمیکند (دست خانم است)، و خانم برای دستمال میرود و آن را نمییابد (دست آقاست). هیچکدام نمیتوانند کار را تمام کنند. اینجا خانم و آقا مثل تردهاییاند که هرکدام منبعی را در دست دارند که دیگری برای ادامه به آن نیاز دارد.
import threading
lock1 = threading.Lock()
lock2 = threading.Lock()
def thread1():
lock1.acquire()
print("Thread 1 acquired lock 1")
threading.Event().wait(1) # simulate some work
lock2.acquire() # waits for lock2, held by thread2
print("Thread 1 acquired lock 2")
lock2.release()
lock1.release()
def thread2():
lock2.acquire()
print("Thread 2 acquired lock 2")
threading.Event().wait(1)
lock1.acquire() # waits for lock1, held by thread1
print("Thread 2 acquired lock 1")
lock1.release()
lock2.release()
t1 = threading.Thread(target=thread1)
t2 = threading.Thread(target=thread2)
t1.start(); t2.start()
t1.join(); t2.join()
print("Finished")ترد اول lock1 را دارد و منتظر lock2 است؛ ترد دوم lock2 را دارد و منتظر lock1. هیچکدام کوتاه نمیآید و بنبست کامل میشود. راهحل، گرفتن قفلها با یک ترتیب ثابت و یکسان در همهی تردهاست. یعنی بهجای این:
# BAD: opposite orders -> deadlock
def thread1():
lock1.acquire()
lock2.acquire()
def thread2():
lock2.acquire()
lock1.acquire()هر دو ترد قفلها را به یک ترتیب بگیرند:
# GOOD: same order everywhere -> no deadlock
def thread1():
lock1.acquire()
lock2.acquire()
def thread2():
lock1.acquire()
lock2.acquire()مثال واقعی: انتقال همزمان بین دو حساب بانکی
رایجترین بروز واقعیِ «ترتیب اشتباه قفلها» در انتقال پول است. فرض کنید هر حساب قفل خودش را دارد و برای انتقال باید قفل مبدأ و مقصد گرفته شود. حالا اگر همزمان یک انتقال از A به B و انتقال دیگری از B به A انجام شود، تردِ اول قفل A را میگیرد و منتظر B میماند، تردِ دوم قفل B را میگیرد و منتظر A — بنبست!
import threading
class Account:
def __init__(self, account_id, balance):
self.id = account_id
self.balance = balance
self.lock = threading.Lock()
def transfer(src, dst, amount):
# Always lock accounts in a consistent global order (by id) to avoid deadlock
first, second = (src, dst) if src.id < dst.id else (dst, src)
with first.lock:
with second.lock:
src.balance -= amount
dst.balance += amount
a = Account(1, 100)
b = Account(2, 100)
t1 = threading.Thread(target=transfer, args=(a, b, 30))
t2 = threading.Thread(target=transfer, args=(b, a, 50))
t1.start(); t2.start()
t1.join(); t2.join()
print(a.balance, b.balance) # 120 80 - no deadlockترفند کلیدی این است که قفلها را همیشه به ترتیبِ یک شناسهی ثابت (اینجا id حساب) میگیریم؛ در نتیجه هر دو ترد اول قفل حسابِ با شناسهی کوچکتر را میگیرند و دیگر آن حلقهی انتظار متقابل شکل نمیگیرد.
سناریو سوم: مدیریت اشتباهِ خطا
گاهی قفل بهخاطر یک خطای مدیریتنشده آزاد نمیشود. در کد زیر برنامهنویس ZeroDivisionError را مدیریت کرده اما TypeError را فراموش کرده است؛ با ورودی نامعتبر، خطای مدیریتنشده رخ میدهد و قفل هرگز آزاد نمیشود:
import threading
lock = threading.Lock()
def divide(a, b):
lock.acquire()
try:
result = a / b
print(f"result is {result}")
except ZeroDivisionError:
print("Error: can not divide by zero")
lock.release()
# TypeError is not handled -> lock is never released!
t1 = threading.Thread(target=divide, args=(10, 'ali'))
t1.start(); t1.join()
t2 = threading.Thread(target=divide, args=(10, 1))
t2.start(); t2.join()
print("End of program!")راهحل اول: همهی خطاهای ممکن را مدیریت کنیم و در هر شاخه release بگذاریم:
def divide(a, b):
lock.acquire()
try:
result = a / b
print(f"result is {result}")
except ZeroDivisionError:
print("Error: can not divide by zero")
lock.release()
except TypeError:
print("Error: invalid type")
lock.release()راهحل حرفهایتر: استفاده از finally که تضمین میکند قفل در هر حالتی (چه خطا رخ دهد چه ندهد) آزاد شود:
def divide(a, b):
lock.acquire()
try:
result = a / b
print(f"result is {result}")
except ZeroDivisionError:
print("Error: can not divide by zero")
except TypeError:
print("Error: invalid type")
finally:
lock.release() # always runs, even on unhandled pathsو سادهترین و امنترین شکل همین ایده، استفاده از with lock: است که خودش نقش finally را بازی میکند و همیشه قفل را آزاد میکند:
def divide(a, b):
with lock: # auto-release on exit, even if an error is raised
try:
print(f"result is {a / b}")
except (ZeroDivisionError, TypeError) as e:
print("Error:", e)خلاصه
- Deadlock وقتی رخ میدهد که تردها برای همیشه منتظر قفلی بمانند که هرگز آزاد نمیشود؛ نشانهاش برنامهای است که «هنگ» میکند.
- Self-Deadlock: یک ترد قفلی را که خودش گرفته دوباره میگیرد. راهحل: قفلهای مجزا یا
RLock. - ترتیب اشتباه قفلها: راهحل، گرفتن همهی قفلها با یک ترتیب ثابت و یکسان (مثلاً بر اساس یک شناسه).
- مدیریت اشتباه خطا: همیشه قفل را در
finallyآزاد کنید یا بهتر از آن، ازwith lockاستفاده کنید.