İçeriğe geç
academia.sh

Ders 05 / 15

POSIX İş Parçacıkları

Ayni surec icindeki iki is parciciginin ayni bellegi kilit altinda degistirmesi, borunun tasidigi baytlar gibi bir kez tuketilen bir aktarim degil, surekli paylasilan bir ortak degisimdir.

Yayın Güncelleme

Bu derste

  • kilit altında iki iş parçacığının aynı sayacı artırdığını, sonucun tam ve deterministik olduğunu ölçerek gösterir
  • kilidin garanti ettiği şeyin görünürlük olduğunu, sıralama olmadığını ayırt eder
  • iş parçacıklarının bellek uzayını fork'un aksine paylaştığını, önceki derslerdeki sınıflarla karşılaştırarak açıklar
İçindekiler

Sistem arayüzlerinin son dersi aynı süreç içinde kalıyor, ama süreç sınırını aşan bir taşıma biçiminden vazgeçip aynı bellek uzayını paylaşan iki akışa geçiyor: iş parçacığı. fork bir kopya verdi, sinyal bir aktarım bıraktı, boru ve mmap süreçler arası sınırı iki farklı biçimde geçti. İki iş parçacığı ise hiç sınır geçmiyor — aynı sürecin aynı belleğini baştan paylaşıyor. Bir kilit altında yapılan bir değişiklik bu yüzden kursun dördüncü sınıfına düşüyor: ortak.

pthread_create bunu nasıl kurduğuyla da forktan ayrılıyor. fork çağrıldığı anda çekirdek belleğin sayfa tablosunu kopyalıyor — kopyala-yaz (copy-on-write) sayesinde fiziksel sayfalar hemen çoğaltılmıyor, ama iki süreç artık ayrı adres uzaylarına sahip, biri yazınca öteki görmüyor. pthread_create ise yeni bir adres uzayı açmıyor; yalnız yeni bir yığıt (stack) ayırıp aynı sürecin içinde ikinci bir yürütme noktası başlatıyor. Kod bölütü, veri bölütü, öbek (heap) — hepsi tek bir kopya, iki akış aynı adresleri görüyor. Bu yüzden sayac gibi bir küresel değişken iki iş parçacığı için de aynı bellek konumu; fork sonrası aynı isimli bir değişkenin iki bağımsız kopyası olmasıyla karşılaştırıldığında, bu fark bu dersin sınıfını doğrudan belirliyor.

Kilit Altında Yazılan Sayaç

gozlem.h ve gozlem.c değişmiyor. Bu dersin kendi dosyası parcacik_olcumu.c, iki iş parçacığının aynı sayacı kilit altında artırdığı ve sonucun tam olarak beklenen değere ulaştığı tek bir ölçümü koşturuyor.

// gozlem.h — bir degisikligin kime ulastigini siniflayan kayit
#ifndef GOZLEM_H
#define GOZLEM_H

enum Sinif { KOPYA, ORTAK, AKTARIM, GOZLENMEYEN };

/* Bir degisikligi kaydeder: gordu, degisikligi yapmayan tarafin onu
   okuyup okuyamadigidir (1 ya da 0). */
void gozlem(const char *degisiklik, enum Sinif sinif, int gordu);

/* Sinif basina sayilari ve ulasan / ulasmayan toplamini basar. */
void gozlem_ozeti(void);

#endif
// gozlem.c — sayilar yalniz bu birimde tutulur
#include <stdio.h>
#include "gozlem.h"

static const char *const adlar[] = { "kopya", "ortak", "aktarim", "gozlenmeyen" };
static int sayilar[4];
static int ulasan = 0;
static int ulasmayan = 0;

void gozlem(const char *degisiklik, enum Sinif sinif, int gordu) {
    /* ortak ve aktarim ulasir, kopya ve gozlenmeyen ulasmaz */
    int beklenen = (sinif == ORTAK || sinif == AKTARIM);
    sayilar[sinif]++;
    if (gordu) {
        ulasan++;
    } else {
        ulasmayan++;
    }
    printf("%-34s %-8s %s%s\n", degisiklik, gordu ? "gordu" : "gormedi",
           adlar[sinif], gordu == beklenen ? "" : "  TUTARSIZ");
}

