بررسی مشکل Dead Lock در پایتون

Please login to bookmark Close

مقدمه

اگر دیدید یک برنامه‌ی چندتردی اجرا نمی‌شود و انگار «هنگ» کرده و مرده است، به احتمال زیاد دچار مشکل 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 استفاده کنید.
Please login to bookmark Close
پیشرفت شما در «دوره آموزش کانکارنسی در پایتون» (38%)
نظرات

دیدگاهتان را بنویسید

38%
پیشرفت

سرفصل دوره

فهرست مطالب

سرفصل دوره

تمرین

این قسمت تمرین ندارد!

پاسخ تمرین ها

هنوز برای تمرین‌های این قسمت پاسخی ثبت نشده است!

اشتراک گذاری

چرا بهتره از فیلترشکن استفاده کنید؟

من همه ویدئو ها و پادکست های کُدباز رو توی یوتیوب و ساندکلود و پلتفرم هایی آپلود می‌کنم که اغلب فیلتر هستند.

اغلب آموزش‌ها ویدئو و پادکست دارند. پس اگر می‌خواهید از محتوای سایت بیشترین استفاده رو ببرید نیاز به فیلتر شکن دارید.

توجه داشته باشید که برای خرید از فروشگاه بهتره فیلتر شکن رو خاموش کنید.

تنظیمات

انتخاب زبان
تغییر تم