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 |
|---|---|---|---|
|
“ələ keçirdi → scope sonuna qədər saxlayır” | yox | yox |
|
“ə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ə.
GO TO FULL VERSION