1. Niyə yarışlara görə narahat olmaq lazımdır
Əgər çoxprosesli (multi-threaded) kod yazmısınızsa və “iki thread işə düşdü, nəsə hesabladılar, görünür düzgün çıxdı — demək qaydasındadı” fikrinə gəlmisinizsə, sizi təbrik edirəm: klassik yeni başlayanların qustunu keçmisiniz. Problem odur ki, çoxprosesslik tez‑tez hər şey qaydasındadı imitasiyası edə bilir, xüsusən kiçik nümunələrdə və “mənim noutbukda”.
Bunun üçün ayrıca mühazirə ayırmağımızın əsas səbəbi: çoxprosess səhvləri demək olar ki, həmişə "bəzən" rejimində yaşayır. Onlar nadir halda stabil şəkildə çökmür. Onlar Cümə axşamı axşamı prod-da ortaya çıxmağı sevirlər. Ən xoşagəlməz odur ki: C++-dəki bəzi səhvlər formal olaraq Undefined Behavior-a aiddir — yəni kompilyator “istədiyini edə” bilər və siz düzgünlüyü sübut edə bilməzsiniz, hətta min dəfə ardıcıl düzgün nəticə çıxsa da.
Race condition və data race: fərqi nədir
Sözlər oxşardır, hər ikisi “yarış” haqqındadır, qorxunc səslənir — onları qarışdırmaq təəccüblü deyil. Amma bunlar iki fərqli problemdir. Əgər siz onları ayırd edə bilirsinizsə, çoxprosess səhvlərinin yarıdan çoxu ən azı izah edilə bilən olur, mistik deyil.
Məntiqi yarış
Race condition (məntiqi yarış) — nəticənin thread-lərin əməliyyatların hansı sırada icra olunmasından asılı olduğu vəziyyətdir. Burada yaddaşa giriş formal olaraq düzgün ola bilər (məsələn, hər şey mutex-lərlə qorunur), amma “kim əvvəl yetişdi” ssenarisi biznes loqikasına təsir edir.
Data race
Data race — iki thread eyni yaddaş sahəsinə eyni vaxtda müraciət etdiyi, və ən azı birinin yazdığı vəziyyətdir və düzgün sinxronizasiya yoxdur. C++-də bu xüsusilə kritikdir, çünki data race Undefined Behavior sayılır — yəni proqramın davranışı standartla müəyyənləşdirilməyib.
Əlverişlilik üçün — kiçik bir cədvəl (yaddaqalamağa ehtiyac yoxdur, "hiss etmək" üçün):
| Hadisə | Nə haqqındadır | UB ola bilərmi? | Adətən necə düzəldirlər |
|---|---|---|---|
| Race condition | Məntiq və hadisələrin sıralaması | Mütləq deyil | Protokol dizaynı, növbələr, vəziyyətlər, bəzən mutexlər/condition_variable |
| Data race | Yaddaşa səhvli rəqabətli giriş | Bəli, C++-də bu UB-dir | Mutex, atomiklar, düzgün sinxronizasiya |
Data race-i sadə dildə
Akademik dillə deməyə ehtiyac yoxdur (hərçənd standart bunu sevir): data race onda yaranır ki, iki thread eyni vaxtda eyni dəyişənə müraciət edir, və ən azı biri yazır, və siz giriş qaydalarını razılaşdırmamısınız (mutex qoymamısınız, dəyişəni atomik etməmisiniz, ya da başqa sinxronizasiya protokolu qurmayıbsınız).
Mətbəxi təsəvvür edin. İki aşpaz eyni anda eyni şorbaya duz əlavə edir, biri artıq qarışdırır, o biri hələ dadmayıb, və heç kim qaşığı kim tutacağını razılaşdırmayıb. Bəzən şorba normal olur. Bəzən çox duzlu olur. Bəzən qaşıq divara uçur. Data race elə “qaşıq divara uçdu” vəziyyətidir, “çox duz oldu” yox.
Ən qısa nümunə, görünüşcə “təhlükəsiz” amma əslində problemli:
#include <iostream>
#include <thread>
int main() {
int x = 0;
std::jthread t1([&] { ++x; });
std::jthread t2([&] { ++x; });
std::cout << x << '\n'; // 2 də ola bilər, istənilən şey ola bilər (formal olaraq UB)
}
Vacibdir: tez‑tez 2 görsəniz belə, bu kod standart üzrə hələ də uyğun deyil, çünki iki thread sinxronizasiyasız bir int-ə yazır.
2. Niyə data race C++-də UB-dir
Burada adətən belə sual yaranır: “Niyə bu qədər sərtdir? Niyə sadəcə ‘nəticə təxmin edilə bilməz’ demirlər?” Səbəb odur ki, C++-də iki güclü “canavar” var ki, icranı optimallaşdırur: kompilyator və prosessor. Hər ikisi əməliyyatları yenidən sıralamağa və sadələşdirməyə haqqı var, əgər dil qaydalarına görə bu tək‑thread dünyasında mənanı dəyişmirsə.
Koda data race daxil olduqda, əslində kompilyatora deyirsiniz: “Mən qaydaları pozuram, amma sən elə davran ki, qaydalar pozulmayıb.” Kompilyator cavab verir: “Yaxşı, onda mən öz xeyrimə optimallaşdıracağam. Sonra mənə hirslənmə.”
Klassika: sinxronizasiyasız dayandırma bayrağı
Çox göstərişli nümunə — sinxronizasiyasız “dayandırma bayrağı”. Yeni başlayanlar tez‑tez belə edirlər: bir thread döngüdə fırlanır və stop-un true olmasını gözləyir, digər thread isə onu təyin edir.
#include <thread>
int main() {
bool stop = false;
std::jthread worker([&] {
while (!stop) {
// "gözləyirik"
}
});
std::jthread stopper([&] {
stop = true;
});
}
İnsan səviyyəsində belə görünür: “stopper stop = true qoyacaq və worker çıxacaq”. Amma praktikada kompilyator qərar verə bilər ki, worker içində stop dəyişmir (çünki düzgün sinxronizasiya görmür!), ona görə də while(!stop) sonsuz döngü kimi qəbul edilə bilər. Bu kompilyatorun səhvi deyil; siz yaddaş modelinin qaydalarını pozduğunuz üçün ona belə düşünmək haqqı verirsiniz.
Niyə join() yarışı "ləğv etmir"
Tez‑tez belə fikir olur: “Eyni anda işləməyə icazə yoxdur. Amma əgər mən thread-lərin bitməsini gözləsəm (join()), sonra nəticəni oxusam — bu təhlükəsizdir?” Təəssüf ki, yox. join() (və ya std::jthread-də RAII gözləmə) thread-in bitdiyini təmin edir, amma iki thread-in işlədiyi müddətdə data race yaratma faktını ləğv etmir.
Psixoloji rahatlıq verən, amma standart üzrə hələ də qaydaları pozan bir nümunə:
#include <iostream>
#include <thread>
int main() {
int x = 0;
std::jthread t1([&] { ++x; });
std::jthread t2([&] { ++x; });
// t1 və t2 main-dən çıxana qədər dəqiq bitəcək (jthread bunu edir)
std::cout << x << '\n';
}
Problem x-ı sonunda oxumaqda deyil. Problem odur ki, rəqabətli yazılar artıq thread-lər işləyərkən baş verib.
Analogiya olaraq: “İki insan bir sənədi konflikt həll olunmadan redaktə edib bitirdilər” — bu o demək deyil ki, konflikt olmayıb. Bu yalnız “redaktə bitdi” deməkdir.
Niyə sleep_for yalnız simptomları aradan qaldırır
Növbəti populyar “bütün dərmana” — sleep_for. Loqika belə olur: “Əgər thread-lər bir‑biri ilə toqquşursa, onları biraz vaxtla ayıraram”. Bu məntiqli səslənir, amma maşını açmaq üçün açarla döymək kimi işləyir: bəzən kömək edir, amma istehsalçı tövsiyə etmir.
“Pis sakitləşdirici” nümunəsi:
#include <chrono>
#include <iostream>
#include <thread>
int main() {
int x = 0;
std::jthread t1([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
++x;
});
std::jthread t2([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
++x;
});
std::cout << x << '\n'; // daha çox "2", amma yenə də UB
}
Bəli, gecikmə toqquşma ehtimalını azalda bilər, amma o yaddaşa giriş qaydası yaratmır. Deməli, kod formal olaraq səhv qalır. Üstəlik, başqa maşında, başqa yüklənmədə, fərqli CPU tezliyində və fərqli planlaşdırıcıda problem yenidən ortaya çıxacaq.
4. Data race-i necə düzəltmək: mutex və atomic
“Data race-i düzəltmək” dedikdə əslində məqsəd odur: “ortaq verilənlərə giriş qaydalarını razılaşdırmaq”. İki əsas vasitə var: mutex və atomiklar.
Mutex yaxşıdır, çünki “qapını bağlayır”: bir thread kritik bölmədə ikən, digəri gözləyir. Bu rahatdır, əgər bir neçə bağlı dəyişəniniz varsa və “A dəyişəndənsə B də dəyişilməlidir” kimi invariantları qorumaq vacibdirsə.
Atomik isə onu yaxşı edir ki, bir dəyişən üzərində əməliyyatları çoxprosess üçün düzgünsüz edir (məsələn, sayğac, bayraq). Amma atomik bütün proqram üçün “sehrli” alət deyil, konkret məqsəd üçün istifadə olunur.
Maraqlıdır ki, standart kitabxananın özü də konteynerlərin “data race-dən qaçmağa” necə kömək etməli olduğu barədə ayrı müzakirələrə malikdir. Bu təsadüfi deyil: mövzu o qədər əsasdır ki, konteynerlərin davranışını düzgün təsvir etmək üçün önəmlidir.
Bir sayğac: iki düzgün düzəliş
Mutex ilə versiya:
#include <iostream>
#include <mutex>
#include <thread>
int main() {
int x = 0;
std::mutex m;
std::jthread t1([&] { std::lock_guard<std::mutex> lock(m); ++x; });
std::jthread t2([&] { std::lock_guard<std::mutex> lock(m); ++x; });
std::cout << x << '\n'; // 2
}
Atomik ilə versiya:
#include <atomic>
#include <iostream>
#include <thread>
int main() {
std::atomic<int> x{0};
std::jthread t1([&] { ++x; });
std::jthread t2([&] { ++x; });
std::cout << x.load() << '\n'; // 2
}
Diqqət edin: biz hələ atomikların “incə parametrlərini” müzakirə etmirik. Bu gün əsas sərt qayda budur: adi int bir neçə thread-də sinxronizasiyasız təhlükəsiz dəyişdirilə bilməz. Amma std::atomic<int> ilə mümkündür.
Praktik nümunə: mini‑TaskRunner və irəliləyiş (progress)
Bunu boş kosmik threadlər haqqında abstraksiya kimi saxlamamaq üçün fikri tədris tətbiqimizə bağlayaq. Gəlin mini‑TaskRunner olsun: bir neçə worker işə düşür, hər biri “tapşırığı emal edir”, və biz neçə tapşırığın bitdiyini saymaq və irəliləyiş çap etmək istəyirik.
Naiv realizasiya gözəl görünür… və məntiqi cəhətdən pozulur (bəzən daha pis — UB-ə girir), çünki bir neçə thread eyni dəyişənə yazır.
#include <iostream>
#include <thread>
#include <vector>
int main() {
int processed = 0;
std::vector<std::jthread> workers;
for (int i = 0; i < 4; ++i) {
workers.emplace_back([&] { ++processed; });
}
std::cout << processed << '\n'; // "4 gözlənilirdi", amma ümumiyyətlə UB
}
Niyə bu pis kod — artıq aydındır: processed ortaqdır və ora eyni anda yazılır.
Minimal düzəlişlə sayğacı atomik edək. Bu məhz atomikin məqsədi olan ssenaridir: müstəqil irəliləyiş sayğacı, mürəkkəb invariantlar yoxdursa.
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
int main() {
std::atomic<int> processed{0};
std::vector<std::jthread> workers;
for (int i = 0; i < 4; ++i) {
workers.emplace_back([&] { ++processed; });
}
std::cout << processed.load() << '\n'; // 4
}
Əgər iki bağlı dəyişənimiz olsaydı, məsələn processed və errors, və biz gözəl, uyğun statistika çap etmək istəyiriksə (məsələn, əvvəlcə error artırılır, sonra processed artırılır), çox vaxt daha asan və aydın olur ki, bir mutex istifadə edib vəziyyəti bir “paket”də yeniləyəsiniz. Atomiklar da bunu həll edə bilər, amma burada asanlıqla protokolu səhv qura bilərsiniz.
5. Data race-i görməyi necə öyrənmək və onu “sadə səhvdən” ayırmaq
Data race-in ən xoşagəlməz xüsusiyyəti odur ki, o həmişə “təhlükəli hissə” kimi görünmür. Bəzən bu adi ++, bəzən bayrağa yazıdır, bəzən log üçün sadəcə oxumaqdır. Ona görə də ən vacibi fəndlər yox, düzgün suallar vermək vərdişidir.
Əqli model: ortaq obyektə üç sual
Faydalı əqli model belədir: data race ümumi dəyişən olduğu yerlərdə yaranır. Ona görə kodu oxuyanda üç suala cavab axtarın: obyekt harada yaşayır (onu kim “görür”), onu kim dəyişdirir, və hansı qaydalarla girişə icazə verilir.
Bu kiçik blok‑sxemi kimi tutulabilir, başda saxlamaq rahatdır:
flowchart TD
A[Dəyişən/obyekt var] --> B{Ona 2+ thread baxırmı?}
B -- yox --> C[Data race mümkün deyil]
B -- bəli --> D{Ən azı bir thread yazırmı?}
D -- yox --> E[Adətən təhlükəsizdir, amma lifetime/invalidate yoxlanılsın]
D -- bəli --> F{Sinxronizasiya varmı? mutex/atomic/protokol}
F -- bəli --> G["Data race aradan qaldırıldı (loqikanı yoxlayın)"]
F -- yox --> H[Data race => UB]
Yalnız oxumaq budağına diqqət yetirin: bu tez‑tez təhlükəsizdir, amma digər problemlər meydana çıxa bilər (məsələn, obyektin həyat müddəti, keçərlilik problemləri və s.). Bu gün diqqətimizi yazısız sinxronizasiyaya yönəldirik.
Mini‑diaqnostika: UB yoxsa deterministik səhv
Proqram “bəzən 4, bəzən 3 çap edir” deyəndə tez deyirsən: “Bu səhvdir, sonra düzəldərəm”. Amma çoxprosesslikdə daha faydalı disiplin: əvvəlcə müəyyən et, bu məntiqi deterministik səhvdir yoxsa yaddaş qaydalarının pozulması. Birinci dizaynla, ikinci isə sinxronizasiya ilə həll olunur.
Praktikada (alətlərə dərindən girmədən) iki şey kömək edir.
Birinci — inkişaf zamanı "sığorta" kimi assert qoymaq vərdişi, azı mümkünsüz vəziyyətləri tutmaq üçün. Məsələn, əgər TaskRunner gözləyirsə ki, processed heç vaxt mənfi olmayacaq və tapşırıqlar sayından çox olmayacaqsa, inkişaf zamanı bunu yoxlaya bilərsiniz. Bəli, assert yarışları düzəltmir. Amma vəziyyət “sürüşəndə” bunu tutmağa kömək edir.
#include <atomic>
#include <cassert>
int main() {
std::atomic<int> processed{0};
++processed;
int v = processed.load();
assert(v >= 0);
}
İkinci — sanitayzerlər və diaqnostika alətləri (onlarla əvvəllər tanış olmuşduq): onlar tez‑tez data race-i bu problem sinfi kimi tutmağı bacarırlar. Amma alətlər olmadan da bugünkü “əsas silah” ağlınızda düzgün model: əgər ortaq bir int varsa və iki thread onu sinxronizasiyasız yazırsa, bu artıq qırmızı bayraqdır, "bəlkə səhv" deyil.
6. Tipik səhvlər
Səhv №1: “Çünki bu int-dir, demək təhlükəsizdir — o kiçikdir”.
Tipin ölçüsü girişin thread‑təyinatlı olmasını təmin etmir. Problem “registerə sığıb‑sığmaması” deyil, eyni anda iki thread-in razılaşdırılmamış oxuma/yazma əməliyyatları icra etməsidir. Nəticədə C++ yaddaş modelinin qaydalarını pozursunuz və UB-ə düşürsünüz.
Səhv №2: “Mən yazıları mutex ilə qoruyacağam, amma oxumaları mutexsiz edəcəyəm — yəqin yaxşıdır”.
Bu çox təlxək tələsidir: yazıları qorusanız belə, mutexsiz oxuma yazı ilə toqquşa bilər və bu data race-dir. Əgər obyekti mutex ilə qoruyursunuzsa — həm oxumaları, həm yazımları qoruyun (yoxsa düzgün protokol qurun).
Səhv №3: “Sleep_for qoyacağam, thread-lər toqquşmayacaq”.
Gecikmə sinxronizasiya deyil. O “kimin nə vaxt yazmağa haqqı var” qaydasını yaratmır, yalnız cədvəli şansla dəyişir. Bir maşında “düzələcək”, başqa maşında yenə pozulacaq. Ən pis tərəfi: belə bir səhv demək olar ki, reproduksiya etməkdə çətinlik yaradır.
Səhv №4: “Join() hər şeyi düzəldir”.
join() thread-in bitməsini təmin edir, amma artıq baş vermiş data race faktını ləğv etmir. İki thread işləyərkən sinxronizasiyasız yazı olubsa, qaydalar pozulub və nəticə istənilən ola bilər, hətta siz sonra hamının bitməsini gözləsəniz belə.
Səhv №5: “Flag-ı atomic edərəm, qalan məlumat adi qalsın — hər şey düzələr”.
Atomik yalnız öz dəyişəni üçün problemi həll edir. Amma əgər bağlı məlumat dəsti varsa, tək atomik qalan kodu thread‑təhlükəsiz etməz. Bağlı invariantlar üçün adətən əvvəlcə mutex ilə başlamaq daha asandır, daha sonra protokolu və məsuliyyət sərhədlərini dəqiq başa düşəndə optimallaşdırmaq olar.
GO TO FULL VERSION