Haftalık C++ 12 – Multithread programlamada karşılaşılan bazı sıkıntılar

Merhabalar dostlar. Kısa bir aradan sonra, başka bir haftalık C++ yazısı ile birlikteyiz. Bu yazımda, bir süre önce sizler ile paylaştığım thread kütüphanesinin kullanımı ile ilgili bazı hatal durumları ve bunları nasıl önleyebileceğimize bakacağız. Her bir duruma ayrı başlıklarda, kısa açıklama ve örnekler ile değineceğiz. Buradaki durumların bir kısmını, yazılarımı takip edenleriniz hatırlayacaktır, ilgili konuları anlatırken bunlara da kısaca değinmiştim.

Bu yazım için ise kaynakçada verdiğim siteden faydalandım. Aslında bu yazıyı thread yazılarını yazarken görmüştüm ama o zaman seriyi tamamlamadığım için vermek istemedim, ama şimdi tam zamanı. O yazıda 20 ye yakın durumdan bahsedilse de, ben bu yazımda bana göre önemli olduğunu düşündüklerimi ekledim. Bu yazı ile birlikte, bu tarz hatalı durumları toplu bir şekilde inceleme şansınız olacak, hem de thread kütüphanesinin hızlı bir tekrarını yapmış olacağız. Yazımın sonuna ayrıca, multithreaded programlama ile ilgili faydalanabileceğiniz bağlantılar da ekledim. Yine benzer içerikleri sizler ile paylaşıyor olacağım.

Evet daha fazla beklemeden ilgili problemlere bakmaya başlayalım:

1. Farklı thread’ler tarafından kullanılacak verilerin koruma altına alınmaması:

Bir önceki yazımızda gördüğümüz üzere, multithreaded programlama’da karşılaşacağınız en yaygın problem, thread’ler arası paylaşılan verilerin korunmamasıdır.

Siz zaten bu problemi ve bunu nasıl çözebileceğinizi bir önceki yazımdan biliyorsunuzdur 🙂 Evet, doğru tahmin ettiniz, ilgili kritik bölgeleri std::mutex ve benzeri yapılar ile korumak. Bu sayede, ilgili veriye aynı anda sadece bir thread’in eriştiğini teminat altına almış olacaksınız. Peki burada gerçekleştirilen olay neydi? Temel olarak gerçekleştirilen: paylaşılan veri ile çalışacak thread, ilgili kritik bölgeye girmeden önce, std::mutex’i kilitler ve veri ile olan işini gerçekleştirir ve sonrasında bu kilidi kaldırır. Bu sayede, ilgili veri ile çalışmak isteyen diğer threadler bu veri üzerinde çalışabilir ve güvenli bir şekilde güncelleyebilirler.

Burada elbette sıkıntılı durumun, herhangi bir thread’in, paylaşılan veriyi güncellemek istediği durumunda oluşacağını hatırlatmak istiyorum. Yoksa, eğer bütün thread’ler paylaşılan verileri sadece okuyor/kullanıyor ise, herhangi bir problem ile karşılaşmayacaksınız.

Bu başlık biraz bunu hatırlatmaya yönelikti.

2. Kritik bölge kilidinin kaldırılmaması:

Bir önceki problemde, ortak verilere, birden fazla thread tarafından erişip, tutarlı bir şekilde kullanılması için, std::mutex ve benzeri yapıların kullanılması gerektiğini ifade ettik ve nasıl kullanılacağını basit bir örnek ile gösterdik. Peki bunları doğru kullanmazsak ne olur? Örneğin, ilgili kritik bölgeye girdiğinizde kilidi aktfileştirdiniz ama işiniz bittiğinde, kilidi kaldırmadınız ne olacak? Evet, doğru tahmin ettiniz, bu veriye erişip, kullanmak isteyen diğer thread’ler, bu veriye erişmek aşkı ile yanıp kül olacaklar ama yine de erişemeyecekler. Sonuç olarak, uygulamanız asılı kalacak. 

Peki bunu önlemek için ne yapabiliriz? Bu amaçla sunulan, yardımcı yapıları kullanabiliriz (tabi kilidi ilgili API’yi kullanarak kaldırmanın yanında), bunların da en önemlisi, std::mutex ve ilgili kilitleme/kilidi kaldırma API’leri yerine, RAII (Resource Acquisition Is Initialization) tabanlı std::lock_guard benzeri yapıları daha sık kullanmak olacaktır. Bu sayede, kilidi kaldırmayı unutma gibi bir derdiniz de olmayacak. Ne zamanki lock_guard nesneleri, tanımlı kapsamları dışında çıkınca, otomatik olarak ilgili mutex’lerin kilidini kaldırıyor olacaklar.

Aslında, her ne kadar bu yaklaşım çok uzun bir süredir bilinse de, modern C++ bunu akıllı işaretçiler ve benzeri yardımcı yapılar ile daha kolaylaştırdı. Bana kalırsa, bu tarz yapıların, bir diğer avantajı da kodu daha okunabilir yapmaları. 