void gozlem_ozeti(void) {
    printf("siniflar: kopya %d, ortak %d, aktarim %d, gozlenmeyen %d\n",
           sayilar[KOPYA], sayilar[ORTAK], sayilar[AKTARIM], sayilar[GOZLENMEYEN]);
    printf("ulasan %d, ulasmayan %d\n", ulasan, ulasmayan);
}
// parcacik_olcumu.c — iki is parciciginin kilit altinda ayni sayaci artirmasi
#define _POSIX_C_SOURCE 200809L
#include <pthread.h>
#include <stdio.h>
#include "gozlem.h"

#define ADIM 1000

static long sayac = 0;
static pthread_mutex_t kilit = PTHREAD_MUTEX_INITIALIZER;

static void *artir(void *veri) {
    (void)veri;
    for (int i = 0; i < ADIM; i++) {
        pthread_mutex_lock(&kilit);
        sayac = sayac + 1;
        pthread_mutex_unlock(&kilit);
    }
    return NULL;
}

int main(void) {
    pthread_t p1, p2;

    pthread_create(&p1, NULL, artir, NULL);
    pthread_create(&p2, NULL, artir, NULL);

    pthread_join(p1, NULL);
    pthread_join(p2, NULL);

    gozlem("iki is parciciginin kilit altinda yazdigi toplam", ORTAK,
           sayac == 2L * ADIM);

    pthread_mutex_destroy(&kilit);
    gozlem_ozeti();
    return 0;
}
iki is parciciginin kilit altinda yazdigi toplam gordu    ortak
siniflar: kopya 0, ortak 1, aktarim 0, gozlenmeyen 0
ulasan 1, ulasmayan 0

SA16. Tek satır gordu diyor, ve bu sonuç şans eseri değil, garanti edilmiş. İki iş parçacığı da artir işlevini koşturuyor, her biri sayacı bin kez artırıyor; ana iş parçacığı her iki pthread_join çağrısı döndükten sonra sayaca bakıyor ve tam olarak 2000 buluyor. Bu, forkun verdiği bağımsız kopyalardan temelde farklı: pthread_create yeni bir bellek kopyası açmıyor, aynı sürecin aynı adres uzayında yeni bir yürütme akışı başlatıyor. sayac değişkeni tek bir yerde duruyor, ve iki akış da o tek yere yazıyor — bu yüzden sınıf ortak, forktaki kopya değil. ADIM sabiti bin olarak seçildiği için toplam da kesin: iki iş parçacığı toplam 2 * 1000 = 2000 artırma yapıyor, ve kilit hiçbirini kaybettirmiyorsa sonuç yalnız bu sayı olabilir — ara sıra 1998 ya da 2001 çıkan bir ölçüm bu dersin kabul ettiği bir sonuç değil, çünkü kursun ölçüm yöntemi yalnız deterministik, tekrar tekrar aynı çıkan sonuçları doğruluyor.

sayac = sayac + 1 satırı tek bir C ifadesi gibi görünse de üç ayrı adıma karşılık gelir: belleğe git, değeri oku, bir ekle, sonucu geri belleğe yaz. Kilit olmadan bu üç adım kesintiye açıktır — bir iş parçacığı okuma ile yazma arasındayken öteki iş parçacığı da aynı eski değeri okuyabilir, ikisi de kendi hesapladığı +1i geri yazar ve iki artırımdan biri kaybolur. pthread_mutex_lock bu üç adımı bölünmez bir birim hâline getirir: kilidi tutan iş parçacığı üçünü de tamamlamadan öteki iş parçacığı kilidi alamaz, bu yüzden hiçbir artırım kayıp gitmez. ADIM değerinin büyüklüğü de kasıtlı — yeterince küçük bir döngü sayısında kilitsiz bir sürüm bile şans eseri doğru sonuç verebilir, çünkü iki iş parçacığının adımları çakışmadan sırayla ilerleyebilir; bin adımlık bir döngü bu şansı pratikte sıfıra indiriyor, ama kursun metni bunu hiç koşturmadan, yalnız düzyazıyla anlatıyor.

