Race Condition یا شرایط رقابتی در پایتون

Please login to bookmark Close

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

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

36%
پیشرفت

سرفصل دوره

فهرست مطالب

سرفصل دوره

تمرین

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

پاسخ تمرین ها

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

اشتراک گذاری

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

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

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

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

تنظیمات

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