ماژول threading – کلاس Lock و RLock

Please login to bookmark Close

پیش‌نیاز

پیش از مطالعه‌ی این بخش، بهتر است با مفهوم «منبع مشترک» و اینکه چرا دسترسی هم‌زمان چند ترد به یک منبع می‌تواند دردسرساز شود آشنا باشید. در ادامه ابتدا این مشکل را با یک مثال ملموس نشان می‌دهیم و بعد سراغ راه‌حل، یعنی قفل‌گذاری، می‌رویم.

مشکل از کجا می‌آید؟ (Race Condition)

وقتی چند ترد به‌صورت هم‌زمان یک متغیر مشترک را می‌خوانند و تغییر می‌دهند، ممکن است در میانه‌ی کارِ یک ترد، ترد دیگری وارد شود و همان متغیر را دستکاری کند. به این وضعیت «شرایط رقابتی» یا Race Condition می‌گویند و نتیجه‌ی آن، داده‌های خراب و باگ‌هایی است که هر بار اجرا رفتار متفاوتی نشان می‌دهند.

ریشه‌ی ماجرا این است که عملیاتی مثل number += 1 در واقع یک عمل واحد و تجزیه‌ناپذیر نیست؛ بلکه از سه گام تشکیل شده است: خواندن مقدار فعلی، افزودن یک واحد، و نوشتن مقدار جدید. اگر بین این سه گام، ترد دیگری وارد شود، تغییرات یکدیگر را خراب می‌کنند. مثال زیر این مشکل را نشان می‌دهد: یک ترد یک میلیون بار یکی اضافه می‌کند و ترد دیگر یک میلیون بار یکی کم می‌کند؛ انتظار داریم نتیجه صفر شود، اما بدون قفل‌گذاری خروجی غیرقابل‌پیش‌بینی است و اغلب صفر نمی‌شود.

from threading import Thread

number = 0

def add():
    global number
    for i in range(1000000):
        number += 1          # not atomic: read, add, write

def subtract():
    global number
    for i in range(1000000):
        number -= 1          # not atomic: read, subtract, write

thread1 = Thread(target=add)
thread2 = Thread(target=subtract)

thread1.start()
thread2.start()

thread1.join()
thread2.join()

print(number)                # expected 0, but often something else

راه‌حل این است که مطمئن شویم در هر لحظه فقط یک ترد اجازه‌ی دستکاری متغیر مشترک را دارد. این کار با «قفل‌گذاری» انجام می‌شود.

اگر می‌خواهید با مفهوم Race Condition، دلایل بروز آن و مثال‌های بیشتر به‌صورت عمیق‌تر آشنا شوید، این موضوع را در درس جداگانه‌ی شرایط رقابتی (Race Condition) در پایتون به‌طور کامل توضیح داده‌ام.

قفل‌گذاری در threading

قفل‌گذاری در threading در سه مرحله انجام می‌شود:

  1. ایجاد قفل: ساختن یک نمونه از کلاس Lock.
  2. بستن قفل: فراخوانی acquire() پیش از دسترسی به منبع مشترک.
  3. بازکردن قفل: فراخوانی release() پس از پایان کار با منبع مشترک.

قیاس ساده: قفل مثل کلید یک دستشویی عمومی است. هر کس کلید را بردارد (acquire) وارد می‌شود و در را قفل می‌کند؛ بقیه باید پشت در منتظر بمانند تا او بیرون بیاید و کلید را برگرداند (release). به این ترتیب هیچ‌گاه دو نفر هم‌زمان داخل نیستند.

حالا همان مثال قبلی را با قفل‌گذاری اصلاح می‌کنیم. این بار خروجی همیشه و به‌طور قطعی صفر خواهد بود.

from threading import Thread, Lock

# Define lock
lock = Lock()

def add():
    global number
    for i in range(100000):
        # Lock before accessing shared variable
        lock.acquire()
        number += 1
        # Release lock after access
        lock.release()

def subtract():
    global number
    for i in range(100000):
        # Lock before accessing shared variable
        lock.acquire()
        number -= 1
        # Release lock after access
        lock.release()

number = 0

# Create and start threads
thread1 = Thread(target=add)
thread2 = Thread(target=subtract)

thread1.start()
thread2.start()

# Wait for threads to finish
thread1.join()
thread2.join()

# Print final value of number
print(number)

در تکه‌کد بالا:

  • ایجاد قفل: ابتدا با ساخت یک نمونه از کلاس Lock یک قفل می‌سازیم.
  • بستن قفل: با فراخوانی acquire پیش از استفاده از منبع مشترک، آن را قفل می‌کنیم. اگر ترد دیگری قبلاً آن را قفل کرده باشد، ترد فعلی همین‌جا متوقف می‌شود و آن‌قدر منتظر می‌ماند تا قفل آزاد شود.
  • بازکردن قفل: وقتی کارمان با منبع مشترک تمام شد، با فراخوانی release آن را آزاد می‌کنیم تا ترد منتظر بتواند وارد شود.