Java’nın standart koleksiyonlarında bu sorunun farklı bir yüzü var: bir ArrayList ya da HashMap üzerinde gezinirken (iterator) koleksiyon başka bir iş parçacığınca değiştirilirse, çoğu gerçekleştirim veriyi sessizce bozmak yerine ConcurrentModificationException fırlatır — bu “hızlı-başarısız” (fail-fast) davranış, M08/K03’ün “Yineleyiciler ve Eşzamanlı Değişiklik” konusunun kapsamı. parcacik_olcumu.c‘deki sayac ise tekil bir long, gezinilen bir yapı değil; kilit onu bozulmadan tutuyor, ama C’nin kendisinde Java’nınkine benzer bir çalışma zamanı denetimi yok — kilidi unutup sayaca doğrudan yazan bir kod, hatasız derlenir ve sessizce yanlış sonuç üretir, ConcurrentModificationException gibi bir uyarı vermez. Denetim burada derleyicide ya da çalışma zamanında değil, tamamen programcının disiplininde.

Kilidin Garanti Ettiği: Görünürlük, Sıralama Değil

SA17. pthread_mutex_lock ve pthread_mutex_unlock çifti sayac = sayac + 1 satırını sarıyor, ve bu sarmalama iki ayrı şeyi birden garanti ediyor. Birincisi karşılıklı dışlama: iki iş parçacığı aynı anda kilidi tutamaz, bu yüzden sayacı okuyup bir artırıp geri yazma adımı hiçbir zaman yarım kesilmez — biri bu üç adımı tamamlamadan öteki başlayamaz. İkincisi görünürlük: bir iş parçacığı kilidi bıraktığında, yaptığı değişiklik kilidi bir sonra alan iş parçacığına garantili olarak görünür. Kilit olmadan bu iki garanti de kaybolur — iki iş parçacığı aynı sayacı aynı anda okuyup aynı eski değere bir ekleyebilir, ve bir artırma kaybolabilir. parcacik_olcumu.c bu kayıp durumu hiç koşturmuyor; kilitsiz bir sürüm tarif ediliyor ama çalıştırılmıyor, çünkü sonucu deterministik değil, ve bu kursun ölçüm yöntemi yalnız deterministik sonuçları doğruluyor.

Kilit görünürlüğü garanti ederken hangi iş parçacığının önce çalıştığını garanti etmiyor. p1 ve p2 hangi sırayla zamanlanırsa zamanlansın, hangisinin kilidi kaç kez önce aldığı parcacik_olcumu.c içinde hiç ölçülmüyor ve hiç basılmıyor — yalnız nihai toplam ölçülüyor. Bu, kursun baştan koyduğu kurala da uyuyor: iş parçacığı kimliği hiçbir zaman yazdırılmıyor, ve iki akış da kendi aralarında yazdırma yapmıyor, yalnız ana iş parçacığı her iki join döndükten sonra tek bir satır basıyor.

Java Bellek Modeli’nde (JMM) benzer bir görünürlük garantisi volatile anahtar sözcüğüyle ve senkronize bloklarla kuruluyor — M08/K04’ün “JVM, Eşzamanlılık ve Başarım” konusu bunu “önce-olur” (happens-before) ilişkisiyle tanımlıyor: bir kilidi bırakan iş parçacığının o kilitten önceki tüm yazmaları, kilidi bir sonra alan iş parçacığına görünür olur. Kural kelime kelime pthread_mutex_unlock/ pthread_mutex_lock çiftinin verdiği garantiyle örtüşüyor; fark, JMM’nin bunu sanal makinenin bellek sıralaması üzerinden dilin kendi belirtiminde tanımlamış olması, C’de ise bu garantinin POSIX iş parçacığı belirtiminin çağrı sözleşmesinden geldiği — derleyici ve donanım sıralamasını pthread_mutex_lock/unlock çağrılarının kendisi kısıtlıyor, dilin bir bellek modeli olarak ayrı bir kavramı yok.

Go’nun eşzamanlılık modeli aynı sorunu tersten çözüyor. M09/K03’ün “Goroutine ve Kanallar” konusundaki ilke — “iletişimle bellek paylaş, belleği paylaşarak iletişim kurma” — bir değeri bir kanaldan geçirmeyi, o değerin sahipliğini göndericiden alıcıya devretmek olarak tanımlıyor; iki goroutine aynı değişkene aynı anda yazmıyor, değer bir kanal üzerinden bir kez akıyor. parcacik_olcumu.c‘deki sayac ise tam tersi: sahiplik hiç devredilmiyor, iki iş parçacığı da aynı konuma aynı anda erişme hakkına sahip ve bu hakkı pthread_mutex_t ile sınırlıyor. Go’da kanal deseni seçilseydi sayacı paylaşmak yerine her goroutine kendi kısmi toplamını hesaplayıp bir kanaldan gönderir, toplama adımı tek bir alıcıda yapılırdı — bu kursun ölçtüğü ortak sınıfı değil, borunun taşıdığına benzer bir aktarım deseni olurdu.

