
Race Condition چیست؟
یک بستنی داریم و دو کودک؛ چه اتفاقی میافتد؟ سر تصاحبش دعوا میشود و نتیجه معلوم نیست. دقیقاً همین وضعیت بین تردها و منابع سیستم هم پیش میآید. Race Condition (شرایط رقابتی) به حالتی گفته میشود که بخشی از حافظه بهصورت مشترک و مدیریتنشده توسط چند ترد استفاده شود، بهگونهای که خروجی نهایی با چیزی که انتظار میرود متفاوت باشد.
مثلاً انتظار میرود خروجی کد زیر صفر باشد، اما اگر آن را چند بار اجرا کنید، میبینید که گاهی خروجی چیزی جز صفر است:
from threading import Thread
def add():
global number
for i in range(100000):
number += 1
def subtract():
global number
for i in range(100000):
number -= 1
number = 0
Thread(target=add).start()
Thread(target=subtract).start()
print(number)Shared Resource چیست؟
به متغیر number که در کد بالا بهصورت مشترک بین دو ترد استفاده شده، «منبع اشتراکی» یا Shared Resource میگویند. هر جا چند ترد به یک منبع اشتراکی دسترسی همزمان و کنترلنشده داشته باشند، احتمال Race Condition وجود دارد.
علت رخداد Race Condition
یک تشبیه ساده: دو آشپز میخواهند یک آش بپزند. یک دیگ میآورند و هرکدام موادی را که لازم میداند به دیگ اضافه میکند. در پایان، آش یا شور شده یا بینمک. در این مثال، آشپزها نماد تردها، دیگ نماد منبع اشتراکی و آش نماد خروجی برنامه است. وجود دو آشپز لزوماً بد نیست؛ مشکل این است که این دو با هم هماهنگ نبودند. پس ریشهی Race Condition، نبودِ هماهنگی تردها در استفاده از منابع مشترک است.
چرا شمارنده صفر نمیشود؟
عملیات number += 1 در واقع یک عمل واحد نیست؛ سه گام است: خواندن مقدار فعلی، افزودن یک واحد، و نوشتن مقدار جدید. حالا سناریوی خرابشدن را دنبال کنید: فرض کنید counter برابر ۰ است. ترد add و ترد subtract هر دو مقدار ۰ را میخوانند. add یک واحد اضافه میکند و counter را ۱ میکند. اما subtract که هنوز مقدار قدیمی (۰) را در دست دارد، یک واحد کم میکند و counter را روی ۱- مینویسد — و آن ۱ که add نوشته بود کاملاً پاک میشود! این «بازنویسیِ روی هم» بارها تکرار میشود و در پایان یک مقدار کاملاً بیربط بهجای صفر بهدست میآید.
مثال – فروش بیش از ظرفیت بلیت
مثال شمارنده انتزاعی بهنظر میرسد، اما همین باگ در دنیای واقعی فاجعه میسازد. تصور کنید فقط یک بلیت باقی مانده و پنج خریدار همزمان روی دکمهی خرید میزنند. هر ترد اول چک میکند «آیا بلیت هست؟» و بعد آن را کم میکند. اگر بین «چک» و «کمکردن» تردهای دیگر هم چک کنند، همه فکر میکنند بلیت هست و همه میخرند — یعنی «فروش بیش از ظرفیت» (overselling).
import time
from threading import Thread
tickets = 1
sold = 0
def buy():
global tickets, sold
if tickets > 0: # check
time.sleep(0.01) # someone else can sneak in right here
tickets -= 1 # act
sold += 1
buyers = [Thread(target=buy) for _ in range(5)]
for b in buyers:
b.start()
for b in buyers:
b.join()
print("sold:", sold, "| tickets left:", tickets) # e.g. sold: 5, tickets left: -4 (oversold!)خروجی این کد میتواند نشان دهد ۵ بلیت فروخته شده در حالی که فقط ۱ بلیت داشتیم و موجودی به ۴- رسیده است! راهحل، قفلکردن کل بخش «چک و کمکردن» است تا در هر لحظه فقط یک خریدار بتواند آن را اجرا کند:
import time
from threading import Thread, Lock
tickets = 1
sold = 0
lock = Lock()
def buy():
global tickets, sold
with lock: # only one buyer at a time in this block
if tickets > 0:
time.sleep(0.01)
tickets -= 1
sold += 1
buyers = [Thread(target=buy) for _ in range(5)]
for b in buyers:
b.start()
for b in buyers:
b.join()
print("sold:", sold, "| tickets left:", tickets) # sold: 1, tickets left: 0مثال – برداشت همزمان از یک حساب بانکی
مثال کلاسیک دیگر، برداشت از حساب بانکی است. فرض کنید موجودی حساب ۱۰۰ است و دو تراکنش همزمان (مثلاً از دو دستگاه ATM) هرکدام میخواهند ۸۰ برداشت کنند. هر دو موجودی را ۱۰۰ میبینند، هر دو شرط «موجودی کافی است» را درست میبینند و هر دو برداشت میکنند؛ نتیجه یک موجودی منفی و برداشتِ بیش از موجودی است.
import time
from threading import Thread, Lock
balance = 100
lock = Lock()
def withdraw(amount):
global balance
with lock: # protect the check-then-update
if balance >= amount:
time.sleep(0.01) # simulate processing time
balance -= amount
print(f"withdrew {amount}, balance is now {balance}")
else:
print(f"declined {amount}, not enough balance ({balance})")
t1 = Thread(target=withdraw, args=(80,))
t2 = Thread(target=withdraw, args=(80,))
t1.start(); t2.start()
t1.join(); t2.join()با قفل، یکی از دو برداشت انجام میشود و دیگری بهدرستی رد میشود؛ بدون قفل، هر دو انجام میشدند و موجودی منفی میشد. همین الگو در مدیریت موجودی انبار، رزرو صندلی و هر جای دیگری که «اول چک، بعد تغییر» داریم صدق میکند.
جلوگیری از Race Condition
به تشبیه آش برگردیم: دو آشپز تصمیم گرفتند از یک سرآشپز کمک بگیرند. سرآشپز مدیریت میکند؛ هر وقت آشپز اول نمک میریزد بهخاطر میسپارد و اگر آشپز دوم بخواهد دوباره نمک بریزد، مانعش میشود. در این مثال، سرآشپز نماد «قفلکننده» (locker) است. بهطور کلی، چون Race Condition از استفادهی همزمان از منابع مشترک ناشی میشود، برای حلش کافی است از دسترسی همزمان جلوگیری کنیم؛ این کار با قفلکردن منبع مشترک انجام میشود.
جلوگیری در ماژول _thread
کد زیر با ماژول قدیمی _thread نوشته شده و مشکل Race Condition دارد (با sleep عمداً مشکل را تشدید کردهایم تا بهتر دیده شود):
import time
import _thread as thread
counter = 0
def add(thread_id):
global counter
for _ in range(100):
temp = counter
temp += 1
time.sleep(0.01) # widen the race window
counter = temp
print(f"Thread {thread_id}: {counter}")
def subtract(thread_id):
global counter
for _ in range(100):
temp = counter
temp -= 1
time.sleep(0.01)
counter = temp
print(f"Thread {thread_id}: {counter}")
thread.start_new_thread(add, (1,))
thread.start_new_thread(subtract, (2,))
time.sleep(5)
print(f"Final counter value: {counter}")برای حل مشکل، منبع مشترک را با یک قفل محافظت میکنیم:
import _thread as thread
import time
counter = 0
lock = thread.allocate_lock()
def add(thread_id):
global counter
for _ in range(100):
lock.acquire() # lock before touching the shared variable
temp = counter
temp += 1
time.sleep(0.01)
counter = temp
print(f"Thread {thread_id}: {counter}")
lock.release() # release after use
def subtract(thread_id):
global counter
for _ in range(100):
lock.acquire()
temp = counter
temp -= 1
time.sleep(0.01)
counter = temp
print(f"Thread {thread_id}: {counter}")
lock.release()
thread.start_new_thread(add, (1,))
thread.start_new_thread(subtract, (2,))
time.sleep(5)
print(f"Final counter value: {counter}")برای اطمینان و خوانایی بیشتر، بهجای acquire و release دستی میتوان از with استفاده کرد:
import time
import _thread as thread
counter = 0
lock = thread.allocate_lock()
def add(thread_id):
global counter
for _ in range(100):
with lock: # acquire on enter, release on exit
temp = counter
temp += 1
time.sleep(0.01)
counter = temp
print(f"Thread {thread_id}: {counter}")
def subtract(thread_id):
global counter
for _ in range(100):
with lock:
temp = counter
temp -= 1
time.sleep(0.01)
counter = temp
print(f"Thread {thread_id}: {counter}")
thread.start_new_thread(add, (1,))
thread.start_new_thread(subtract, (2,))
time.sleep(5)
print(f"Final counter value: {counter}")جلوگیری در threading.Thread
در عمل معمولاً از ماژول مدرنتر threading استفاده میکنیم. کد زیر همان مشکل Race Condition را دارد:
from threading import Thread
def add():
global number
for i in range(100000):
number += 1
def subtract():
global number
for i in range(100000):
number -= 1
number = 0
Thread(target=add).start()
Thread(target=subtract).start()
print(number)راه اول، استفاده از قفل با 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.acquire()
number -= 1
lock.release()
number = 0
thread1 = Thread(target=add)
thread2 = Thread(target=subtract)
thread1.start()
thread2.start()
thread1.join()
thread2.join()
print(number)راه دوم و بهتر، استفاده از قفل با with:
from threading import Thread, Lock
# Define lock
lock = Lock()
def add():
global number
for i in range(100000):
with lock:
number += 1
def subtract():
global number
for i in range(100000):
with lock:
number -= 1
number = 0
thread1 = Thread(target=add)
thread2 = Thread(target=subtract)
thread1.start()
thread2.start()
thread1.join()
thread2.join()
print(number)جلوگیری در ThreadPoolExecutor
شاید فکر کنید چون در ThreadPoolExecutor خودمان تردها را دستی نمیسازیم، دیگر درگیر Race Condition نمیشویم. اما اینطور نیست! کارگرهای این executor هم ترد هستند و در حافظهی مشترک کار میکنند؛ پس هر بار که چند کارِ submitشده به یک منبع مشترک دست بزنند، دقیقاً همان مشکل رخ میدهد. مثال زیر را ببینید: موجودی حساب ۱۰۰ است و پنج کار همزمان میخواهند ۸۰ برداشت کنند.
import time
from concurrent.futures import ThreadPoolExecutor
balance = 100
def withdraw(amount):
global balance
if balance >= amount: # check
time.sleep(0.01) # another worker can sneak in right here
balance -= amount # act
return f"withdrew {amount}"
return f"declined {amount}"
with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(withdraw, 80) for _ in range(5)]
for f in futures:
print(f.result())
print("final balance:", balance) # e.g. -300 (all 5 withdrew from a balance of 100!)خروجی نشان میدهد که هر پنج کار موفق به برداشت شدهاند و موجودی به عددی منفی رسیده است؛ چون همه پیش از کمشدن موجودی، شرط «موجودی کافی است» را درست دیدهاند. راهحل همان راهحل همیشگی است: بخش بحرانی («چک و برداشت») را با یک threading.Lock محافظت میکنیم تا در هر لحظه فقط یک کار آن را اجرا کند.
import time
from concurrent.futures import ThreadPoolExecutor
from threading import Lock
balance = 100
lock = Lock()
def withdraw(amount):
global balance
with lock: # only one worker at a time in this block
if balance >= amount:
time.sleep(0.01)
balance -= amount
return f"withdrew {amount}"
return f"declined {amount}"
with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(withdraw, 80) for _ in range(5)]
for f in futures:
print(f.result())
print("final balance:", balance) # 20 - only one withdrawal succeedsحالا فقط یکی از برداشتها انجام میشود و بقیه بهدرستی رد میشوند و موجودی هرگز منفی نمیشود. نکتهی کلیدی این است که ThreadPoolExecutor صرفاً روشِ راحتتری برای مدیریت تردهاست و چیزی از قواعد ایمنی را تغییر نمیدهد؛ هر جا کارها به منبع مشترک دست میزنند، همچنان به قفل نیاز دارید. (اگر کارتان CPU-bound است و بهجای تردها از ProcessPoolExecutor استفاده میکنید، برای منبع مشترک باید از multiprocessing.Lock استفاده کنید، نه threading.Lock.)
نکات مهم دربارهی Lock و Release
وقتی منبع اشتراکی قفل است و هنوز آزاد (release) نشده، تردهای دیگر باید منتظر بمانند تا تردی که قفل را گرفته آن را آزاد کند. پس همیشه مطمئن شوید هر acquire یک release متناظر دارد؛ استفاده از with این تضمین را خودکار میکند. همچنین فقط بخشی را داخل قفل بگذارید که واقعاً به منبع مشترک دست میزند تا عملکرد برنامه بیجهت افت نکند. جزئیات بیشتر قفلگذاری در درس کلاس Lock و RLock آمده است.
خلاصه
- Race Condition وقتی رخ میدهد که چند ترد بهصورت هماهنگنشده به یک منبع مشترک دسترسی پیدا کنند و خروجی از حالت موردانتظار خارج شود.
- ریشهی مشکل، غیراتمیبودن عملیاتهایی مثل «چککن سپس تغییر بده» یا
n = n + 1است. - راهحل، قفلکردن منبع مشترک است؛ ترجیحاً با
with lock. - به کدی که مشکل Race Condition ندارد thread safe میگویند (هرچند thread safe بودن گاهی چیزی فراتر از نبود Race Condition میطلبد).