Haftalık C++ 7- std::thread (I)

Bir başka haftalık C++ yazısı ile tekrar beraberiz. Bu yazımda, sizlere C++ 11 ile birlikte sunulmaya başlanan bir kütüphane bilgi aktaracağım. Bu kütüphaneden daha önce tamamladığım Modern C++ yazılarımda bahsetmemiştim (neden diye sormayın), ama artık vakti geldi. Eminim geliştirdiğiniz programlarda, özellikle çoklu çekirdeğe sahip işlemciler için :), bir noktada buna ihtiyacınız olmuştur.  Evet, tahmin edebileceğiniz üzere bu kütüphane thread kütüphanesi. Çoklu çekirdekli işlemcilerin yaygınlaşması ile birlikte, multithreaded (çok fazla kafa karıştırmamak adına bu şekilde kullanacağım) uygulama geliştirme artık önemli bir kabiliyet haline geldi. Uzun bir süre, C++ programlama dilini kullanarak multithreaded yazılım geliştirmek için platform bağımlı Win32 soketleri, MFC soketleri, PThreads ve benzeri API’leri kullanmak zorunda kalıyorduk (eğer tabi Boost ya da POCO kullanmıyor iseniz). C++ 11’den önce nasıl multithreaded yazılım geliştirildiğini merak ediyorsanız, şu bağlantıdaki yazıya bir göz atabilirsiniz.

Seriye ilişkin yazılara aşağıdaki bağlantılardan ulaşabilirsiniz:

Haftalık C++ 7- std::thread (I)

Haftalık C++ 8- std::thread (II)

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

Giriş:

C++ 11 ile birlikte, STL ile sunulan Thread kütüphanesini kullanarak, çoklu platform için, standard bir şekilde multihreaded yazılım geliştirebiliyoruz. Eğer daha önce bir şekilde, Boost Thread library kullanarak multithreaded yazılım geliştirdiyseniz, C++ 11 ile kütüphanesinin de, büyük ölçüde bu kütüphaneye dayandığını bilmek sizi sevindirecektir. Tabi burada özel olarak, sizi Boost ya da başka bir kütüphaneyi kullanmaya sizi zorlayan bir neden yok ise STL ile sunulan std::thread kütüphanesini kullanmalısınız.

Yazımın devamında, multithreaded yazılım geliştirmenize yardımcı olacak bazı thread ve benzeri kavramlara ilişkin bilgi aktaracağım. Sonrasında ise diğer yazılarımda olduğu gibi önemli kabiliyetleri örnek kodlar üzerinden sizlere aktaracağım. Tabi kütüphane oldukça büyük, bu yazılarda bütün ince ayrıntıların üzerinden geçmek imkansız. Hatta bu yazımda sadece temel thread API’lerinden bahsedip, başka bir yazımda yardımcı yapılardan bahsetmeyi planlıyorum. Eğer, bu konu ile ilgili açıkta bir husus kaldığını düşünüyor iseniz bir sonraki yazıda olabilir, ya da hiç beklemeden direk sorabilirsiniz. Hadi başlayalım.

Thread’lere giriş:

Bildiğiniz üzere, haftalık C++ yazılarımda olabildiğince detaylara girmemeye gayret ediyorum 🙂 ama bu sefer de, bir istisna yapalım ve threadler nedir sorusuna bir göz atalım. Bu konuya ilişkin gerçek anlamda detaylı bir bilgi almak istiyorsanız, nadide ders kitaplarımızdan olan Tanenbaum’s Modern Operating Systems book kitabının “Processes and Threads” konusunu okumanızı şiddetle tavsiye ederim, daha C++ ağırlıklı bilgi için ise  Anthony Williams C++ Concurreny in Action books kitabına bir göz atabilirsiniz. Ben bu başlık altında, verdiğim kaynaklarda detayları anlatılan konulara ilişkin özet bilgi geçeceğim.