Sınırlayıcı Ölçüm

SA18. Bu ölçüm yalnız nihai değeri doğruluyor, ara durumu değil. sayacın kilit altındayken hangi ara değerlerden geçtiği — 1, 2, 3 sırasıyla mi yoksa iki iş parçacığının adımları nasıl örülerek mi ilerlediği — hiçbir yerde ölçülmüyor, çünkü ölçmenin kendisi ek bir kilitli okuma gerektirir ve bu da ölçülen sistemi değiştirir. parcacik_olcumu.c’nin doğruladığı tek şey, 2000 adımın hiçbirinin kaybolmadığı; hangi adımın hangi iş parçacığından geldiği sorusu bu dersin kapsamı dışında. Bu sınır kasıtlı: kurs, iki akışın birbirini görüp görmediğini ölçüyor, aralarındaki yarışı yeniden kurmuyor. Bir yarış durumunun kendisini gözlemlemek isteyen bir ölçüm — örneğin hangi iş parçacığının kilidi kaçıncı sırada aldığını saymak — zamanlamaya bağımlı hâle gelir ve bu kursun deterministik-sonuç kuralını bozar; bu yüzden böyle bir ölçüm burada yer almıyor.

Bu dersin en büyük sınırı, kilidin doğru kullanıldığını varsaymasında. kilit değişkeni artir içinde her erişimden önce alınıyor, ama bunu zorlayan hiçbir mekanizma yok — sayacı kilitsiz okuyan bir üçüncü işlev eklense, derleyici hiçbir uyarı vermez, kod sessizce hatalı olur. Rust’ın eşzamanlılık modeli bu boşluğu tip sistemine taşıyor: M10/K04’ün “Send ve Sync” konusu, bir tipin iş parçacıkları arasında güvenle taşınıp taşınamayacağını (Send) ve paylaşılan referans olarak güvenle kullanılıp kullanılamayacağını (Sync) derleme zamanında işaretliyor. Paylaşılan bir sayaç Rust’ta çıplak bir değişken olarak değil, Mutex<i64> gibi bir sarmalayıcı içinde tutulur, ve bu sarmalayıcıya kilit almadan erişmeye çalışan kod derlenmez — parcacik_olcumu.c‘deki gibi bir hatayı çalışma zamanına değil derleme zamanına taşır. C’de böyle bir denetim yok: pthread_mutex_t kilit sayacla aynı dosyada duruyor olsa bile, aralarındaki bağ yalnız bu dersin metninde anlatılan bir kural, derleyicinin bildiği bir kısıt değil. Bu kursun ölçüm yöntemi de zaten bu boşluğu kapatmıyor — yalnız kilidin doğru kullanıldığı tek bir koşuyu doğruluyor, kilidin unutulduğu bir sürümü hiç koşturup göstermiyor.

Sonraki Adım ve Sınıfların Toplu Görünümü

SA19. Bu beş ders boyunca aynı dört sınıf farklı taşıyıcılarla karşımıza çıktı. fork sonrası bir değişken kopyaydı — iki taraf bağımsız, hiçbir değişiklik karşı tarafa ulaşmadı. Bir sinyal işleyicisinin bıraktığı bayrak aktarımdı — ana akışa bir kez ulaştı. Bir borunun taşıdığı bayt da aktarımdı, ikinci okuma dosya sonuyla bitti. MAP_SHARED ile kurulan bir eşlem ve bu dersin kilitli sayacı ise ortaktı — bir kayıt sürekli paylaşılıyor, taraflardan biri değiştirdiğinde öteki de görüyor. Dördüncü sınıf, gözlenmeyen, ilk dersin kendi kapsamında kaldı: gözlemin kendisinin görülmediği durumlar.

Aynı sonucu farklı bir düzenekle üretmek de mümkündü — örneğin sayacı kilit yerine atomik bir işlemle artırmak, ya da iş parçacığı yerine paylaşılan bellek üzerinde başka bir eşzamanlama aracı kullanmak. Bu dersin sınırı, yalnız pthread_mutex_t ile kurulan en yaygın deseni ölçmesi; atomik işlemler ve öteki kilit türleri bu kursun kapsamı dışında kalıyor.