هنگام استفاده از Lock ممکن است با خطای بن‌بست (Deadlock) روبه‌رو شوید. برای کاهش احتمال بروز آن، دو اقدام توصیه می‌شود: استفاده از دستور with به‌جای acquire و release، و در سناریوهای بازگشتی، استفاده از کلاس RLock به‌جای Lock. در ادامه هر دو را می‌بینیم.

استفاده از with برای قفل‌گذاری

در روش دستیِ acquire/release یک خطر جدی وجود دارد: اگر بین این دو خط خطایی رخ دهد و release اجرا نشود، قفل برای همیشه بسته می‌ماند و بقیه‌ی تردها تا ابد منتظر می‌مانند — یک بن‌بست تمام‌عیار. راه‌حل استاندارد این است که قفل را به‌صورت context manager با with استفاده کنیم. در این حالت acquire هنگام ورود و release هنگام خروج — حتی اگر خطا رخ دهد — به‌صورت خودکار انجام می‌شود. این کار هم خوانایی کد را بالا می‌برد و هم احتمال بن‌بست را کم می‌کند.

from threading import Thread, Lock

# Define lock
lock = Lock()

def add():
    global number
    for i in range(100000):
        # Lock using "with" before accessing the shared variable
        with lock:
            number += 1

def subtract():
    global number
    for i in range(100000):
        # Lock using "with" before accessing the shared variable
        with lock:
            number -= 1

number = 0

# Create and start threads
thread1 = Thread(target=add)
thread2 = Thread(target=subtract)

thread1.start()
thread2.start()

# Wait for threads to finish
thread1.join()
thread2.join()

# Print final value of number
print(number)

این نسخه دقیقاً همان کار نسخه‌ی قبلی را انجام می‌دهد، اما امن‌تر و تمیزتر است. تا جای ممکن همین الگوی with را ترجیح دهید.

بن‌بست (Deadlock) چیست؟

بن‌بست وضعیتی است که در آن یک یا چند ترد برای همیشه منتظر آزادشدن قفلی می‌مانند که هرگز آزاد نمی‌شود. رایج‌ترین حالت‌هایش این‌هاست: قفلی که پس از acquire به‌خاطر یک خطا release نمی‌شود؛ یا تردی که قفلی را که خودش گرفته، دوباره acquire می‌کند (که با Lock معمولی خودش را قفل می‌کند). راه‌حل مشکل دوم، استفاده از RLock است که در بخش بعدی توضیح می‌دهیم.

در اینجا فقط به‌اختصار به بن‌بست اشاره کردیم؛ اگر می‌خواهید انواع حالت‌های بروز Deadlock، سناریوهای واقعی و راه‌های پیشگیری از آن را با جزئیات بیشتری یاد بگیرید، این موضوع را در درس جداگانه‌ی بن‌بست (Deadlock) در مالتی‌تردینگ پایتون به‌طور کامل بررسی کرده‌ام.

استفاده از RLock برای قفل‌گذاری

RLock (مخفف Reentrant Lock یا «قفل بازگشتی») بیشتر مناسب سناریوهایی است که یک ترد ممکن است قفلی را که خودش گرفته، دوباره بگیرد — مثلاً در توابع بازگشتی. با RLock می‌توان بارها و بارها بدون نگرانی از بن‌بست، متد acquire را از داخل همان ترد فراخوانی کرد.

مثال: اتاق سرور بانک مرکزی

فرض کنید می‌خواهید وارد اتاق سرور بانک مرکزی شوید. سرورها در اتاق A هستند، اتاق A داخل اتاق B و اتاق B داخل اتاق C قرار دارد. برای ورود به هر اتاق باید درِ اتاق قبلی را پشت سر خود قفل کنید؛ تا وقتی داخل هستید کسی دیگر نمی‌تواند وارد شود. برای خروج هم باید قفل‌ها را یکی‌یکی، به‌ترتیب معکوس، باز کنید.

اگر برای قفل‌گذاری از Lock استفاده کرده باشید، وقتی یک ترد به acquire دوم برسد در حالی که قفل قبلاً بسته است، برنامه متوقف می‌شود و دچار بن‌بست می‌گردد. اما با RLock، همان ترد می‌تواند بدون توقف ادامه دهد و صرفاً یک «قفل روی قفل قبلی» گذاشته می‌شود.

در واقع RLock یک شمارنده‌ی داخلی دارد: هر بار acquire از سوی همان ترد، شمارنده را یکی زیاد می‌کند و هر release آن را یکی کم؛ قفل تنها زمانی واقعاً آزاد می‌شود که شمارنده به صفر برسد. پس هر تعداد بار که acquire کنید، باید دقیقاً به همان تعداد release کنید. در این قیاس، فردی که وارد اتاق‌ها می‌شود همان ترد است و قفل‌کردن درِ هر اتاق، همان قفل‌کردن منبع مشترک.