Öncelikli olarak threadleri (iş parçacığı) daha iyi anlayabilmek için programlara, işlemlere (process) ve bunların birbirleri ile olan ilişkilerine bakmamız gerekiyor.  Programlar, çeşitli C++ benzeri programlama dilleri ile geliştirilmiş olan kodların derlenip, bağlanması ile elde edilen ikili sisteme uygun saklanan (binary) dosyalardır (Python ve benzeri dillerde ise ilgili kaynak kod çalışma zamanında ilgili platforma göre yorumlanır. Bu dosyalar, çalıştırılmak amacı ile belleğe alınana kadar, dosya sisteminde saklanırlar.
Bu program belleğe yüklendikten sonra, kabaca ilgili bütün kaynaklar ile birlikte işlem (Process) halini alır. Bu işlemler, belleğe yüklendikten sonra ilgili işletim sistemi tarafından yönetilirler ve her işletim sistemine özgü ek parametre veya kaynaklara sahip olabilirler ama genel olarak çoğu işletim sistemi bu işlemlere dair aşağıda verilen bilgileri tutar:

  • Program/Yönerge Sayacı (Program/Instruction counter): Çalıştırılan uygulamada işlemcinin şu işeyeceği yönergeyi gösteren sayaç,
  • Kayıt Alanları (Registers): CPU üzerinde bulunan veri slotları,
  • Stack: Mevcut aktif rutin ya da fonksiyona ilişkin bilgileri tutan veri yapısı. Ayrıca geçici veriler de burada tutulur,
  • Heap: Dinamik olarak ayrılan bellekler
  • Dosya/Soket/Sinyal Kotarıcıları: Bu işlem tarafından açılan dosya, soket ve sinyal kotarıcıları.

Özellikle Stack ve Heap ile ilgili daha fazla bilgi edinmek için şu sonuçlara bakabilirsiniz.

Her bir işlem, kendisine ait bellek adres uzaylarına sahiptir, diğer işlemlerden bağımsız ve izole bir şekilde bu alan içerisinde çalışırlar. Bunlar diğer işlemlerin verilerine direk erişemezler. Bu bağımsızlık önemli, çünkü herhangi bir işlemde oluşacak bir problemin, diğer işlemleri etkilememesi veya bozmaması işletim sisteminin en önemli amacıdır ve bu bağımsızlık buna hizmet eder.

Şimdi thread’lere bir göz atalım. Thread’ler, işlemler içerisindeki işleri yerine getiren birimlerdir. Bu birimler aynı program/işlem içerisinde diğer birimlerden bağımsız bir şekilde kodların/yönergelerin koşturulmasını sağlarlar.  Bazı kaynaklarda, thread’ler hafif işlem (lightweight processes) olarak da adlandırılmışlardır. Çünkü kendilerine ait stack’leri vardır ve bunlar diğer thread’ler ile paylaşılmaz, yani birbirlerinin stack’lerinde bulunana verilere erişemezler. Bunun ile birlikte işleme ait ve Heap’te bulunan verilere, soketlere ve dosya kotarıcılarına erişebilirler. Thread’ler aynı işlem içerisinde bulunurlar ve ilgili bellek adres uzayını paylaşırlar. Bu nedenler, işlemler arası haberleşmeye göre thread’ler arası haberleşmenin maliyeti çok daha azdır. Tabi burada, herhangi bir thread’te oluşacak problemin diğer thread’leri ve nihayetinde işlemin çalışmasını sekteye uğratma riski bulunmaktadır.

İşlem ve içerisindeki thread’ler arasındaki ilişki, aşağıdaki figürde gösterilmektedir. Detaylar için bu kaynağa bir göz atabilirsiniz.

Şimdi işlem ve thread’ler arasındaki farklara bakalım (Bilgisayar müh. öğrencileri OS sınavlarınıza belki yardımcı olur 😉

  • İşlemler, threadlere göre daha yüklü işler için kullanılırlar,
  • İşlemler, diğer işlemler ile belleklerini paylaşmazlar. Thread’ler ise aynı işlemi paylaştıkları thread’ler ile bir kısım belleği (heap) paylaşırlar,
  • Thread’ler arası haberleşme, işlemler arası haberleşmeden genelde daha hızlıdır,
  • İşletim sistemi açısından thread yönetimi (bağlam değişimi, vb.), işlem yönetimine göre daha ucuz ve kolaydır.

Thread ya da işlem kullanımı her ne kadar bu yazımızın konusu olmasa da, yukarıdaki sekmeler size bu konuda bir fikir vereceğini umuyorum. Ayrıca, bu kararın nasıl verildiğine ilişkin Google’dan güzel bir örnek, Örnek İşlem/Thread Seçim Analizi, de mevcut. Bir göz atabilirsiniz.

Şimdi konumuzla ilintili olan iki kavrama daha bir göz atalım: paralellik (parallelism) ve aynı anda kullanım (concurrency). Eğer kullandığınız bilgisayarın işlemcisi sadece bir çekirdeğe sahip ise, oluşturduğunuz thread’ler birbirleri ile birlikte paralel çalışırlar ama aynı anda çalışmazlar. Çünkü bir çekirdek fiziksel olarak aynı anda sadece bir yönergeyi işleyebilir. Bir diğer ifade ile, her bir thread, çekirdeğin zamanın bir kısmını alarak yönergeleri çalıştırır, daha sonra durdurulur ve diğer bir thread çalışmaya devam eder. Bu benzer şekilde diğer threadler için de aynı şekilde işler ve çekirdek zamanı aralarında paylaştırılır. Aslında tek çekirdek için aynı anda çalışma durumu yoktur. Bunun ile birlikte, eğer işlemciniz birden fazla çekirdeğe sahip ise, işte bu durumda, gerçek anlamda aynı anda kullanım sağlanır ve her bir çekirdekte aynı anda bir thread çalışabilir.

Tabi burada göz önünde bulundurulması gereken önemli bir nokta, sizlerin threadlerin işlemci üzerinde yönergeleri çalıştırma, zaman paylaşımı ve bunların önceliği konusunda kontrolünüz olmadığıdır. Bu konuda STL tarafından da bir yöntem de sunulmaz. Tabi burada çeşitli mutex veya benzeri üst seviye yapılar sayesinde el ile kontrolden bahsetmiyorum.

Son olarak, bu başlığı kapatmadan önce, multithreaded yazılım geliştirirken göz önünde bulundurmanız gereken iki husustan bahsedeceğim:

  • Bunlardan ilki, multithreaded programlama kullanarak çözmeye çalıştığınız problemi doğru anlamanız. Yani problemin hangi kısımları paralelleştirilebilir ya da gerçekten problem buna uygun mudur? Pareto prensibine dayanarak şunu söyleyebiliriz ki: İşlemcinin %90 zamanı, kodun %10 kısmı için harcanır. Bu noktada kodun %90’nından ziyade, çalıştırılan %10 luk kısma odaklanmalısınız.
  • İkinci konu ise paylaşılacak olan kaynaklardır. Thread’ler arası bu kaynakların emniyetli bir şekilde paylaşılması ve threadlerin bu kaynaklar için boşuna beklemelerinin önüne geçilmesi de önemli bir husustur. Bu noktada thread senkronizasyon yapıları devreye girer ki, bir sonraki yazımızda bu yapılara değineceğiz. Ama threadler arası emniyetli veri paylaşımı ve yöntemleri genelde programlama dillerinden bağımsız ortak yaklaşımlar ya da tasarım kalıpları ile çözülebilmektedir. Bu konu ile ilgili bir çok kaynak bulabilirsiniz.

Thread temelleri:

Evet, artık std::thread kütüphanesinin nasıl kullanılacağına bakabiliriz. Öncelikli olarak thread’lere ilişkin temel işlere bakacağız. Bu arada bu kütüphaneyi kullanabilmek için ihtiyaç duyulan derleyici gereksinimleri aşağıdaki gibidir:

Windows: Visual Studio 2012

Linux: gcc 4.8.1

Kütüphaneyi kullanmak için, <thread> dosyasını eklememiz gerekiyor. Basit bir şekilde ana thread (her C++ uygulaması varsayılan bir thread ile başlar) yanında yeni bir thread oluşturmak için, std::thread nesnesi oluşturup aşağıdaki çağrılabilir elemanlardan birisi ile bu nesneyi oluşturuyoruz:

  • Statik bir üye sınıf metodu ya da bağımsız metot,
  • Sınıf metot işaretçisi ve ilgili sınıf nesnesi,
  • Fonksiyon nesneleri,
  • Lambda fonksiyonları.

İlgili thread nesne oluşturulur oluşturulmaz çalışmaya başlar. Bunun ile birlikte mevcut thread ile birlikte kullanılabilecek std::this_thread isim uzayı (namespace) ile sunulan bir takım yardımcı metotlar bulunmaktadır. Aslında dört tane:

  • sleep_for(): Mevcut thread’i verilen zaman boyunca uyutur (yani işlemci zamanı almaz).
  • get_id(): Mevcut thread’e ilişkin kullanılabilecek tanımlayıcı bir sayı döner.
  • yield(): Çağıran thread işlemci kullanımından tekrar planlanması amacı feragat eder, yani bekleyen diğer threadlere imkan sağlar.
  • sleep_until(): Mevcut thread’i verilen zaman kadar uyutur. Daha sonra ilgili thread çalışmasına devam eder.

Aşağıdaki örnekte, ana thread’e ilaveten yukarıdaki yöntemler kullanılarak dört farklı thread oluşturuluyor. Ayrıca std::this_thread API’lerine ilişkin de örnek kod görülebilir:

Aynı anda çalıştırılabilicek thread adeti std::thread::hardware_ concurrency() metodu ile aşağıda gösterildiği şekilde öğrenilebilir. Bu genelde işlemci çekirdek adetini döner. Bu özellikle dinamik olarak thread oluşturma ve bunları işlemci çekirdek adetinden bağımsız şekilde yönetmek istediğiniz durumlarda size yardım olabilir.

Thread’leri birleştirmek/ayırmak:

Thread’leri nasıl oluşturabileceğimize baktık. Şimdi de onların nasıl tamamlandığına bakacağız. Bu iki şekilde gerçekleştiriliyor: birleştirmek (join) ya da ayırmak (detach).

Thread’e geçirilen metot tamamlandığı zaman, kütüphane bazı thread tamamlanma işleri yapar ve topu işletim sistemine atar.

Yukarıda verilen örnekte eğer thread’lerin tamamlanmasını beklemeden main() metodundan çıkarsanız, thread’lerin 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 emin olun. join() API’si bu metodu çağıran thread’i ilgili thread bitene kadar bekletir ve ilgili thread bitince çalışmaya devam eder. Tabi burada, ilgili thread’in muhakkak döneceğinden emin olmalısınız aksi takdirde, çağıran thread sonsuza kadar bekler :). Eğer çağıran thread’in ilgili thread’i beklemesini istemiyorsanız o zaman da detach() API’ni kullanırsınız ve bu durumda çağıran thread çalışmaya devam eder. Bu noktadan sonra, ilgili thread üzerinde herhangi bir kontrolü de olmaz.

Burada önemli bir nokta, mevcut thread’in ilişkisi bulunmadığı ya da mevcut olmayan bir thread’e ilişkin join() ve detach() API’lerini çağırmamaktır. Aksi durumda uygulamanız beklenmedik bir şekilde sonlanabilir.

Thread’lere parametrelerin geçirilmesi:

Biraz da thread’lere, daha doğrusu, thread metotlarına nasıl parametre geçirebilirize bakmaya. Aslına bakarsanız, ilk verdiğim örnekte sınıf üyesi metot kullanımında buna örnek vermiş oldum ki orada da parametre thread nesne yapıcısına argüman olarak geçiriliyor. Aslında bakarsanız, diğer kullanımlar da aynı. Tabi burada, thread’lere geçirdiğiniz parametrelerin geçerliliğinin korunmasından sizlerin, yani çağıran thread’in, sorumlu olduğunu unutmamak. Örneğin, dinamik olarak bellekten ayrılmış bir nesneyi ilgili thread’e geçirip daha sonra bunu silerseniz sıkıntı yaşarsınız ya da yerel bir değişkeni geçirip, bu değişken kapsam dışına çıkarsa, benzer şekilde sıkıntı yaşayabilirsiniz. Aşağıdaki örnek de, bu kullanımlara örnekler verdim:

Bunu önleme için yeni bir std::string oluşturulup, bu string thread’e geçirilebilir.

Ayrıca thread’lere referans geçirmeniz durumunda da dikkat etmeniz gereken bir durum var. Aşağıdaki örnek kod ile bu duruma bakalım:

Her ne kadar, thread metodu parametreyi referans olarak alıyor olsa da, yeni thread oluşturulurken str 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 “Updated Hello World!” metnini içeren str değişkeni, thread metoduna geçirilen ve kopyaları oluşturulan diğer parametreler ile birlikte yok edilir. Bu sebeple de asıl str 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ılabilir. Aşağıda bu kullanım gösterilmiştir:

Thread sahipliklerinin aktarılması:

Yazımı tamamlamadan önce bahsetmek istediğim son konu da thread’lerin sahipliği ile alakalı olacak. Açıkçası bu yazıda verilen anoloji hoşuma gitti. İlgili yazıda thread’in sahipliği std::unique_ptr’ın kine benzetilmiş ki kendisine ait nesneler kopyalanamıyor sadece taşınabiliyor. Aynı durumu thread’ler için de geçerli, thread nesnelerini bir diğer thread değişkenine atayamıyorsunuz, bunun için std::move() kullanmanız gerekiyor (std::move ile ilgili malumata daha önceki yazılarımdan ulaşabilirsiniz). Peki bu sahiplik olayı bizlere nerede lazım olabilir? Öncelikli olarak sizler için bir thread oluşturup arka planda çalıştırmayı amaçlayan bir sınıf tasarlamak isteyebilirsiniz ya da basitçe oluşturmuş olduğunuz thread’in sahipliğini bir başka metoda geçirmek isteyebilirsiniz. Yeni bir thread nesnesi oluşturarak, geçici bir değişkene atadığınız durumda özel olarak std::move() çağırmanıza gerek yok. Çünkü geçici nesnelerden sahipliğin aktarılması otomatik ve kendiliğinden yapılabiliyor.

std::thread nesnesi oluşturan create_thread() fonksiyonu ile arka planda çalışan bir thread oluşturmak da, bir metot aracılığı ile thread nesnesi oluşturup sahipliğini aktarmak için de, ilgili std::thread nesnesi sahipliğini aktarmanız gerekir. Aşağıda bu kullanımlara ilişkin örnek kodu görebilirsiniz:

Bu bölümü ilgili thread nesnelerinin std::vector benzeri konteynırlar ile kullanımına ilişkin bir örnek ile kapatayım. Bu kullanım özellikle thread havuzu tarzı yapılar için kullanışlı olabilir.

Sonraki konular:

Bir sonraki yazımda thread senkronizasyon yapılarından, atomic’lerden ve async kullanımından bahsetmeyi planlıyorum. Kendinize iyi bakın ve bunları kullanmaya hemen başlayın 🙂

Referanslar:

Bir cevap yazın

E-posta hesabınız yayımlanmayacak.

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