Bu dört sınıf, taşıyıcının kendisinden bağımsız bir soruyu yanıtlıyor: bir değişiklik yapan taraf dışındaki bir taraf, o değişikliği görebiliyor mu, ve görüyorsa ne zaman, kaç kez? fork sorusuna hayır diyor — kopya, göstermez. Sinyal ve boru evet diyor ama yalnız bir kez — aktarım, tüketilir. MAP_SHARED ve kilitli sayaç evet diyor ve sürekli — ortak, tüketilmez. İlk dersin gözlem düzeneğinin kendisi ise bu sorunun dördüncü, çürük bir yanıtı: bir taraf bir şey görüyor ama ölçüm bunu hiç yakalamıyor — gözlenmeyen. Beş ders boyunca kullanılan gozlem() işlevi bu dört yanıtı tek bir kelimeyle etiketleyip, o etiketin ölçülen gordu/gormedi değeriyle tutarlı olup olmadığını kendiliğinden denetledi; hiçbir ders bu denetimi atlamadı ve hiçbir ölçüm TUTARSIZ etiketiyle bitmedi.

Bu sınıflandırma taşıyıcıya değil görünürlük desenine bakıyor olduğu için, başka dillerdeki eşzamanlılık araçlarına da uygulanabilir — M08/K04’teki JMM, M09/K03’teki kanal, M10/K04’teki Send/Sync hepsi aynı dört soruyu kendi sözdizimleriyle yanıtlıyor, yalnız garantiyi nerede kurduğu (çalışma zamanı, tip sistemi, dil belirtimi) değişiyor. C’nin POSIX iş parçacığı ve süreç arayüzü bu garantiyi hep çağrı sözleşmesinde kuruyor: fork çağrısının kendisi kopya sınıfını, pthread_ mutex_lock/unlock çifti ortak sınıfını, sinyal teslimi ve boru okuması aktarım sınıfını tanımlıyor. Sistem arayüzleri burada kapanıyor: beş ders, bir değişikliğin bir sürecin ya da bir iş parçacığının dışına kimin görebileceği sorusunu dört sınıfla yanıtladı.

Özet

  • İki iş parçacığı aynı süreç içinde aynı bellek uzayını paylaşır; forkun aksine ayrı bir kopya açılmaz.
  • Bu dersin sınıfı ortak: kilit altında yapılan bir değişiklik her iki tarafa da görünür, bir kayıt gibi sürekli paylaşılır.
  • pthread_mutex_lock/unlock iki garanti verir: karşılıklı dışlama ve görünürlük. Hangi iş parçacığının önce çalıştığını garanti etmez.
  • Ölçüm yalnız nihai toplamı doğruluyor; ara durumları ya da iş parçacıkları arasındaki sıralamayı ölçmüyor, çünkü bu ölçmenin kendisi zamanlamaya bağımlı hâle gelirdi.
  • Kilitsiz bir sürüm yalnız düzyazıyla tarif ediliyor, hiç koşturulmuyor; sonucu deterministik olmadığı için bu kursun ölçüm yöntemine uymuyor.
  • Aynı görünürlük sorusu başka dillerde farklı yerlerde çözülüyor: Java’da JMM’nin happens-before ilişkisinde, Go’da kanal sahiplik devrinde, Rust’ta Send/Sync tipinde — C’de ise yalnız pthread_mutex_t çağrı sözleşmesinde, derleyici desteği olmadan.

Sonraki Adım

Sistem arayüzleri burada tamamlanıyor. Beş ders boyunca aynı gozlem() birimi beş farklı taşıyıcıyı — kopyalanan bellek, işleyicinin bıraktığı bayrak, boru, paylaşılan sayfa, kilitli sayaç — aynı dört sınıfla ölçtü; hiçbiri diğerinin yerine geçmedi, çünkü her biri farklı bir soruyu, “kim ne zaman neyi görüyor” sorusunun farklı bir biçimini yanıtladı. Bir sonraki ders (Bağlı Yapılar) aynı gözlem birimini süreç ve iş parçacığı sınırından alıp tek bir veri yapısının içine taşıyor: iki işaretçi aynı düğümü gösterdiğinde, birinin yaptığı değişikliği öteki görüyor mu?

İ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