İçeriğe geç
academia.sh

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::thread kurulduktan sonra join() ile beklenir; iş parçacığının yazdığı her şey yalnızca join() döndükten sonra okunur. std::jthread aynı işi yıkıcısında yapar.
  • Ortak veriye korumasız yazmak veri yarışıdır ve davranışı tanımsızdır; std::mutex ile std::lock_guard bu yarışı bitirir, kilidi kapsamın sonunda bırakır.
  • std::scoped_lock birden çok kilidi kilitlenmeye yol açmayan bir sırayla alır; sıra disiplini gerekmez.
  • std::condition_variable bir koşulu beklemek içindir ve std::unique_lock ister; koşul her uyanışta yeniden sınanır. unique_lock kilidi bırakıp yeniden alabildiği için üretici/tüketici kuyruğunda işin kilitsiz yapılmasına izin verir.
  • kilitli sayac kırk bin kilit alması, yerel toplam dö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.

Aramak için yazmaya başlayın.

↑↓ ↵ Esc gezin · aç · kapat