import threading

# Create a reentrant lock
rlock = threading.RLock()

def rfunc(n):
    if n <= 0:
        return
    rlock.acquire()
    try:
        print(f"Thread {threading.current_thread().name} acquired lock with n = {n}")
        rfunc(n - 1)            # recursive call re-acquires the same lock
        print(f"Thread {threading.current_thread().name} released lock with n = {n}")
    finally:
        rlock.release()

# Create and start threads
threads = []
for i in range(3):
    t = threading.Thread(target=rfunc, args=(3,), name=f'Thread-{i}')
    threads.append(t)
    t.start()

# Wait for all threads
for t in threads:
    t.join()

print("All threads have finished execution.")

در این مثال:

  • یک نمونه از RLock ساخته‌ایم.
  • تابع rfunc داخل خودش قفل‌گذاری می‌کند و سپس خودش را به‌صورت بازگشتی صدا می‌زند؛ یعنی همان ترد چند بار پشت‌سرهم acquire می‌کند. با RLock این کار بدون بن‌بست انجام می‌شود، در حالی که با Lock معمولی برنامه در همان acquire دوم قفل می‌شد.
  • قراردادن release داخل بلوک finally تضمین می‌کند که قفل حتی در صورت بروز خطا هم آزاد شود.

یک مثال واقعی و ساده: حساب بانکی

بازگشت به توابع بازگشتی تنها کاربرد RLock نیست؛ رایج‌ترین کاربرد واقعی آن جایی است که یک متدِ قفل‌دار، متد قفل‌دارِ دیگری از همان شیء را صدا می‌زند. یک کلاس حساب بانکیِ thread-safe را در نظر بگیرید: متدهای deposit (واریز) و withdraw (برداشت) هرکدام قفل می‌گیرند. حالا متد transfer (انتقال) هم قفل می‌گیرد و در داخل خود متد withdraw را صدا می‌زند — یعنی همان قفل باید بار دوم هم گرفته شود.

import threading

class BankAccount:
    def __init__(self, balance=0):
        self.balance = balance
        self.lock = threading.RLock()

    def deposit(self, amount):
        with self.lock:
            self.balance += amount

    def withdraw(self, amount):
        with self.lock:
            self.balance -= amount

    def transfer(self, other, amount):
        # acquires self.lock, then withdraw() acquires the SAME lock again
        with self.lock:
            self.withdraw(amount)
            other.deposit(amount)

a = BankAccount(100)
b = BankAccount(50)
a.transfer(b, 30)
print("a:", a.balance, "b:", b.balance)   # a: 70 b: 80

اینجا transfer قفل را می‌گیرد و بعد withdraw را صدا می‌زند که می‌خواهد همان قفل را دوباره بگیرد. با RLock این کار بی‌دردسر انجام می‌شود، چون همان ترد اجازه دارد قفل خودش را دوباره بگیرد. اما اگر به‌جای RLock از یک Lock معمولی استفاده می‌کردیم، برنامه دقیقاً در withdraw به بن‌بست می‌خورد و برای همیشه متوقف می‌ماند. همین الگوی «متدی که متد قفل‌دار دیگری را صدا می‌زند» در کلاس‌های thread-safe بسیار پیش می‌آید و بهترین دلیل برای انتخاب RLock است.

مقایسه Lock و RLock

ویژگیLockRLock
گرفتن مجدد قفل توسط همان تردباعث بن‌بست می‌شودمجاز است (شمارنده بالا می‌رود)
تعادل acquire و releaseیک acquire، یک releaseبه تعداد acquire باید release شود
سرعت / سربارسبک‌ترکمی سنگین‌تر
مناسب برایقفل‌گذاری ساده و تختتوابع بازگشتی یا قفل‌گذاری تودرتو

خلاصه

  • وقتی چند ترد به یک منبع مشترک دسترسی دارند، برای جلوگیری از مشکلات ناشی از Race Condition آن منبع را قفل می‌کنیم.
  • قفل‌گذاری در سه گام انجام می‌شود: ساخت Lock، acquire پیش از دسترسی، و release پس از آن.
  • برای افزایش خوانایی و کاهش خطای Deadlock، به‌جای acquire/release از with استفاده کنید.
  • در سناریوهای بازگشتی یا قفل‌گذاری تودرتو از RLock استفاده کنید تا همان ترد بتواند بدون بن‌بست قفل را چند بار بگیرد.
Please login to bookmark Close
پیشرفت شما در «دوره آموزش کانکارنسی در پایتون» (14%)
نظرات

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

14%
پیشرفت

سرفصل دوره

فهرست مطالب

سرفصل دوره

تمرین

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

پاسخ تمرین ها

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

اشتراک گذاری

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

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

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

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

تنظیمات

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