CodeGym /Kurslar /C++ SELF /std::condition_variable: wait(lock, predicate)

std::condition_variable: wait(lock, predicate)

C++ SELF
Səviyyə, Dərs
Mövcuddur

1. Niyə std::condition_variable lazımdır, əgər artıq mutex var

Əgər mutex-ə yeni başlayan kimi baxsanız, belə görünə bilər: “mən artıq task queue-ni qoruyuram, demək hər şey qaydasındadır”. Həqiqətən, mutex məlumatları yarışlardan qorumaq üçün əladır. Amma bir problem var: o “gözəl gözləməyi” bilmir. Əgər consumer-in edəcək işi yoxdursa, o ya dövrədə fırlanır (busy-wait), ya da periodik olaraq sleep_for ilə yatır və “göz qoyur” ki, iş gəlibmi. Hər iki yanaşma — israf və gecikmə arasında kompromisdir.

std::condition_variable qapı zəngi kimidir: siz xalçanın üstündə dayanıb hər 10 millisaniyədən bir yoxlamırsınız ki, “kuryer gəlməyib?”. Siz “gözləmək” düyməsini basırsınız və… həqiqətən gözləyirsiniz. Kimsə pizzanı (task-ı) gətirəndə — sizə zəng gəlir və işə davam edirsiniz.

Protokolun üç iştirakçısı: vəziyyət, mutex və condition_variable

İndi çox vacib bir fikir gəlir, onu çoxsaylı thread proqramlaşdırmada mantra kimi təkrarlamaq lazımdır: biz “siqnalı” yox, vəziyyəti gözləyirik. Vəziyyət — bu, indi yoxlanıla bilən nəsnədir: ready flagi, tokens saygacı, queue (boş/boş deyil). Və bildiriş (notify_*) — sadəcə nəzakətli işarədir: “yenidən yoxla”.

Bu birlik belə görünür: bizim qorunan vəziyyətimiz var (məsələn, task queue), hansı ki yalnız bir mutex altında oxunur və dəyişdirilir. Və yanında condition_variable var, hansının köməyi ilə thread vəziyyət uyğun olana qədər “yatdırıla” bilər.

Protokolun qısa sxemi:

flowchart TD
    A[Consumer mutex-i ələ keçirdi] --> B{Vəziyyət hazırdır?}
    B -- yox --> C["cv.wait(lock, predicate) mutex müvəqqəti buraxılır thread yatır"]
    C --> D[Oyandırma mutex yenidən ələ keçirildi]
    D --> B
    B -- bəli --> E[Məlumatı götür və kritik bölmədən çıx]

Elə həmin “yoxlama dövrü” wait(lock, predicate)-də daxili olaraq mövcuddur.

2. Niyə wait std::unique_lock-lə işləyir, std::lock_guard-la yox

İlk baxışda bu əsəbiləşdirici görünə bilər: “niyə həmişəki kimi lock_guard istifadə olunmur?” Çünki wait bir hiylə edir ki, lock_guard prinsipcə bunu edə bilmir: o müvəqqəti olaraq mutex-i buraxır və sonra yenidən tutur. Thread yatmalıdı, amma eyni zamanda mutex-i tutmamalıdır, yoxsa digər thread-lər vəziyyəti dəyişdirib onu oyandıra bilməzlər.

unique_lock-in ideyasına baxın: o sadəcə “ələ keçirdi və scope sonuna qədər saxladı” deyil, həm də unlock() və lock() edə bilir.

#include <mutex>

std::mutex m;

void unique_lock_demo() {
    std::unique_lock<std::mutex> lock(m);
    lock.unlock(); // mutex-i buraxdıq
    lock.lock();   // yenidən ələ keçirdik
}

İndi cədvəldə müqayisə:

Alət Necə işləyir Əl ilə unlock() etmək mümkündür? cv.wait() üçün uyğundurmu
std::lock_guard
“ələ keçirdi → scope sonuna qədər saxlayır” yox yox
std::unique_lock
“ələ keçirdi → buraxa bilər → yenidən ələ keçirə bilər” bəli bəli

Və məhz bu “idarəoluna bilənlik” unique_lock-i condition_variable-in standart yoldaşı edir.

4. wait(lock, predicate)-in imzası və mənası

