Haftalık C++ 10- std::thread (III)

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:

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“).

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:

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:

Ş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:

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:

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.

Waiting, Appointment, Schedule, Time, Hurry, Urgent

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:

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:

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.

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:

Bir cevap yazın

E-posta hesabınız yayımlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir

This site uses Akismet to reduce spam. Learn how your comment data is processed.