[:tr]Merhaba arkadaşlar, uzun bir aradan sonra haftalık C++ yazılarımıza devam ediyoruz. std::thread kütüphanesine ilişkin daha önce başlamış olduğumuz serinin üçüncü yazısı ile sizler ile birlikteyim. Eğer diğer yazılarımı henüz okumadı iseniz, aşağıdaki bağlantılardan muhakkak okumanız öneriyorum, özellikle birinci yazıyı:
Haftalık C++ 7- std::thread (I)
Haftalık C++ 8- std::thread (II)
Haftalık C++ 10- std::thread (III)
Giriş:
Gelelim bu yazının konusuna. Bundan önceki yazılarımda, temel std::thread kullanımı ve yardımcı yapılara değinmiştim. Bu yazımda ise, multithreaded yazılım geliştirmede çok önemli bir yere sahip olan senkronizasyon yapılarından sizlere bahsedeceğim. Aslında bakarsanız, bir önceki yazımda anlattığım std::atomics de senkronizasyon yapılarından birisidir ve her ne kadar bütün durumları karşılamasa da, burada anlatacağım bazı problemler için kullanılabilir.
Senkronizasyon yapıları, özellikle bir den fazla thread’in aynı bellek uzayındaki ortak kaynaklara (özellikle bellekte her iki thread’in erişimi olduğu ve yerel olarak alınmamış değişkenler) erişerek değiştirmek istediği durumlarda, ortaya çıkabilecek problemleri önlemek için kullanılırlar. Bunun yanında thread’ler arası haberleşme ve koordinasyon için de kullanılırlar. Bunu uygulamak, tabi söylemekten biraz daha zor, çünkü bu yapıların yanlış kullanılması, bulunması ve ayıklanması zor hatalara yol açabileceği gibi, performans anlamında da uygulamalarınıza ket vurabilir. Bu sebeple de özellikle kritik verilerin paylaşımı konusunu, oldukça dikkatli değerlendirmenizde fayda var. Her ne kadar bu yazımda değinmesem de (ileride belki ayrı bir yazıda bu konuya değinebiliriz), multithreaded programlama için kullanılabilecek bir çok tasarım kalıpları ve yaklaşımları mevcut, uygulamalarınızda mümkün olduğunca bunları kullanmanız, bu tarz problemler ile karşılaşma riskini azaltacaktır.
Şimdi dilerseniz, bir önceki paragrafta ortak kaynaklara erişim konusunu basit bir örnekle inceleyelim:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | void saveItem(int item) { .... int freeSlotIndex{0}; ... freeSlotIndex = foundFreeIndex; storage[freeSlotIndex] = item; foundFreeIndex++; .... } |
Yukarıdaki metot basitçe verilen bir sayıyı, basit bir dizide bulduğu ilk yerde saklıyor. Normal şartlarda, hiçbir sıkıntı görünmeyen bu metodun paralel çalıştırılması durumunda karşılaşabileceğimiz örnek bir duruma göz atalım. Aşağıda iki farklı thread’in bu metodu aynı anda çağırdığı durumda, oluşabilecek örnek bir koşum sırası vermeye çalıştım (bu elbette değişebilir).
| Thread 1 Koşumu: | Thread 2 Koşumu: |
| 1) freeSlotIndex = foundFreeIndex; 4) storage[freeSlotIndex] = item; 5) foundFreeIndex++; | 2) freeSlotIndex = foundFreeIndex; 3) storage[freeSlotIndex] = item; 6) foundFreeIndex++; |
Göreceğiniz üzere hangi satırın hangi sıra ile çağrıldığı duruma göre ilgili dizinin boş yerine yazılan değerlerden birisi diğerini ezebiliyor. Bu probleme multihreaded programlama dünyasında “race condition” (Yarış Koşulu) denilmektedir. Ortak kaynaklara erişilmesi durumunda, bu tarz problemler oluşabilmektedir. Tabi bu problem her zaman oluşacak diye bir durum da yok (inanın bana oluşması ve bunları yakalamak sizler için çok daha hayırlı :). Uygulamada bu tarz problemlere yol açabilecek kısımlara da “critical section” (Kritik Bölüm) denilir. Özellikle, bu yarış koşullarını bulmak her zaman bu kadar kolay olamayabiliyor, çünkü her zaman oluşmuyorlar. Bu problemleri önlemek için, kritik bölümleri koruma altına alıyoruz. Burada özellikle dikkat etmenizi istediğim konu: kaynaklara olan erişimde, eğer her parti sadece okuma yapıyorsa, herhangi bir sıkıntı olmaz ve emniyetli bir şekilde hepsi okuma yapabilir. Ama en az bir taraf, bu veriler üzerine yazma işlemi yapacak ise, işte çarşı o zaman karışıyor. Bunu da not alalım bir kenara.
Yukarıdaki örnekte oluşabilecek olan bir probleme göz attık, peki kritik bölümleri koruma altına almazsak ne tarz problemler ile karşılaşabiliriz? Hemen bakalım:
- Senkronize Olmayan Veri Erişimi (“Unsynchronized Data Access”): Aslında yukarıda verdiğimiz örnek bu gruba girmekte. Birden fazla thread paralel bir şekilde, ortak bir veriye okuma ve yazma yapıyor ise hangisinin önce yazdığı problemi ortaya çıkabilir.
- Yarı Yazılmış Veri (“Half-written Data”): Benzer şekilde bir thread veriye yazıyorken, diğer thread onu tam da yazma işleminin ortasında okuyabilir. Elinde ne eski ne de yeni veri olabilir 🙂 Bunu da çok basit bir örnek ile anlatmaya çalışayım: elimizde aşağıdaki gibi bir kod bildirimi olduğunu düşünelim:
long long x = 0;
Bir thread bu veriyi aşağıdaki gibi değiştiriyor:
x = -1;
Diğeri de aşağıdaki gibi okuyor:
std::cout << x;
İşte tam bahsettiğimiz probleme örnek olabilecek bir durum.
Sıraları Değiştirilmiş Kod Bildirimleri (“Reordered Statements”): Ayrı ayrı threadlerdeki kod bildirimleri, performans veya benzeri sebeplerle değiştirilmiş olabilirler. Bunlar her ne kadar tek başlarına sıkıntı olmasalar da (sıralı koşturma), paralel koşumlarda, beklenen davranış gözlemlenmeyecektir.
Bu konulara ilişkin daha detaylı bilgiler için (özellikle bu tarz problemler ve yaklaşımlar için), ilk yazımda bahsettiğim aşağıdaki kitaplara bir göz atabilirsiniz:
Şimdi bu yapılardan ilki olan std::mutex’lere bakalım.
std::mutex:
Mutex’ler, “mutual exclusion” olarak da bilinir, bir kaynağa olan eş zamanlı erişim ihtiyacını, o kaynağa özel erişim sunarak sağlayan yapılara denir. Burada taraflar ilgili kaynağa erişmek için std::mutex üzerinden kilitleme işini yapar ve kaynağa erişir, bu sırada diğer threadlerin bu kaynağa erişimi engellenir. Ta ki, ilgili taraf bu kilidi kaldırana kadar. İlk olarak Edsger W. Dijsktra tarafından tanımlanmıştır. Basitçe aslında paylaşılan kaynağa, tek erişimi sağlayan en temel senkronizasyon yapısıdır.
std::mutex sınıfını kullanabilmek için ‘<mutex>’ başlık dosyasını eklemeniz gerekmekte. Bu kütüphane altında mutex anlamında kullanabileceğiniz önemli sınıfları ve hangi C++ ile kullanıma sunulduklarını, aşağıdaki kısa açıklamalar ile özetlemeye çalıştım. Bunlara ilişkin referans bilgilerine bu adresten ulaşabilirsiniz. Sonrasında bunlarına kullanımına hep birlikte örnek kodlar üzerinden bakacağız. Daha detaylı örnek ve kullanımlar için ise, kaynakların adreslerini en son bölümde vereceğim.
| Temel mutex Sınıfları – I | |
| std::mutex (C++11) | Temel karşılıklı dışlama (mutual exclusion)/güvenli erişim kabiliyeti sunar |
| std::timed_mutex (C++11) | Karşılıklı dışlama (mutual exclusion)/güvenli erişim kabiliyetini süreli bir şekilde sunar |
| recursive_mutex (C++11) | Karşılıklı dışlama (mutual exclusion)/güvenli erişim kabiliyetini, aynı thread için yineli bir şekilde gerçekleştirilmesine olanak sağlar |
| recursive_timed_mutex (C++11) | Karşılıklı dışlama (mutual exclusion)/güvenli erişim kabiliyetini, aynı thread için yineli ve verilen zaman için gerçekleştirilecek şekilde yapılabilmesine olanak sağlar |
| lock_guard (C++11) | RAII (Resource Acquisition Is Initialization) mekanizmasına uygun bir şekilde mutex kullanılarak verilen kapsam için karşılıklı dışlama kabiliyeti sunar.Aslında arka planda yapılan şey, yapıcı içerisinde ilgili mutex’i kilitlemek ve kapsam dışında çıkan scoped_lock nesnesi ile çağrılacak yıkıcı içerisinde de bu kilidi geri açmak. |
| unique_lock (C++11) | Karşılıklı dışlamayı içerisinde barındıran ve sahipliğini taşıma yolu ile aktarabileceğiniz mekanizmayı sunan sınıftır |
Kullanıma Sunulan Bazı Bağımsız Fonksiyonlar | |
| try_lock (C++11) | Metoda geçirilen kilitlenebilir nesnelerin, hepsini kitlemeye çalışır. Kilitleyemediği ilk nesnenin indeksini döner. Başarılı durumda -1 dönülür.
|
| lock (C++11) | Metoda geçirilen kitlenebilir nesneleri kilitler, eğer kilitleyemez ise mevcut thread’i bloklar |
İlk yazımda da bahsettiğim gibi, std::thread kütüphanesi aslında ilk olarak C++ 11 ile sunulmaya başlandı, yukarıdaki bahsettiğim sınıflar da öle. C++ 14 ve 17 ile de aşağıdaki gibi bir takım eklemeler oldu:
| Temel Mutex Sınıfları II | |
| shared_mutex (C++17) | Temel karşılıklı dışlama (mutual exclusion) kabiliyetinin bir çok nesne tarafından paylaşılabilmesine olanak sağlar. Özellikle aynı anda birden fazla okuma ve yaz yapılabileceği durumlarda, okumalar esnasında kullanıcıların herhangi bir bloklama olmadan veriye erişmelerine olanak sağlar, |
| shared_timed_mutex (C++14) | Bir önceki sınıfın, belirli bir süre için bu kilitlerin tutulabilmesine olanak sağlayan sınıfı |
| scoped_lock (C++17) | RAII (Resource Acquisition Is Initialization) mekanizmasına uygun bir şekilde, bir ya da birden fazla mutex kullanılarak verilen kapsam için karşılıklı dışlama kabiliyeti sunar. Lock_guard’tan farkı, birden fazla mutex’i alabilmesidir. Bu kullanıma ilişkin bir örnek aşağıdaki adreste verilmekte: https://stackoverflow.com/questions/17113619/whats-the-best-way-to-lock-multiple-stdmutexes/17113678 |
| shared_lock (C++14) | Verilen paylaşımlı mutex için, taşınabilir sahiplik ve bir önceki sınıftakine benzer kabiliyetler sunar |
Evet, elimizdeki cephanenin neler olduğunu öğrendiğimize göre, şimdi bunları nasıl kullanabileceğimize bir göz atalım.
İlk örneğimiz, multi-threaded yazılım geliştirirken, eminim hepiniz, konsola bir şeyler bastırmak istemişsinizdir ve ilk tecrübe ettiğiniz deneyim de karmakarışık çıktılar olacaktır muhtemelen. Gelin bunu, yukarıdaki temel yapıları kullanarak basitçe nasıl çözebileceğimize bakalım. Tahmin edebileceğiniz üzere, bu durumdaki paylaşılan kaynak standart çıktı akışı (“standard output stream“).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | #include <thread> #include <mutex> #include <iostream> #include <string> #include <chrono> // Ortak veri paylaşımı (ki burada standard output) için muteximiz std::mutex gPrintMutex; void threadSafePrintVersion1(const std::string& input) { gPrintMutex.lock(); for(auto c : input) { std::cout.put(c); std::this_thread::sleep_for(std::chrono::milliseconds(1)); } gPrintMutex.unlock(); } void threadSafePrintVersion2(const std::string& input) { std::lock_guard<std::mutex> localLock(gPrintMutex); for(auto c : input) { std::cout.put(c); std::this_thread::sleep_for(std::chrono::milliseconds(1)); } } int main() { std::thread firstThread(threadSafePrintVersion1, "Hello world from thread 1!\n"); std::thread secondThread(threadSafePrintVersion2, "Hello world from thread 2!\n"); std::cout << "Hello world from main thread!\n"; firstThread.join(); secondThread.join(); return 0; } |
Yukarıda verilen kodta her iki metot da aynı işi yapmakta. Fakat std::scoped_lock daha okunaklı (tabi bana göre 🙂 hem de direk std::mutex kullanımı durumunda ortaya çıkabilecek unlock() unutmak ya da gereksiz lock() çağrılması benzeri problemleri bertaraf etmekte. Burada tabi sıkıntılı durumu ortaya koymak için koda bir takım eklemeler (bekleme ve put API’lerinin kullanımı) yaptım 🙂 Normalde basit uygulamalarda std::cout bunu sıkıntı yaşamadan görüntüleyebilir. Ama yukarıdaki mutex’ler ile ilgili satırları yorumladığınızda neler olduğunu görebilirsiniz.
Şimdi de, ilk durum kadar olmasa da, std::recursive_lock kullanmanızı gerektirecek durumlara bir göz atalım. Bu ihtiyaç, genellikle her bir metodu içerisinde mutex kullanılan ve bu metotların birbirlerini çağırmaları gerektiği durumlarda ortaya çıkar. Hemen bir örneğe bakalım:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | struct Calculator { std::mutex mMutex; int mLastResult; Calculator() : i(0) {} void multiply(int x) { std::lock_guard<std::mutex> lock{mMutex}; mLastResult *= x; } void divide(int x) { std::lock_guard<std::mutex> lock{mMutex}; mLastResult /= x; } // Yukarıdaki her iki metodu da cagirmamiz gerekecek! void both(int x, int y) { std::lock_guard<std::mutex> lock{mMutex}; multiply(x); divide(y); } }; int main() { Complex complex; complex.both(32, 23); return 0; } |
Bu örnekte gördüğünüz gibi tek tek multiply() ya da divide() metotlarını çağırdığınız zaman herhangi bir problem ile karşılaşmayacaksınız. Fakat both() metodunu çağırdığınızda uygulama bloklanacak, sizce neden?
both() metodu, mMutex kilidini başta kilitliyor, daha sonra multiply()’ı çağırdığımızda ise aynı kilidi kilitlemeye çalıştığında, “deadlock” dediğimiz duruma sebebiyet vermiş oluyoruz. Aynı thread, bir mutex’i iki kere kilitleyemez. Şimdi std::mutex kullanımlarının hepsini, std::recursive_mutex ile değiştirelim ve öyle çalıştıralım, bu durumda herhangi bir problem olmadığını göreceksiniz.
Yukarıdaki kullanımlarda, eğer ilgili mutex zaten kilitli ise ilgili thread bloklanır. Bunun yerine ilgili mutex’i kilitleyip/kilitleyemediğinizi sorgulamak isteyebilirsiniz. Bu durumda try_lock() ve türevlerini kullanabilirsiniz. Hemen bir örnek kullanımına (referans sayfasından) bakalım:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | // try_lock #include <chrono> #include <mutex> #include <thread> #include <iostream> // std::cout std::chrono::milliseconds interval(100); std::mutex mutex; int job_shared = 0; // both threads can modify 'job_shared', // mutex will protect this variable int job_exclusive = 0; // only one thread can modify 'job_exclusive' // no protection needed // this thread can modify both 'job_shared' and 'job_exclusive' void job_1() { std::this_thread::sleep_for(interval); // let 'job_2' take a lock while (true) { // try to lock mutex to modify 'job_shared' if (mutex.try_lock()) { std::cout << "job shared (" << job_shared << ")\n"; mutex.unlock(); return; } else { // can't get lock to modify 'job_shared' // but there is some other work to do ++job_exclusive; std::cout << "job exclusive (" << job_exclusive << ")\n"; std::this_thread::sleep_for(interval); } } } // this thread can modify only 'job_shared' void job_2() { mutex.lock(); std::this_thread::sleep_for(5 * interval); ++job_shared; mutex.unlock(); } int main() { std::thread thread_1(job_1); std::thread thread_2(job_2); thread_1.join(); thread_2.join(); } |
Şimdi bir diğer kullanıma daha göz atalım ve sonrasında mutex sayfasını kapatalım. Bu kullanım, std::timed_mutex’ler ile ilgili. Bu sınıf standart std::mutex’in API’leri yanında try_lock_for ve try_lock_until API’lerini sunar. try_lock_for() API’si, verilen mutex’i kilitlemeye çalışır, eğer bu mutex kilitli ise, sonsuza kadar beklemek yerine, verilen süre kadar bu mutex’in kilidinin açılmasını bekler. Eğer alabilirse, true döner, alamaz ise false döner. try_lock_until’de buna benzer bir kullanım sunar, tek farkı süre yerine direk zaman verilmesidir. Yine referans sayfasında verilen örneğe bakalım:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | #include <iostream> #include <mutex> #include <thread> #include <vector> #include <sstream> std::mutex cout_mutex; // control access to std::cout std::timed_mutex mutex; void job(int id) { using Ms = std::chrono::milliseconds; std::ostringstream stream; for (int i = 0; i < 3; ++i) { if (mutex.try_lock_for(Ms(100))) { stream << "success "; std::this_thread::sleep_for(Ms(100)); mutex.unlock(); } else { stream << "failed "; } std::this_thread::sleep_for(Ms(100)); } std::lock_guard<std::mutex> lock(cout_mutex); std::cout << "[" << id << "] " << stream.str() << "\n"; } int main() { std::vector<std::thread> threads; for (int i = 0; i < 4; ++i) { threads.emplace_back(job, i); } for (auto& i: threads) { i.join(); } } |
Bazen bir metodun, farklı thread’lerden sadece bir kere çağrılmasını isteyebilirsiniz. Bu kütüphane bu tarz durumlar için de std::call_once fonksiyonunu sunuyor. Buna ilişkin de bir örneğe bakalım:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | std::once_flag flag; void do_something() { std::call_once(flag, [](){std::cout << "Called once" << '\n';}); std::cout << "Called each time" << '\n'; } int main(){ std::thread t1(do_something); std::thread t2(do_something); std::thread t3(do_something); std::thread t4(do_something); t1.join(); t2.join(); t3.join(); t4.join(); return 0; } |
Yukarıda verdiğim örnek kod içerisindeki, doSomenthing() metodunun ilk kısmı, kullanılan ilgili std::once_flag sayesinde sadece bir kere çağrılıyor olacak.
Bu kullanım ile birlikte mutex’lere ilişkin baya bir konuyu işlemiş olduk. Daha bir şey kalmadı mı? Elbette bir çok konu daha var (daha detaylı bilgi için kaynaklara bir göz atabilirsiniz, ya da her zaman google’a danışabilirsiniz 🙂 ama bence bunlar size başlangıç için yeterli olacaktır. Bir sonraki başlıkta bir diğer önemli yapı olan std::condition_variable’lara bakacağız.
std::condition_variable:
Thread kütüphanesi ile sunulan bir diğer önemli yapı da std::condition_variable’dır. Özellikle multithreaded yazılım geliştirdiğiniz durumlarda, bir thread’in diğerini beklemesi, onun yaptığı iş ya da bir koşul sağlandığı zaman çalışmaya devam etmesi gibi durumlar ile karşılaşmışsınızdır. İşte tam da bu gibi durumlar için std::conditional_variable yapıları kullanılabilir. Burada tabi, bir thread’ten veri almak için std::future mekanizmasının kullanılabileceğini söyleyebilirsiniz, doğrudur, fakat std::future’ların tek amacı farklı bir thread’ten veri döndürülmesi ya da hatalı durumun bildirilmesi olduğu için bazı durumlarda daha güçlü bir mekanizmaya ihtiyaç duyabiliriz.

Daha önce pek bahsetmedik ama bir thread işini yaparken diğerine ilişkin durumu aslında bir döngü içerisinde bekleyerek de kontrol edebiliriz. Bu yaklaşım “busy wait” ya da “polling” de deniliyor. Hemen bir örnek üzerinden bu duruma bir göz atalım:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | #include <thread> #include <mutex> #include <chrono> // Durum 1: Iyimser bir kontrol durumu bool isReady {false}; std::mutex isReadyMutex; // Burada ilgili isReady değişkeni true olana kadar döngü içerisinde bekliyoruz // ... // unique_lock burada kilitlemeye baslar std::unique_lock<std::mutex> loopLock{isReadyMutex}; while(!isReady) { loopLock.unlock(); // CPU kullanimini bir sonraki thread'e gecirmek icin haber vermek olarak dusunebiliriz std::this_thread::yield(); // 100 ms sonra tekrar kontrol edelim std::this_thread::sleep_for(std::chrono::milliseconds(100)); loopLock.lock(); } |
Burada gördüğünüz üzere sürekli bir kontrol var ve bu her ne kadar küçük bir iş gibi dursa da, küçük bekleme süreleri sağlamak zor olabilir, daha uzunları için ise gecikmeler yaşanabilir. İşte bunu yerine std::condition_variable kullanabilirsiniz. Peki nedir bu yapılar, hemen referans dokümana bakalım, yine en güzel tanımı kendisi gayet öz bir şekilde veriyor:
Bir çok thread’in birbirleri ile iletişim kurmasına olanak sağlayan bir senkronizasyon yapısıdır. Bu yapı bir ya da daha fazla thread’in, bir ya da fazla thread’i beklemesini (istenirse verilen bir süre kadar), daha sonra ise işine devam etmesini sağlar.
Her bir koşullu değişkeni muhakkak bir mutex ile ilişkilendirilerek kullanılır. Koşullu değişkenlerini kullanmak için ‘<mutex>’ ve ‘<condition_variable>’ başlık dosyalarını eklemeniz gerekmekte. Yukarıda verdiğim örneğe benzer durumlar için:
- Bir ya da birden fazla thread herhangi bir koşulun sağlandığını haber vermek için, haber verilecek olan thread sayısına göre:
- notify_one() ya da notify_all() API’lerinden birisini çağırır
- Belirtilen koşulu beklemek için ya da ondan haberdar olmak için de ilgili thread içerisinde:
- wait() API’si kullanılır.
Şimdi ilk verdiğimiz örneği bu API’leri kullanarak yazalım:
1 2 3 4 5 6 7 8 9 10 11 12 13 | #include <mutex> #include <condition_variable> bool isReady {false}; std::mutex isReadyMutex; std::condition_variable isReadyCondVariab; // Bekleyen thread isi bitince asagidaki API'leri cagirmali isReadyCondVariab.notify_one(); // Ilgili isReady kosulu icin bekleyen thread de asagidaki API'yi cagirir std::unique_lock<std::mutex> lockCV{isReadyMutex}; isReadyCondVariab.wait(lockCV); |
Burada önemli bir durumdan daha bahsetmekte fayda var. Özellikle bu koşullu değişkenleri zaman zaman ilgili thread bunları tetiklemese de, öyleymiş gibi bloklamayı sonlandırabilir. Bu durumlarda ilgili koşulların tekrar kontrol edilmesinde fayda var. http://www.modernescpp.com/index.php/c-core-guidelines-be-aware-of-the-traps-of-condition-variables sayfasında bu durum örnekler ile güzel bir şekilde açıklanmış, oraya bir göz atabilirsiniz.
Şimdi çoklu thread’ tarafından kullanılabilecek bir kuyruk sınıfında koşullu değişkeni ve diğer senkronizasyon yapılarının kullanımına bir göz atalım.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 | template <typename T> class SharedQueue { public: // Yıkıcı ~SharedQueue(void) { Invalidate(); } // Kuyruktaki ilk elemani, dönebilir ise out üzerinden döner. Eger dönemez ise false, öbür türlü true döner bool tryPop(T& out) { std::lock_guard<uMutex> lock{mMutex}; if (mQueue.empty() || !mValid) return false; out = std::move(mQueue.front()); mQueue.pop(); return true; } // Bir önceki metottan farkli olarak eğer kuyrukta bir eleman yok ise gelene kadar bekler // Basarili olarak out doldurulabilirse true, obur turlu false donulur bool waitPop(T& out) { std::unique_lock<uMutex> lock{mMutex}; mCondition.wait(lock, [this]() { // Yukarıda bahsettiğim beklenmedik uyanmalara karsi, buradaki gibi onlem alabilirsiniz. // Yani queue hala bos ve gecerli oldugu muddetce beklemeye devam return !mQueue.empty() || !mValid; }); if (!mValid) { return false; } out = std::move(mQueue.front()); mQueue.pop(); return true; } // Kuyruga yeni bir eleman ekleyelim void push(T value) { std::lock_guard<uMutex> lock{mMutex}; mQueue.push(std::move(value)); mCondition.notify_one(); } // Kuyruk bos mu kontrolu bool empty(void) const { std::lock_guard<uMutex> lock{mMutex}; return mQueue.empty(); } // Kuyrugu bosalt void clear(void) { std::lock_guard<uMutex> lock{mMutex}; while (!mQueue.empty()) { mQueue.pop(); } mCondition.notify_all(); } // Kuyrugu tasarladigimiz yaklasimdan oturu. Kuyrugu yok etmeden once veya uygulamadan cikmadan once // herhangi bir bekleyen thread var ise burada onlar haberdar ediliyor. Bu cagridan sonra artik // kuyruk kullanılmaz hale gelir. void invalidate(void) { std::lock_guard<uMutex> lock{ mMutex }; mValid = false; mCondition.notify_all(); } // Mevcut kuyruk kullanılabilir mi, degil mi? bool isValid(void) const { std::lock_guard<uMutex> lock{ mMutex }; return mValid; } private: std::atomic_bool mValid{ true }; uMutex mMutex; std::queue<T> mQueue; std::condition_variable mCondition; }; |
Bu örnek ile aslında çoğu senkronizasyon yapısının birlikte nasıl kullanılabileceğini de görmüş olduk.
Burada bahsettiğim koşullu değişkenleri bir kaç API’si daha var ama onların da kullanımı aynı mantık, sadece bazı ek kullanım kolaylıkları (verilen zaman ya da zamana kadar bekle gibi) sunuluyor. Onlara da bakmak için https://en.cppreference.com/w/cpp/thread/condition_variable sayfasına bir göz atabilirsiniz.
Sonuç:
Evet arkadaşlar, üç yazılık thread maceramız nihayet son buluyor. Bu üç yazı ile birlikte C++ thread kütüphanesine ilişkin en önemli yapıları sizlere aktarmaya çalıştım, umarım faydalı olmuştur. Bundan sonraki adım, tabiki bunları kullanmak 🙂 Her ne kadar kütüphaneye ilişkin anlatacaklarım bitse de, multithreaded programlamaya ilişkin bir yazım daha olacak, sonra yeni yazılara yelken açabiliriz.
Ben yazılımperver, bol ve eğlenceli kodlamalar 🙂
Kaynaklar:
- https://en.cppreference.com/w/cpp/thread
- http://web.mit.edu/6.005/www/fa15/classes/23-locks/
- http://www.modernescpp.com/index.php/c-core-guidelines-be-aware-of-the-traps-of-condition-variables
- https://solarianprogrammer.com/2011/12/16/cpp-11-thread-tutorial/
- https://thispointer.com/category/multithreading/
- https://stackoverflow.com/questions/17113619/whats-the-best-way-to-lock-multiple-stdmutexes/17113678
- Tanenbaum’s Modern Operating Systems book
- Anthony Williams C++ Concurreny in Action books.
[:en]Hi friends, after a long break we continue our weekly C ++ posts. I am with you about the third article of the series that we started earlier about the std :: thread library. If you have not read my other articles yet, I suggest you to read that articles through following links, especially the first article:
Weekly C++ 8- std::thread (II)
Weekly C++ 10- std::thread (III)
Introduction:
Let’s start with the subject of this article. In my previous writings, I mentioned the use of basic std::thread and utilities. In this article, I will tell you about the synchronization structures which have a very important place in multithreaded software development. As a matter of fact, the std::atomics I mentioned in my previous article is one of these synchronization structures, and although it does not meet all the situations, it can be used for some problems I will describe here.
Now, if you’d like, we can look at a simple example about accessing to common resources that I had mentioned in the previous paragraph:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | void saveItem(int item) { .... int freeSlotIndex{0}; ... freeSlotIndex = foundFreeIndex; storage[freeSlotIndex] = item; foundFreeIndex++; .... } |
The above method, simply stores a given number in the first place it finds in a simple sequence. Let us take a look what will happen when this code will behave when executed in parallel. In the following listing, I tried to give a sample instruction sequences that could occur if two different threads called this method at the same time (this could of course change).
| Thread 2 Execution: | |
| 1) freeSlotIndex = foundFreeIndex; 4) storage[freeSlotIndex] = item; 5) foundFreeIndex++; | 2) freeSlotIndex = foundFreeIndex; 3) storage[freeSlotIndex] = item; 6) foundFreeIndex++; |
As you can see, the value written into empty slots of sequence can crush other one with respect to execution order. This problem is called the race condition in the multihreaded programming world. In case of access to common resources, such problems can occur. Of course, this problem is not always a situation that will occur (believe me to occur and catching them is much better for you :). The parts that can lead to such problems are called critical section. In particular, it is not always easy to find these race conditions, because they do not always occur. To prevent these problems, we protect critical sections. I would like you to pay particular attention to: resources access purpose, if each party only reads, there is no problem and they can all read safely. But if at least one party is going to write this data, then problems may arise. Let’s note that aside.
We looked at a problem that might occur in the above example, but what kind of problems can we face if we do not protect critical parts? Let’s see:
- Unsynchronized Data Access: In fact, the example given above is in this group. If more than one thread is reading and writing to a common data in a parallel manner, which one may have written before problem may occur.
- Half-written Data: Similarly, while a thread is writing data, the other thread can read it exactly in the middle of the writing process. You can have neither old nor new data 🙂 Let me try to explain this with a very simple example:
long long x = 0;
A thread changes this data as follows:
x = -1;
The other reads as follows:
std::cout << x;
This is the that can be an example of exactly the problem we are talking about.
Reordered Statements: Code declarations in individual threads may have been modified for performance or similar reasons. Although these are not a problem alone (sequential running), in parallel runs, expected behavior will not be observed.
For more detailed information on these issues (especially for such problems and approaches), please see the following books I mentioned in my first article:
Now let’s look at the std::mutex, the first of these structures.
std::mutex:
| Basic mutex classes- I | ||
| std::mutex (C++11) |
| |
| std::timed_mutex (C++11) | Provides mutual exclusion facility which implements locking with a timeout | |
| recursive_mutex (C++11) | Provides mutual exclusion facility which can be locked recursively by the same thread | |
| recursive_timed_mutex (C++11) | Provides mutual exclusion facility which can be locked recursively by the same thread and implements locking with a timeout | |
| lock_guard (C++11) | Implements a strictly scope-based mutex ownership wrapper |
| unique_lock (C++11) | Implements movable mutex ownership wrapper |
Independent Functions | |
| try_lock (C++11) | Attempts to obtain ownership of mutexes via repeated calls to try_lock
|
| lock (C++11) | Locks specified mutexes, blocks if any are unavailable |
As I mentioned in my first article, the std::thread library was originally introduced with C ++ 11, and the classes I mentioned above too. The additions that are introduced with C ++ 14 and 17 are as follows:
| Basic Mutex Classes II | |
| shared_mutex (C++17) | Provides shared mutual exclusion facility |
| shared_timed_mutex (C++14) | Provides shared mutual exclusion facility and implements locking with a timeo |
| scoped_lock (C++17) | Deadlock-avoiding RAII wrapper for multiple mutexes https://stackoverflow.com/questions/17113619/whats-the-best-way-to-lock-multiple-stdmutexes/17113678 |
| shared_lock (C++14) | Implements movable shared mutex ownership wrapper |
Well, now that we’ve learned what the ammunition we’ve got, let’s take a look at how we can use them now.
As our first example for multi-threaded software, I’m sure all of you wanted to put something on the console, and the experience you’ve experienced in the first place is probably a mess. Let’s look at how we can solve this by simply using the basic structures above. As you might expect, in this case the shared resource standard output stream (“standard output stream“).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | #include <thread> #include <mutex> #include <iostream> #include <string> #include <chrono> // Ortak veri paylaşımı (ki burada standard output) için muteximiz std::mutex gPrintMutex; void threadSafePrintVersion1(const std::string& input) { gPrintMutex.lock(); for(auto c : input) { std::cout.put(c); std::this_thread::sleep_for(std::chrono::milliseconds(1)); } gPrintMutex.unlock(); } void threadSafePrintVersion2(const std::string& input) { std::lock_guard<std::mutex> localLock(gPrintMutex); for(auto c : input) { std::cout.put(c); std::this_thread::sleep_for(std::chrono::milliseconds(1)); } } int main() { std::thread firstThread(threadSafePrintVersion1, "Hello world from thread 1!\n"); std::thread secondThread(threadSafePrintVersion2, "Hello world from thread 2!\n"); std::cout << "Hello world from main thread!\n"; firstThread.join(); secondThread.join(); return 0; } |
In the code given above, both methods do the same. But std::scoped_lock is more readable (of course, me 🙂 It also eliminates the incorrect use of std::mutex unlock(), or lock () calls. Here I made a number of additions to the code (use of wait and put APIs) to reveal the distressed situation 🙂 In simple applications, std::cout can display it without any hassle. But, you can see what happens when you comment on the lines above.
Now, let’s take a look at the situations that require you to use std::recursive_lock, although not as much as the first case. This need usually occurs when mutex is used in each method and these methods need to call each other. Let’s look at an example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | struct Calculator { std::mutex mMutex; int mLastResult; Calculator() : i(0) {} void multiply(int x) { std::lock_guard<std::mutex> lock{mMutex}; mLastResult *= x; } void divide(int x) { std::lock_guard<std::mutex> lock{mMutex}; mLastResult /= x; } // Yukarıdaki her iki metodu da cagirmamiz gerekecek! void both(int x, int y) { std::lock_guard<std::mutex> lock{mMutex}; multiply(x); divide(y); } }; int main() { Complex complex; complex.both(32, 23); return 0; } |
As you can see in this example, when you call the multiply () or divide () methods, you will not encounter any problems. But when you call both () method, the application will be blocked, why do you think?
The both() method locks the mMutex lock in the first place, and when we call multiply(), we try to lock the same lock, then we call it deadlock. The same thread cannot lock a mutex twice. Now let’s replace all std::mutex uses with std::recursive_mutex and run it so you won’t have any problems.
In the above uses, the corresponding thread is blocked if the corresponding mutex is already locked. Instead, you may want to inquire if you can lock the corresponding mutex. In this case, you can use try_lock() and its variants. Let’s look at an example (from the reference page):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | // try_lock #include <chrono> #include <mutex> #include <thread> #include <iostream> // std::cout std::chrono::milliseconds interval(100); std::mutex mutex; int job_shared = 0; // both threads can modify 'job_shared', // mutex will protect this variable int job_exclusive = 0; // only one thread can modify 'job_exclusive' // no protection needed // this thread can modify both 'job_shared' and 'job_exclusive' void job_1() { std::this_thread::sleep_for(interval); // let 'job_2' take a lock while (true) { // try to lock mutex to modify 'job_shared' if (mutex.try_lock()) { std::cout << "job shared (" << job_shared << ")\n"; mutex.unlock(); return; } else { // can't get lock to modify 'job_shared' // but there is some other work to do ++job_exclusive; std::cout << "job exclusive (" << job_exclusive << ")\n"; std::this_thread::sleep_for(interval); } } } // this thread can modify only 'job_shared' void job_2() { mutex.lock(); std::this_thread::sleep_for(5 * interval); ++job_shared; mutex.unlock(); } int main() { std::thread thread_1(job_1); std::thread thread_2(job_2); thread_1.join(); thread_2.join(); } |
Now, let’s take a look at another usage and then close the mutex page. This usage is related to std::timed_mutex. This class offers the try_lock_for and try_lock_until APIs as well as the standard std::mutex APIs. The try_lock_for () API tries to lock the given mutex, if this mutex is locked, it waits for this mutex to be unlocked for the given time instead of waiting forever. If it can, it returns true, returns false if it cannot. In try_lock_until, it provides a similar use, except that it is time-consuming instead of time. Let’s look at the example given in the reference page:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | #include <iostream> #include <mutex> #include <thread> #include <vector> #include <sstream> std::mutex cout_mutex; // control access to std::cout std::timed_mutex mutex; void job(int id) { using Ms = std::chrono::milliseconds; std::ostringstream stream; for (int i = 0; i < 3; ++i) { if (mutex.try_lock_for(Ms(100))) { stream << "success "; std::this_thread::sleep_for(Ms(100)); mutex.unlock(); } else { stream << "failed "; } std::this_thread::sleep_for(Ms(100)); } std::lock_guard<std::mutex> lock(cout_mutex); std::cout << "[" << id << "] " << stream.str() << "\n"; } int main() { std::vector<std::thread> threads; for (int i = 0; i < 4; ++i) { threads.emplace_back(job, i); } for (auto& i: threads) { i.join(); } } |
Sometimes you want a method to be called only once from different threads. This library also offers std::call_once function for such situations. Let’s look at an example of this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | std::once_flag flag; void do_something() { std::call_once(flag, [](){std::cout << "Called once" << '\n';}); std::cout << "Called each time" << '\n'; } int main(){ std::thread t1(do_something); std::thread t2(do_something); std::thread t3(do_something); std::thread t4(do_something); t1.join(); t2.join(); t3.join(); t4.join(); return 0; } |
In the example code I have given above, the first part of the doSomenthing () method will be called only once with the corresponding std::once_flag used.
Together with this usage, we have covered very much about mutex. Any more left? Of course, there are a lot of issues (you can have a look at the resources for more detailed information, or you can always consult with google 🙂 but I think these are enough to get you started. In the next section we will look at another important structure, std::condition_variable.
std::condition_variable:
Another important structure presented with the thread library is std::condition_variable. In particular, when you develop multithreaded software, you experience situations where a thread waits for the other and it does the work or continues to work when a condition is met. This is exactly the case for std::conditional_variable structures can be used. Of course, you can say that the std::future mechanism can also be used to retrieve data from a thread, but its sole purpose is to return data from a different thread or to report a bad condition, in some cases, we need a better mechanism.

We haven’t talked about it before, but we can actually control the situation by waiting in a loop while doing a thread job. This approach is also called busy wait or polling. Let’s take a look at this situation immediately:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | #include <thread> #include <mutex> #include <chrono> // Durum 1: Iyimser bir kontrol durumu bool isReady {false}; std::mutex isReadyMutex; // Burada ilgili isReady değişkeni true olana kadar döngü içerisinde bekliyoruz // ... // unique_lock burada kilitlemeye baslar std::unique_lock<std::mutex> loopLock{isReadyMutex}; while(!isReady) { loopLock.unlock(); // CPU kullanimini bir sonraki thread'e gecirmek icin haber vermek olarak dusunebiliriz std::this_thread::yield(); // 100 ms sonra tekrar kontrol edelim std::this_thread::sleep_for(std::chrono::milliseconds(100)); loopLock.lock(); } |
As you can see here, there is a continous control, and although this may seem like a small job, small waiting times can be difficult to perform, and longer periods may cause more delays. Here, you can use std::condition_variable instead. So what are these structures, let’s look at the reference document immediately, where a good definition is provided:
The condition_variable class is a synchronization primitive that can be used to block a thread, or multiple threads at the same time, until another thread both modifies a shared variable (the condition), and notifies the condition_variable.
Each conditional variable is necessarily associated with a mutex. To use conditional variables, you need to add <mutex> and <condition_variable> header files. For cases similar to the one I’ve given above:
- One or more threads to tell if any conditions are met, depending on the number of threads to be notified:
- One of notify_one() or notify_all() API is called
- To be aware of or wait for the specified condition in a thread:
- wait() API is used.
Şimdi ilk verdiğimiz örneği bu API’leri kullanarak yazalım:
1 2 3 4 5 6 7 8 9 10 11 12 13 | #include <mutex> #include <condition_variable> bool isReady {false}; std::mutex isReadyMutex; std::condition_variable isReadyCondVariab; // Bekleyen thread isi bitince asagidaki API'leri cagirmali isReadyCondVariab.notify_one(); // Ilgili isReady kosulu icin bekleyen thread de asagidaki API'yi cagirir std::unique_lock<std::mutex> lockCV{isReadyMutex}; isReadyCondVariab.wait(lockCV); |
It is worth mentioning an important situation here. Specifically, these conditional variables may sometimes stop blocking the relevant thread, even if the thread does not trigger them. In these cases, it is useful to check the relevant conditions again. On the http://www.modernescpp.com/index.php/c-core-guidelines-be-aware-of-the-traps-of-condition-variables page, this case is beautifully explained with examples, so you can take a look there.
Now let’s take a look at the use of conditional variable and other synchronization structures in a queue class that can be used by multi-thread applications:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 | template <typename T> class SharedQueue { public: // Yıkıcı ~SharedQueue(void) { Invalidate(); } // Kuyruktaki ilk elemani, dönebilir ise out üzerinden döner. Eger dönemez ise false, öbür türlü true döner bool tryPop(T& out) { std::lock_guard<uMutex> lock{mMutex}; if (mQueue.empty() || !mValid) return false; out = std::move(mQueue.front()); mQueue.pop(); return true; } // Bir önceki metottan farkli olarak eğer kuyrukta bir eleman yok ise gelene kadar bekler // Basarili olarak out doldurulabilirse true, obur turlu false donulur bool waitPop(T& out) { std::unique_lock<uMutex> lock{mMutex}; mCondition.wait(lock, [this]() { // Yukarıda bahsettiğim beklenmedik uyanmalara karsi, buradaki gibi onlem alabilirsiniz. // Yani queue hala bos ve gecerli oldugu muddetce beklemeye devam return !mQueue.empty() || !mValid; }); if (!mValid) { return false; } out = std::move(mQueue.front()); mQueue.pop(); return true; } // Kuyruga yeni bir eleman ekleyelim void push(T value) { std::lock_guard<uMutex> lock{mMutex}; mQueue.push(std::move(value)); mCondition.notify_one(); } // Kuyruk bos mu kontrolu bool empty(void) const { std::lock_guard<uMutex> lock{mMutex}; return mQueue.empty(); } // Kuyrugu bosalt void clear(void) { std::lock_guard<uMutex> lock{mMutex}; while (!mQueue.empty()) { mQueue.pop(); } mCondition.notify_all(); } // Kuyrugu tasarladigimiz yaklasimdan oturu. Kuyrugu yok etmeden once veya uygulamadan cikmadan once // herhangi bir bekleyen thread var ise burada onlar haberdar ediliyor. Bu cagridan sonra artik // kuyruk kullanılmaz hale gelir. void invalidate(void) { std::lock_guard<uMutex> lock{ mMutex }; mValid = false; mCondition.notify_all(); } // Mevcut kuyruk kullanılabilir mi, degil mi? bool isValid(void) const { std::lock_guard<uMutex> lock{ mMutex }; return mValid; } private: std::atomic_bool mValid{ true }; uMutex mMutex; std::queue<T> mQueue; std::condition_variable mCondition; }; |
With this example, we have seen how most synchronization structures can be used together.
The conditional variables I have mentioned here, have a few more APIs, but their use is similar, only some additional ease of use (such as wait until the given time or time) features are provided. You can also take a look at https://en.cppreference.com/w/cpp/thread/condition_variable for such features.
Conclusion:
Yes, friends, our three post thread adventure is finally coming to an end. With the help of these three articles, I tried to convey the most important structures of the C ++ thread library to you. The next step, of course, is to use them. Although I’m done with the library, there will be other posts on multithreaded programming,
I am yazilimperver, plentiful and entertaining codings 🙂
References:
- https://en.cppreference.com/w/cpp/thread
- http://web.mit.edu/6.005/www/fa15/classes/23-locks/
- http://www.modernescpp.com/index.php/c-core-guidelines-be-aware-of-the-traps-of-condition-variables
- https://solarianprogrammer.com/2011/12/16/cpp-11-thread-tutorial/
- https://thispointer.com/category/multithreading/
- https://stackoverflow.com/questions/17113619/whats-the-best-way-to-lock-multiple-stdmutexes/17113678
- Tanenbaum’s Modern Operating Systems book
- Anthony Williams C++ Concurreny in Action books.
[:]

Güzel Bir Kaynak Olmuş Ellerine Sağlık.