رفتن به محتوا

متغیرهای شرطی و تولیدکننده-مصرف‌کننده

از متغیرهای شرطی (condition variable) برای هماهنگ‌سازی کارآمد نخ‌ها استفاده کنید. یک صف تولیدکننده-مصرف‌کننده امن از نظر نخ بسازید، بیدارشدن‌های جعلی را مدیریت کنید و از مشکل بیدارشدن گمشده جلوگیری کنید.

هماهنگ‌سازی نخ‌ها با متغیرهای شرطی

یک متغیر شرطی (condition variable) به یک نخ اجازه می‌دهد منتظر بماند تا شرطی درست شود، بدون حلقه مشغول. به جای چرخش while (!ready) { /* سوزاندن CPU */ }، نخ می‌خوابد و توسط نخ دیگری که notify_one() یا notify_all() را فراخوانی می‌کند بیدار می‌شود.

std::condition_variable فقط با std::unique_lock کار می‌کند. الگو همیشه این است:

1. mutex را قفل کنید.
2. شرط را بررسی کنید — اگر نادرست است، wait() را فراخوانی کنید که به‌طور اتمیک mutex را آزاد کرده و نخ را به حالت تعلیق درمی‌آورد.
3. وقتی اعلان شد (و شرط درست است)، mutex دوباره گرفته شده و اجرا ادامه می‌یابد.

نخ اعلان‌کننده حالت مشترک را تحت همان mutex تغییر می‌دهد، سپس notify_one() یا notify_all() را فراخوانی می‌کند.

استفاده پایه از متغیر شرطی

الگوی اصلی اینجاست: یک نخ منتظر داده می‌ماند، نخ دیگر آن را فراهم می‌کند.

condition_variable_basic.cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <string>

std::mutex mtx;
std::condition_variable cv;
std::string data;
bool ready = false;

void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    // Wait until ready is true (handles spurious wakeups)
    cv.wait(lock, []{ return ready; });
    std::cout << "Consumer received: " << data << "\n";
}

void producer() {
    {
        std::lock_guard<std::mutex> lock(mtx);
        data = "Hello from producer";
        ready = true;
    }  // unlock before notify to avoid waking thread into contention
    cv.notify_one();
}

int main() {
    std::thread t1(consumer);
    std::thread t2(producer);
    t1.join();
    t2.join();
    return 0;
}
دام

یک متغیر شرطی ممکن است یک نخ را حتی وقتی هیچ اعلانی ارسال نشده بیدار کند — این بیدارشدن جعلی (spurious wakeup) نامیده می‌شود. این توسط استاندارد ++C مجاز است (و در سیستم‌های واقعی رایج است) به دلایل عملکردی. شما باید همیشه از فرم محمول (predicate) wait() استفاده کنید یا آن را در یک حلقه while قرار دهید:

// صحیح — فرم محمول بیدارشدن‌های جعلی را مدیریت می‌کند
cv.wait(lock, []{ return ready; });

// معادل حلقه دستی
while (!ready) {
    cv.wait(lock);
}

// اشتباه — به‌طور تصادفی بیدار می‌شود، بدون داده ادامه می‌دهد
cv.wait(lock);  // بدون بررسی شرط!

هرگز cv.wait(lock) را بدون محمول یا حلقه while احاطه‌کننده فراخوانی نکنید.

بافر محدود امن از نظر نخ (تولیدکننده-مصرف‌کننده)

الگوی تولیدکننده-مصرف‌کننده (producer-consumer) یکی از رایج‌ترین الگوهای همروندی است. تولیدکنندگان آیتم‌ها را به یک بافر مشترک اضافه می‌کنند؛ مصرف‌کنندگان آن‌ها را حذف می‌کنند. یک بافر محدود وقتی پر است تولیدکنندگان را و وقتی خالی است مصرف‌کنندگان را مسدود می‌کند.

bounded_queue.cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <vector>

template <typename T>
class BoundedQueue {
    std::queue<T> queue_;
    std::mutex mtx_;
    std::condition_variable not_full_;
    std::condition_variable not_empty_;
    size_t capacity_;
public:
    explicit BoundedQueue(size_t cap) : capacity_(cap) {}

    void push(T item) {
        std::unique_lock<std::mutex> lock(mtx_);
        not_full_.wait(lock, [this]{ return queue_.size() < capacity_; });
        queue_.push(std::move(item));
        not_empty_.notify_one();
    }

    T pop() {
        std::unique_lock<std::mutex> lock(mtx_);
        not_empty_.wait(lock, [this]{ return !queue_.empty(); });
        T item = std::move(queue_.front());
        queue_.pop();
        not_full_.notify_one();
        return item;
    }
};