İndi mühazirənin mərkəzi hissəsi gəlir: düzgün gözləmə patterni. Standard kitabxanada predikat qəbul edən wait overload-u var. Bu overload xüsusi olaraq gözləmənin düzgün və etibarlı olması üçün mövcuddur. Standart materiallarında ayrıca “condition_variable::wait predikatla” mövzusu müzakirə olunur (tələbələrin və implementasiya müzakirələrinin predmeti kimi).

“Yalnız” wait(lock) — məntiqsiz gözləmə

Əgər siz belə yazırsınız:

cv.wait(lock);

siz əsasən deyirsiniz: “məni yatızdır və istənilən vaxt oyandır”. Bu bəzən aşağı səviyyəli protokollarda uyğundur, amma öyrənmə və tətbiqi kod üçün demək olar hər zaman təhlükəlidir: oyandılandan sonra siz mütləq özünüz davam edə bilib-bilmədiyinizi yoxlamalısınız.

wait(lock, predicate) — düzgün yoxlama ilə gözləmə

Düzgün forma belə görünür:

cv.wait(lock, [&] { return /* hazirlik sherti */; });

Və mənası budur: “vəziyyət doğru olana qədər gözlə”. Kitabxananın içində “yoxladım → əgər yoxdursa, yatdım → oyandım → yenə yoxladım” ekvivalent dövrü həyata keçirilir.

Klassis ekvivalent yazılış:

while (!predicate()) {
    cv.wait(lock);
}

Və məhz buna görə predikat yan təsirlərə malik olmamalıdır: o çox dəfə çağırıla bilər.

5. Mini-nümunə: ready flagini düzgün gözləmək

Çoxthread kod yazmağa başlayanda, ilk öncə sadə bir şeydə sınamaq istəyirsən: hazır/hazır deyil flagi var, producer true qoyur, consumer gözləyir.

#include <condition_variable>
#include <mutex>

std::mutex m;
std::condition_variable cv;
bool ready = false;

void consumer_waits() {
    std::unique_lock<std::mutex> lock(m);
    cv.wait(lock, [] { return ready; });
    // burada ready == true və mutex yenidən ələ keçirilib
}

Producer vəziyyəti eyni mutex altında dəyişməlidir:

#include <condition_variable>
#include <mutex>

extern std::mutex m;
extern std::condition_variable cv;
extern bool ready;

void producer_sets_ready() {
    {
        std::lock_guard<std::mutex> lock(m);
        ready = true;
    }
    cv.notify_one();
}

Qeyd edin formanı: biz ready-ni blok daxilində dəyişirik ki, mutex mütləq buraxılsın, və yalnız sonra notify_one()-ı çağırırıq.

6. wait(lock, predicate)-i mini-tətbiqə yerləşdirmək

Tutaq ki, əvvəlki mühazirədə (busy-wait haqqında) biz “konsol üçün task emalçısı” yazmağa başlamışdıq: bir thread tapşırıq əlavə edir, ikinci thread onları yerinə yetirir. Indiyə qədər consumer queue.empty()-i dövrədə yoxlayırdı. İndi düzgün edəcəyik: consumer queue boş olduğu müddətcə yatacaq.

Sadəliyə görə, task sadəcə bir tam ədəddir (int) (məsələn, “sətiri neçə dəfə çap et” və ya “hansı ədədi hesabla”).

Tətbiq vəziyyəti: queue + sinxronizasiya.

#include <condition_variable>
#include <mutex>
#include <queue>

std::mutex g_mutex;
std::condition_variable g_cv;
std::queue<int> g_tasks;

Producer: task qoy və consumer-i oyandır

Producer — məsələn, istifadəçi komandalarını oxuyan main-dir.

#include <condition_variable>
#include <mutex>
#include <queue>

extern std::mutex g_mutex;
extern std::condition_variable g_cv;
extern std::queue<int> g_tasks;

void push_task(int x) {
    {
        std::lock_guard<std::mutex> lock(g_mutex);
        g_tasks.push(x);
    }
    g_cv.notify_one();
}

Consumer: queue boş olmayana qədər gözlə

Elə burada bizim wait(lock, predicate) yaşayır:

#include <condition_variable>
#include <mutex>
#include <queue>

extern std::mutex g_mutex;
extern std::condition_variable g_cv;
extern std::queue<int> g_tasks;