3. İlgili std::mutex’in iki kere kilitlenmesi:

Herhangi bir mutex’in iki kere kilitlenmesi bir çok thread kütüphanesinde, beklenmedik davranışlara sebep olabilir ve bu da çoğunlukla uygulamanın çakılması şeklinde ortaya çıkacaktır. Burada dikkat edilecek iki durum var: birincisi, ilgili mutex’in yanlışlıkla iki kere kilitlenmesi diğer ise böyle bir ihtiyacın olması.

İlki için dikkatli olmak ve ilgili hatayı düzeltmekten başka çareniz yok ama ikincisi için bir önceki yazımda bahsettiğim std::recursive_mutex sınıfını kullanabilirisiniz (iligli yazıda Calculator sınıfında buna ilişkin bir örneğe bakmıştık). Tabi, ilk durumda bahsettiğim hatayı önlemek için de std::recursive_mutex kullanabilirsiniz ama bu doğru bir yaklaşım değil ve bunu yapmamalısınız 🙂

4. Korunması gereken kritik bölgeyi uzun tutmak

Bir diğer göz önünde bulundurulması gereken ve özellikle beklenmedik beklemelere yol açan durum, kritik bölgelerin uzun tutulması. Bir diğer ifade ile, bu bölgelerde yapılan işlerin çok fazla olması. Burada amacımız basit: bu bölgelerde yapılan işleri olabildiğince azaltmak, paylaşılan kritik veri ile ilgili olmaya bütün işleri, bu bölge dışında, mutex’in kapsamı dışında, tutmak. Bu anlamda, std::scoped_lock sınıflarını kullanmanızı öneriyorum.

5. Çoklu mutex kullanılması durumunda, kilitleme sırasının karıştırılması

Geldik, bir diğer önemli probleme. Bir önceki yazımda, “deadlock” durumundan, yani thread’lerin birbirlerini sonsuza dek beklemeleri durumundan bahsetmiştim. İşte bunun en önemli sebeplerinden birisi de bu. Yani birden fazla thread’in, birden fazla mutex’i farklı sıralar ile kilitleyip, kilidini kaldırmaya çalışması. Öncelikle iki thread için bu durumun nasıl olabileceğine bakalım:

Thread 1 Thread 2
A Mutex’ini kilitle

//.. Bir şeyler yapalım

B Mutex’ini kilitle

// .. Daha da bir şeyler yapalım

B Mutex’inin kilidini kaldır

A Mutex’inin kilidini kaldır

B Mutex’ini kilitle

//.. Bir şeyler yapalım

A Mutex’ini kilitle

//.. Daha da bir şeyler yapalım

A Mutex’inin kilidini kaldır

B Mutex’inin kilidini kaldır

Şimdi Thread 1’in A mutex’inin kilitlediği sırada, Thread 2’nin de B Mutex’ini kilitlediğini düşünelim. Ne olur? Thread 1, B mutex’ini kilitlemeye çalıştığı zaman, bu mutex, A tarafından kilitlendiği için bloklanacak ve bekleyecek. Benzer şekilde Thread 2’de A mutexi için bekleyecek ve “Deadlock” dediğimiz durum, işte tam bu noktada oluşacak.

Aşağıda verilen örnek kod, burada bahsettiğimiz durumu ortaya koyuyor:

Peki bu durumu önlemek için ne yapacaksınız? Öncelikle, bu tarz çoklu mutex kullanılması durumunda aynı sırayı takip etmek olacaktır.

Ayrıca, yine bir önceki yazımda bahsettiğim ve çoklu mutex alan std::scoped_lock tarzı sınıfları kullanmak olacaktır.

6. join()/detach() metotlarını çağırmayı unutmak

Aslında başlık, ilgili problemi net bir şekilde tanımlıyor. Peki neden bu metotları çağırmaya ihtiyaç duyuyoruz ve çağırmaz isek ne olur? Eğer thread’lerin tamamlanmasını beklemeden main() metodundan çıkarsanız, thread’ler işlerini düzgün bir şekilde tamamlamadıkları için, bütün uygulamanızın göçtüğünü göreceksiniz.

Bu sebeple thread’ler ile çalışırken, birleştirmek için join() ya da ayırmak için detach() API’lerini çağırdığınızdan muhakkak emin olun. join() API’si bu metodu çağıran thread’i ilgili thread bitene kadar bekletir ve ilgili thread bitince çalışmaya devam eder. Eğer çağıran thread’in ilgili thread’i beklemesini istemiyorsanız o zaman da detach() API’sini kullanırsınız ve bu durumda çağıran thread, arka planda çalışmaya devam eder. Bu noktadan sonra, ilgili thread üzerinde herhangi bir kontrolü de olmaz.

Daha detaylı bilgi için ilk thread yazıma göz atabilirsiniz.

7. Daha önce detach() ile ayrılmış bir thread için join() API’sini çağırma