int main() {
    BoundedQueue<int> bq(5);  // capacity 5

    // Producer
    std::thread producer([&bq]() {
        for (int i = 0; i < 20; ++i) {
            bq.push(i);
            std::cout << "Produced: " << i << "\n";
        }
    });

    // Consumer
    std::thread consumer([&bq]() {
        for (int i = 0; i < 20; ++i) {
            int val = bq.pop();
            std::cout << "Consumed: " << val << "\n";
        }
    });

    producer.join();
    consumer.join();
    return 0;
}

مشکل بیدارشدن گمشده

یک بیدارشدن گمشده (lost wakeup) زمانی رخ می‌دهد که notify_one() قبل از فراخوانی wait() توسط مصرف‌کننده فراخوانی شود. چون هیچ نخی در انتظار نیست، اعلان از دست می‌رود — مصرف‌کننده سپس برای همیشه می‌خوابد.

الگوی استاندارد با بررسی محمول قبل از خوابیدن از این جلوگیری می‌کند. اگر تولیدکننده قبلاً شرط را به درست تنظیم کرده باشد، محمول درست برمی‌گرداند و wait() بلافاصله بدون خوابیدن برمی‌گردد. به همین دلیل فرم محمول cv.wait(lock, pred) ضروری است — شرط را قبل و بعد از هر بیدارشدن بررسی می‌کند:

1. mutex را قفل کنید.
2. محمول را بررسی کنید — اگر قبلاً درست است، از انتظار صرف‌نظر کنید.
3. در غیر این صورت، به‌طور اتمیک باز کرده و بخوابید.
4. در هنگام بیدارشدن، دوباره قفل کرده و محمول را بررسی کنید.

تا زمانی که هم تولیدکننده و هم مصرف‌کننده پرچم مشترک را با همان mutex محافظت کنند، هیچ بیدارشدنی از دست نمی‌رود.

notify_one در مقابل notify_all

notify_one() یک نخ در انتظار را بیدار می‌کند؛ notify_all() همه آن‌ها را بیدار می‌کند. از notify_all() وقتی استفاده کنید که چندین نخ ممکن است نیاز به بررسی مجدد شرایط خود داشته باشند (مثلاً سیگنال‌های خاموشی)، و از notify_one() وقتی که دقیقاً یک نخ باید ادامه دهد (مثلاً یک مصرف‌کننده واحد که یک وظیفه را برمی‌دارد).

notify_all_shutdown.cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <vector>
#include <atomic>

std::mutex mtx;
std::condition_variable cv;
bool shutdown = false;

void worker(int id) {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, []{ return shutdown; });
    std::cout << "Worker " << id << " shutting down\n";
}

int main() {
    std::vector<std::thread> workers;
    for (int i = 0; i < 5; ++i) {
        workers.emplace_back(worker, i);
    }

    std::this_thread::sleep_for(std::chrono::seconds(1));

    {
        std::lock_guard<std::mutex> lock(mtx);
        shutdown = true;
    }
    cv.notify_all();  // wake ALL workers for shutdown

    for (auto& w : workers) w.join();
    return 0;
}
بهترین شیوه

همیشه از نسخه محمول‌دار (predicate) wait() برای محافظت در برابر بیدارشدن‌های جعلی و بیدارشدن‌های گمشده استفاده کنید. در صورت امکان mutex را قبل از فراخوانی notify_one()/notify_all() باز کنید — این از مسدود شدن فوری نخ بیدار شده روی mutex جلوگیری می‌کند. وقتی فقط یک نخ باید بیدار شود notify_one() و برای سناریوهای پخش‌همگانی مانند خاموشی notify_all() را ترجیح دهید. هرگز با lock_guard روی یک متغیر شرطی منتظر نمانید — شما به unique_lock نیاز دارید.

نکات کلیدی
  • std::condition_variable به نخ‌ها اجازه می‌دهد تا شرطی درست شود بخوابند، و از انتظار مشغول پرهزینه جلوگیری می‌کند
  • همیشه از فرم محمول (predicate) wait() برای مدیریت بیدارشدن‌های جعلی و بیدارشدن‌های گمشده استفاده کنید
  • الگوی تولیدکننده-مصرف‌کننده از دو متغیر شرطی استفاده می‌کند: «پر نیست» و «خالی نیست»
  • notify_one() یک منتظر را بیدار می‌کند؛ notify_all() همه منتظران را بیدار می‌کند — مورد مناسب را برای سناریوی خود انتخاب کنید
  • هم منتظر و هم اعلان‌کننده باید از همان mutex برای محافظت از شرط مشترک استفاده کنند

آزمون — دانش خود را بسنجید

(20 امتیاز)

1. چرا باید همیشه از محمول (predicate) با `condition_variable::wait()` استفاده کنید؟

2. در یک صف تولیدکننده-مصرف‌کننده محدود، تولیدکننده وقتی صف پر است باید چه کند؟

3. `std::condition_variable::wait()` به چه نوع قفلی نیاز دارد؟