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/unlockiki 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-beforeilişkisinde, Go’da kanal sahiplik devrinde, Rust’taSend/Synctipinde — C’de ise yalnızpthread_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.