int pop_task_blocking() {
    std::unique_lock<std::mutex> lock(g_mutex);
    g_cv.wait(lock, [] { return !g_tasks.empty(); });

    int x = g_tasks.front();
    g_tasks.pop();
    return x;
}

Görürsünüz, bu necə “insana yaxın”dır: biz sadəcə “queue boş deyil” kimi yazdıq. Bu sehr deyil: wait mutex-i azad edir, yatırır, sonra oyandırıldıqda yenidən ələ keçirir və predikatı yenidən yoxlayır.

7. Predikatda vəziyyəti necə tutmaq: [&], [], [this]

cv.wait(lock, ...) yazanda tez-tez lambda istifadə edirsiniz. O zaman sual yaranır: “onu necə capture etmək lazımdır?”. Bu kosmetika deyil — bu uyğunluq və oxunaqlılığa təsir edir.

Əgər vəziyyət qlobal dəyişənlərdirsə (bizim qısa tədris nümunələrində olduğu kimi), [] yazmaq olar, çünki giriş sadəcə qlobal obyektlərə olacaq:

g_cv.wait(lock, [] { return !g_tasks.empty(); });

Amma əgər queue obyektin içində enkapsulyasiya edilibsə (sonrakı nümunələrdə belə edəcəyik), adətən such as this->tasks_, this->closed_ olacaq və o zaman this-i capture etmək əlverişlidir ya implicity [&] ilə, ya da explicit [this] ilə. Tədris kodunda adətən [&] daha oxunaqlıdır:

cv_.wait(lock, [&] { return !tasks_.empty(); });

Burada vacib vərdiş: predikat “təmiz yoxlama” olmalıdır, push və pop etməməli, konsola çıxış etməməlidir. O çox dəfə çağırıla bilər və siz istəmirsiniz ki, kodunuz “oyandım!” mesajını 200 dəfə çap etsin, sadəcə çünki thread lazımsız vaxtda oyandı.

8. notify_one(), notify_all() və notify_*-ın çağırılması yeri

Bu mərhələdə təfərrüatlarda boğulmamaq vacibdir, amma əsas intuisiya qətiyyətlə yerində olmalıdır. Gözləyənləri oyatmaq üçün iki yol var:

notify_one() bir gözləyən thread-i oyadır. Bu “bir yeni task → bir worker oyandı və onu götürdü” üçün ideal seçimdir.

notify_all() bütün gözləyənləri oyadır. Bu faydalıdır, əgər vəziyyət dəyişib və potensial olaraq çox thread davam edə bilər (yaxud hamıya vacib bir dəyişiklik bildirmək lazım gəlirsə). Korretli sonlandırma və hallarda notify_all()-ın zəruri olduğu mövzusu ayrıca müzakirə olunacaq.

Qeyd edim ki, standard kitabxanasının mətnlərində condition_variable-ın effektivliyi və davranışın düzgün ifadələri xüsusi vurğulanır.

İndi klassik sual: “notify_one() çağırmaq mümkünmü hələ mutex tutulubkən?”. Texniki olaraq mümkündür, amma adətən daha rahat və təmiz belə etmək məsləhətdir: əvvəlcə vəziyyəti mutex altında dəyiş, sonra mutex-i burax (scope-dan çıx) və yalnız bundan sonra bildiriş göndər.

Səbəb praktiki isə budur. Əgər notify_one()-ı mutex altında çağırarsınızsa, oyandırılan thread dərhal mutex-i ələ keçirməyə çalışacaq, amma siz hələ də onu tutduğunuz üçün alınmayacaq. Formal olaraq bu səhv deyil, amma belə vəziyyət “qapını döydüm, amma qapını bağlı saxlayıram” effektinə oxşayır.

Yaxşı tədris şablonu:

{
    std::lock_guard<std::mutex> lock(m);
    ready = true;
}
cv.notify_one();

Belə kod oxumaq və saxlamaq baxımından asandır: vəziyyətin dəyişməsi və onun haqqında siqnal vizual olaraq ayrılıb.

9. wait(lock, predicate)-in daha bir neçə mini-nümunəsi

Bəzən eyni pattern-i müxtəlif kontekstlərdə görmək faydalıdır. Bu xarici dili öyrənmək kimidir: 10 cümlə görmədən “anladım” demək tez-tez səhv olur.

“Tokenlər”: saygac sıfırdan böyük olana qədər gözləmə