Bir önceki başlıkta da ifade ettiğimiz gibi, daha önce detach() ile ayrılmış olan bir thread üzerinde artık herhangi bir kontrolünüz kalmıyor. Bir şekilde eğer bu thread’e ilişkin olarak join() çağırırsanız (tabi normal şartlarda bunu çağırmazsınız, fakat eğer ilgili kodlar farklı yerlerde ise gözünüzden kaçabilir), uygulamanız çöker. Peki ne yapacağız? Bu durum için thread kütüphanesi bir API sunmakta: joinable(). Eğer thread data önce ayrılmış ise, bu API false dönecektir, aksi takdirde true dönecektir.

8. join() API’sinin davranışı

Bir önceki API ile ilintili olarak, eğer çalışan bir thread’e ilişkin join() API’sini çağırırsanız, ilgili API mevcut thread koşumunu, çağrılan thread tamamlanana kadar bloklar. Bu sebeple, bu API’den önce ya ilgili thread’in işinin bittiğinden emin olun ya da yazılımınızı bu doğrultuda tasarlayın.

9. Thread argümanlarının referans olarak geçirildiğini düşünmek

İlk thread yazımda bir örnek üzerinden gösterdiğim bir durumdu bu. Ne demiştik bakalım:

Her ne kadar, thread metodu parametreyi referans olarak alıyor olsa da, yeni thread oluşturulurken ilgili değişkeni arka tarafta kopyalanır ve thread çalışmaya başladığında da bu kopyayı metoda geçirir. Bu sebeple, yeni oluşturulan thread çalışmayı tamamlandığında, thread metodundaki değişken, thread metoduna geçirilen ve kopyaları oluşturulan diğer parametreler ile birlikte yok edilir. Bu sebeple de asıl değişken güncellenmez. Bu problemi önlemek ve parametrenin kendisini içeren bir referans göndermek için yine STL tarafından sunulan std::ref() fonksiyonu kullanılmalıdır. Nasıl kullanacağız? Aşağıdaki gibi:

10. std::atomic tipinin kullanımı

İkinci thread yazımda bahsettiğim gibi, temel ve basit veri tipleri için std::atomic mekanizmasını kullanabiliriz. Bu bize hem performans hem de okunabilirlik adına birçok fayda sağlar. std::mutex kullanabilir miyiz? Elbette, ama atomic kullanımı bizim için performans açısından ciddi fark yaratabilir. https://www.arangodb.com/2015/02/comparing-atomic-mutex-rwlocks/ sitesinde bunun ile ilgili güzel bir çalışma var.

11. std::future nesnesine ilişkin get() API’sinin bilinçsiz bir şekilde çağrılması

Şimdi de eski yazılarımda bahsettiğim bir sınıf olan, std::future nesnesine ilişkin bir duruma göz atalım. O yazımda da bahsettiğim gibi, eğer bu nesnenin işi henüz bitmemiş ise ve get() metodu çağrılırsa, ilgili iş bitene kadar, mevcut thread bloklanır ki bunu genelde istemeyiz. Hemen bir örnek üzerinden bu duruma bakalım:

Yukarıda verilen kodta, nerede bloklama olacağını görebilirsiniz. Bu kod ile ilgili bir diğer sıkıntı da, ilgili std::future nesnesi bir kere alınıp, artık alınacak bir şey olmamasına rağmen, bunun sorgulanmasıdır.Bunu önlemek için, ilgili nesnenin hazır olur olmadığını kontrol edebiliriz. Bunu da valid() API si ile yapabiliriz. Şöyleki:

12. std::async içerisinde gerçekleşen istisna (exception) durumları std::future nesnesine get() API’si ile geçirilmesi

Eğer std::async kullanıyorsanız ve bunun içerisinde bir istisnai durum oluştu ise bunu çağıran tarafa geçirmek için de get() API’sini çağırmalısınız. Aşağıda buna dair örnek bir kullanım gösterelim:

13. Mevcut çekirdek sayısında çok daha fazla thread oluşturulması

İlk thread yazımda da bahsettiğimiz gibi, aynı anda çalışabilecek thread sayısı, mevcut işlemcinin bizlere sunduğu çekirdek sayısı ile ilintilidir. Eğer ilgili thread sayısı bundan fazla ise, fazladan thread oluşturmak, bize performans anlamında ciddi bir kazanım sağlamaz, hatta kötü yönde de etkileyebilir.

Evet arkadaşlar, bir yazımızın daha sonuna geldik. Umarım thread’ler konusunda sizlere yardımcı olacak bir kaynak olmuştur. Buradaki konuların yanında, daha detaylı bilgi için aşağıdaki sitelere de göz atmayı unutmayın. Bir sonraki yazımda görüşmek üzere.

Kaynaklar:

Bir cevap yazın

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

Bu site, istenmeyenleri azaltmak için Akismet kullanıyor. Yorum verilerinizin nasıl işlendiği hakkında daha fazla bilgi edinin.