پیشنیاز
پیش از مطالعهی این بخش، بهتر است با مفهوم «منبع مشترک» و اینکه چرا دسترسی همزمان چند ترد به یک منبع میتواند دردسرساز شود آشنا باشید. در ادامه ابتدا این مشکل را با یک مثال ملموس نشان میدهیم و بعد سراغ راهحل، یعنی قفلگذاری، میرویم.
مشکل از کجا میآید؟ (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 در سه مرحله انجام میشود:
- ایجاد قفل: ساختن یک نمونه از کلاس
Lock. - بستن قفل: فراخوانی
acquire()پیش از دسترسی به منبع مشترک. - بازکردن قفل: فراخوانی
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
| ویژگی | Lock | RLock |
|---|---|---|
| گرفتن مجدد قفل توسط همان ترد | باعث بنبست میشود | مجاز است (شمارنده بالا میرود) |
| تعادل acquire و release | یک acquire، یک release | به تعداد acquire باید release شود |
| سرعت / سربار | سبکتر | کمی سنگینتر |
| مناسب برای | قفلگذاری ساده و تخت | توابع بازگشتی یا قفلگذاری تودرتو |
خلاصه
- وقتی چند ترد به یک منبع مشترک دسترسی دارند، برای جلوگیری از مشکلات ناشی از Race Condition آن منبع را قفل میکنیم.
- قفلگذاری در سه گام انجام میشود: ساخت
Lock،acquireپیش از دسترسی، وreleaseپس از آن. - برای افزایش خوانایی و کاهش خطای Deadlock، بهجای
acquire/releaseازwithاستفاده کنید. - در سناریوهای بازگشتی یا قفلگذاری تودرتو از
RLockاستفاده کنید تا همان ترد بتواند بدون بنبست قفل را چند بار بگیرد.