#include <condition_variable>
#include <mutex>

std::mutex m;
std::condition_variable cv;
int tokens = 0;

void take_token() {
    std::unique_lock<std::mutex> lock(m);
    cv.wait(lock, [] { return tokens > 0; });
    --tokens;
}

“İş var”: ən azı bir element yaranana qədər gözləmə

#include <condition_variable>
#include <mutex>
#include <queue>

std::mutex m;
std::condition_variable cv;
std::queue<int> q;

int pop_one() {
    std::unique_lock<std::mutex> lock(m);
    cv.wait(lock, [] { return !q.empty(); });
    int x = q.front();
    q.pop();
    return x;
}

“Hazırlıq yoxlanışı” və konsola çıxış

#include <iostream>
#include <condition_variable>
#include <mutex>

std::mutex m;
std::condition_variable cv;
bool ready = false;

void wait_and_print() {
    std::unique_lock<std::mutex> lock(m);
    cv.wait(lock, [] { return ready; });
    std::cout << "Ready!\n"; // Ready!
}

10. wait(lock, predicate) ilə işləmədə tipik səhvlər

Səhv №1: istifadə etmək cv.wait(lock) “çünki qısadır”.
Bəli, bu qısadır, amma bu, elə bil ki, if (x) ... yazmaq kimidir — x-in nə olduğunu və kim dəyişdiyini anlamadan. Əgər siz konkret şərti gözləyirsinizsə (queue boş deyil, hazır flagi qaldırıldı, tokenlər ortaya çıxdı), onda əsas stil — wait(lock, predicate) olmalıdır. O gözləməni vəziyyətə bağlayır, yoxsa “mistik oyandırılma” faktına yox.

Səhv №2: cəhd etmək wait-ə std::lock_guard-ı ötürmək.
lock_guard mutex-i buraxmağı bacarmır, amma wait yatma zamanı onu buraxmaq məcburiyyətindədir. Ona görə də gözləmə üçün məhz std::unique_lock lazımdır. Əgər kompilyator sizə xəta verirsə, çox güman ki, səhv tipdə lock seçmisiniz.

Səhv №3: predikatın yan təsirləri olması.
Bəzən yeni başlayanlar predikat yazır ki, o konsola çap edir, sayğacı artırır və ya hətta queue-dan pop() edir. Bu xaos almaq üçün əla yoldur: predikat çox dəfə çağırıla bilər, o cümlədən hələ həqiqi iş olmadığı anlarda da. Predikat yalnız vəziyyətin yoxlanışı olmalıdır — sadə bool.

Səhv №4: vəziyyəti mutex olmadan dəyişib ümid etmək ki, notify_one() “nəsə sinxronizasiya edəcək”.
notify_one() məlumatları qorumur və mutex-in yerinə keçmir. O yalnız thread-i oyadır. Əgər ready-yə yazı və ya queue.push eyni mutex ilə edilməyibsə, siz ümumi vəziyyətə giriş intizamını pozursunuz. Yaxşı halda nadir qəribə bug-lar, pis halda isə sonsuz gözləmə və ya çökmələr ala bilərsiniz.

Səhv №5: oyandırıldıqdan sonra uzun müddətli iş zamanı mutex-i saxlamaq.
Düzgün kritik bölmə adətən belə olur: “şərti yoxladım → məlumatı götürdüm → strukturu yenilədim → mutex-i buraxdım”. Əgər wait-dən sonra ağır hesablama edirsinizsə, çoxlu konsola çıxış edirsinizsə və ya sleep_for ilə işi imitasiya edirsinizsə mutex tutulmuş halda, bütün multithread kodunu tək-threadli bir proqrama çevirirsiniz, əlavə sinirlərlə birlikdə.

1
Tapşırıq
C++ SELF, səviyyə, dərs
Bağlanıb
Enter ilə başlamaq
Enter ilə başlamaq
1
Tapşırıq
C++ SELF, səviyyə, dərs
Bağlanıb
Ümumi start
Ümumi start
1
Tapşırıq
C++ SELF, səviyyə, dərs
Bağlanıb
Navqdan kvadratlar
Navqdan kvadratlar
1
Tapşırıq
C++ SELF, səviyyə, dərs
Bağlanıb
Token hovuzu
Token hovuzu
Şərhlər
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION