İşletim Sistemleri · Ders Notu
Bölüm 4: İş Parçacıkları
Bir süreç, çalışan programın bütün kaynaklarını bir arada tutar: adres alanını, açık dosyaları, sinyal işleyicilerini. Ama tek bir sürecin içinde birden çok işin aynı anda ilerlemesi çoğu zaman gerekir. Tarayıcı bir yandan sayfayı çizer, bir yandan ağdan veri bekler; web sunucusu yüzlerce isteğe aynı anda yanıt verir. İş parçacığı bu ihtiyaca verilen cevaptır. Aynı adres alanını paylaşan ama her biri kendi program sayacı, yazmaçları ve yığınıyla yürüyen akışlar, süreç oluşturmanın maliyetine katlanmadan eş zamanlılık sağlar. Not, iş parçacığının tanımıyla ve neden gerektiğiyle başlar. Ardından çok çekirdekli işlemcilerde paralel programlamanın sınırlarını Amdahl yasasıyla ele alır. Kullanıcı ve çekirdek düzeyi iş parçacıklarını, N:1, 1:1 ve M:N eşleme modellerini karşılaştırır. Pthreads ve Java ile iş parçacığı oluşturma, havuzlar, fork-join, OpenMP ve GCD gibi örtük yaklaşımlar kodlarıyla anlatılır. Son kesim, iş parçacıklarının beraberinde getirdiği sorunlara ayrılmıştır: fork(), sinyaller, iptal, iş parçacığına yerel depolama, yeniden girilir kod ve yarış durumu.
Öğrenme çıktıları
- Kavram
- İş parçacığını süreçten ayırmak; hangi durumun bütün iş parçacıklarınca paylaşıldığını, hangisinin her iş parçacığına özel olduğunu söylemek.
- Performans
- Eş zamanlılık ile paralelliği ayırt etmek ve Amdahl yasasıyla belirli bir çekirdek sayısında elde edilebilecek hızlanmayı ve hızlanmanın üst sınırını hesaplamak.
- Modeller
- Kullanıcı ve çekirdek düzeyi iş parçacıklarını; N:1, 1:1 ve M:N eşleme modellerini geçiş maliyeti, bloke olma ve çok çekirdek kullanımı açısından karşılaştırmak.
- Programlama
- Pthreads ve Java ile iş parçacığı oluşturmak, argüman geçirmek, sonuç almak ve beklemek; havuz, fork-join, OpenMP ve GCD gibi örtük yaklaşımları tanımak.
- Sorunlar
- Yarış durumunu, yeniden girilemeyen kodu, iş parçacığına yerel depolamayı, iptali, sinyal teslimini ve çok iş parçacıklı süreçte
fork()davranışını açıklamak. - Gerçek sistemler
- Linux
clone()çağrısını, Java 21 sanal iş parçacıklarını ve Go çalışma zamanını eşleme modelleri açısından yorumlamak.
4.1Temel kavramlar
Süreç kavramı, işletim sisteminin programları birbirinden yalıtmak için kullandığı temel araçtır. Her sürecin kendi adres alanı vardır; bir süreç ötekinin belleğini göremez, bozamaz. Bu yalıtım güvenlik için değerlidir, ama aynı veri üzerinde birlikte çalışması gereken işler için bir engele dönüşür. İki süreç aynı veriyi paylaşacaksa paylaşımlı bellek bölgesi kurmak ya da ileti göndermek gerekir; ikisi de zahmetli ve yavaştır. İş parçacığı, yalıtımı süreç düzeyinde bırakıp tek bir adres alanının içinde birden çok yürütme akışına izin verir.
4.1.1İş parçacığı nedir?
Bir programın çalışmasını iki ayrı açıdan düşünmek mümkündür. Birinci açı kaynaklardır: program bellekte bir yer kaplar, dosya açar, ağ bağlantısı kurar, alt süreç başlatır. İkinci açı yürütmedir: işlemci programın komutlarını sırayla işler, bir program sayacı bir sonraki komutu gösterir, yazmaçlar ara değerleri tutar, yığın fonksiyon çağrılarının izini sürer. Klasik süreç kavramı bu iki açıyı birleştirir: her sürecin hem kaynakları hem de tek bir yürütme akışı vardır. İş parçacığı kavramı bu ikisini birbirinden ayırır.
Süreç (process), yürütülmekte olan bir programdır. Adres alanı, açık dosyalar, çocuk süreçler, sinyal işleyicileri ve kimlik bilgisi gibi kaynakların sahibi süreçtir.
İş parçacığı (thread), süreç içindeki bir yürütme akışıdır. Kendi program sayacı (PC), yazmaç kümesi, yığını ve durumu vardır. İşlemci kullanımının ve çizelgelemenin temel birimi iş parçacığıdır.
Tanenbaum bu ayrımı tek cümleyle özetler: süreç kaynakları bir araya toplar, iş parçacığı ise işlemci üzerinde yürütülen varlıktır. Bir süreçte bir ya da daha çok iş parçacığı bulunur. Hepsi aynı adres alanını görür; birinin global bir değişkene yazdığı değeri öteki hemen okuyabilir. Buna karşılık her iş parçacığı programın farklı bir yerinde olabilir, farklı fonksiyonlar çağırabilir, farklı yerel değişkenlerle çalışabilir.
İşletim sistemi her süreç için bir süreç denetim bloğu (process control block, PCB) tutar. İş parçacıkları için de benzer bir yapı gerekir: iş parçacığı denetim bloğu (thread control block, TCB). TCB'de iş parçacığının kimliği (tid), durumu, kaydedilmiş PC ve yazmaç değerleri, yığın işaretçisi (SP), önceliği ve iş parçacığına yerel depolama alanını gösteren bir işaretçi bulunur. Kaynak bilgileri TCB'de değil, iş parçacığının ait olduğu sürecin PCB'sindedir. Şekil 4.1 bu ilişkiyi gösterir.
task_struct yapısıyla temsil edilir.İş parçacıklarının eski bir adı hafif süreçtir (lightweight process, LWP). “Hafif” sözcüğü maliyete işaret eder. Yeni bir süreç oluşturmak, adres alanını kurmayı ya da kopyalamayı, sayfa tablolarını hazırlamayı, dosya tablosunu çoğaltmayı gerektirir. Yeni bir iş parçacığı için ise yalnız bir yığın ve bir TCB yeterlidir; adres alanı zaten vardır. Aynı nedenle iki iş parçacığı arasında geçiş de iki süreç arasındaki geçişten ucuzdur.
İş parçacığını “küçük bir süreç” sanıp her iş parçacığının kendi belleği olduğunu düşünmek. İş parçacıklarının ayrı bir adres alanı yoktur. Global değişkenler, öbekte ayrılan veriler ve kod bütün iş parçacıklarınca ortaktır. Ayrı olan yalnız yürütme durumudur: PC, yazmaçlar, yığın ve iş parçacığına yerel değişkenler.
4.1.2Neden çok iş parçacığı?
Bugünkü uygulamaların çoğu çok iş parçacıklıdır. Bir web tarayıcısı, bir tümleşik geliştirme ortamı (integrated development environment, IDE), bir oyun motoru, bir veritabanı sunucusu ya da bir web sunucusu düzinelerce iş parçacığıyla çalışır. Bunun birkaç nedeni vardır.
- Farklı görevler ayrı akışlarda yürür. Bir uygulama aynı anda ekranı güncellemek, ağdan veri getirmek, ağ isteklerine yanıt vermek, yazım denetimi yapmak zorunda olabilir. Her görev kendi iş parçacığında, sanki programdaki tek iş oymuş gibi sıralı bir kodla yazılır.
- Bekleyen görev ötekileri durdurmaz. Disk ya da ağ beklenirken o iş parçacığı bloke olur, ama aynı süreçteki öteki iş parçacıkları ilerlemeye devam eder.
- Ucuzdur. Süreç oluşturmak pahalıdır; iş parçacığı oluşturmak çok daha ucuzdur.
- Programı basitleştirir. Her akış baştan sona okunabilen sıralı bir kod olarak kalır. Aynı işi tek akışla yapmak, programcıyı her görevin nerede kaldığını elle saklamaya zorlar.
İşletim sistemi çekirdeği de çok iş parçacıklıdır. Linux'ta ps komutuyla bakıldığında köşeli parantez içinde adları görünen çekirdek iş parçacıkları buna örnektir: kthreadd öteki çekirdek iş parçacıklarını oluşturur, kworker iş parçacıkları ertelenmiş işleri yürütür, ksoftirqd yazılım kesmelerini işler. Şekil 4.2 bir uygulama sürecinin içindeki üç iş parçacığını ve bunları çizelgeleyen çekirdeği gösterir.
4.1.3Tek ve çok iş parçacıklı süreç
Tek iş parçacıklı bir sürecin tek bir yürütme akışı vardır: bir PC, bir yazmaç kümesi ve bir yığın. Program bir anda yalnız bir yerde olabilir. Çok iş parçacıklı süreçte kod, global veri ve açık dosyalar yine birer tanedir; ama her akışın kendi yazmaçları ve kendi yığını vardır. Şekil 4.3 iki durumu yan yana gösterir.
Şekildeki ayrım, iş parçacıklarıyla ilgili hemen her sorunun kaynağıdır. Paylaşılan satırdaki her şey, bütün iş parçacıklarının aynı anda erişebildiği ortak veridir. Bir iş parçacığının global değişkene yazdığını öteki iş parçacıkları hemen görür. Bu, işbirliğini kolaylaştırır; ama iki iş parçacığı aynı değişkeni aynı anda değiştirmeye kalkarsa sonuç bozulabilir. Özel satırdaki öğeler ise her akışın kendi işini kaybetmeden sürdürebilmesini sağlar.
4.1.4Paylaşılan ve özel öğeler
Hangi öğenin süreç başına, hangisinin iş parçacığı başına tutulduğunu bilmek, çok iş parçacıklı bir programı doğru yazmanın ilk adımıdır. Tablo 4.1 bu ayrımı listeler.
| Süreç başına (bütün iş parçacıkları paylaşır) | İş parçacığı başına (özel) |
|---|---|
| Adres alanı: kod, global değişkenler, öbek | Program sayacı (PC) |
| Açık dosyalar, soketler | Yazmaçlar |
| Çocuk süreçler | Yığın ve yığın işaretçisi |
| Bekleyen alarmlar (zamanlayıcılar) | Durum: çalışıyor, hazır, bekliyor, sonlandı |
| Sinyal işleyicileri (signal handlers) | İş parçacığı kimliği, sinyal maskesi |
| Kimlik ve hesap (accounting) bilgisi | İş parçacığına yerel depolama (TLS), errno |
Tablonun sol sütunu, işletim sisteminin kaynakları süreç düzeyinde yönettiğini gösterir. Bir iş parçacığı bir dosya açarsa dosya tanımlayıcısı bütün süreç için geçerlidir; başka bir iş parçacığı aynı tanımlayıcıyla okuyup yazabilir, hatta dosyayı kapatabilir. Sinyal işleyicileri de süreç geneldir: bir iş parçacığının SIGINT için kurduğu işleyici, sinyal hangi iş parçacığına teslim edilirse edilsin çalışır.
Sağ sütundaki öğeler yürütmenin kendisine aittir. İki iş parçacığı aynı fonksiyonu aynı anda çalıştırabilir; her birinin yerel değişkenleri kendi yığınında durduğu için birbirine karışmaz. errno gibi “global gibi görünen ama iş parçacığına özel” değişkenler ise iş parçacığına yerel depolama ile sağlanır (4.6.5).
“Yığın iş parçacığına özeldir, öyleyse başka iş parçacığı ona dokunamaz” diye düşünmek. Yığın özeldir ama korunmaz. Bütün yığınlar aynı adres alanındadır; hatalı bir işaretçi başka bir iş parçacığının yığınındaki veriyi okuyabilir ya da bozabilir. Donanım bunu engellemez, çünkü sayfa tablosu bütün iş parçacıkları için aynıdır.
Bu bölümün geri kalanı için özet bir kural yeterlidir: paylaşılan veriye erişim senkronizasyon gerektirir. Birden çok iş parçacığının yazdığı her global değişken, her öbek nesnesi ve her açık dosya, bir kilitle ya da atomik işlemle korunmadıkça yarış durumuna açıktır (4.6.7).
4.1.5Her iş parçacığının kendi yığını
Fonksiyon çağrıları ve yerel değişkenler her akış için ayrı tutulmak zorundadır. İş parçacığı A bir f fonksiyonunun içindeyken iş parçacığı B g'yi çağırabilir. İkisi aynı yığını kullansaydı B'nin çerçevesi A'nın çerçevesinin üstüne yazılır, A f'ten döndüğünde yanlış adrese atlardı. Bu yüzden her iş parçacığının kendi yığını vardır. Şekil 4.4 üç iş parçacıklı bir sürecin adres alanında yığınların nasıl yerleştiğini gösterir.
mmap bölgesi, altta yukarı doğru büyüyen öbek, global veri ve kod vardır. Her iş parçacığının SP'si kendi yığınını gösterir.Linux'ta glibc kütüphanesi, yeni bir iş parçacığının yığınını mmap ile ayrılmış anonim bir bellek bölgesine yerleştirir. Varsayılan yığın boyutu genellikle ulimit -s değeri kadardır; çoğu dağıtımda bu değer 8 MiB'dir. Bu 8 MiB'nin tamamı hemen fiziksel belleğe yerleşmez: sanal bellek sayesinde yalnız dokunulan sayfalar gerçekten bellek tüketir.
Yığınlar aşağı doğru büyür ve iki yığın arasında başka bir şey yoksa taşan yığın komşusunun üstüne yazabilir. Bunu önlemek için her yığının ucuna bir koruma sayfası (guard page) konur. Koruma sayfası erişilemez olarak işaretlenmiştir; taşan bir yığın bu sayfaya dokunduğu anda sayfa hatası olur ve süreç SIGSEGV sinyaliyle sonlanır. Böylece hata sessizce komşu yığını bozmak yerine hemen fark edilir.
Yığın boyutu ayarlanabilir. Pthreads'te iş parçacığı oluşturulmadan önce pthread_attr_setstacksize ile istenen boyut verilir. Java'da -Xss seçeneği her platform iş parçacığının yığın boyutunu belirler. Derin özyineleme yapan bir iş parçacığı için yığını büyütmek, binlerce kısa ömürlü iş parçacığı için ise küçültmek gerekebilir.
Java 21 ile gelen sanal iş parçacıkları bu tabloyu değiştirir. Sanal iş parçacığının yığın çerçeveleri sabit büyüklükte bir bellek bölgesinde değil, öbekteki nesnelerde tutulur ve gerektiği kadar büyür. Bir sanal iş parçacığının birkaç yüz baytlık yığınla başlayabilmesi, milyonlarca sanal iş parçacığının aynı anda var olabilmesini sağlar (4.3.8).
Bir sunucu her bağlantı için bir platform iş parçacığı oluşturuyor; varsayılan yığın boyutu 8 MiB. 10 000 bağlantı için kaç bayt sanal adres alanı yalnız yığınlara ayrılır?
- Bir yığın 8 MiB = 8 × 220 bayttır.
- 10 000 yığın: 10 000 × 8 MiB = 80 000 MiB ≈ 78,1 GiB.
- 64 bitlik bir adres alanı bunu rahatça taşır ve dokunulmayan sayfalar fiziksel bellek tüketmez. Ama her yığının en az birkaç sayfası kullanılır; her iş parçacığının çekirdekte de bir TCB'si ve çekirdek yığını vardır. Bu yüzden on binlerce platform iş parçacığı, sanal iş parçacıklarına ya da olay güdümlü tasarıma göre belirgin biçimde daha çok kaynak harcar.
4.1.6Örnek: üç iş parçacıklı kelime işlemci
İş parçacıklarının neden yararlı olduğunu göstermek için Tanenbaum'un klasik örneği bir kelime işlemcidir. Yüzlerce sayfalık bir kitap düzenlenirken kullanıcı birinci sayfada bir satırı siliyor. Bu satırın silinmesi, ardından gelen bütün sayfaların yeniden biçimlendirilmesini gerektirir. Aynı zamanda kullanıcı yazmaya devam etmek ister, program da belgeyi belirli aralıklarla diske kaydetmelidir. Şekil 4.5 bu üç işi üç iş parçacığına dağıtır.
- Etkileşim
- Tuş ve fare olaylarını hemen işler. Arka planda ne kadar iş olursa olsun arayüz donmaz.
- Biçimlendirme
- Bir satır silinince yüzlerce sayfayı arka planda yeniden düzenler. Kullanıcı 600. sayfaya atladığında o sayfa büyük olasılıkla hazırdır.
- Otomatik kaydetme
- Belgeyi belirli aralıklarla diske yazar. Disk G/Ç'si beklenirken kullanıcıyı bekletmez.
Aynı tasarım üç ayrı süreçle kurulamazdı. Üç işin de aynı belge üzerinde çalışması gerekir; belge üç ayrı adres alanına kopyalansaydı her değişiklik ötekilere iletilmek zorunda kalırdı. Ortak adres alanı burada temel avantajdır. Tek iş parçacıklı bir tasarım ise iki kötü seçenek arasında kalırdı: ya biçimlendirme bitene kadar klavye girdisine yanıt verilmez, ya da biçimlendirme kodu her birkaç satırda bir durup klavyeyi yoklar. İkinci seçenek kodu okunmaz hâle getirir.
4.1.7Çok iş parçacıklı web sunucusu
İş parçacıklarının en tanıdık kullanım yeri sunuculardır. Bir web sunucusu her saniye çok sayıda istek alır. İsteklerin bir kısmı sık erişilen sayfalar içindir ve bu sayfalar bellekteki bir web sayfası önbelleğinde hazır durur. Öteki istekler için sayfanın diskten okunması gerekir; bu da milisaniyeler sürer. Sunucu her isteği sırayla işlerse diski bekleyen bir istek, arkasındaki bütün istekleri bekletir.
Tanenbaum'un tasarımında sunucu sürecinde iki tür iş parçacığı vardır. Dağıtıcı (dispatcher) iş parçacığı ağdan gelen istekleri okur. Her istek için boşta bekleyen bir işçi (worker) iş parçacığı seçer, isteği ona devreder ve onu uyandırır. İşçi sayfayı önce önbellekte arar; bulamazsa diskten okur ve yanıtı gönderir. Şekil 4.6 bu düzeni, işçilerden birinin diskte bloke olduğu an için gösterir.
read() çağrısında bloke olmuştur. Bu sırada dağıtıcı ve işçi 3 çalışmaya devam eder.Bu tasarımda dört an öne çıkar. İstek gelir ve dağıtıcı onu ağdan okur. Dağıtıcı boştaki bir işçiyi seçer, isteği ona verir ve uyandırır. İşçi 1 sayfayı önbellekte bulursa yanıtı hemen gönderir; diske gitmez. Yeni bir istek işçi 2'ye gider ve sayfa önbellekte yoksa işçi 2 diskte bloke olur. Önemli olan son durumdur: işçi 2'nin bloke olması yalnız işçi 2'yi durdurur. Dağıtıcı yeni istekleri kabul etmeye, işçi 3 de başka bir isteği işlemeye devam eder.
İki iş parçacığının kodu son derece sadedir. Dağıtıcı sonsuz bir döngüde bir sonraki isteği okur ve bir işçiye verir:
while (TRUE) {
get_next_request(&buf); /* ağdan oku */
handoff_work(&buf); /* işçiye ver */
}get_next_request(&buf) ağdan bir istek gelene kadar bloke olur ve isteği buf ara belleğine yazar. handoff_work(&buf) isteği boştaki bir işçiye aktarır ve o işçiyi uyandırır. Dağıtıcı bundan sonra işin ne olacağıyla ilgilenmez; hemen döngünün başına döner. İşçinin kodu da benzer bir döngüdür:
while (TRUE) {
wait_for_work(&buf);
look_for_page_in_cache(&buf, &page);
if (page_not_in_cache(&page))
read_page_from_disk(&buf, &page);
return_page(&page);
}İşçi wait_for_work içinde, dağıtıcı ona bir iş verene kadar bloke olur. İş gelince sayfayı önbellekte arar (look_for_page_in_cache). Sayfa önbellekte yoksa read_page_from_disk ile diskten okur; bu çağrı disk okuması bitene kadar bloke eder. Son olarak return_page sayfayı istemciye gönderir ve işçi yeni bir iş beklemek üzere döngünün başına döner.
İki kod da sıralıdır ve okunaklıdır. Bloke eden read çağrısı yalnız onu çağıran işçiyi durdurur. Tek iş parçacıklı bir sunucuda durum farklıdır: önbellekte olmayan bir sayfa için bütün süreç bloke olur, disk beklenirken işlemci boşta kalır ve o sırada gelen istekler kuyrukta bekler. Çok iş parçacıklı sunucuda iş bir işçiye aktarılır, sunucu yeni istekleri kabul etmeyi sürdürür.
4.1.8Kod izleme: dağıtıcı ve işçiler
Tasarımın nasıl çalıştığını somut bir senaryoyla izleyelim. Sunucu sürecinde üç iş parçacığı var: dağıtıcı ve iki işçi, W1 ile W2. Başlangıçta dağıtıcı ağı dinliyor, iki işçi de wait_for_work içinde bloke. Önbellekte yalnız a.html sayfası var. Önce b.html, ardından a.html için birer istek geliyor. İzlenen kod, iki döngünün bir araya getirilmiş biçimidir:
/* dağıtıcı iş parçacığı */
while (TRUE) {
get_next_request(&buf);
handoff_work(&buf);
}
/* işçi iş parçacığı */
while (TRUE) {
wait_for_work(&buf);
look_for_page_in_cache(&buf, &page);
if (page_not_in_cache(&page))
read_page_from_disk(&buf, &page);
return_page(&page);
}| Adım | Olay | Dağıtıcı | W1 | W2 | Disk | Önbellek |
|---|---|---|---|---|---|---|
| 0 | başlangıç | çalışıyor | bloke (iş bekler) | bloke (iş bekler) | boşta | a.html |
| 1 | istek 1 gelir: b.html | çalışıyor | bloke (iş bekler) | bloke (iş bekler) | boşta | a.html |
| 2 | handoff_work: istek 1 → W1 | çalışıyor | hazır | bloke (iş bekler) | boşta | a.html |
| 3 | W1 önbellekte arar: yok | bloke (ağ) | çalışıyor | bloke (iş bekler) | boşta | a.html |
| 4 | W1 read_page_from_disk | bloke (ağ) | bloke (disk) | bloke (iş bekler) | okunuyor | a.html |
| 5 | istek 2 gelir: a.html | çalışıyor | bloke (disk) | bloke (iş bekler) | okunuyor | a.html |
| 6 | W1 meşgul: istek 2 → W2 | çalışıyor | bloke (disk) | hazır | okunuyor | a.html |
| 7 | W2 a.html'i önbellekte bulur | bloke (ağ) | bloke (disk) | çalışıyor | okunuyor | a.html |
| 8 | W2 return_page: istek 2 yanıtlandı | bloke (ağ) | bloke (disk) | çalışıyor | okunuyor | a.html |
| 9 | disk biter, W1 uyanır, istek 1 yanıtlanır | bloke (ağ) | çalışıyor | bloke (iş bekler) | boşta | a.html, b.html |
| 10 | iki işçi yeniden iş bekler | bloke (ağ) | bloke (iş bekler) | bloke (iş bekler) | boşta | a.html, b.html |
İzlemedeki kilit adım 4. adımdır. W1 diskten okumak için bloke olur, ama yalnız W1 durur; aynı süreçteki öteki iş parçacıkları çalışmaya devam eder. Disk beklenirken dağıtıcı ikinci isteği alır ve W1 meşgul olduğu için onu W2'ye verir. W2 sayfayı önbellekte bulur ve yanıtlar. Sonuçta istek 2, istek 1'den önce yanıtlanır. Disk okuması bitince W1 uyanır, sayfayı önbelleğe de koyar ve birinci istemciye gönderir. Şekil 4.7 4. ve 8. adımları yan yana gösterir.
a.html'i döndürüyor.Tek iş parçacıklı bir sunucuda istek 2, disk okuması bitene kadar bekleyecekti; oysa kendi sayfası bellekte hazırdı. Çok iş parçacıklı sunucu, bekleme ile hesaplamayı örtüştürerek bu boşa geçen süreyi kullanır. Her kod parçası da yine sıralı ve okunaklı kalır.
İstek 2'nin istek 1'den önce yanıtlanmasını bir hata saymak. Çok iş parçacıklı bir sunucuda istekler geliş sırasıyla değil, hazır oldukları sırayla tamamlanır. Her istek kendi bağlantısına yanıt verdiği için sıranın değişmesi doğruluğu bozmaz; tersine, ortalama yanıt süresini kısaltır.
4.1.9Sunucu tasarımının üç yolu
Bir sunucunun bloke eden işlemlerle nasıl başa çıkacağı konusunda üç temel seçenek vardır. Tanenbaum ve Bos bunları Tablo 4.3'teki gibi karşılaştırır.
| Model | Paralellik / örtüşme | Sistem çağrıları | Programlama |
|---|---|---|---|
| Çok iş parçacıklı süreç | var | bloke eden | sıralı kod, kolay |
| Tek iş parçacıklı süreç | yok | bloke eden | kolay ama yavaş |
| Sonlu durum makinesi (olay güdümlü) | var (G/Ç örtüşür) | bloke etmeyen + bildirim / kesme | zor: her isteğin durumu elle saklanır |
Üçüncü model biraz açıklama ister. Olay güdümlü sunucuda tek bir iş parçacığı vardır, ama bu iş parçacığı hiçbir zaman bloke olmaz. Diskten okuma gerektiğinde bloke etmeyen bir çağrı yapar ve okuma bitince bir bildirim alacağını bilerek başka işlere geçer. Bu sırada yarım kalan isteğin nerede kaldığını, hangi sayfayı beklediğini, kime yanıt verileceğini bir tabloya kendisi yazar. Bildirim gelince tablodan o isteği bulur ve kaldığı yerden sürdürür. Program böylece bir sonlu durum makinesine dönüşür: her istek bir durumdan ötekine geçen küçük bir makinedir. G/Ç ile hesaplama örtüşür, ama iş parçacığı modelinin sağladığı sıralı kod düzeni kaybolur.
Bugün olay güdümlü model yaygındır: nginx, Node.js ve Redis bu modeli kullanır. Linux'ta epoll ve io_uring, BSD ve macOS'ta kqueue çok sayıda bağlantıyı tek iş parçacığıyla izlemeye yarayan arayüzlerdir. Çoğu gerçek sunucu iki yaklaşımı birleştirir: her çekirdek için bir olay döngüsü çalışır, bloke edebilecek işler (ör. disk erişimi, sıkıştırma) ise bir iş parçacığı havuzuna gönderilir.
Tek işlemcili bir sunucuda isteklerin 3/4'ü önbellekten karşılanıyor ve 10 ms işlemci zamanı harcıyor. Kalan 1/4'ünde 10 ms işlemci zamanına ek olarak 50 ms disk beklemesi var. Disk aynı anda birçok isteğe hizmet verebiliyor. Sunucu tek iş parçacıklıysa ve çok iş parçacıklıysa saniyede kaç isteğe yanıt verir?
- Tek iş parçacığı. Disk beklenirken süreç bloke olur, işlemci boşta kalır. Bir isteğin ortalama süresi 3/4 × 10 + 1/4 × (10 + 50) = 7,5 + 15 = 22,5 ms'dir. Saniyede 1000 / 22,5 ≈ 44,4 istek yanıtlanır.
- Çok iş parçacığı. Diski bekleyen işçi bloke olurken başka bir işçi işlemciyi kullanır; disk beklemesi hesaplamayla örtüşür. Darboğaz yalnız işlemcidir ve her istek 10 ms işlemci zamanı ister. Saniyede 1000 / 10 = 100 istek yanıtlanır.
- Yorum. Tek işlemci olduğu için kazanç paralellikten gelmez; bloke eden çağrıların hesaplamayla örtüşmesinden gelir. Aynı örtüşme olay güdümlü tek iş parçacığıyla da sağlanabilir, ama kod karmaşıklaşır.
4.1.10Çok iş parçacığının faydaları
Buraya kadarki örnekler çok iş parçacıklı programlamanın dört temel faydasını ortaya koyar.
- Duyarlılık (responsiveness)
- Programın bir kısmı bloke olsa ya da uzun bir hesap yapsa bile program yanıt vermeye devam eder. Kullanıcı arayüzlerinde bu özellik belirleyicidir: düğmeye basıldığında başlayan uzun bir işlem ayrı bir iş parçacığında yürürse pencere donmaz.
- Kaynak paylaşımı
- İş parçacıkları sürecin belleğini ve dosyalarını kendiliğinden paylaşır. Süreçler arasında aynı paylaşım için paylaşımlı bellek bölgesi ya da ileti geçirme düzeneği kurmak gerekir; iş parçacıklarında ek bir şey yapılmaz.
- Ekonomi
- İş parçacığı oluşturmak ve iş parçacıkları arasında geçiş yapmak süreçlere göre ucuzdur: adres alanı kopyalanmaz, sayfa tabloları değişmez.
- Ölçeklenebilirlik
- Tek bir süreç birden çok çekirdekte aynı anda çalışabilir. Tek iş parçacıklı bir süreç, makinede kaç çekirdek olursa olsun yalnız birini kullanır.
Ders kitaplarında sık aktarılan “iş parçacığı oluşturmak süreç oluşturmaktan 30 kat, iş parçacıkları arasında geçiş 5 kat hızlıdır” rakamları eski bir Solaris ölçümüne dayanır. Bu oranlar her sistemde geçerli değildir. Linux'ta süreç de iş parçacığı da aynı clone() çağrısıyla oluşturulur (4.6.9), bu yüzden oluşturma maliyetleri arasındaki fark daha küçüktür. Asıl kazanç, bellek eşlemesinin kopyalanmaması ve geçiş sırasında adres çeviri önbelleğinin korunmasıdır.
4.1.11Bağlam değiştirme maliyeti
İşlemcinin bir yürütme akışından ötekine geçmesine bağlam değiştirme (context switch) denir. Geçişin iki türü arasındaki fark, iş parçacıklarının neden daha ucuz olduğunu açıklar. Şekil 4.9 iki türü karşılaştırır.
Her iki geçişte de çekirdek, çalışan akışın yazmaçlarını, PC'sini ve SP'sini kaydeder ve yeni akışınkileri yükler. Süreçler arası geçişte buna bir adım daha eklenir: sayfa tablosunun taban adresi değiştirilir. x86 işlemcilerde bu adres CR3 yazmacında tutulur. Yeni adres alanına geçildiğinde, sanal adresleri fiziksel adreslere çeviren kayıtları saklayan adres çeviri önbelleğindeki (translation lookaside buffer, TLB) eski girdiler artık geçersizdir. İşlemci girdileri adres alanı kimliğiyle (PCID ya da ASID) etiketleyemiyorsa TLB boşaltılır. Ardından yeni sürecin ilk bellek erişimleri TLB'yi ve veri önbelleklerini yeniden doldurana kadar yavaş çalışır.
Maliyetin iki bileşeni vardır. Doğrudan maliyet, yazmaçları kaydedip yüklemenin ve çizelgeleyiciyi çalıştırmanın süresidir; bugünkü işlemcilerde mikrosaniyeler mertebesindedir. Dolaylı maliyet ise geçişten sonra soğuk önbellekler ve TLB kaçırmaları yüzünden yavaşlayan bellek erişimlerinden gelir. Dolaylı maliyet çoğu zaman doğrudan maliyetten büyüktür. Aynı süreçteki iş parçacıkları arasında geçişte adres alanı değişmediği için dolaylı maliyetin büyük kısmı ortadan kalkar.
Kullanıcı düzeyi iş parçacıklarında (4.3.2) geçiş daha da ucuzdur. Geçişi çekirdek değil, kullanıcı alanındaki bir kütüphane fonksiyonu yapar. Tuzak yoktur, çekirdeğe giriş yoktur; geçiş birkaç komutluk sıradan bir fonksiyon çağrısına iner.
Bağlam değiştirmenin maliyetini yalnız yazmaç kaydetme süresi sanmak. Yazmaçları kaydetmek birkaç yüz nanosaniye sürebilir, ama ardından gelen önbellek ve TLB kaçırmaları programı çok daha uzun süre yavaşlatabilir. Gereğinden fazla iş parçacığı çalıştıran bir program, sık geçişler yüzünden işlemciyi yararlı iş yerine önbelleği yeniden doldurmaya harcar.
4.2Çok çekirdekli programlama
Yaklaşık 2005'e kadar işlemciler her yıl daha yüksek saat hızlarıyla piyasaya çıktı ve tek iş parçacıklı programlar kendiliğinden hızlandı. Güç tüketimi ve ısı bu artışı durdurunca üreticiler aynı yonga üzerine birden çok çekirdek koymaya yöneldi. Bugün bir dizüstü bilgisayarda 8–16, bir sunucuda 64–128 çekirdek bulunur. Bu çekirdeklerden yararlanmanın yolu, programı aynı anda çalışabilecek parçalara bölmektir. İş parçacıkları bunun en doğrudan aracıdır.
4.2.1Eş zamanlılık ve paralellik
Gündelik dilde birbirinin yerine kullanılan iki sözcük, işletim sistemlerinde farklı anlamlar taşır.
Eş zamanlılık (concurrency): birden çok görevin aynı zaman aralığında ilerlemesidir. Görevlerin aynı anda çalışması gerekmez; tek bir çekirdekte iç içe geçerek de ilerleyebilirler.
Paralellik (parallelism): birden çok görevin aynı anda yürütülmesidir. En az iki çekirdek ya da işlem birimi gerekir.
Tek çekirdekli bir makinede dört görev olduğunu düşünelim. Çizelgeleyici her birine kısa bir zaman dilimi verir: önce T1, sonra T2, T3, T4 ve yeniden T1. Dilimler kısa olduğu için insan gözüne dört görev birlikte ilerliyormuş gibi görünür. Gerçekte herhangi bir anda yalnız biri çalışır. Bu, eş zamanlılıktır ama paralellik değildir. Aynı dört görev iki çekirdekli bir makinede çalışırsa T1 ile T3 birinci çekirdeği, T2 ile T4 ikinci çekirdeği paylaşır. Artık her an iki görev gerçekten aynı anda çalışır: bu, paralel yürütmedir. Şekil 4.10 iki durumu karşılaştırır.
Paralellik eş zamanlılığı gerektirir: aynı anda çalışan iki görev, aynı zaman aralığında da ilerliyor demektir. Tersi doğru değildir: eş zamanlı bir program tek çekirdekte paralellik olmadan çalışabilir. Aynı program iki farklı donanımda farklı davranır. Tek çekirdekte iş parçacıkları yalnız iç içe geçer; çok çekirdekte gerçekten aynı anda çalışır ve paylaşılan veriye gerçekten aynı anda erişebilir.
Tek çekirdekte iş parçacığı kullanmanın anlamsız olduğunu düşünmek. Paralellik olmasa da eş zamanlılık değerlidir: bloke olan bir iş parçacığı beklerken öteki çalışır (Örnek 4.2'deki sunucu tek işlemcide iki kattan fazla hızlanmıştı). Benzer biçimde, tek çekirdekte yarış durumu olmayacağını sanmak da yanlıştır: zaman dilimi bir oku-artır-yaz dizisinin ortasında bitebilir.
4.2.2Çok çekirdekli programlamanın zorlukları
Çekirdek sayısının artması programları kendiliğinden hızlandırmaz. Programcının ya da derleyicinin işi çekirdeklere bölmesi gerekir. Silberschatz bu işin zorluklarını beş başlıkta toplar; bunlara bellek etkilerini de eklemek gerekir.
- Görevleri belirleme
- Uygulamada birbirinden bağımsız, paralel yürüyebilecek etkinlikleri bulmak. Bazı algoritmalar doğal olarak bölünür (bir dizinin toplamı), bazıları adım adım ilerler ve bölünmez.
- Denge (balance)
- Görevler benzer büyüklükte olmalıdır. Dört çekirdekten biri ötekilerin dört katı iş alırsa toplam süreyi o çekirdek belirler. Çok küçük bir görev için ayrı bir çekirdek ayırmak da kazandırmaz; görevi başlatma maliyeti kazancı aşar.
- Veri bölme
- Görevlerin eriştiği veri de çekirdeklere bölünmelidir. Bütün çekirdekler aynı veriye erişirse paralellik çekişmeye dönüşür.
- Veri bağımlılığı
- Bir görev ötekinin sonucunu bekliyorsa erişim senkronize edilmelidir. Bağımlılıklar paralel çalışabilecek kısmı küçültür.
- Test ve hata ayıklama
- Yürütme sırası her çalıştırmada değişebilir. Bir hata yüz çalıştırmada bir kez ortaya çıkabilir ve hata ayıklayıcı altında hiç görünmeyebilir.
- Bellek etkileri
- Farklı değişkenler aynı önbellek satırındaysa ve iki çekirdek bunlara yazıyorsa, önbellek tutarlılık düzeneği satırı çekirdekler arasında sürekli taşır. Mantıksal olarak hiçbir şey paylaşılmadığı hâlde çekirdekler birbirini yavaşlatır. Bu duruma yanlış paylaşım (false sharing) denir.
Yarışları bulmaya yardım eden araçlar vardır: GCC ve Clang'da -fsanitize=thread seçeneğiyle etkinleşen ThreadSanitizer, Valgrind'in Helgrind aracı, Java Flight Recorder. Ama hiçbir araç bütün yarışları yakalamaz; araçlar yalnız o çalıştırmada gerçekleşen erişimleri inceler. Paylaşımı tasarım aşamasında azaltmak en etkili yöntemdir.
4.2.3Veri ve görev paralelliği
İşi çekirdeklere bölmenin iki temel yolu vardır. Veri paralelliğinde (data parallelism) veri parçalara bölünür ve her çekirdek kendi parçasında aynı işlemi yapar. Görev paralelliğinde (task parallelism) iş farklı görevlere bölünür ve her çekirdek farklı bir işlem yapar; görevler aynı veriyi ya da farklı verileri kullanabilir. Şekil 4.11 iki yolu aynı sekiz öğeli dizi üzerinde gösterir.
N öğeli bir diziyi dört çekirdekle toplamak veri paralelliğinin tipik örneğidir: her çekirdek N/4 öğe toplar, sonda dört kısmi toplam birleştirilir. Grafik işlemciler bu modelin uç örneğidir; binlerce basit çekirdek aynı komutu farklı piksellere ya da matris öğelerine uygular. Görev paralelliğine örnek olarak bir dizinin istatistiklerini hesaplayan bir program verilebilir: bir çekirdek en küçüğü, biri en büyüğü, biri ortalamayı, biri standart sapmayı bulur. Gerçek uygulamalar çoğunlukla ikisini birlikte kullanır.
4.2.4Amdahl yasası
Bir programı çekirdek sayısını artırarak ne kadar hızlandırabiliriz? Hemen her programın paralelleştirilemeyen bir kısmı vardır: girdiyi okumak, sonuçları birleştirmek, bir kilidin arkasında sırayla yapılan işler. Bu kısım ne kadar çekirdek eklenirse eklensin aynı sürede çalışır. Gene Amdahl'ın 1967'de dile getirdiği gözlem, bu seri kısmın hızlanmaya kesin bir üst sınır koyduğudur.
Programın tek çekirdekteki çalışma süresini 1 birim alalım. Bu sürenin S kadarı seridir, 1 − S kadarı paralelleştirilebilir. N çekirdekte seri kısım yine S sürer; paralel kısım ise N çekirdeğe eşit bölünür ve (1 − S) / N sürer. Hızlanma (speedup), eski sürenin yeni süreye oranıdır.
Seri oranı S olan bir program N çekirdekte en fazla
hızlanma ≤ 1 / ( S + (1 − S) / N )
kat hızlanır. N → ∞ iken paralel terim sıfıra gider ve hızlanmanın üst sınırı 1 / S olur.
Eşitsizlik işareti, formülün iyimser bir sınır olduğunu söyler. Formül paralel kısmın kusursuz bölündüğünü, iş parçacıkları arasında hiçbir iletişim ve senkronizasyon maliyeti olmadığını varsayar. Gerçek hızlanma bu sınırın altında kalır. Bir başka ölçü de verimdir: hızlanmanın çekirdek sayısına oranı, yani her çekirdeğin ne kadar iyi kullanıldığı.
Bir programın çalışma süresinin %25'i seridir (S = 0,25). 1, 2 ve 4 çekirdekte süre ve hızlanma nedir? Hızlanmanın üst sınırı kaçtır?
- N = 1: süre 0,25 + 0,75 / 1 = 1, yani %100. Hızlanma 1×.
- N = 2: süre 0,25 + 0,75 / 2 = 0,25 + 0,375 = 0,625, yani %62,5. Hızlanma 1 / 0,625 = 1,60×. Çekirdek sayısı iki katına çıktı, hızlanma 2× değil.
- N = 4: süre 0,25 + 0,75 / 4 = 0,25 + 0,1875 = 0,4375, yani %43,75. Hızlanma 1 / 0,4375 ≈ 2,29×. Verim 2,29 / 4 ≈ %57.
- N → ∞: paralel kısım sıfıra yaklaşır, seri %25 kalır. Hızlanma en fazla 1 / 0,25 = 4×.
Şekil 4.12 yasanın özünü görsel olarak verir. Çekirdek eklendikçe yalnız mavi bölüm kısalır. Bir noktadan sonra çubuğun neredeyse tamamı turuncu bölümden oluşur ve yeni çekirdekler süreyi pek değiştirmez. Küçük görünen bir seri kısım bile ölçeklenmeyi belirgin biçimde sınırlar.
Sekiz çekirdekli bir makinede bir hesaplamanın en az 6 kat hızlanması isteniyor. Seri kısım en fazla ne kadar olabilir?
- Hızlanma formülünü eşitliğe çevirelim: 6 = 1 / (S + (1 − S) / 8). Buradan S + (1 − S) / 8 = 1/6 olur.
- İki tarafı 8 ile çarpalım: 8S + 1 − S = 8/6, yani 7S = 8/6 − 1 = 1/3.
- S = 1/21 ≈ 0,0476. Seri kısım en fazla yaklaşık %4,8 olabilir; kodun en az %95,2'si paralel olmalıdır.
- Sağlama: 1 / (1/21 + (20/21) / 8) = 1 / (1/21 + 5/42) = 1 / (7/42) = 6.
Seri oran ile paralel oranı karıştırmak. Formüldeki S seri (paralelleştirilemeyen) orandır. Soru “programın %80'i paralelleştirilebiliyor” diyorsa S = 0,2'dir, 0,8 değil. Bir başka yaygın hata da hızlanmayı süreyle karıştırmaktır: 0,625 süre, 1,60 ise hızlanmadır.
4.2.5Amdahl eğrileri ve Gustafson yasası
Amdahl yasasının sonuçları farklı seri oranlar için çizildiğinde daha da çarpıcıdır. Şekil 4.13 paralel oranı %50, %75, %90 ve %95 olan dört program için hızlanmayı çekirdek sayısının logaritmik ölçeğinde gösterir.
| Seri oran S | N = 2 | N = 4 | N = 8 | N = 16 | N = 64 | N = 1024 | Sınır 1/S |
|---|---|---|---|---|---|---|---|
| 0,50 | 1,33 | 1,60 | 1,78 | 1,88 | 1,97 | 2,00 | 2 |
| 0,25 | 1,60 | 2,29 | 2,91 | 3,37 | 3,82 | 3,99 | 4 |
| 0,10 | 1,82 | 3,08 | 4,71 | 6,40 | 8,77 | 9,91 | 10 |
| 0,05 | 1,90 | 3,48 | 5,93 | 9,14 | 15,42 | 19,64 | 20 |
Tablodan iki sonuç çıkar. Birincisi, sınır seri orana çok duyarlıdır: seri kısmı %10'dan %5'e indirmek, sınırı 10'dan 20'ye çıkarır. İkincisi, çekirdek eklemenin getirisi hızla azalır. Programın %95'i paralel olsa bile 1024 çekirdekte hızlanma yalnız 19,6× olur; çekirdeklerin %98'inden fazlası boşa çalışıyor demektir. Gerçekte senkronizasyon ve iletişim yükü yüzünden hızlanma bu değerlerin de altında kalır.
Amdahl yasası problem boyutunun sabit kaldığını varsayar: aynı iş daha çok çekirdekle yapılır. John Gustafson 1988'de farklı bir soruyu sordu: çekirdek sayısı artınca genellikle problem de büyür. Hava tahmini yapan biri 1024 çekirdeğe kavuştuğunda aynı tahmini daha hızlı yapmakla yetinmez; daha ince bir ızgarayla daha ayrıntılı bir tahmin yapar. Bu durumda paralel kısım çekirdeklerle birlikte büyür, seri kısım ise aşağı yukarı sabit kalır. Gustafson yasası, bu varsayımla ölçeklenmiş hızlanmanın
ölçeklenmiş hızlanma = S + (1 − S) · N
olduğunu söyler. Burada S, büyütülmüş problemin N çekirdekteki çalışma süresinin seri payıdır. Örneğin S = 0,1 ve N = 16 için ölçeklenmiş hızlanma 0,1 + 0,9 · 16 = 14,5 olur; Amdahl yasası aynı değerlerle 6,4 verir. İki yasa çelişmez; farklı soruları yanıtlar. Amdahl, “bu işi ne kadar hızlı bitirebilirim?” sorusunu; Gustafson, “aynı sürede ne kadar büyük bir iş yapabilirim?” sorusunu cevaplar.
4.2.6Kod izleme: Amdahl hızlanması
Aşağıdaki kısa C programı, seri oranı %10 olan bir program için 1, 2, 4, 8 ve 16 çekirdekte hızlanmayı yazdırır ve sonda üst sınırı verir:
double S = 0.1; /* seri oran */
int N = 1; /* çekirdek */
while (N <= 16) {
double h = 1 / (S + (1 - S) / N);
printf("N=%2d %.2fx\n", N, h);
N = N * 2;
}
printf("sınır %.0fx\n", 1 / S);Program S = 0.1 ve N = 1 ile başlar. while döngüsü N 16'yı aşmadığı sürece döner. Her turda Amdahl formülüyle hızlanma h hesaplanır, printf ile iki ondalık basamakla yazdırılır ve N iki katına çıkarılır. N = 32 olduğunda koşul yanlış olur, döngü biter ve son satır 1 / S sınırını yazdırır. Tablo 4.5 döngünün her turunu izler.
| Tur | N | S + (1 − S) / N | h | Çıktı satırı | Önceki tura göre artış |
|---|---|---|---|---|---|
| 1 | 1 | 0,1 + 0,9 = 1 | 1,00 | N= 1 1.00x | — |
| 2 | 2 | 0,1 + 0,45 = 0,55 | 1,82 | N= 2 1.82x | ×1,82 |
| 3 | 4 | 0,1 + 0,225 = 0,325 | 3,08 | N= 4 3.08x | ×1,69 |
| 4 | 8 | 0,1 + 0,1125 = 0,2125 | 4,71 | N= 8 4.71x | ×1,53 (%53) |
| 5 | 16 | 0,1 + 0,05625 = 0,15625 | 6,40 | N=16 6.40x | ×1,36 (%36) |
| — | 32 | 32 ≤ 16 yanlış: döngü biter; son satır sınır 10x | |||
Son sütun, azalan getiriyi sayılarla gösterir. Çekirdek sayısı her turda iki katına çıkar, ama hızlanmadaki artış oranı 1,82'den 1,36'ya düşer. 8'den 16 çekirdeğe geçmek hızlanmayı yalnız %36 artırır. Çıktıdaki ondalık ayırıcının nokta olması C'nin printf biçimlendirmesinden gelir; metin içinde aynı değerler virgülle yazılır.
N sonsuza giderken paralel kısım sıfıra iner, seri %10 kalır ve hızlanma 10×'u asla geçemez. 16 çekirdekte bile hızlanma 6,4×'tür; verim 6,4 / 16 = %40'tır. Çekirdeklerin %60'ı fiilen seri kısmı beklemekle geçer.
4.3İş parçacığı modelleri
İş parçacıklarını kimin yönettiği sorusunun iki uç cevabı vardır. Ya bütün yönetim kullanıcı alanındaki bir kütüphanede yapılır ve çekirdek iş parçacıklarından habersizdir, ya da çekirdek her iş parçacığını tanır ve kendisi çizelgeler. Bu iki uç ve aralarındaki karma çözümler, iş parçacıklarının maliyetini, bloke olma davranışını ve çok çekirdekten yararlanıp yararlanamayacağını belirler.
4.3.1Kullanıcı ve çekirdek düzeyi iş parçacıkları
Kullanıcı düzeyi iş parçacıkları (user-level threads), kullanıcı alanında çalışan bir kütüphanede, yani bir çalışma zamanı sisteminde (run-time system) yönetilir. Çekirdek her süreci tek akışlı sanır.
Çekirdek düzeyi iş parçacıkları (kernel-level threads), çekirdeğin tanıdığı ve kendisinin çizelgelediği iş parçacıklarıdır. Linux, Windows ve macOS bu yaklaşımı kullanır.
Şekil 4.15 iki yaklaşımda iş parçacığı tablosunun nerede durduğunu gösterir. Kullanıcı düzeyinde her sürecin kendi çalışma zamanı sistemi ve kendi iş parçacığı tablosu vardır; çekirdek yalnız süreç tablosunu bilir. Çekirdek düzeyinde iş parçacığı tablosu çekirdektedir ve bütün süreçlerin iş parçacıklarını içerir.
4.3.2Kullanıcı düzeyi iş parçacıkları
İş parçacıklarını tamamen kullanıcı alanında yönetmek, ilk bakışta tuhaf görünebilir; ama bu yaklaşımın ciddi avantajları vardır.
- Çekirdek desteği gerekmez. İş parçacığı desteği olmayan bir çekirdekte bile iş parçacıkları kütüphane olarak gerçeklenebilir. 1990'lara kadar pek çok UNIX sürümü böyleydi.
- Her süreç kendi tablosunu tutar. Tablo, her iş parçacığı için PC'yi, yazmaçları, SP'yi ve durumu saklar. Çekirdeğin süreç tablosuna benzer, ama kullanıcı belleğindedir.
- Geçiş çok hızlıdır. Bir iş parçacığından ötekine geçmek için tuzak (trap) gerekmez, çekirdeğe girilmez, bellek eşlemesi değişmez, TLB boşaltılmaz. Geçiş birkaç komutluk bir fonksiyon çağrısıdır.
- Çizelgeleme algoritması seçilebilir. Her süreç kendine uygun bir çizelgeleme algoritması kullanabilir; örneğin bir çöp toplayıcı iş parçacığını en uygun anda çalıştırmak için.
Dezavantajlar ise daha temeldir ve kullanıcı düzeyi iş parçacıklarının işletim sistemi düzeyinde neden terk edildiğini açıklar.
- Bloke eden sistem çağrısı bütün süreci durdurur. Bir iş parçacığı
readçağırıp diskte bloke olursa çekirdek bütün süreci bloke eder; çünkü çekirdek için süreçte tek bir akış vardır. Öteki iş parçacıkları hazır olsa bile çalışamaz. Çözümler zarif değildir: çağrıyı önceselectile sınayan ve okunacak veri yoksa başka iş parçacığına geçen sarmalayıcılar (jacket) ya da bütün çağrıları bloke etmeyen sürümlerle değiştirmek. - Sayfa hatası da bütün süreci bloke eder. Bir iş parçacığının eriştiği sayfa bellekte değilse çekirdek sayfayı diskten getirirken süreci bekletir; kütüphanenin bundan haberi bile olmaz.
- Saat kesmesi kütüphaneye gelmez. Çekirdek zaman dilimi dolunca süreci kesebilir, ama süreç içinde hangi iş parçacığının çalışacağına kütüphane karar verir. Kütüphanenin elinde bir saat kesmesi olmadığı için bir iş parçacığı CPU'yu gönüllü bırakmazsa (
yield) öteki iş parçacıkları hiç çalışmaz. - Tek süreç birden çok çekirdeği kullanamaz. Çekirdek süreci tek bir akış olarak gördüğü için onu aynı anda tek bir çekirdeğe yerleştirir.
4.3.3Kod izleme: kullanıcı düzeyinde thread_yield
Kullanıcı düzeyinde geçişin ne kadar basit olduğunu görmek için bir kütüphanenin thread_yield fonksiyonunu adım adım izleyelim. Aşağıdaki kod, gerçek bir kütüphanenin basitleştirilmiş hâlidir. PC ve SP, işlemcinin program sayacını ve yığın işaretçisini temsil eden sözde değişkenlerdir; gerçek bir kütüphane bunları birkaç satır çevirme diliyle okur ve yazar.
enum { HAZIR, CALISIYOR, BEKLIYOR };
struct tcb { int pc, sp, durum; } T[3];
int cur = 0; /* çalışan */
void thread_yield(void) { /* kütüphanede */
T[cur].pc = PC; T[cur].sp = SP;
T[cur].durum = HAZIR;
do cur = (cur + 1) % 3;
while (T[cur].durum != HAZIR);
T[cur].durum = CALISIYOR;
PC = T[cur].pc; SP = T[cur].sp;
}struct tcb her iş parçacığının kaydedilmiş PC'sini, SP'sini ve durumunu tutar; T[3] üç iş parçacıklı bir tablodur. cur o anda çalışan iş parçacığının indisidir. thread_yield önce çalışan iş parçacığının PC ve SP değerlerini tabloya kaydeder ve onu HAZIR yapar. Ardından do … while döngüsü cur'u dairesel olarak ilerletir ve HAZIR bir iş parçacığı bulana kadar döner. Bulunan iş parçacığı ÇALIŞIYOR yapılır, kaydedilmiş PC ve SP değerleri işlemciye yüklenir. Fonksiyonun dönüş komutu artık yeni iş parçacığının yığınından döner; yürütme yeni iş parçacığının kaldığı yerden sürer.
Başlangıçta T0 çalışıyor; işlemcide PC = 0x124, SP = 0x8FF0. Tablodaki T0 satırında eski değerler (0x100, 0x9000) duruyor; bunlar T0'ın son kaydedildiği andan kalmıştır. T1 kütüphanedeki bir kilidi bekliyor (BEKLİYOR), T2 hazır ve en son PC = 0x480, SP = 0xB000 iken bırakmış. T0 gönüllü olarak thread_yield() çağırıyor.
| Adım | Yürüyen satır | T0 (pc, sp, durum) | T1 | T2 | cur | CPU |
|---|---|---|---|---|---|---|
| 0 | (başlangıç) | 0x100, 0x9000, Ç | 0x300, 0xA000, B | 0x480, 0xB000, H | 0 | PC=0x124 SP=0x8FF0 |
| 1 | T[cur].pc = PC; T[cur].sp = SP; | 0x124, 0x8FF0, Ç | aynı | aynı | 0 | aynı |
| 2 | T[cur].durum = HAZIR; | 0x124, 0x8FF0, H | aynı | aynı | 0 | aynı |
| 3 | cur = (cur + 1) % 3; | aynı | aynı | aynı | 1 | aynı |
| 4 | while (T[1].durum != HAZIR): B, döngü sürer | aynı | aynı | aynı | 1 | aynı |
| 5 | cur = (cur + 1) % 3; | aynı | aynı | aynı | 2 | aynı |
| 6 | while (T[2].durum != HAZIR): H, döngü biter | aynı | aynı | aynı | 2 | aynı |
| 7 | T[cur].durum = CALISIYOR; PC = …; SP = …; | 0x124, 0x8FF0, H | 0x300, 0xA000, B | 0x480, 0xB000, Ç | 2 | PC=0x480 SP=0xB000 |
| 8 | T2 biraz çalışır, PC = 0x48C'de thread_yield() çağırır: kaydet, HAZIR, cur = (2 + 1) % 3 = 0, T0 HAZIR | 0x124, 0x8FF0, H | aynı | 0x48C, 0xAFF8, H | 0 | PC=0x48C SP=0xAFF8 |
| 9 | T0 ÇALIŞIYOR yapılır, yazmaçları yüklenir | 0x124, 0x8FF0, Ç | aynı | 0x48C, 0xAFF8, H | 0 | PC=0x124 SP=0x8FF0 |
Adım 1 ve 2'de T0'ın durumu kaydedilir. Bu sıradan bir fonksiyon çağrısıdır: tuzak yoktur, çekirdeğe girilmez. Adım 3–6'da tablo taranır; T1 beklediği için atlanır, T2 hazır olduğu için seçilir. Adım 7'de T2'nin yazmaçları yüklenir. Bütün geçiş birkaç komutla biter. Adım 8'de T2 de gönüllü olarak bırakır ve sıra dairesel olarak yeniden T0'a gelir. Adım 9'da T0, 0x124 adresinden, yani kaldığı yerden sürer. Şekil 4.16 başlangıcı ve 7. adımı gösterir.
cur T0'ı gösteriyor, işlemcide PC = 0x124. (b) 7. adım: T0'ın değerleri kaydedilmiş ve HAZIR, T1 hâlâ BEKLİYOR, T2 ÇALIŞIYOR ve işlemciye PC = 0x480, SP = 0xB000 yüklenmiş. Çekirdek bütün bu süre boyunca tek bir akış görür.Kullanıcı düzeyi iş parçacıklarının da zaman dilimiyle kesileceğini varsaymak. T2 sonsuz bir hesap döngüsüne girip hiç thread_yield() çağırmasaydı T0 sonsuza dek bekleyecekti. Saat kesmesi çekirdeğe gelir; çekirdek süreci kesip başka bir süreç çalıştırabilir, ama süreç yeniden çalıştığında yine T2 kaldığı yerden sürer. Kütüphanenin araya girme şansı yoktur.
4.3.4Bloke eden çağrı: N:1 ve 1:1
Kullanıcı düzeyi iş parçacıklarının en ağır sorunu, bloke eden sistem çağrılarıdır. Sorunu görünür kılmak için iki modeli yan yana koyalım. Çoktan bire (N:1) modelde bir sürecin bütün kullanıcı iş parçacıkları tek bir çekirdek iş parçacığı üzerinde çalışır. Bire bir (1:1) modelde her kullanıcı iş parçacığının kendi çekirdek iş parçacığı vardır.
read() çağırır; çekirdek yalnız K1'i görür ve onu bloke eder, bu yüzden U2 ve U3 de bekler.N:1 modelde U1'in read() çağrısı çekirdeğe ulaştığında çekirdek bu sürece ait tek bir çizelgelenebilir varlık görür: K1. Okuma bitene kadar K1'i bloke eder. U2 ve U3 hazır olsa da onları çalıştıracak bir çekirdek iş parçacığı yoktur; bütün süreç durur. 1:1 modelde aynı çağrı yalnız U1'in çekirdek iş parçacığını, K1'i bloke eder. U2 ve U3'ün kendi çekirdek iş parçacıkları (K2 ve K3) vardır; çizelgeleyici onları iki çekirdekte paralel olarak çalıştırmayı sürdürür.
4.3.5Çekirdek düzeyi iş parçacıkları
Çekirdek düzeyi iş parçacıklarında çekirdek, kullanıcı düzeyindeki tablonun aynı bilgilerini (yazmaçlar, durum ve benzerleri) içeren bir iş parçacığı tablosu tutar. Bu düzenin sonuçları şöyle sıralanabilir.
- İş parçacığı oluşturmak ve sonlandırmak bir sistem çağrısıdır. Bu yüzden kullanıcı düzeyine göre daha pahalıdır.
- Bir iş parçacığı bloke olursa çekirdek başka birini seçer. Seçilen iş parçacığının aynı süreçten olması gerekmez.
- Sayfa hatası yalnız hatayı yapan iş parçacığını bekletir.
- Bir sürecin iş parçacıkları farklı çekirdeklerde gerçekten paralel çalışır.
- Her iş parçacığı çekirdek belleğinde yer kaplar: TCB ve çekirdek yığını. Bu yüzden sayısı sınırsız değildir.
Bugün Linux (NPTL, Native POSIX Thread Library), Windows ve macOS 1:1 modelini kullanır. Çekirdek iş parçacığı oluşturmanın maliyeti yıllar içinde azaldığı için on binlerce çekirdek iş parçacığı sorun değildir. Linux'ta iş parçacığı sayısını üç sınır belirler: sistem geneli /proc/sys/kernel/threads-max, kullanıcı başına ulimit -u ve denetim grubu başına pids.max. Bir sürecin iş parçacıklarını gözlemlemek için ps -eLf (her iş parçacığı bir satır), top -H ya da /proc/<pid>/task/ dizini kullanılır; bu dizinde her iş parçacığı için bir alt dizin bulunur.
4.3.6Eşleme modelleri
Kullanıcı iş parçacıklarının çekirdek iş parçacıklarına nasıl eşlendiği, dört model altında toplanır. Şekil 4.20 dördünü yan yana gösterir.
- Çoktan bire (N:1)
- Geçiş hızlıdır, ama bir bloke eden çağrı bütün süreci durdurur ve paralellik yoktur. Örnekler: Solaris'in yeşil iş parçacıkları (green threads) ve GNU Portable Threads; ikisi de bugün tarihîdir.
- Bire bir (1:1)
- Gerçek paralellik vardır ve her iş parçacığı bağımsız olarak bloke olabilir. Bedeli, her iş parçacığının çekirdek kaynağı harcamasıdır. Linux, Windows ve macOS bu modeli kullanır.
- Çoktan çoğa (M:N)
- Çok sayıda kullanıcı iş parçacığı daha az sayıda çekirdek iş parçacığına çoğullanır. Kullanıcı iş parçacığı oluşturmak ucuzdur, birden çok çekirdek kullanılır ve bir iş parçacığı bloke olunca çalışma zamanı ötekileri başka çekirdek iş parçacıklarında çalıştırabilir. Sorun gerçeklemenin zorluğudur: kullanıcı alanında ve çekirdekte iki ayrı çizelgeleyici birbirinden habersiz karar verir.
- İki düzeyli (hibrit)
- M:N'e ek olarak bir kullanıcı iş parçacığı tek bir çekirdek iş parçacığına kalıcı olarak bağlanabilir (bound). Gecikmeye duyarlı bir iş parçacığı böylece kendi çekirdek iş parçacığına kavuşur.
4.3.7Modellerin karşılaştırılması
| Ölçüt | N:1 | 1:1 | M:N |
|---|---|---|---|
| Geçiş maliyeti | çok düşük | orta (çekirdek) | düşük |
| Bloke eden çağrı | bütün süreç durur | yalnız o iş parçacığı | yalnız o iş parçacığı* |
| Çok çekirdek | kullanılamaz | kullanılır | kullanılır |
| İş parçacığı sayısı | çok fazla | çekirdek belleğiyle sınırlı | çok fazla |
| Gerçekleme | kolay (kütüphane) | kolay | zor (iki çizelgeleyici) |
Yıldızlı hücre bir koşula bağlıdır: M:N modelde bir iş parçacığının bloke olması ötekileri ancak çalışma zamanı bloke olan çağrıyı fark edip öteki kullanıcı iş parçacıklarını başka bir çekirdek iş parçacığına aktarabiliyorsa etkilemez.
İşletim sistemi düzeyinde M:N modeli terk edildi. Solaris 9 ve FreeBSD 7, iki çizelgeleyicinin uyum sorunları ve gerçeklemenin karmaşıklığı yüzünden 1:1 modeline geçti. Ancak M:N modeli dil çalışma zamanlarında geri döndü. Go dilinin hafif iş parçacıkları (goroutines), Erlang süreçleri ve Java 21 sanal iş parçacıkları M:N modelini çekirdekte değil, dilin çalışma zamanında uygular. Çalışma zamanı bloke edebilecek çağrıları zaten kendisi yönettiği için eski M:N gerçeklemelerinin en büyük sorunu ortadan kalkar.
Bir program çalışması boyunca 500 000 iş parçacığı geçişi yapıyor. Kullanıcı düzeyinde (N:1) bir geçiş 50 ns, çekirdek düzeyinde (1:1) 2 µs sürüyor. İki modelde geçişlere toplam ne kadar zaman gider? İş parçacıklarından biri 10 ms süren bir disk okuması yaptığında ne olur?
- N:1: 500 000 × 50 ns = 25 000 000 ns = 0,025 s.
- 1:1: 500 000 × 2 µs = 1 000 000 µs = 1 s. Kullanıcı düzeyi geçiş 40 kat ucuzdur, çünkü tuzak ve çekirdeğe giriş yoktur.
- Disk okuması, N:1: Çekirdek tek bir çekirdek iş parçacığı görür ve onu bloke eder. Bütün süreç 10 ms bekler; bu süre 200 000 kullanıcı düzeyi geçişe eşittir.
- Disk okuması, 1:1: Yalnız okuyan iş parçacığı bekler. Öteki iş parçacıkları o 10 ms içinde, başka çekirdeklerde paralel olarak bile çalışır.
- Sonuç. Geçiş maliyeti tek başına model seçimini belirlemez. G/Ç yapan bir programda tek bir bloke eden çağrı, N:1 modelinin geçişlerde kazandırdığı zamanın büyük kısmını yok edebilir. M:N modeli iki üstünlüğü birleştirmeye çalışır: ucuz geçiş ve bloke olmayan çalışma zamanı.
4.3.8Güncel M:N: sanal iş parçacıkları ve Go
Java 21 ile kalıcı hâle gelen sanal iş parçacıkları (virtual threads, Loom projesi), M:N modelinin bugünkü en yaygın örneğidir. JVM çok sayıda sanal iş parçacığını az sayıda platform iş parçacığına çoğullar. Sanal iş parçacığını çalıştıran platform iş parçacığına taşıyıcı (carrier) denir; taşıyıcılar sıradan çekirdek iş parçacıklarıdır ve işletim sistemi onları 1:1 modeliyle çizelgeler. Şekil 4.21 bu iki katmanı gösterir.
Sanal iş parçacığı bloke eden bir G/Ç çağrısı yaptığında JVM onu taşıyıcısından indirir (unmount): yığın çerçevelerini öbeğe kaydeder ve iş parçacığını park eder. Taşıyıcı serbest kalır ve başka bir sanal iş parçacığını çalıştırır. G/Ç bitince park edilmiş iş parçacığı yeniden bir taşıyıcıya bindirilir (mount) ve kaldığı yerden sürer. Şekildeki V3, V6 ve V8 bloke değildir, park edilmiştir; taşıyıcıları bu sırada başka sanal iş parçacıklarını çalıştırır. Çekirdek yalnız taşıyıcıları görür. Bu yüzden milyonlarca sanal iş parçacığı oluşturmak mümkündür. Sanal iş parçacığı Thread.ofVirtual().start(r) ile başlatılır.
Go dilinin çalışma zamanı aynı fikri üç harfle anlatır. G bir Go iş parçacığıdır (go f() ile başlatılır). M bir işletim sistemi iş parçacığıdır (machine). P, Go kodu çalıştırma hakkını temsil eden mantıksal işlemcidir; sayısı GOMAXPROCS ile belirlenir ve varsayılan olarak çekirdek sayısına eşittir. Her P'nin çalıştırılmayı bekleyen G'lerden oluşan bir kuyruğu vardır. Kuyruğu boşalan bir P, başka bir P'nin kuyruğundan iş çalar. Bir G bloke eden bir sistem çağrısına girerse çalışma zamanı P'yi o M'den ayırıp başka bir M'ye verir; öteki G'ler çalışmaya devam eder.
4.3.9Açılır iş parçacıkları
Dağıtık sistemlerde gelen iletileri işlemenin geleneksel yolu, receive çağrısında bloke olmuş bir iş parçacığının ileti gelince uyanmasıdır. Bu iş parçacığının durumu, yani yazmaçları ve yığını, her iletide geri yüklenmelidir. Daha hızlı bir seçenek, ileti geldiğinde onu işlemek için yeni bir iş parçacığı oluşturmaktır. Bu iş parçacığına açılır iş parçacığı (pop-up thread) denir.
Açılır iş parçacığı yenidir; geri yüklenecek bir geçmişi, yazmaçları ya da yığın içeriği yoktur. Bu yüzden iletinin gelişiyle işlenmeye başlaması arasındaki gecikme düşüktür. Aynı fikir çekirdek içinde de kullanılır: bazı çekirdekler kesmeleri, kesme geldiğinde oluşturulan kısa ömürlü iş parçacıklarında işler. Pthreads ile bir açılır iş parçacığı şöyle oluşturulabilir:
/* mesaj gelince */
pthread_t t;
pthread_create(&t, NULL, isle, msg);
pthread_detach(t); /* join yok */pthread_create iletiyi isle fonksiyonuna argüman olarak verir. pthread_detach ise iş parçacığını ayrık yapar: iş parçacığı bitince kaynakları kendiliğinden geri verilir ve kimsenin onu pthread_join ile beklemesi gerekmez. Sunucunun gelen her ileti için oluşturduğu iş parçacıklarını tek tek beklemesi anlamsız olacağı için ayrık iş parçacığı burada doğal seçimdir.
4.4İş parçacığı kütüphaneleri
Programcı iş parçacıklarını bir iş parçacığı kütüphanesi aracılığıyla oluşturur ve yönetir. Kütüphane, iş parçacığı oluşturma, bekleme, sonlandırma ve senkronizasyon için bir uygulama programlama arayüzü (application programming interface, API) sunar. Kütüphane tamamen kullanıcı alanında olabilir; bu durumda bir kütüphane fonksiyonu çağırmak sıradan bir fonksiyon çağrısıdır. Ya da kütüphane çekirdeğin desteğine dayanır ve fonksiyonlarının çoğu bir sistem çağrısıyla sonuçlanır.
4.4.1Kütüphaneler ve iki kullanım biçimi
- POSIX Pthreads
- Linux, macOS ve BSD sistemlerinin C API'sidir. İş parçacığı oluşturma ve beklemenin yanında karşılıklı dışlama kilidi (mutex) ve koşul değişkeni de sağlar.
- Windows API
CreateThreadile oluşturur,WaitForSingleObjectile bekler. Çekirdek düzeyidir ve 1:1 modelini kullanır.- Java
Thread,RunnableveExecutorServicesınıflarıyla çalışır. Java sanal makinesi (JVM) işletim sisteminin iş parçacıklarını, Java 21'den beri de sanal iş parçacıklarını kullanır.- C ve C++ standardı
- C11
<threads.h>başlığını; C++11std::thread'i, C++20std::jthread'i getirdi. Bunlar eskiden yaygın olan Boost.Thread kütüphanesinin yerini aldı. - Python
threadingmodülünü sunar. Ancak standart yorumlayıcıdaki global yorumlayıcı kilidi (global interpreter lock, GIL) bir anda yalnız bir iş parçacığının Python kodu çalıştırmasına izin verir; bu yüzden işlemci yoğun işlerde paralellik elde edilmez. Python 3.13'te deneysel olarak gelen GIL'siz derleme, 3.14'te desteklenen bir seçenek oldu.- Go
go f()ifadesi yeni bir Go iş parçacığı başlatır. Çalışma zamanı M:N modelini kullanır; iş parçacıkları kanallarla iletişim kurar.
İş parçacıkları iki farklı biçimde kullanılır. Eş zamansız (asynchronous) kullanımda ebeveyn iş parçacığı çocuğu başlatır ve kendi işine devam eder; ikisi arasında az veri paylaşılır. Çok iş parçacıklı web sunucusu buna örnektir: dağıtıcı işçiye işi verir ve sonucunu beklemez. Senkron kullanımda ise ebeveyn çocuklarının bitmesini bekler (join) ve sonuçlarını birleştirir. Bu düzene fork-join denir: iş çatallanır (fork), parçalar paralel çalışır, sonra yeniden birleşir (join).
4.4.2POSIX Pthreads
Pthreads, IEEE 1003.1c (POSIX.1c) standardının tanımladığı iş parçacığı oluşturma ve senkronizasyon API'sidir. Önemli bir ayrıntı, Pthreads'in bir tanımlama olmasıdır, gerçekleme değil. Standart kütüphanenin nasıl davranacağını söyler; bunun kullanıcı düzeyinde mi, çekirdek düzeyinde mi yapılacağını gerçekleyiciye bırakır. Linux'ta glibc içindeki NPTL, Pthreads'i 1:1 modeliyle gerçekler; macOS ve BSD'lerde de Pthreads çekirdek iş parçacıkları üzerinde çalışır. Pthreads kullanan bir program gcc prog.c -pthread komutuyla derlenir.
| Çağrı | Açıklama |
|---|---|
pthread_create | Yeni iş parçacığı oluşturur ve verilen fonksiyonu onda çalıştırır. |
pthread_exit | Çağıran iş parçacığını sonlandırır. |
pthread_join | Belirli bir iş parçacığının bitmesini bekler. |
pthread_detach | İş parçacığını ayrık yapar: bitince kaynakları kendiliğinden geri verilir; join edilmez. |
pthread_self | Çağıran iş parçacığının kendi kimliğini döndürür. |
sched_yield | İşlemciyi bırakır. (pthread_yield standart dışıdır ve glibc 2.34'te kullanımdan kaldırıldı.) |
pthread_attr_init / _destroy | Yığın boyutu, ayrık durum gibi ayarları tutan öznitelik yapısını ilklendirir ya da kaldırır. |
pthread_create'in imzası Pthreads'in genel tarzını gösterir: int pthread_create(pthread_t *tid, const pthread_attr_t *attr, void *(*fonk)(void *), void *arg). İlk parametreye yeni iş parçacığının kimliği yazılır. İkinci parametre öznitelikleri verir; NULL varsayılan ayarlar demektir. Üçüncü parametre iş parçacığının çalıştıracağı fonksiyondur; bu fonksiyon tek bir void * alır ve bir void * döndürür. Dördüncü parametre o fonksiyona geçirilecek argümandır. Çağrı başarıda 0, hatada bir hata kodu döndürür.
4.4.3Pthreads: oluştur ve bekle
İlk örnek Silberschatz'ın Şekil 4.11'deki programından uyarlanmıştır. Program, komut satırından gelecek bir üst sınıra kadar olan tam sayıları ayrı bir iş parçacığında toplar; burada üst sınır doğrudan "5" dizgisi olarak verilmiştir. #include <pthread.h>, <stdio.h> ve <stdlib.h> satırları gösterilmemiştir.
int toplam; /* paylaşılan */
void *runner(void *param) {
int ust = atoi(param);
toplam = 0;
for (int i = 1; i <= ust; i++)
toplam += i;
pthread_exit(0);
}
int main(void) {
pthread_t tid;
pthread_create(&tid, NULL, runner, "5");
pthread_join(tid, NULL);
printf("toplam = %d\n", toplam);
return 0;
}toplam global bir değişkendir; bu yüzden iki iş parçacığı da onu görür. runner, iş parçacığının çalıştıracağı fonksiyondur. Parametresini atoi ile tam sayıya çevirir, kendi yığınındaki ust değişkenine koyar, 1'den ust'e kadar sayıları toplam'a ekler ve pthread_exit(0) ile sonlanır. main, pthread_create ile yeni iş parçacığını başlatır, pthread_join ile onun bitmesini bekler ve sonucu yazdırır.
| Adım | main | runner | toplam | Açıklama |
|---|---|---|---|---|
| 1 | çalışıyor | henüz yok | 0 | Süreç tek iş parçacığıyla başlar; global toplam sıfır ilklendirilmiştir. |
| 2 | pthread_create | hazır | 0 | Yeni iş parçacığı oluşturulur; runner("5") çalışmaya adaydır. |
| 3 | pthread_join → bekliyor | çalışıyor | 0 | runner'ın ne zaman başlayacağını çizelgeleyici belirler. |
| 4 | bekliyor | ust = atoi("5") = 5 | 0 | ust runner'ın kendi yığınındadır. |
| 5 | bekliyor | döngü: 1 + 2 + 3 + 4 + 5 | 15 | Paylaşılan toplam güncellenir. |
| 6 | uyanıyor | pthread_exit(0) → bitti | 15 | runner sonlanır; join döner. |
| 7 | printf | bitti | 15 | Çıktı: toplam = 15. |
Programın çıktısı her çalıştırmada aynıdır, çünkü main toplam'ı yalnız pthread_join döndükten sonra okur. Join, runner'ın bütün yazmalarını bitirdiğini garanti eder. Join olmasaydı main, runner daha başlamadan toplam'ı okuyup 0 yazdırabilirdi. Daha kötüsü, main'den dönmek bütün süreci sonlandırır; runner işini bitiremeden ölürdü. 3. adımdaki gözlem de önemlidir: pthread_create döndüğünde yeni iş parçacığının çalışmaya başladığının hiçbir garantisi yoktur. Hangisinin önce çalışacağına çizelgeleyici karar verir.
main pthread_join'de bekliyor, runner çalışıyor.4.4.4Birden çok iş parçacığı
Veri paralelliğini Pthreads ile göstermek için 1'den 1000'e kadar sayıların toplamını dört iş parçacığına bölelim. Her iş parçacığı 250 sayı toplar.
#define N 4
long kismi[N]; /* her biri kendi hücresine */
void *topla(void *arg) {
long k = (long)arg; /* 0 .. N-1 */
for (long i = k*250 + 1; i <= (k+1)*250; i++)
kismi[k] += i;
return NULL;
}
int main(void) {
pthread_t t[N];
for (long k = 0; k < N; k++)
pthread_create(&t[k], NULL, topla, (void *)k);
long toplam = 0;
for (int k = 0; k < N; k++) {
pthread_join(t[k], NULL);
toplam += kismi[k];
}
printf("%ld\n", toplam); /* 500500 */
}İş parçacığı numarası k, işaretçi olarak değil, değer olarak geçirilir: (void *)k ifadesi sayıyı doğrudan işaretçi alanına koyar, topla içindeki (long)arg onu geri çıkarır. long ile işaretçi aynı genişlikte olduğu için bu dönüşüm güvenlidir. k numaralı iş parçacığı 250k + 1'den 250(k + 1)'e kadar sayıları kendi kismi[k] hücresine ekler. main dört iş parçacığını oluşturur, sonra sırayla her birini join ile bekleyip kısmi toplamı ana toplama ekler.
| k | Aralık | kismi[k] |
|---|---|---|
| 0 | 1 … 250 | 31 375 |
| 1 | 251 … 500 | 93 875 |
| 2 | 501 … 750 | 156 375 |
| 3 | 751 … 1000 | 218 875 |
| Toplam | 500 500 | |
Programda yarış durumu yoktur, çünkü her iş parçacığı yalnız kendi hücresine yazar ve main bir hücreyi ancak o hücrenin sahibi bittikten sonra okur. İş parçacıkları hangi sırayla çalışırsa çalışsın sonuç her zaman 500 500'dür.
Argüman olarak döngü değişkeninin adresini, &k'yi geçirmek. Dört iş parçacığı da aynı k değişkenini gösteren bir işaretçi alır. Döngü bu sırada k'yi artırmaya devam ettiği için bir iş parçacığı *arg'ı okuduğunda k çoktan 2 ya da 4 olmuş olabilir; iki iş parçacığı aynı aralığı toplar, bir aralık hiç toplanmaz. Döngü bitince k'nin ömrü de sona erer. Çözüm, değeri doğrudan geçirmek ya da her iş parçacığına ayrı bir bellek hücresi vermektir.
Kodda bir performans tuzağı da vardır. kismi dizisinin dört hücresi bellekte yan yanadır ve aynı önbellek satırına düşer. Dört çekirdek bu satıra sürekli yazdığı için satır çekirdekler arasında gidip gelir: yanlış paylaşım. Her iş parçacığı toplamı önce yerel bir değişkende biriktirip sonda bir kez kismi[k]'ye yazarsa sorun ortadan kalkar.
4.4.5Argüman geçirme ve sonuç alma
İş parçacığı fonksiyonunun tek bir void * parametresi ve tek bir void * dönüş değeri vardır. Birden çok değer geçirmek ya da almak için değerler bir yapıya konur ve yapının adresi geçirilir. Aşağıdaki program bir aralığın alt ve üst sınırını yapı içinde verir, toplamı ve sayı adedini de yapı içinde geri alır.
typedef struct { int alt, ust; } aralik_t;
typedef struct { long toplam; int adet; } sonuc_t;
void *topla(void *arg) {
aralik_t *a = arg; /* void* → tür */
sonuc_t *s = malloc(sizeof *s); /* öbekte */
s->toplam = 0; s->adet = 0;
for (int i = a->alt; i <= a->ust; i++) {
s->toplam += i; s->adet++;
}
return s; /* = pthread_exit(s) */
}
int main(void) {
aralik_t a = { 1, 100 };
pthread_t t; void *ret;
pthread_create(&t, NULL, topla, &a);
pthread_join(t, &ret); /* ret = s */
sonuc_t *s = ret;
printf("%d sayı, toplam %ld\n", s->adet, s->toplam);
free(s);
return 0;
}main, a yapısını kendi yığınında oluşturur ve adresini argüman olarak verir. topla, void *'ı gerçek türüne, aralik_t *'a çevirir. Sonuç için öbekte malloc ile bir sonuc_t ayırır, alanlarını sıfırlar ve döngüde 1'den 100'e kadar sayıları toplarken adedi de sayar. return s; satırı pthread_exit(s) ile aynı etkiyi yapar: işaretçi, bu iş parçacığını join ile bekleyene teslim edilir. main'deki pthread_join(t, &ret) bu işaretçiyi void ** türündeki ikinci parametre üzerinden ret'e yazar.
| Adım | main yığını | T yığını | Öbek |
|---|---|---|---|
| 1 | a = {1, 100} | yok | — |
| 2 | a; pthread_create | arg → a | — |
| 3 | a | a = arg, s → öbek | toplam = 0, adet = 0 |
| 4 | a | i = 101 (döngü bitti) | toplam = 5050, adet = 100 |
| 5 | a | çerçeve silindi | toplam = 5050, adet = 100 |
| 6 | ret → öbek, s = ret | T bitti | toplam = 5050, adet = 100 |
| 7 | printf, free(s) | T bitti | (geri verildi) |
Program 100 sayı, toplam 5050 yazdırır. İzlemede iki ömür sorusu öne çıkar. Birincisi argümanın ömrüdür: a main'in yığınındadır ve main T'yi join edene kadar bu çerçeve yaşar. Bu yüzden adresini vermek güvenlidir. İkincisi sonucun ömrüdür: T'nin yığını T bitince yok olur, ama öbekteki nesne yaşamaya devam eder. Sahiplik join ile main'e geçer ve belleği geri vermek (free) artık main'in görevidir. Şekil 4.24 iki önemli anı gösterir.
a ve s işaretçileri, öbekteki yapıda toplam = 5050 ve adet = 100. (b) 6. adım: T bitmiş ve yığını gitmiş; main'in ret ve s işaretçileri öbekteki yapıyı gösteriyor.4.4.6Yaygın hata: yığındaki sonucu döndürmek
Sonucu öbekte ayırmak yerine iş parçacığının kendi yığınında tutup adresini döndürmek, Pthreads programlarında sık görülen bir hatadır:
void *topla(void *arg) {
sonuc_t s = { 0, 0 }; /* T’nin yığınında */
/* ... s doldurulur ... */
return &s; /* HATA */
}Hatalı sürümde sonuç, T'nin yığınındaki yerel s değişkenidir ve &s döndürülür. Fonksiyon dönünce çerçevesi silinir. İş parçacığı bitince yığınının tamamı geri verilir ya da daha sonra oluşturulacak başka bir iş parçacığına verilebilir. main join'den aldığı adresi okuduğunda sonuç tanımsızdır: çöp bir değer, SIGSEGV ya da en kötüsü, bazen doğru görünen bir değer. Sonuncusu en tehlikelisidir, çünkü hata testlerde görünmeyip kullanımda ortaya çıkar. Doğru sürüm öbeği kullanır:
void *topla(void *arg) {
sonuc_t *s = malloc(sizeof *s);
/* ... s doldurulur ... */
return s; /* öbekte: yaşar */
}main'deki ret hâlâ eski adresi (0x7f3a…e40) tutar; ret->toplam'ı okumak tanımsız davranıştır.Aynı kural argüman için de geçerlidir: iş parçacığına verilen adres, iş parçacığı onu kullandığı sürece yaşamalıdır. GCC çoğu durumda yerel bir değişkenin adresini döndüren fonksiyonlar için -Wreturn-local-addr uyarısı verir; bu uyarı hata gibi ele alınmalıdır.
4.4.7Java: Thread, start ve join
Java'da her program en az bir iş parçacığıyla, main metodunu çalıştıran ana iş parçacığıyla başlar. Yeni bir iş parçacığı oluşturmanın en kısa yolu, Thread kurucusuna çalıştırılacak kodu bir lambda ifadesi olarak vermektir. Aşağıdaki program, Pthreads örneğindeki toplamı Java ile yapar.
public class Topla {
static int toplam = 0;
public static void main(String[] a)
throws InterruptedException {
Thread t = new Thread(() -> {
for (int i = 1; i <= 5; i++) toplam += i;
});
t.start();
System.out.println("main: bekliyor");
t.join();
System.out.println("toplam = " + toplam);
}
}new Thread(lambda) yalnız bir nesne oluşturur; henüz hiçbir iş parçacığı çalışmaz ve nesnenin durumu NEW'dur. t.start() yeni bir işletim sistemi iş parçacığı başlatır ve lambda o iş parçacığında çalışır. main bu sırada yazmaya devam eder; iki iş parçacığı aynı anda çalışabilir. t.join() çağrısında main, t bitene kadar WAITING durumunda bekler. t döngüyü bitirince toplam 15 olur ve t'nin durumu TERMINATED'a geçer.
| Satır | main | t |
|---|---|---|
new Thread(…) | RUNNABLE | NEW (oluşturuldu, başlamadı) |
t.start() | RUNNABLE | RUNNABLE |
println("main: bekliyor") | RUNNABLE | RUNNABLE |
t.join() | WAITING (join) | RUNNABLE |
| (t döngüyü bitirir) | uyanıyor | TERMINATED |
println("toplam = " + toplam) | RUNNABLE | TERMINATED |
Program JDK 21'in javac ve java komutlarıyla derlenip çalıştırıldı. İki satırlık çıktısı her seferinde aynıdır: önce main: bekliyor, sonra toplam = 15. Durumları gözlemlemek için programa t.getState() yazdıran iki satır eklendiğinde de start'tan önce NEW, join'den sonra TERMINATED görüldü.
toplam değişkeni ne volatile ne de bir kilitle korunuyor; buna rağmen main'in 15'i görmesi garantidir. Java bellek modeli, bir iş parçacığının yaptığı bütün yazmaların, o iş parçacığını join ile bekleyen iş parçacığına görünür olacağını söyler. Bu ilişkiye önce-olur (happens-before) ilişkisi denir. Join, beklemenin yanında görünürlüğü de sağlar.
start() yerine run() çağırmak. t.run() sıradan bir metot çağrısıdır: lambda yeni bir iş parçacığında değil, çağıran iş parçacığının, yani main'in içinde çalışır. Program doğru sonucu verir ama hiçbir eş zamanlılık yoktur. Thread.currentThread().getName() yazdırıldığında fark hemen görülür: run() içinde ad main'dir. Bir Thread nesnesi de yalnız bir kez başlatılabilir; ikinci start() çağrısı IllegalThreadStateException fırlatır (JDK 21 ile denendi).
4.4.8Java 21: sanal iş parçacıkları
Sanal iş parçacıklarının neden önemli olduğunu göstermek için 10 000 görev oluşturan bir program yazalım. Her görev kendi sanal iş parçacığında bir saniye uyur; uyku, bir ağ yanıtını beklemenin yerine geçer.
import java.time.Duration;
import java.util.concurrent.*;
import java.util.stream.IntStream;
public class Sanal {
public static void main(String[] args) {
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i ->
ex.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
}));
} // close(): tüm görevleri bekler
System.out.println("bitti");
}
}Executors.newVirtualThreadPerTaskExecutor() her gönderilen görev için yeni bir sanal iş parçacığı oluşturan bir yürütücü döndürür. IntStream.range(0, 10_000) 0'dan 9999'a kadar sayıları üretir ve her biri için ex.submit bir görev gönderir. Görev Thread.sleep ile bir saniye uyur ve numarasını döndürür. try bloğu kaynaklı biçimde (try-with-resources) yazılmıştır: blok bitince yürütücünün close() metodu kendiliğinden çağrılır ve bu metot gönderilen bütün görevler bitene kadar bekler. Son olarak bitti yazdırılır.
Program sıralı çalışsaydı 10 000 saniye, yani yaklaşık 2 saat 47 dakika sürerdi. İki farklı makinede JDK 21 ile yapılan ölçümlerde bütün görevler yaklaşık 2 saniyede ve 1,2 saniyede bitti. İkinci makinede 12 mantıksal çekirdek vardı ve görevler 12 taşıyıcı iş parçacığı üzerinde çalıştı. Ölçülen süre makineye göre değişir, ama büyüklük mertebesi değişmez: on bin görevin her biri beklerken taşıyıcısını bırakır, birkaç taşıyıcı bütün görevlere yeter.
Aynı programı 10 000 platform iş parçacığıyla çalıştırmak, 10 000 çekirdek iş parçacığı ve 10 000 yığın demektir. Bu çoğu sistemde çalışır, ama bellek ve çekirdek kaynağı açısından pahalıdır; görev sayısı yüz binlere çıkınca sistem sınırlarına takılır. Sanal iş parçacıklarının uygun olduğu alan, çok sayıda G/Ç bekleyen görevdir: binlerce eş zamanlı bağlantıya hizmet veren sunucular. İşlemci yoğun işte kazanç yoktur, çünkü o durumda darboğaz çekirdek sayısıdır. Sanal iş parçacıkları klasik yolla da oluşturulabilir: Thread.ofVirtual().start(r) sanal, Thread.ofPlatform() ise platform iş parçacığı oluşturan bir kurucu döndürür.
4.5Örtük iş parçacığı
4.5.1Görevi belirtmek, iş parçacığını çalışma zamanına bırakmak
İş parçacığı sayısı yüzlere ya da binlere çıkınca her birini elle oluşturmak, beklemek ve senkronize etmek hem zorlaşır hem de hataya açık hâle gelir. Örtük iş parçacığı (implicit threading) bu yükü programcının üzerinden alır. Programcı yalnız görevleri, yani paralel çalışabilecek işleri belirtir. İş parçacıklarını derleyici ve çalışma zamanı kütüphaneleri oluşturur, görevleri onlara dağıtır ve yönetir. Tablo 4.13 yaygın yaklaşımları listeler.
| Yaklaşım | Nerede | Fikir |
|---|---|---|
| İş parçacığı havuzu | Java ExecutorService, Windows iş parçacığı havuzu | Hazır iş parçacıkları kuyruktaki görevleri alır. |
| Fork-join | Java ForkJoinPool, paralel akışlar | Görevi böl, parçaları paralel çöz, sonuçları birleştir. |
| OpenMP | C, C++, Fortran | Derleyici yönergeleriyle paralel bölgeler. |
| Grand Central Dispatch | macOS, iOS (libdispatch) | Bloklar dağıtım kuyruklarına konur. |
| oneTBB (eski adı Intel TBB) | C++ şablon kütüphanesi | parallel_for, görev çizelgeleme, iş çalma. |
4.5.2İş parçacığı havuzu
Çok iş parçacıklı web sunucusunun her istek için yeni bir iş parçacığı oluşturduğunu düşünelim. Bu tasarımın iki sorunu vardır. Birincisi, her istekte iş parçacığı oluşturup sonra yok etmenin maliyetidir; iş kısa sürüyorsa bu maliyet işin kendisinden büyük olabilir. İkincisi, eş zamanlı iş parçacığı sayısına bir sınır yoktur: ani bir istek dalgası binlerce iş parçacığı oluşturup belleği ve işlemciyi tüketebilir.
İş parçacığı havuzu (thread pool), başlangıçta oluşturulan ve kuyruğa konan görevleri sırayla alıp çalıştıran sabit (ya da sınırlı) sayıda iş parçacığıdır. Görevini bitiren iş parçacığı yok olmaz; kuyruktaki bir sonraki görevi alır.
Havuz başlangıçta sabit sayıda iş parçacığı oluşturur; uygulama görevleri kuyruğa koyar. Boştaki işçiler kuyruktan görev alır; yeni iş parçacığı oluşturma maliyeti ödenmez. İşçi bir görevi bitirince hemen sıradakini alır. Kuyruk boşalınca işçiler bir sonraki görev gelene kadar bekler. Eş zamanlı çalışan iş parçacığı sayısı hiçbir zaman havuz boyutunu aşmaz. Havuzun faydaları üç maddede toplanır:
- Daha hızlı hizmet. Hazır bir iş parçacığı görevi hemen alır; oluşturma maliyeti bir kez ödenmiştir.
- Sistemi korur. Havuz boyutu, aynı anda var olabilecek iş parçacığı sayısına üst sınır koyar. İstek dalgası iş parçacığı sayısını değil, kuyruk uzunluğunu artırır.
- Görevi çalıştırma biçiminden ayırır. Görev yalnız ne yapılacağını söyler. Gecikmeli mi, periyodik mi, hangi öncelikle çalışacağına havuz karar verir.
Havuz boyutunu seçmek için kaba bir kural kullanılır. İşlemci yoğun görevlerde boyut çekirdek sayısı kadar olmalıdır; Java'da bu sayı Runtime.getRuntime().availableProcessors() ile öğrenilir. Fazlası paralellik kazandırmaz, yalnız bağlam değiştirme ve önbellek çekişmesi ekler. G/Ç yoğun görevlerde iş parçacıklarının çoğu beklediği için daha büyük bir havuz gerekir. Bir görevin bekleme süresi B, hesap süresi H ise yaklaşık Niş ≈ Nçekirdek × (1 + B / H) iş parçacığı çekirdekleri dolu tutar.
Dört çekirdekli bir makinede çalışan bir görev 10 ms hesap yapıyor, sonra 30 ms bir uzak servisin yanıtını bekliyor. Havuz kaç iş parçacıklı olmalıdır?
- Bekleme/hesap oranı B / H = 30 / 10 = 3'tür.
- Niş ≈ 4 × (1 + 3) = 16.
- Yorum: her çekirdek için dört iş parçacığı vardır. Bir iş parçacığı 10 ms hesap yapıp 30 ms beklerken öteki üçü sırayla aynı çekirdeği kullanır ve çekirdek sürekli dolu kalır. Bu sayı bir başlangıç noktasıdır; gerçek değer ölçümle ayarlanır. Çok büyük havuz bellek tüketir, çok küçük havuz işlemciyi boş bırakır.
4.5.3Java ExecutorService
Java'nın java.util.concurrent paketi havuzları ExecutorService arayüzüyle sunar. Aşağıdaki kod parçası dört görevi iki iş parçacıklı bir havuza verir; her görev kendi numarasının karesini hesaplar.
ExecutorService havuz = Executors.newFixedThreadPool(2);
List<Future<Integer>> sonuclar = new ArrayList<>();
for (int g = 1; g <= 4; g++) {
final int n = g;
sonuclar.add(havuz.submit(() -> n * n));
}
int toplam = 0;
for (Future<Integer> f : sonuclar)
toplam += f.get(); // sonucu bekler
System.out.println("toplam = " + toplam);
havuz.shutdown();Executors.newFixedThreadPool(2) iki iş parçacıklı sabit bir havuz oluşturur. Döngü g = 1, 2, 3, 4 için birer görev gönderir. Lambda içinde döngü değişkeni doğrudan kullanılamaz, çünkü Java lambdaların yakaladığı yerel değişkenlerin sonradan değişmemesini şart koşar; bu yüzden her turda final int n = g; ile yeni bir kopya alınır. submit görevi kuyruğa koyar ve hemen bir Future nesnesi döndürür. Future, henüz hazır olmayabilecek bir sonucun yer tutucusudur. İkinci döngüde f.get() her görevin sonucunu, hazır değilse hazır olana kadar bekleyerek alır. Son satırdaki shutdown() havuzun yeni görev kabul etmesini durdurur; çalışan görevler biter ve iş parçacıkları sonlanır.
| Görev | n | f.get() | toplam |
|---|---|---|---|
| 1 | 1 | 1 | 1 |
| 2 | 2 | 4 | 5 |
| 3 | 3 | 9 | 14 |
| 4 | 4 | 16 | 30 |
JDK 21 ile çalıştırıldığında program şunu yazdırır:
toplam = 30Sonuç deterministiktir: görevler iki işçiye hangi sırayla dağılırsa dağılsın, get() çağrıları listedeki sırayla yapılır ve her biri kendi görevinin sonucunu döndürür. Executors sınıfının başka fabrika metotları da vardır. newCachedThreadPool gerektikçe yeni iş parçacığı oluşturur ve boşta kalanları bir süre sonra kapatır. newScheduledThreadPool gecikmeli ve periyodik görevler için kullanılır. newVirtualThreadPerTaskExecutor ise her görev için yeni bir sanal iş parçacığı oluşturur (4.4.8).
4.5.4Fork-join
Fork-join, böl ve fethet (divide and conquer) algoritmalarının paralel karşılığıdır. Görev bir eşikten büyükse ikiye bölünür; parçalar ayrı görevler olarak çatallanır (fork) ve havuzdaki farklı iş parçacıklarında çalışabilir. Eşiğe inen küçük görevler doğrudan, sıralı olarak hesaplanır. Ebeveyn görev çocuklarını bekler (join) ve sonuçlarını birleştirir. Şekil 4.27 sekiz sayının toplamını bu yöntemle hesaplar.
Yöntemin üç aşaması şekilden okunabilir. Önce büyük görev, eşikten büyük olduğu için bölünür. Fork adımında iki alt görev oluşturulur; bunlar havuzdaki farklı iş parçacıklarında çalışabilir. Alt görevler yine bölünür ve eşiğe (iki öğe) inen yapraklar doğrudan hesaplanır: [3,1] = 4, [4,1] = 5, [5,9] = 14, [2,6] = 8. Join adımında her ebeveyn çocuklarını bekler ve birleştirir: 4 + 5 = 9, 14 + 8 = 22. Kökte sonuç 9 + 22 = 31'dir. Fork-join, senkron iş parçacığı kullanımının tipik örneğidir: her ebeveyn çocuklarının sonucunu bekler.
4.5.5ForkJoinPool ve iş çalma
Java, fork-join için java.util.concurrent paketinde ForkJoinPool havuzunu ve RecursiveTask<V> sınıfını sunar. Sonuç döndüren bir görev RecursiveTask'tan türetilir ve compute() metodunu yazar. Aşağıdaki sınıf bir dizinin [bas, son) aralığını toplar; a, bas, son alanları ve kurucu kısa olsun diye gösterilmemiştir.
class Toplam extends RecursiveTask<Long> {
protected Long compute() {
if (son - bas <= ESIK) { // küçükse doğrudan
long s = 0;
for (int i = bas; i < son; i++) s += a[i];
return s;
}
int orta = (bas + son) / 2;
Toplam sol = new Toplam(a, bas, orta);
Toplam sag = new Toplam(a, orta, son);
sol.fork(); // eş zamansız başlat
long sagSonuc = sag.compute();
return sol.join() + sagSonuc;
}
}
long s = ForkJoinPool.commonPool()
.invoke(new Toplam(a, 0, a.length));compute() önce aralığın uzunluğunu eşikle karşılaştırır. Aralık küçükse sıradan bir döngüyle toplar ve döndürür. Büyükse ortadan ikiye böler ve iki alt görev oluşturur. sol.fork() sol yarıyı, o anda çalışan işçinin kuyruğuna koyar ve hemen döner; görev henüz çalışmamıştır. Sağ yarı aynı iş parçacığında, doğrudan sag.compute() ile hesaplanır. Son satırda sol.join() sol yarının sonucunu bekler ve iki sonuç toplanır. ForkJoinPool.commonPool(), JVM'in paylaşılan havuzudur; invoke kök görevi başlatır ve sonucunu bekler.
Sağ yarıyı fork etmek yerine aynı iş parçacığında doğrudan çalıştırmak bilinçli bir seçimdir. İki yarı da fork edilseydi iş parçacığı hiçbir şey yapmadan ikisini beklerdi. Böylece her bölünmede yalnız bir görev kuyruğa girer ve iş parçacığı boş durmaz.
1'den 100 000'e kadar sayılarla doldurulmuş bir dizi için program JDK 21'de toplam = 5000050000 yazdırır. Bu değer 100 000 × 100 001 / 2'ye eşittir. 1000 öğelik bir eşik ve 11 iş parçacıklı ortak havuzla yapılan denemede de sonuç aynıdır.
Havuzun yükü nasıl dengelediği de önemlidir. ForkJoinPool'da her işçinin kendi çift uçlu kuyruğu (deque) vardır. İşçi fork ettiği görevleri kendi kuyruğunun bir ucuna koyar ve yine o uçtan alır; bu, en son bölünen, en küçük görevle devam etmesini sağlar. Kuyruğu boşalan bir işçi boşta beklemez; başka bir işçinin kuyruğunun öbür ucundan görev alır. Buna iş çalma (work stealing) denir. Öbür uçta genellikle en eski, en büyük görevler durur; tek bir çalma işlemi bu yüzden çok iş taşır ve sahibiyle çakışma olasılığı düşüktür.
4.5.6Kod izleme: fork-join ile toplam
Fork ve join'in tam olarak ne yaptığını görmek için küçük bir diziyi tek işçili bir havuzda izleyelim: a = {2, 7, 1, 8, 2, 8, 1, 8}, ESIK = 2 ve çağrı new ForkJoinPool(1).invoke(new Toplam(a, 0, 8)). Tek işçi seçmek izlemeyi deterministik yapar: kimse görev çalmaz. İzlenen metot şudur:
protected Long compute() { // ESIK = 2
if (son - bas <= ESIK) {
long s = 0;
for (int i = bas; i < son; i++)
s += a[i];
return s;
}
int orta = (bas + son) / 2;
Toplam sol = new Toplam(a, bas, orta);
Toplam sag = new Toplam(a, orta, son);
sol.fork(); // kuyruğa
long sagSonuc = sag.compute();
return sol.join() + sagSonuc;
}| Adım | Olay | Kuyruk | Hesaplanan |
|---|---|---|---|
| 1 | Kök [0,8): 8 − 0 = 8 > ESIK, bölünür | boş | — |
| 2 | sol.fork(): [0,4) kuyruğa; hemen çalışmaz | [0,4) | — |
| 3 | sag.compute(): [4,8) aynı iş parçacığında | [0,4) | — |
| 4 | [4,8) bölünür: [4,6) kuyruğa | [0,4) [4,6) | — |
| 5 | [6,8): 2 öğe, eşikte; doğrudan toplanır | [0,4) [4,6) | [6,8) = 1 + 8 = 9 |
| 6 | sol.join(): [4,6) kuyruğun ucunda, kimse çalmamış; işçi onu çıkarıp kendisi çalıştırır | [0,4) | [4,6) = 2 + 8 = 10 |
| 7 | [4,8) birleşir ve köke döner | [0,4) | [4,8) = 10 + 9 = 19 |
| 8 | Kök sol.join(): [0,4) kuyruktan çıkar ve çalışır; bölünür, [0,2) kuyruğa | [0,2) | — |
| 9 | [2,4) yaprak | [0,2) | [2,4) = 1 + 8 = 9 |
| 10 | join [0,2) yaprağını çalıştırır; [0,4) birleşir | boş | [0,2) = 2 + 7 = 9; [0,4) = 9 + 9 = 18 |
| 11 | Kök birleşir | boş | [0,8) = 18 + 19 = 37 |
Program JDK 21 ile çalıştırıldı ve toplam = 37 yazdırdı. Her görevin bitişinde aralığı ve sonucu yazdıran bir izleme satırı eklendiğinde görevler tam olarak tablodaki sırayla bitti: [6,8) = 9, [4,6) = 10, [4,8) = 19, [2,4) = 9, [0,2) = 9, [0,4) = 18, [0,8) = 37. Toplam yedi görev oluşur; dördü yapraktır.
Havuzda boşta bir işçi daha olsaydı 2. adımda kuyruğa giren [0,4) görevini çalabilirdi. O durumda [0,4) ile [4,8) gerçekten paralel hesaplanır ve görevlerin bitiş sırası değişirdi. Sonuç ise değişmezdi: her düğüm yine iki çocuğunun toplamıdır. Çok iş parçacıklı programlarda bu ayrım sık karşımıza çıkar: yürütme sırası belirlenimci olmayabilir, ama doğru senkronize edilmiş bir programın sonucu belirlenimcidir.
4.5.7OpenMP
OpenMP, C, C++ ve Fortran için paylaşımlı bellekli sistemlerde paralel programlamayı kolaylaştıran bir derleyici yönergeleri (compiler directives) ve kütüphane kümesidir. Programcı paralel çalışabilecek bölgeleri #pragma omp ile işaretler; iş parçacıklarını derleyici ve OpenMP çalışma zamanı oluşturur.
#include <omp.h>
#include <stdio.h>
int main(void) {
#pragma omp parallel
printf("merhaba, ben %d\n", omp_get_thread_num());
double a[1000], b[1000], c[1000], s = 0;
/* ... a ve b doldurulur ... */
#pragma omp parallel for reduction(+:s)
for (int i = 0; i < 1000; i++) {
c[i] = a[i] + b[i];
s += c[i];
}
return 0;
}#pragma omp parallel bir paralel bölge açar: yönergeyi izleyen deyim, bir iş parçacığı ekibinin her üyesinde ayrı ayrı çalışır. Ekipteki iş parçacığı sayısı varsayılan olarak çekirdek sayısı kadardır; OMP_NUM_THREADS ortam değişkeniyle ya da yönergeye eklenen num_threads(n) ile değiştirilir. Bu örnekte bölge tek bir printf deyimidir; her iş parçacığı omp_get_thread_num() ile kendi numarasını alır ve bir “merhaba” satırı yazdırır. Satırların sırası her çalıştırmada değişebilir. Bölgenin sonunda örtük bir bariyer vardır: bütün iş parçacıkları bitmeden program devam etmez.
İkinci yönerge #pragma omp parallel for, ardından gelen for döngüsünün yinelemelerini iş parçacıklarına böler. Dört iş parçacığıyla her biri yaklaşık 250 yinelemeyi alır. Döngüdeki s += c[i] satırı ise sorunludur: s bütün iş parçacıklarınca paylaşılan tek bir değişkendir ve birden çok iş parçacığının aynı anda ona eklemesi bir yarış durumu yaratır. reduction(+:s) kısmı bunu çözer: her iş parçacığına s'nin özel bir kopyası verilir, kopyalar döngü bitince toplanıp asıl s'ye eklenir. Program gcc -fopenmp ile derlenir; bu seçenek verilmezse yönergeler yok sayılır ve program sıralı çalışır.
4.5.8Grand Central Dispatch ve oneTBB
Grand Central Dispatch (GCD), Apple'ın macOS ve iOS için geliştirdiği bir çalışma zamanı kütüphanesi (libdispatch) ile C ve Objective-C dillerine eklenen bir dil özelliğinin birleşimidir. Dil özelliği bloklardır: ^{ printf("Ben bir bloğum"); } gibi, şapka işaretiyle başlayan ve süslü parantez içinde yazılan bağımsız iş birimleri.
dispatch_queue_t q = dispatch_get_global_queue(
QOS_CLASS_USER_INITIATED, 0);
dispatch_async(q, ^{ puts("Ben bir bloğum"); });Bloklar dağıtım kuyruklarına (dispatch queues) konur. Seri kuyruk blokları sırayla, birer birer çalıştırır; her uygulamanın ana kuyruğu seridir ve kullanıcı arayüzü işleri orada yapılır. Eş zamanlı (global) kuyruk ise blokları paralel çalıştırır. Global kuyruklar hizmet kalitesi (quality of service, QoS) sınıflarına ayrılır; örnekteki QOS_CLASS_USER_INITIATED, kullanıcının başlattığı ve sonucunu beklediği işler içindir. dispatch_async bloğu kuyruğa koyar ve hemen döner. Kuyruktan çıkan blok, sistemin yönettiği havuzdaki boş bir iş parçacığına atanır. İş parçacığı yönetimi tamamen GCD'dedir; programcı hiçbir iş parçacığı oluşturmaz.
oneTBB, Intel Threading Building Blocks kütüphanesinin açık kaynak devamıdır ve C++ şablonları olarak gelir. tbb::parallel_for(0, n, [&](int i) { f(v[i]); }); gibi bir çağrı, döngünün aralığını parçalara böler ve parçaları kendi çizelgeleyicisiyle, iş çalma kullanarak iş parçacıklarına dağıtır. Programcı yalnız “bu döngünün yinelemeleri birbirinden bağımsızdır” der; kaç iş parçacığı kullanılacağına ve parçaların büyüklüğüne kütüphane karar verir. Apple'ın Swift dilinde ise GCD'nin yerini büyük ölçüde dilin kendi async/await düzeneği almıştır.
4.6İş parçacığı sorunları
İşletim sisteminin pek çok arayüzü, iş parçacıklarından önce, her sürecin tek akışlı olduğu varsayımıyla tasarlandı. fork(), sinyaller, global errno değişkeni ve statik ara bellek kullanan kütüphane fonksiyonları bunun örnekleridir. Bir süreçte birden çok akış olunca bu arayüzlerin her biri için yeni bir soru doğar.
4.6.1İş parçacıklı süreçte fork() ve exec()
Üç iş parçacıklı bir süreçte iş parçacıklarından biri fork() çağırırsa çocuk süreçte kaç iş parçacığı olmalıdır? Bazı UNIX sistemleri bu soruya iki cevap verip iki ayrı fork sürümü sundu: biri bütün iş parçacıklarını, öteki yalnız çağıranı kopyalıyordu. POSIX'in cevabı tektir: yalnız fork'u çağıran iş parçacığı kopyalanır. Adres alanının tamamı kopyalanır, ama iş parçacıklarından yalnız biri.
fork() çağırır. POSIX'e göre çocuk süreçte yalnız T2'nin kopyası (T2′) bulunur. T1'in o sırada tuttuğu bir kilit çocukta sonsuza dek kilitli kalabilir.fork'tan hemen sonra exec çağrılacaksa bu davranış doğrudur: yeni program bütün adres alanını zaten değiştirir ve tek bir iş parçacığıyla başlar. Öteki iş parçacıklarını kopyalamak boşuna iş olurdu. Sorun, çocuğun exec çağırmadan çalışmaya devam ettiği durumdadır. Kopyalanan adres alanında, ebeveynin öteki iş parçacıklarının fork anında tuttuğu kilitler de kilitli olarak bulunur. Örneğin T1 o anda malloc'un iç kilidini tutuyorsa, çocukta bu kilidi açacak bir T1 yoktur ve çocuğun ilk malloc çağrısı sonsuza dek bekler.
Bu yüzden POSIX, çok iş parçacıklı bir sürecin çocuğunda exec'e kadar yalnız async-signal-safe fonksiyonların (sinyal işleyicisinden güvenle çağrılabilen, iç kilit kullanmayan fonksiyonlar) çağrılmasına izin verir. pthread_atfork, fork'tan önce ve sonra çalışacak işleyiciler kaydederek kütüphanelerin kilitlerini düzenli bir duruma getirmesine yarar. Amaç yeni bir program başlatmaksa, çoğu zaman fork ile exec'i tek adımda yapan posix_spawn daha iyi bir seçimdir.
4.6.2Sinyal işleme
Sinyal, bir sürece bir olayın olduğunu bildiren yazılım bildirimidir. Tek iş parçacıklı süreçte sinyalin nereye gideceği bellidir. Çok iş parçacıklı süreçte ise soru şudur: sinyal hangi iş parçacığına teslim edilmeli? Sinyalin türüne göre dört seçenek vardır.
| Seçenek | Örnek |
|---|---|
| Sinyalin ilgili olduğu iş parçacığına | senkron sinyaller: SIGSEGV, SIGFPE |
| Süreçteki her iş parçacığına | bazı sonlandırma durumları |
| Sinyali engellemeyen herhangi bir iş parçacığına | POSIX süreç sinyali: SIGINT |
| Bütün sinyalleri alan özel bir iş parçacığına | sigwait döngüsü |
Senkron sinyaller, bir iş parçacığının kendi yaptığı bir işlemden doğar: geçersiz bellek erişimi SIGSEGV, sıfıra bölme SIGFPE üretir. Bu sinyaller doğal olarak hatayı yapan iş parçacığına gider. Eş zamansız sinyaller ise dışarıdan gelir: kullanıcı Ctrl+C'ye bastığında süreç SIGINT alır. POSIX böyle bir süreç sinyalini, o sinyali engellemeyen herhangi bir iş parçacığına teslim eder.
Burada iki ayrım önemlidir. Sinyal işleyicileri süreç geneldir: bir sinyale karşı hangi fonksiyonun çalışacağı bütün iş parçacıkları için aynıdır. Sinyal maskesi ise iş parçacığına özeldir: her iş parçacığı hangi sinyalleri engellediğini pthread_sigmask ile kendisi belirler. Belirli bir iş parçacığına sinyal göndermek için pthread_kill(tid, sig) kullanılır.
SIGINT (Ctrl+C) sürece gelir. T1 ve T2 sinyali maskeyle engellemiştir; sinyal yalnız engellemeyen ve sigwait() ile bekleyen T3'e teslim edilir.Çok iş parçacıklı programlarda iyi uygulama, şekildeki düzendir. Bütün iş parçacıkları süreç sinyallerini engeller; tek bir iş parçacığı sigwait ile bu sinyalleri bir döngüde bekler. Sinyal böylece eş zamansız bir işleyici yerine sıradan bir fonksiyon dönüşü olarak işlenir ve işleyicinin kısıtlarından (yalnız async-signal-safe fonksiyonlar) kurtulunur. Kullanıcı düzeyi iş parçacıklarında bu inceliklerin hiçbiri mümkün değildir: çekirdek yalnız süreci tanır ve doğru iş parçacığını adresleyemez. Windows'ta sinyal kavramı yoktur; benzer bir görevi eş zamansız yordam çağrıları (asynchronous procedure call, APC) görür.
4.6.3İş parçacığı iptali
Bir iş parçacığını işi bitmeden sonlandırmak gerekebilir. Bir veritabanında aynı sorguyu paralel arayan birkaç iş parçacığından biri sonucu bulunca ötekiler gereksiz hâle gelir. Tarayıcıda “dur” düğmesine basıldığında sayfayı yükleyen iş parçacıkları durmalıdır. Sonlandırılacak iş parçacığına hedef iş parçacığı denir. İptalin iki biçimi vardır.
- Eş zamansız iptal
- Hedef hemen sonlandırılır. Hedef o anda bir kilit tutuyor ya da bir veri yapısını yarım güncellemiş olabilir; kilit hiç açılmaz, veri tutarsız kalır. Bu yüzden tehlikelidir.
- Ertelenmiş iptal (varsayılan)
- Hedef, iptal isteğini belirli iptal noktalarında denetler ve istek varsa düzenli biçimde sonlanır.
main pthread_cancel(T) çağırır; istek bekletilir. T bir iptal noktasına (read()) gelince temizlik işleyicileri çalışır (kilit açılır, bellek geri verilir), T sonlanır ve join PTHREAD_CANCELED döndürür.Ertelenmiş modda pthread_cancel(T) yalnız bir istektir; T çalışmaya devam eder. T bir iptal noktasına geldiğinde istek işlenir. İptal noktaları, POSIX'in belirlediği ve genellikle bloke edebilen çağrılardır: read, sleep, pthread_cond_wait ve benzerleri. Uzun bir hesap döngüsü hiç iptal noktasından geçmiyorsa, programcı döngüye pthread_testcancel() çağrısı ekleyerek yapay bir iptal noktası koyar. İptal işlenirken önce pthread_cleanup_push ile kaydedilmiş temizlik işleyicileri, kayıt sırasının tersiyle çalışır: kilitler açılır, ayrılmış bellek geri verilir. Sonra T sonlanır ve onu join ile bekleyen iş parçacığı dönüş değeri olarak PTHREAD_CANCELED alır.
Java'da iptalin karşılığı işbirlikçi interrupt() düzeneğidir. t.interrupt() hedefe bir kesme bayrağı koyar; hedef bayrağı Thread.interrupted() ile denetler ya da bloke eden bir çağrıdan InterruptedException ile çıkar. Eş zamansız iptalin Java'daki karşılığı olan Thread.stop(), JDK 20'den beri çalışmaz ve çağrıldığında istisna fırlatır.
4.6.4Global değişken çakışması
Tek iş parçacıklı programlar için tasarlanmış global değişkenler, iş parçacıkları arasında çakışır. En bilinen örnek UNIX'in errno değişkenidir. Bir sistem çağrısı başarısız olunca hata kodunu errno'ya yazar; program çağrıdan sonra bu değişkeni okuyarak hatanın nedenini öğrenir.
errno çakışması. İş parçacığı 1 access("dosya") çağırır, errno = EACCES olur. Bağlam değişir; iş parçacığı 2 open("x") çağırır ve errno'nun üzerine ENOENT yazar. İş parçacığı 1 yeniden çalıştığında yanlış değeri (ENOENT) okur.Sorunun iki çözümü vardır: değişkeni yasaklamak ya da her iş parçacığına kendi kopyasını vermek. İlki eski kodla uyumsuzdur; bu yüzden ikincisi seçildi. Bugünkü C kütüphanelerinde errno bir makrodur ve iş parçacığına yerel bir değişkene karşılık gelir. Her iş parçacığı kendi errno'sunu görür.
4.6.5İş parçacığına yerel depolama
İş parçacığına yerel depolama (thread-local storage, TLS), aynı adla erişilen ama her iş parçacığında ayrı bir kopyası bulunan değişkenlerdir. Bir iş parçacığının TLS değişkenine yazdığını öteki iş parçacıkları görmez.
TLS'nin yerel değişkenden farkı ömrüdür. Yerel değişken yalnız tanımlandığı fonksiyon çalışırken yaşar ve başka fonksiyonlar ona erişemez. TLS değişkeni ise iş parçacığının ömrü boyunca, fonksiyon çağrıları boyunca yaşar: global bir değişken gibidir, ama iş parçacığına özeldir. C11'de _Thread_local, C23 ve C++'ta thread_local, GCC'de __thread anahtar sözcüğüyle tanımlanır. Pthreads aynı işi işlev çağrılarıyla yapar: pthread_key_create bir anahtar oluşturur, pthread_setspecific ve pthread_getspecific o anahtarın çağıran iş parçacığındaki değerini yazar ve okur. Tipik kullanımlar errno, bir işlemin kimliği ve iş parçacığı başına rastgele sayı üretecidir.
int hata;: T1 ve T2 tek bir kopyayı paylaşır; son yazan kazanır. (b) _Thread_local int hata;: aynı ad, iş parçacığı başına ayrı kopya; T1'in ENOENT'i ile T2'nin EACCES'i birbirine karışmaz.Java'da TLS, ThreadLocal sınıfıyla sağlanır. Aşağıdaki program iki iş parçacığında aynı ThreadLocal değişkenine farklı değerler yazar ve sonda main'in kendi kopyasını okur:
public class Yerel {
static ThreadLocal<Integer> no =
ThreadLocal.withInitial(() -> 0);
public static void main(String[] args) throws Exception {
Thread a = new Thread(() -> { no.set(1); System.out.println("A: " + no.get()); });
Thread b = new Thread(() -> { no.set(2); System.out.println("B: " + no.get()); });
a.start(); a.join();
b.start(); b.join();
System.out.println("main: " + no.get());
}
}ThreadLocal.withInitial(() -> 0), her iş parçacığının kopyasının ilk okunduğunda 0 ile başlamasını sağlar. A iş parçacığı kendi kopyasına 1, B iş parçacığı kendi kopyasına 2 yazar. JDK 21 ile çalıştırıldığında çıktı A: 1, B: 2, main: 0 olur. main hiçbir şey yazmadığı için başlangıç değerini görür. A ile B burada join ile sırayla çalıştırıldığı için satır sırası da her seferinde aynıdır; iki iş parçacığı birlikte başlatılsaydı A ve B satırlarının sırası değişebilirdi, ama değerler değişmezdi.
4.6.6Yeniden girilir ve iş parçacığı güvenli kod
Kütüphane yordamlarının bir kısmı iç durumlarını statik değişkenlerde saklar. Böyle bir yordama yeniden girilemez (non-reentrant) denir. Bir iş parçacığı bir iletiyi kütüphanenin ortak ara belleğine koyarken bağlam değişir ve başka bir iş parçacığı aynı ara belleğe yazarsa ilk ileti kaybolur. malloc da tarihsel olarak böyleydi: serbest bellek listesini güncellerken listeyi kısa bir süre tutarsız bırakır, araya giren iş parçacığı geçersiz bir işaretçi alabilirdi. Bugünkü malloc gerçeklemeleri iç kilitler ve iş parçacığı başına bellek alanları (arenas) kullanır.
İş parçacığı güvenli (thread-safe) kod, birden çok iş parçacığından aynı anda çağrılabilir; paylaşılan durumu bir kilitle korur ya da hiç durum tutmaz. Yeniden girilir (reentrant) kod ise hiç paylaşılan durum kullanmaz; gereken bütün durum çağıranın verdiği parametrelerde ve yerel değişkenlerdedir.
Klasik C kütüphanesindeki pek çok fonksiyonun güvensiz bir sürümü ve sonradan eklenmiş güvenli bir sürümü vardır. Güvenli sürümler genellikle _r (reentrant) ekiyle biter ve durumu statik bir değişkende değil, çağıranın verdiği bir işaretçide tutar.
| Güvensiz | Güvenli sürüm |
|---|---|
strtok | strtok_r |
localtime | localtime_r |
rand | rand_r ya da yerel üreteç |
asctime, ctime | strftime |
Java SimpleDateFormat | DateTimeFormatter |
strtok'un statik işaretçisi yüzünden iş parçacıklarının karışması. T1 "a,b,c"'yi parçalamaya başlar ve "a"'yı alır. T2 "x;y" ile çağırır ve tek kopya olan static char *son'u kendi dizgisine yöneltir. T1'in sonraki strtok(NULL, ",") çağrısı "b" yerine "y" döndürür. strtok_r'de bu durum çağıranda tutulur.strtok, bir dizgiyi ayırıcılara göre parçalara böler. İlk çağrıda dizgi verilir, sonraki çağrılarda NULL verilir ve fonksiyon kaldığı yeri statik son işaretçisinden hatırlar. Şekildeki senaryoda T2'nin çağrısı bu işaretçiyi kendi dizgisine taşır; T1 artık kendi dizgisinin geri kalanını değil, T2'nin dizgisinden kalan "y"'yi alır. strtok_r aynı işaretçiyi çağıranın verdiği bir char ** parametresinde tutar; her iş parçacığının kendi işaretçisi olduğu için karışma olmaz.
İş parçacığı güvenli ile yeniden girilir kavramlarını eş anlamlı saymak. Kilitle korunan bir fonksiyon iş parçacığı güvenlidir ama yeniden girilir değildir: aynı iş parçacığı fonksiyonun içindeyken bir sinyal işleyicisinden aynı fonksiyonu yeniden çağırırsa, kilidi zaten kendisi tuttuğu için sonsuza dek bekleyebilir. Yeniden girilir bir fonksiyon ise paylaşılan durum kullanmadığı için her iki durumda da güvenlidir.
4.6.7Yarış durumu: sayac++
Paylaşılan veriye erişimin neden senkronizasyon gerektirdiğini en sade biçimde gösteren örnek, bir sayacı artırmaktır. C ya da Java'da sayac++ tek bir satırdır, ama makinede üç adımdır:
LOAD R, sayac
ADD R, 1
STORE sayac, RDeğer bellekten bir yazmaca okunur (LOAD), yazmaçta bir artırılır (ADD) ve belleğe geri yazılır (STORE). İki iş parçacığı bu üç adımı iç içe yürütürse bir artış kaybolabilir.
Yarış durumu (race condition), birden çok iş parçacığının paylaşılan veriye eriştiği ve sonucun erişimlerin zamanlamasına bağlı olduğu hatalı durumdur.
Başlangıçta sayac = 5 olsun; T1 ve T2 sayacı birer kez artırıyor. Beklenen sonuç 7'dir. Tablo 4.18 bir artışın kaybolduğu sıralamayı izler.
| Adım | T1 | T2 | R1 | R2 | sayac |
|---|---|---|---|---|---|
| 1 | LOAD R1 ← sayac | 5 | — | 5 | |
| 2 | LOAD R2 ← sayac | 5 | 5 | 5 | |
| 3 | ADD R2, 1; STORE | 5 | 6 | 6 | |
| 4 | ADD R1, 1; STORE | 6 | 6 | 6 |
T1 değeri yazmacına okur: R1 = 5. Araya T2 girer (tek çekirdekte bir bağlam değişimiyle, çok çekirdekte ise aynı anda çalışarak) ve o da 5 okur. T2 artırıp yazar: sayac = 6. T1 eski değerinden devam eder, 5 + 1 = 6 yazar. T2'nin artışı kaybolmuştur; bu hataya kayıp güncelleme (lost update) denir. İki iş parçacığının dört adımı C(4, 2) = 6 farklı sırayla iç içe geçebilir. Python ile bütün sıralamalar denendiğinde ikisinde sonuç 7, dördünde 6 çıkar: bir iş parçacığı ötekinin okuması ile yazması arasına girdiğinde artış kaybolur.
pthread_mutex_lock ya da atomic_fetch_add, Java'da synchronized ya da AtomicInteger kullanılır.Çözüm, oku-artır-yaz dizisini atomik yapmaktır: dizi ya bir kilitle korunur ve bir anda yalnız bir iş parçacığı onu yürütür, ya da donanımın tek komutta oku-değiştir-yaz yapan atomik işlemleri kullanılır. Kilitler, atomik komutlar ve kritik bölge problemi senkronizasyonun konusudur; burada önemli olan, sorunun iş parçacıklarının paylaşılan adres alanından doğduğunu görmektir.
Paylaşılan sayac 0'dır. Üç iş parçacığı sayacı birer kez artırıyor; her artırma iki adımdır: oku (r = sayac) ve yaz (sayac = r + 1). Olası son değerler nelerdir?
- En büyük değer: İş parçacıkları sırayla çalışırsa her biri bir öncekinin yazdığını okur: 0 → 1 → 2 → 3.
- En küçük değer: Üçü de yazmadan önce okursa üçü de 0 okur ve üçü de 1 yazar. Sonuç 1'dir.
- Ara değer: İkisi aynı değeri okur, üçüncüsü onlardan birinin yazmasından sonra okursa sonuç 2 olur.
- Sayım. Altı adımın iç içe geçebileceği 6! / (2! · 2! · 2!) = 90 sıralama vardır. Python ile hepsi denendiğinde 6'sında 3, 36'sında 2, 48'inde 1 çıkar. Doğru sonuç olan 3, sıralamaların yalnız %6,7'sinde elde edilir.
4.6.8Yarışı gözlemlemek
Kayıp güncelleme kuramsal bir olasılık değildir; basit bir Java programıyla gözlenebilir. Aşağıdaki programda iki iş parçacığı paylaşılan sayac'ı birer milyon kez artırır.
public class Yaris {
static int sayac = 0;
public static void main(String[] a) throws Exception {
Runnable is = () -> {
for (int i = 0; i < 1_000_000; i++) sayac++;
};
Thread t1 = new Thread(is), t2 = new Thread(is);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("sayac = " + sayac);
}
}Beklenen sonuç 2 000 000'dur. Program JDK 21 ile üç kez çalıştırıldığında şu sonuçlar elde edildi:
sayac = 1456465
sayac = 1544210
sayac = 1910165Aynı program başka bir makinede JDK 21.0.3 ile beş kez daha çalıştırıldığında 1 024 058, 1 047 114, 1 929 112, 1 055 456, 1 079 458 sonuçları elde edildi. Bu sayılar her çalıştırmada değişir; aynı makinede bile iki çalıştırma aynı sonucu vermez. Değişmeyen tek şey, sonucun her seferinde 2 000 000'dan küçük olmasıdır: artışların bir kısmı kaybolur.
Sonuç belirlenimci değildir: çizelgelemeye, çekirdek sayısına ve anında derleyicinin (just-in-time compiler, JIT) kodu nasıl derlediğine bağlıdır. JIT, döngüyü sayacı yazmaçta tutacak biçimde derleyebilir ve yazmaları seyrekleştirebilir; bu da kaybolan artış sayısını değiştirir. Bu yüzden böyle bir programın çıktısı adım adım izlenemez; yalnız olası değer aralığı söylenebilir.
Düzeltmenin üç yolu vardır. Artırma bir synchronized blok içine alınabilir, AtomicInteger.incrementAndGet() kullanılabilir ya da çok yoğun güncellemeler için LongAdder seçilebilir. İlk ikisi JDK 21 ile denendiğinde iki iş parçacığı birer milyon artırma yaptı ve çıktı her seferinde şuydu:
synchronized: 2000000 atomik: 2000000En iyi çözüm ise paylaşımı hiç yapmamaktır: her iş parçacığı yerel bir değişkende sayar, sonda sonuçlar birleştirilir. Bu, fork-join'in ve kismi[k] örneğinin fikridir; kilit maliyeti de ortadan kalkar.
4.6.9Linux'ta iş parçacıkları: clone()
Linux çekirdeği “süreç” ile “iş parçacığı” arasında ayrım yapmaz. İkisi de bir görevdir (task) ve aynı task_struct yapısıyla temsil edilir. Yeni bir görev clone() sistem çağrısıyla oluşturulur; yeni görevin ebeveyniyle neyi paylaşacağını clone()'a verilen bayraklar belirler. Hiç paylaşım bayrağı verilmezse sonuç fork gibidir: her şeyin kopyası çıkarılır.
fork(): B görevi bellek (mm), files, fs ve sighand yapılarının kopyalarını alır. Sağda clone(CLONE_VM | CLONE_FILES | …): A ve B aynı yapıları gösterir.CLONE_VM adres alanını, CLONE_FS çalışma dizini ve kök dizin gibi dosya sistemi bilgilerini, CLONE_FILES açık dosya tablosunu, CLONE_SIGHAND sinyal işleyicilerini paylaştırır. pthread_create aşağı yukarı clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | …) çağrısına karşılık gelir. CLONE_THREAD yeni görevi ebeveynle aynı iş parçacığı grubuna koyar. Grubun kimliği (TGID, thread group ID), kullanıcıya süreç kimliği olarak görünür: getpid() gruptaki herkes için aynı değeri döndürür. gettid() ise her görevin kendi kimliğini, yani iş parçacığı kimliğini döndürür. Bu esneklik, Linux'un tek bir düzenekle hem süreçleri hem iş parçacıklarını, hem de ikisinin arasındaki karma durumları (ör. belleği paylaşan ama dosya tablosu ayrı görevler) desteklemesini sağlar.
4.7Süreç mi, iş parçacığı mı?
İş parçacıklarının avantajları şöyle özetlenir:
- Duyarlılık: biri bloke olunca ötekisi kullanıcı G/Ç'sini işler.
- Kaynak paylaşımı: bellek, açık dosyalar ve soketler kendiliğinden ortaktır.
- Hız: oluşturma ve geçiş süreçlerden ucuzdur.
- Donanım paralelliği: tek bir süreç birçok çekirdeği kullanır.
- Kolay programlama modeli: bloke eden çağrılarla bile paralellik sağlanır (web sunucusu); her akış sıralı kod olarak yazılır.
Dezavantajlar ise aynı paylaşımın öbür yüzüdür:
- Senkronizasyon: paylaşılan veriye erişim denetlenmelidir; yarış ve kilitlenme riski vardır.
- Bağımlılık: aynı süreçteki iş parçacıkları bağımsız değildir; biri çökerse bütün süreç çöker.
- Bellek koruması yok: bir iş parçacığı başkasının yığınının üzerine yazabilir.
- Hata ayıklama ve test zordur: hatalar zamanlamaya bağlıdır ve her çalıştırmada ortaya çıkmaz.
Seçim, yalıtım ile paylaşım arasındaki dengeye göre yapılır. Yalıtım gerekiyorsa, örneğin tarayıcı sekmeleri birbirinin çökmesinden etkilenmemeli ya da bir güvenlik sınırı çizilmeliyse süreç seçilir. Sıkı veri paylaşımı ve düşük maliyet gerekiyorsa iş parçacığı seçilir. Chrome ikisini birlikte kullanır: her sekme ve eklenti ayrı bir süreçte çalışır, her sürecin içinde de çizim, ağ ve betik yürütme için birden çok iş parçacığı vardır.
4.8Özet
- Süreç kaynakların sahibidir; iş parçacığı süreç içindeki bir yürütme akışıdır ve çizelgelemenin temel birimidir. Kod, global veri, öbek, açık dosyalar ve sinyal işleyicileri paylaşılır; PC, yazmaçlar, yığın, durum, sinyal maskesi ve TLS her iş parçacığına özeldir. Yığın özeldir ama korunmaz.
- Her iş parçacığının kendi yığını vardır; Linux'ta varsayılan boyut çoğunlukla 8 MiB'dir ve yığınlar arasındaki koruma sayfaları taşmayı
SIGSEGVile yakalar. - Çok iş parçacıklı web sunucusunda dağıtıcı istekleri işçilere verir; diskte bloke olan işçi yalnız kendini durdurur. Kazanç, tek işlemcide bile bekleme ile hesaplamanın örtüşmesinden gelir. Olay güdümlü tasarım aynı örtüşmeyi tek iş parçacığıyla sağlar ama durumu elle saklamayı gerektirir.
- Faydalar: duyarlılık, kaynak paylaşımı, ekonomi, ölçeklenebilirlik. İş parçacıkları arası geçişte adres alanı değişmez, TLB ve önbellek sıcak kalır.
- Eş zamanlılık görevlerin aynı zaman aralığında ilerlemesidir; paralellik aynı anda yürütülmeleridir ve en az iki çekirdek gerektirir. Paralellik eş zamanlılığı gerektirir; tersi doğru değildir.
- Amdahl yasası: hızlanma ≤ 1 / (S + (1 − S) / N); üst sınır 1 / S. S = 0,1 için 16 çekirdekte hızlanma 6,4×, sınır 10×'tur. Gustafson yasası problem büyüdüğünde ölçeklenmiş hızlanmayı S + (1 − S) · N olarak verir.
- Kullanıcı düzeyi iş parçacıklarında geçiş çok ucuzdur, ama bloke eden çağrı ve sayfa hatası bütün süreci durdurur, saat kesmesi kütüphaneye gelmez ve tek süreç çok çekirdek kullanamaz. Çekirdek düzeyi iş parçacıkları bağımsız bloke olur ve paralel çalışır, ama her biri çekirdek kaynağı harcar.
- N:1 ucuz ama paralelliksizdir; 1:1 (Linux, Windows, macOS) gerçek paralellik sağlar; M:N işletim sistemlerinde terk edildi, dil çalışma zamanlarında (Go, Erlang, Java 21 sanal iş parçacıkları) geri döndü.
- Pthreads:
pthread_create,pthread_join,pthread_exit,pthread_detach. Argüman ve sonuçvoid *ile geçer; iş parçacığına verilen adres yeterince uzun yaşamalı, sonuç öbekte döndürülmelidir. Java:start()yeni iş parçacığı başlatır,run()başlatmaz;joinhem bekler hem görünürlüğü sağlar. - Örtük iş parçacığı: havuzlar oluşturma maliyetini bir kez öder ve iş parçacığı sayısını sınırlar (G/Ç yoğun işte Nçekirdek × (1 + B / H)); fork-join görevi böler ve iş çalma ile yükü dengeler; OpenMP derleyici yönergeleriyle, GCD bloklar ve kuyruklarla, oneTBB şablonlarla çalışır.
- POSIX'te
fork()yalnız çağıran iş parçacığını kopyalar. Sinyal işleyicileri süreç geneli, sinyal maskesi iş parçacığına özeldir. Ertelenmiş iptal iptal noktalarında güvenle sonlanır.errnogibi değişkenler TLS ile iş parçacığına özel yapılır. sayac++oku-artır-yaz üç adımıdır; korunmazsa artışlar kaybolur ve sonuç her çalıştırmada değişir. Çözüm kilit, atomik işlem ya da paylaşımsız tasarımdır. Linux'ta süreç ve iş parçacığı aynıclone()çağrısıyla, farklı paylaşım bayraklarıyla oluşturulur.
4.9Kavram listesi
Terimlerin İngilizce karşılıkları ve öteki bölümlerdeki kullanımları terim sözlüğünde bulunur.
- iş parçacığı thread
- çok iş parçacıklı süreç multithreaded process
- bağlam değiştirme context switch
- eş zamanlılık concurrency
- paralellik parallelism
- yanlış paylaşım false sharing
- Amdahl yasası Amdahl's law
- kullanıcı düzeyi / çekirdek düzeyi iş parçacığı user-level / kernel-level thread
- iş parçacığı havuzu thread pool
- iş çalma work stealing
- sinyal signal
- iş parçacığına yerel depolama (TLS) thread-local storage
- yeniden girilir / iş parçacığı güvenli reentrant / thread-safe
- yarış durumu race condition
- atomik komut atomic instruction
4.10Kendini Dene
Aynı süreçteki iki iş parçacığı için şu öğelerden hangileri ortaktır, hangileri her birine özeldir: yığın işaretçisi, açık dosya tablosu,
staticbir değişken,_Thread_localbir değişken, öbektemallocile ayrılmış bir dizi?Cevap
Ortak: açık dosya tablosu,
staticdeğişken ve öbekteki dizi; üçü de sürecin adres alanına ya da kaynaklarına aittir. Özel: yığın işaretçisi (her iş parçacığının kendi yığını vardır) ve_Thread_localdeğişken (her iş parçacığında ayrı kopya bulunur).Bir programın %70'i paralelleştirilebiliyor. Dört çekirdekte hızlanma ve teorik üst sınır nedir?
Cevap
S = 0,3. Dört çekirdekte süre 0,3 + 0,7 / 4 = 0,3 + 0,175 = 0,475; hızlanma 1 / 0,475 ≈ 2,11×. Üst sınır 1 / 0,3 ≈ 3,33×'tür.
Tek çekirdekli bir makinede çalışan çok iş parçacıklı bir programda yarış durumu olabilir mi, neden?
Cevap
Evet. Paralellik olmasa da eş zamanlılık vardır. Bir iş parçacığının zaman dilimi
LOADileSTOREarasında bitebilir; çizelgeleyici öteki iş parçacığını çalıştırır, o da aynı eski değeri okur. Kayıp güncelleme tek çekirdekte de olur; yalnız daha seyrek görülür.N:1 modelde çalışan bir programın iş parçacıklarından biri, bellekte olmayan bir sayfaya erişiyor. Ne olur? Aynı durum 1:1 modelde nasıl sonuçlanır?
Cevap
N:1 modelde sayfa hatası çekirdeğe gider ve çekirdek sayfayı diskten getirirken sürecin tek çekirdek iş parçacığını bekletir; kütüphane başka bir iş parçacığına geçemez ve bütün süreç durur. 1:1 modelde yalnız hatayı yapan iş parçacığı bekler; ötekiler çalışmaya devam eder.
a = {4, 1, 3, 5, 2, 6}dizisi,ESIK = 2olan fork-joinToplamgöreviyle toplanıyor. Görev ağacını ve her düğümün sonucunu bulun.Cevap
Kök [0,6) bölünür: orta = 3. Sol [0,3) üç öğelidir ve eşikten büyüktür; orta = 1 ile [0,1) = 4 ve [1,3) = 1 + 3 = 4'e bölünür, toplam 8. Sağ [3,6) da bölünür: [3,4) = 5 ve [4,6) = 2 + 6 = 8, toplam 13. Kök 8 + 13 = 21'dir. Yedi görev oluşur; dördü yapraktır. (Tek işçili
ForkJoinPoolile JDK 21'de çalıştırıldı: 21.)Dört çekirdekli bir makinede her görev 10 ms hesap yapıp 30 ms veritabanı yanıtı bekliyor. Havuz boyutu için kaba kural ne verir? Görevler yalnız hesap yapsaydı ne olurdu?
Cevap
Niş ≈ 4 × (1 + 30 / 10) = 16. Yalnız hesap yapan görevlerde boyut çekirdek sayısı kadar, yani 4 olmalıdır; fazlası yalnız bağlam değiştirme ve önbellek çekişmesi ekler.
Dört iş parçacıklı bir süreçte bir iş parçacığı
fork(), çocuk da hemen ardındanexec("/bin/ls")çağırıyor.exec'ten önce ve sonra çocukta kaç iş parçacığı vardır?Cevap
exec'ten önce bir: POSIX'te yalnızfork'u çağıran iş parçacığı kopyalanır.exec'ten sonra da bir: yeni program bütün adres alanını değiştirir ve tek bir iş parçacığıyla başlar.Üç iş parçacığı başlangıçta 0 olan bir sayacı birer kez, korumasız
sayac++ile artırıyor. Olası son değerler nelerdir?Cevap
1, 2 ya da 3. Üçü de yazmadan önce 0 okursa sonuç 1; ikisi aynı değeri okursa 2; hiç çakışma olmazsa 3'tür. 90 olası sıralamanın 48'inde 1, 36'sında 2, yalnız 6'sında 3 çıkar.
Bir Java programı
Thread t = new Thread(r);satırından sonrat.run();çağırıyor ve hiçstart()çağırmıyor. Kaç iş parçacığı çalışır vet.getState()ne döndürür?Cevap
Yalnız
mainiş parçacığı çalışır;r'nin kodumain'in içinde sıradan bir metot gibi yürür.thiç başlatılmadığı içingetState()NEWdöndürür.
4.11İleri okuma
- R. H. Arpaci-Dusseau ve A. C. Arpaci-Dusseau, Operating Systems: Three Easy Pieces, “Concurrency: An Introduction” ve “Interlude: Thread API” bölümleri: yarış durumunu ve Pthreads API'sini küçük, çalıştırılabilir C örnekleriyle anlatır; ostep.org'da ücretsizdir.
- A. S. Tanenbaum ve H. Bos, Modern Operating Systems, Kesim 2.2: kelime işlemci, web sunucusu, kullanıcı ve çekirdek düzeyi iş parçacıkları, açılır iş parçacıkları ve tek iş parçacıklı kodu çok iş parçacıklı hâle getirme sorunlarını bu nottaki örneklerin kaynağı olarak ayrıntılı işler.
- OpenJDK, JEP 444: Virtual Threads: sanal iş parçacıklarının neden tasarlandığını, taşıyıcıya bağlanma ve indirme düzeneğini ve hangi durumlarda kazanç sağlamadığını resmî belgede anlatır.
- M. D. Hill ve M. R. Marty, “Amdahl's Law in the Multicore Era”, IEEE Computer, 41(7), 2008: Amdahl yasasını farklı çekirdek tasarımları için genişletir ve seri kısmın çok çekirdekli yongaların tasarımını nasıl etkilediğini gösterir.
4.12Kaynaklar
- A. Silberschatz, P. B. Galvin, G. Gagne, Operating System Concepts, 10. baskı, Wiley, 2018 — Bölüm 4: Threads & Concurrency.
- A. S. Tanenbaum, H. Bos, Modern Operating Systems, 4. baskı (2014) / 5. baskı (2023), Pearson — Kesim 2.2: Threads.
- R. H. Arpaci-Dusseau, A. C. Arpaci-Dusseau, Operating Systems: Three Easy Pieces (OSTEP), ostep.org — “Concurrency: An Introduction”, “Thread API”.
- POSIX.1-2017 (IEEE Std 1003.1);
pthreads(7)veclone(2)Linux kılavuz sayfaları. - JEP 444: Virtual Threads (Java 21); OpenMP 5.2 belirtimi.
- G. M. Amdahl, “Validity of the single processor approach to achieving large scale computing capabilities”, AFIPS Spring Joint Computer Conference, 1967, s. 483–485.
- J. L. Gustafson, “Reevaluating Amdahl's Law”, Communications of the ACM, 31(5), 1988, s. 532–533.
- M. D. Hill, M. R. Marty, “Amdahl's Law in the Multicore Era”, IEEE Computer, 41(7), 2008, s. 33–38.
İlgili sayfalar: Bölüm 4 sunumu · Çalışma soruları · Terim sözlüğü · © Sercan Külcü. Tüm hakları saklıdır.