Ders 07 / 12
İş Parçacıkları ve Kilitler
std::thread ve join, std::jthread, std::mutex ile std::lock_guard, std::scoped_lock, std::unique_lock ile std::condition_variable, üretici/tüketici kuyruğu ve kilidin kaç eşgüdüm noktası eklediğinin sayılması.
Yayın Güncelleme
Bu derste
- std::thread ve std::jthread ile iş parçacığı açar, std::mutex ve std::lock_guard ile paylaşılan bir değişkeni veri yarışından korur.
- std::scoped_lock ile birden çok kilidi kilitlenmeden alır.
- std::unique_lock ve std::condition_variable ile bir üretici/tüketici kuyruğu kurar.
- Bir kilidin bedelini, kaç kez alındığını sayarak ölçer ve düşürür.
İçindekiler
Önceki ders, tip kümesi derlemede kapalıysa çok biçimliliğin bedelinin derlemeye taşındığını gösterdi: işlev çağrısı koşarken hiçbir şey ödemez, ama somutlaştırılan her tip için ayrı bir gövde üretilir. Bu derste ödemenin başka bir yere gittiği durumu ölçüyoruz: iş birden çok iş parçacığına bölününce ortak veriyi korumak için eklenen kilitler. Kursun ölçtüğü soru burada da aynı kalıyor: bir düzenek bedeli nereye taşıyor? Cevabın sınıfı beklemedir. Bu ölçü gerçek beklemeyi değil, bir iş parçacığının beklemek zorunda kalabileceği nokta sayısını sayar; süre basılmaz, çünkü süre makineye ve o anki yüke bağlıdır, nokta sayısı ise koddan okunur.
Bir ayrımı baştan söyleyelim. M10/K03’te “kaç kez koştu” sorusunda sayı ölçünün kendisiydi. Burada sayı yalnızca kanıt aracıdır: ders “kilit kaç kez alındı” sayısına değil, o sayının gösterdiği sınıfa, yani beklemeye cevap verir. Ayrıca M08/K04’ün sorusu olan “adımı hangi iş parçacığı koşturuyor” bu derste sorulmaz; hangi iş parçacığının ne yaptığı değil, kaç eşgüdüm noktası olduğu ölçülür.
İş Parçacığı Açmak ve Beklemek
std::thread, kurulduğu anda verilen işlevi ayrı bir iş parçacığında koşturmaya başlar.
Nesne yıkılmadan önce ya join() ile beklenmeli ya da detach() ile bırakılmalıdır;
ikisinden hiçbiri yapılmadan yıkılan nesne programı sonlandırır. Bu yüzden her örnekte
join() çağrılır ve sonuç yalnızca join() döndükten sonra okunur: join() iş
parçacığının yazdığı her şeyin çağırana görünür olduğunu garanti eder.
Ortak veriye hiçbir koruma olmadan iki iş parçacığı yazarsa, programın davranışı tanımsızdır: buna veri yarışı denir. Dört iş parçacığının kilitsiz bir sayacı onar bin kez artırması, sonucun kırk bin olacağını garanti etmez; hiçbir sonucu garanti etmez. Tanımsız davranışı koşturmuyoruz; yalnızca tanımlı karşılığını koşturup düzyazıda neden gerektiğini anlatıyoruz.
jthread: Kendiliğinden Katılan İş Parçacığı
std::jthread C++20’yle gelir ve std::thread’in en sık yapılan hatasını kaldırır.
Yıkıcısı, iş parçacığı henüz katılmamışsa önce bir durdurma isteği gönderir, sonra
join() çağırır; araya bir return ya da bir
istisna girse bile iş parçacığı kapsamdan çıkmadan bitirilir. Katılmadan yıkılan bir
std::thread ise programı sonlandırır. Fark yazım kolaylığından çok güvenliktir:
unutulan bir join() artık bir çökme olmaz.
İkinci fark, jthread’in bir durdurma isteği taşımasıdır. İşlev bir std::stop_token
parametresi alırsa, dışarıdan request_stop() çağrıldığında döngüsünden çıkabilir.
İsteğin ne zaman görüleceği zamanlamaya bağlı olduğundan bunu koşturmuyoruz; yalnızca
kapsamın sonunda katılma davranışını kullanıyoruz. Ölçümün konusu iş parçacığının nasıl
açıldığı değil, açıldıktan sonra ortak veriyi nasıl koruduğumuzdur.
Kilit ve lock_guard
Tanımlı karşılık, ortak veriyi bir dışlama kilidinin arkasına almaktır. std::mutex
kilitlendiğinde, kilidi bırakana kadar başka hiçbir iş parçacığı onu alamaz; alamayan
iş parçacığı bekler. std::lock_guard kilidi kurulduğunda alır, yıkıldığında bırakır;
bırakma işini kapsamın sonuna bağladığı için bir return ya da bir istisna kilidi
açık bırakamaz.
Aşağıdaki başlık dosyası ortak sayacı ve onu artırmanın iki yolunu tanımlar. alma,
kilidin kaç kez alındığını sayan ikinci alandır; ona yalnızca kilit tutulurken
dokunulur. İlk yol her artışta kilit alır. İkincisini birazdan açıklayacağız.
// kilit_olcum.hpp — ortak sayac ve iki artirma bicimi
#pragma once
#include <mutex>
struct KilitliSayac {
std::mutex kilit;
long deger = 0;
long alma = 0;
};
inline void artir_her_seferde(KilitliSayac& s, int kez) {
for (int i = 0; i < kez; ++i) {
std::lock_guard<std::mutex> koruma(s.kilit);
++s.deger;
++s.alma;
}
}
inline void artir_yerel(KilitliSayac& s, int kez) {
long yerel = 0;
for (int i = 0; i < kez; ++i) ++yerel;
std::lock_guard<std::mutex> koruma(s.kilit);
s.deger += yerel;
++s.alma;
}
Birden Çok Kilit ve scoped_lock
Tek kilit tek değişkeni korur. İki nesneyi birden değiştiren bir işlem, ikisinin de kilidini almak zorundadır. İki iş parçacığı bu kilitleri ters sırayla alırsa, her biri öbürünün tuttuğu kilidi bekler ve hiçbiri ilerleyemez: buna kilitlenme denir. Kilitlenmenin ortak kökü, kilitlerin tutarsız sırayla alınmasıdır.
İki çıkış yolu vardır. Birincisi bütün kodda tek bir sıra belirlemektir; örneğin kilitleri
her zaman hesap numarasına göre küçükten büyüğe almak. Bu yol çalışır ama disiplin ister:
sıra yalnızca bir kuraldır ve yeni yazılan her işlev ona uymak zorundadır. İkincisi
std::scoped_lock’tur: birden çok kilidi tek seferde, kilitlenmeyi önleyen bir sırayla
alır; standart bunun için kilitlenmeden kaçınan bir algoritma kullanılmasını ister. Kilitleri
hangi sırayla yazdığınız artık önemli olmaz.
// hesap.hpp — iki ve uc kilidi birden alan islemler
#pragma once
#include <mutex>
struct Hesap {
std::mutex kilit;
long bakiye = 0;
};
inline void aktar(Hesap& kaynak, Hesap& hedef, long miktar) {
std::scoped_lock ikisi(kaynak.kilit, hedef.kilit);
kaynak.bakiye -= miktar;
hedef.bakiye += miktar;
}
inline long toplam_bakiye(Hesap& a, Hesap& b, Hesap& c) {
std::scoped_lock uclu(a.kilit, b.kilit, c.kilit);
return a.bakiye + b.bakiye + c.bakiye;
}
aktar(a, b, 1) ile aktar(b, a, 1) aynı anda çağrılsa bile kilitlenme olmaz. Elle
a.kilit.lock() ardından b.kilit.lock() yazılsaydı, iki çağrı ters sırada alırsa
kilitlenme mümkün olurdu; bunu koşturmuyoruz, çünkü sonucun programı asılı bırakması
mümkündür. toplam_bakiye üç kilidi birden alır: aktarmalar yalnızca iki kilit tutar,
bu yüzden toplamı okuyan, hiçbir aktarmanın ortasına denk gelmez.
Koşul Değişkeni ve unique_lock
Bazen bir iş parçacığının ortak veriyi korumasının ötesinde, bir şeyin olmasını
beklemesi gerekir. std::condition_variable bunun içindir: bekleyen iş parçacığı
kilidi bırakıp uyur, başka bir iş parçacığı koşulu sağlayıp haber verince uyanır. Bekleyen
tarafın kilidi elinde tutup bırakabilmesi için std::lock_guard yetmez; bırakılıp yeniden
alınabilen std::unique_lock gerekir. unique_lock, lock_guard’ın yaptığı her şeyi
yapar ve üstüne unlock() ile lock() çağrılabilir; bedeli küçük bir ek durum bilgisidir.
// teslim.hpp — kosul degiskeniyle bir kez uyanma
#pragma once
#include <condition_variable>
#include <mutex>
struct Teslim {
std::mutex kilit;
std::condition_variable uyandir;
bool hazir = false;
int deger = 0;
};
inline void hazirla(Teslim& t, int v) {
{
std::lock_guard<std::mutex> koruma(t.kilit);
t.deger = v;
t.hazir = true;
}
t.uyandir.notify_one();
}
inline int bekle(Teslim& t) {
std::unique_lock<std::mutex> kilitli(t.kilit);
t.uyandir.wait(kilitli, [&t] { return t.hazir; });
return t.deger;
}
wait koşulu bir koşul ifadesiyle alır ve kilit elde iken denetler; uyanma haberi
boşuna gelmiş ya da gelmeden önce de koşul sağlanmış olabilir, bu yüzden koşul her
uyanışta yeniden sınanır. Bayrak ve değer yalnızca kilit altında yazılıp okunur.
Üretici ve Tüketici
Tek bir değer yerine bir değer akışı taşındığında aynı düzenek bir kuyruğa dönüşür.
Üretici kuyruğa değer koyar ve haber verir; tüketici kuyruk doluyken değerleri alır,
boşken uyur. İş bittiğinde üretici bir bitti bayrağı kaldırır, çünkü tüketicinin
“kuyruk boş, ama daha değer gelecek mi” sorusuna başka cevabı yoktur.
Tüketici değeri kuyruktan aldıktan sonra kilidi bırakır, işi kilitsiz yapar ve kilidi
yeniden alır. Bu, unique_lock’un lock_guard’dan ayrıldığı yerdir: kilit yalnızca
ortak veriye dokunulan süre boyunca tutulur, değerin işlenmesi kuyruğu başkalarına
kapatmaz.
// kuyruk.hpp — uretici/tuketici kuyrugu
#pragma once
#include <condition_variable>
#include <mutex>
#include <queue>
struct Kuyruk {
std::mutex kilit;
std::condition_variable uyandir;
std::queue<int> ogeler;
bool bitti = false;
};
inline void uret(Kuyruk& k, int adet) {
for (int i = 0; i < adet; ++i) {
{
std::lock_guard<std::mutex> koruma(k.kilit);
k.ogeler.push(i);
}
k.uyandir.notify_one();
}
{
std::lock_guard<std::mutex> koruma(k.kilit);
k.bitti = true;
}
k.uyandir.notify_one();
}
inline long tuket(Kuyruk& k, int& alinan) {
long toplam = 0;
std::unique_lock<std::mutex> kilitli(k.kilit);
for (;;) {
k.uyandir.wait(kilitli, [&k] { return !k.ogeler.empty() || k.bitti; });
if (k.ogeler.empty()) break;
int v = k.ogeler.front();
k.ogeler.pop();
kilitli.unlock();
toplam += v;
++alinan;
kilitli.lock();
}
return toplam;
}
Tüketicinin kaç kez uyanıp kaç kez beklediği çizelgeye bağlıdır ve basılmaz. Basılan, çizelgeden bağımsız iki sayıdır: kaç değerin alındığı ve değerlerin toplamı.
Ölçüm: Kilit Kaç Kez Alındı
Şimdi iki artırma biçimini dört iş parçacığıyla, her biri on bin artış yapacak biçimde
koşturuyoruz. Aynı programda aktar, Teslim ve Kuyruk da çalışır; her sonuç yalnızca
join() döndükten sonra basılır, böylece çıktı iş parçacıklarının sırasından bağımsızdır.
// kilit_olcumu.cpp — kilit alma sayisi ve yardimci sinamalar
#include <functional>
#include <iostream>
#include <thread>
#include <vector>
#include "hesap.hpp"
#include "kilit_olcum.hpp"
#include "kuyruk.hpp"
#include "teslim.hpp"
namespace {
void dort_is_parcacigi(KilitliSayac& s, void (*islev)(KilitliSayac&, int)) {
std::vector<std::thread> isler;
for (int i = 0; i < 4; ++i) isler.emplace_back(islev, std::ref(s), 10000);
for (auto& is : isler) is.join();
}
} // namespace
int main() {
KilitliSayac her_seferde;
dort_is_parcacigi(her_seferde, artir_her_seferde);
std::cout << "kilitli sayac bekleme alma=" << her_seferde.alma
<< " deger=" << her_seferde.deger << "\n";
KilitliSayac yerel;
dort_is_parcacigi(yerel, artir_yerel);
std::cout << "yerel toplam bekleme alma=" << yerel.alma
<< " deger=" << yerel.deger << "\n";
Hesap a, b;
a.bakiye = 1000;
b.bakiye = 1000;
{
std::thread ileri([&] { for (int i = 0; i < 500; ++i) aktar(a, b, 1); });
std::thread geri([&] { for (int i = 0; i < 500; ++i) aktar(b, a, 1); });
ileri.join();
geri.join();
}
std::cout << "iki yonlu aktarma bekleme toplam=" << a.bakiye + b.bakiye << "\n";
Hesap x, y, z;
x.bakiye = y.bakiye = z.bakiye = 1000;
int ihlal = 0;
{
std::thread t1([&] { for (int i = 0; i < 300; ++i) aktar(x, y, 1); });
std::thread t2([&] { for (int i = 0; i < 300; ++i) aktar(y, z, 1); });
std::thread t3([&] { for (int i = 0; i < 300; ++i) aktar(z, x, 1); });
std::thread denetci([&] {
for (int i = 0; i < 200; ++i)
if (toplam_bakiye(x, y, z) != 3000) ++ihlal;
});
t1.join();
t2.join();
t3.join();
denetci.join();
}
std::cout << "uc hesap halkasi bekleme toplam=" << toplam_bakiye(x, y, z)
<< " ihlal=" << ihlal << "\n";
Teslim teslim;
int alinan = 0;
{
std::jthread tuketici([&] { alinan = bekle(teslim); });
hazirla(teslim, 42);
}
std::cout << "kosul degiskeni bekleme alinan=" << alinan << "\n";
Kuyruk kuyruk;
int alinan_oge = 0;
long oge_toplami = 0;
{
std::jthread tuketici([&] { oge_toplami = tuket(kuyruk, alinan_oge); });
uret(kuyruk, 1000);
}
std::cout << "uretici tuketici bekleme alinan=" << alinan_oge
<< " toplam=" << oge_toplami << "\n";
return 0;
}
kilitli sayac bekleme alma=40000 deger=40000 yerel toplam bekleme alma=4 deger=40000 iki yonlu aktarma bekleme toplam=2000 uc hesap halkasi bekleme toplam=3000 ihlal=0 kosul degiskeni bekleme alinan=42 uretici tuketici bekleme alinan=1000 toplam=499500
EB1. İlk satır dört iş parçacığının kırk bin artışının her birinde kilit aldığını
gösterir: alma=40000, deger=40000. Her kilit alma, başka bir iş parçacığının
tuttuğu kilidi beklemek zorunda kalabileceği bir noktadır; ders bu noktaların
sayısını ölçüyor, gerçekte kaç kez beklendiğini değil. Sonuç deger=40000 tanımlıdır
çünkü her artış kilit altında yapıldı; bu doğruluk kilidin verdiği garantidir.
EB2. Üçüncü satırdaki toplam=2000, iki yönde ters sırayla yapılan aktarmaların
std::scoped_lock sayesinde ne kilitlenme ne de kayıp bıraktığını gösterir: iki
bakiyenin toplamı değişmedi. Burada kilitleri hangi sırayla yazdığımızın bir önemi
yoktu; aktar(a, b, …) ve aktar(b, a, …) aynı iki kilidi zıt sırayla istedi.
EB3. Dördüncü satır üç kilidin bir halkasını sınar: x’ten y’ye, y’den z’ye ve
z’den x’e aktarma yapan üç iş parçacığı çalışırken, dördüncü iş parçacığı iki yüz
kez üç kilidi birden alıp toplamı okudu. Toplam her seferinde 3000 göründü ve
ihlal=0 çıktı. Okuyan hiçbir aktarmanın ortasına denk gelmedi, çünkü hiçbir aktarma
iki kilidin yalnızca birini tutarken bakiyeyi değiştirmedi.
EB4. Beşinci satırda bekleyen iş parçacığı hazirla çağrılınca uyandı ve 42
değerini aldı. std::jthread kapsamın sonunda join() yaptı, bu yüzden alinan
okunmadan önce yazılmış oldu. Uyanmanın hangi anda olduğu basılmadı; yalnızca ne alındığı
basıldı.
EB5. Son satır, tüketicinin bin değerin hepsini aldığını ve toplamın 499500
olduğunu gösterir; bu, sıfırdan dokuz yüz doksan dokuza kadar sayıların toplamıdır.
Hiçbir değer kaybolmadı ve hiçbiri iki kez alınmadı. bitti bayrağı, kuyruk boşalsa
bile üretici henüz bitmemişken tüketicinin çıkmamasını, üretici bitip kuyruk boşalınca da
beklemekte takılı kalmamasını sağladı.
EB6. Beşinci ve altıncı satırdaki iki std::jthread de elle join() yazılmadan
bitirildi: kapsam kapanırken yıkıcı katıldı. Bu, sonucun okunabilir olmasının tek
dayanağıdır; join() ya da onu çağıran yıkıcı olmasaydı, alinan ve oge_toplami
okunurken iş parçacığı hâlâ yazıyor olabilirdi.
Sınırlayıcı Ölçüm
Bu dersin ölçtüğü düzenek, ortak veriyi kilitle korumaktır; ve ikinci satır bu
düzeneğin bedelini düşürür. artir_yerel her iş parçacığında on bin artışı kendi yerel
toplamında yapar, kilidi yalnızca sonunda bir kez alıp toplamı ortak sayaca ekler.
Sonuç aynıdır, deger=40000; ama eşgüdüm noktası kırk binden dörte düştü:
alma=4. Eşgüdüm noktası on bin kat azaldı, doğruluk korundu.
Bundan çıkan ders, bedelin kilidin kendisinden değil kaç kez alındığından geldiğidir.
Aynı std::mutex iki ayrı yazımda kırk bin ya da dört kez alındı. Bunun sınırı da
şudur: yerel toplam yalnızca işin parçalara ayrılıp sonda birleştirilebildiği durumda
çalışır. Her adım ortak durumu görmek zorundaysa, yani bir iş parçacığının sonucu
öbürünün bir sonraki adımına girdiyse, kilit her adımda alınır ve sayı düşmez.
Bu durumda bedeli düşürmenin yolu kilidi bırakmak değil, ortak durumu bölmektir; bunu
sonraki iki derste bellek sıralaması ve veri düzeni olarak göreceğiz.
Özet
- Bu dersin ölçtüğü düzenek bedeli bekleme sınıfına taşır: kilit, bir iş parçacığının beklemek zorunda kalabileceği nokta sayısını artırır; ölçü gerçek bekleme süresi değil, bu noktaların sayısıdır.
std::threadkurulduktan sonrajoin()ile beklenir; iş parçacığının yazdığı her şey yalnızcajoin()döndükten sonra okunur.std::jthreadaynı işi yıkıcısında yapar.- Ortak veriye korumasız yazmak veri yarışıdır ve davranışı tanımsızdır;
std::mutexilestd::lock_guardbu yarışı bitirir, kilidi kapsamın sonunda bırakır. std::scoped_lockbirden çok kilidi kilitlenmeye yol açmayan bir sırayla alır; sıra disiplini gerekmez.std::condition_variablebir koşulu beklemek içindir vestd::unique_lockister; koşul her uyanışta yeniden sınanır.unique_lockkilidi bırakıp yeniden alabildiği için üretici/tüketici kuyruğunda işin kilitsiz yapılmasına izin verir.kilitli sayackırk bin kilit alması,yerel toplamdört kilit alması ile aynı sonucu verdi: bedeli kilit değil, kaç kez alındığı belirler.
Sonraki Adım
Kilit, bir verinin kimin elinde olduğunu söyler ve bir iş parçacığının kilit altında
yazdığını kilidi sonra alana görünür kılar; ama görünürlük için hangi sıralamanın gerçekten
gerektiğini ve bunun kaç eşgüdüm noktasına mal olduğunu söylemez. Bir sonraki ders bellek modelini açıyor: bir
bellek sıralaması kaç eşgüdüm noktası istiyor sorusuyla std::atomic, release ve acquire
bağıntısını ölçeceğiz.
İlerlemeni kaydetmek ve not almak için Giriş yap
Notlarım
Not almak için giriş yapmalısın.