İçeriğe geç
academia.sh

Ders 08 / 11

Eşzamanlılık Yardımcıları

Karşılaştır-ve-ata kurgulanmış bir çakışmada üç denemede sonuca ulaşıyor ve iş boyunca çağırandan hiç ayrılmıyor. CountDownLatch ile Semaphore bekleyeni engelliyor, sayacı başka taraf düşürüyor; CopyOnWriteArrayList dört anlık görüntünün her birini değişmeden koruyor, yazan taraf her değişiklikte yeni bir dizi üretiyor.

İçindekiler

Önceki ders kilidi elle almak ve bırakmaktan söz etti — synchronized bloğu ya da lock()/unlock() çifti, her çağrıda yeniden yazıldı ve serbest bırakmayı garanti etmek finally’e düştü. Standart kitaplığın eşzamanlılık paketi bu elle tutma işini kaynak metinden kaldırıyor: AtomicInteger hiç kilit içermeden atomik güncelleme sağlıyor, CountDownLatch ve Semaphore bekleme ile serbest bırakmayı ayrı iki tarafa bölüyor, CopyOnWriteArrayList ve ConcurrentHashMap gezinme sırasında eşzamanlı değişikliğe karşı kendi disiplinini taşıyor. Kaynak metinde artık hiçbir synchronized anahtar sözcüğü yok — ama işin kendisi hâlâ birinin üzerine düşüyor. Bu dersin sorusu: yardımcı tip, bu işi hangi tarafa yüklüyor?

Dört yardımcı, dört farklı yanıt veriyor. AtomicInteger işi hiç dağıtmıyor, tamamen çağıranın kendi döngüsünde tutuyor. CountDownLatch ve Semaphore işi iki tarafa bölüyor: biri bekliyor, öteki serbest bırakıyor, ve bu iki rol hiçbir zaman aynı çağrıda birleşmiyor. CopyOnWriteArrayList yükü yazan tarafa yüklüyor ve gezinen tarafı tamamen muaf tutuyor. ConcurrentHashMap ise hiçbir tarafı engellemeden ikisinin de aynı anda çalışmasına izin veriyor. Ortak nokta, kaynak metinde hiçbirinin bir kilit sözcüğü taşımaması; fark, kilidin yerini alan mekanizmanın işi nereye koyduğu.

Karşılaştır-ve-Ata: İş Çağıranda Kalıyor

  • EZ12 — Gerçek bir çakışma koşturulmuyor; ilk iki denemede “başka bir taraf araya girmiş” gibi sayaç kasıtlı olarak kaydırılıyor. Bu kurgu tek bir iş parçacığı içinde, belirlenimci biçimde çalışıyor.

AtomicInteger hiç kilit içermiyor, ama tek bir yöntemle atomik güncelleme sağlıyor: karşılaştır-ve-ata. Bu yöntem “değeri şuysa şuna çevir, değilse hiçbir şey yapma ve başarısız olduğunu söyle” der; çağıran başarısız yanıtı aldığında yeni değeri okuyup tekrar dener. Döngünün tamamı çağıranın kendi kod akışında kalır — hiçbir yeni iş parçacığı oluşmaz, hiçbir kilit devreye girmez.

// KarsilastirVeAta.java — is caginanda kalir, cakisma kurgulanarak yeniden deneme sayisi belirlenimci olculur
import java.util.concurrent.atomic.AtomicInteger;

public class KarsilastirVeAta {
    public static void main(String[] args) {
        AtomicInteger sayac = new AtomicInteger(0);
        int denemeSayisi = 0;
        int eski, yeni;
        do {
            eski = sayac.get();
            yeni = eski + 1;
            denemeSayisi++;
            // ilk iki denemede kurgulanmis bir cakisma: baska bir taraf araya girmis gibi degeri kaydiriyoruz
            if (denemeSayisi <= 2) sayac.incrementAndGet();
        } while (!sayac.compareAndSet(eski, yeni));
        System.out.println("cagiranin gordugu deneme sayisi: " + denemeSayisi);
        System.out.println("sayacin son degeri              : " + sayac.get());
    }
}
cagiranin gordugu deneme sayisi: 3
sayacin son degeri              : 3

Döngü her turda önce sayacı okuyor (eski), hedef değeri hesaplıyor (yeni), sonra compareAndSet(eski, yeni) çağırıyor. İlk iki turda araya kasıtlı bir “kayma” giriyor — gerçek bir programda bu kaymayı başka bir iş parçacığının kendi compareAndSet’i yapardı; bu ders böyle bir iş parçacığı hiç oluşturmuyor, kaymayı doğrudan yazıyor ve modellediğini burada kaydediyor. İlk iki çağrı bu yüzden başarısız oluyor — çağıranın okuduğu eski değer, compareAndSet çalıştığı anda artık geçerli değil — üçüncü çağrıda araya kimse girmiyor ve işlem sonuca ulaşıyor. Üç deneme boyunca iş hep aynı iş parçacığındaydı; ne bir kilit alındı ne bir başkası çağrıldı, yalnız okuma-hesapla-dene döngüsü tekrarlandı.

Bu döngünün synchronized bloğundan farkı, başarısızlığa verdiği yanıtta. Bir kilit isteği reddedildiğinde çağıran engellenir ve kilit boşalana kadar hiçbir şey yapamaz; compareAndSet reddedildiğinde çağıran engellenmez, yalnız bir mantıksal false alır ve dilediği gibi karşılık verebilir — burada olduğu gibi yeniden deneyebilir, isterse vazgeçip başka bir işe geçebilir. Yeniden deneme sayısının üstte bir sınırı yoktur: çakışma ne kadar sık olursa yeniden deneme de o kadar sık olur, ama her denemede iş yine çağıranın kendi akışında kalır. Gerçek bir çok iş parçacıklı yükte bu sayı ortama bağlı olurdu; burada kurgulanmış olduğu için 3 sabit ve tekrarlanabilir.

Bekleyen Engellenir, Sayacı Başka Taraf Düşürür

  • EZ13 — Günlüğün sırası tek bir kanıt taşıyor: B’nin izni aldığı satır, A’nın izni bıraktığı satırdan önce hiç görünemiyor — çünkü Semaphore tek izin veriyor ve B, A bırakana kadar acquire() içinde engelleniyor.

CountDownLatch sayaç sıfıra düşene kadar bekleyeni engelliyor; sayacı düşürmek her zaman başka bir tarafın işi — bekleyenin kendisi asla kendi sayacını düşürmüyor. Semaphore buna yakın ama farklı bir sözleşme taşıyor: sabit sayıda izin dağıtıyor, acquire() izin kalmayınca engelliyor, release() izni geri veriyor. Aşağıdaki kurulum ikisini birlikte kullanıyor: girdiA ve bittiB birer CountDownLatch, aradaki karşılıklı dışlamayı ise tek izinlik bir Semaphore sağlıyor.

// Nobetci.java — CountDownLatch bekleyeni engeller, Semaphore izni baska taraf birakir
import java.util.*;
import java.util.concurrent.*;

public class Nobetci {
    public static void main(String[] args) throws InterruptedException {
        List<String> gunluk = Collections.synchronizedList(new ArrayList<>());
        Semaphore izin = new Semaphore(1);
        CountDownLatch girdiA = new CountDownLatch(1);
        CountDownLatch birak = new CountDownLatch(1);
        CountDownLatch bittiB = new CountDownLatch(1);

        Thread a = new Thread(() -> {
            try {
                izin.acquire();
                gunluk.add("A izni aldi");
                girdiA.countDown();
                birak.await();
                gunluk.add("A izni birakiyor");
                izin.release();
            } catch (InterruptedException e) { }
        });
        a.start();
        girdiA.await();

        Thread b = new Thread(() -> {
            try {
                izin.acquire(); // A birakana kadar bekler
                gunluk.add("B izni aldi");
                izin.release();
                bittiB.countDown();
            } catch (InterruptedException e) { }
        });
        b.start();

        birak.countDown();
        bittiB.await();
        a.join(); b.join();
        System.out.println(gunluk);
    }
}
[A izni aldi, A izni birakiyor, B izni aldi]

A tek izni aldıktan sonra girdiA ile haber veriyor ve kendi birak mandalını bekliyor. B bu sırada başlatılıyor ve izin.acquire() çağırıyor — ama izin A’da, bu yüzden B’nin "B izni aldi" satırı günlüğe düşemiyor, tıpkı önceki dersteki ikinci kilit isteyicisi gibi engelleniyor. Ana iş parçacığı birak.countDown() çağırdığında A uyanıyor, izni bırakıyor ("A izni birakiyor"), ancak o andan sonra B izni alabiliyor. Bekleyen taraf (B) kendi kendini serbest bırakamıyor; sayacı düşüren de izni bırakan da her zaman öteki taraf.

Üç ayrı senkronizasyon aracının burada aynı programda üç ayrı iş gördüğüne dikkat etmek gerekiyor. girdiA ve bittiB birer kerelik haber taşıyor — her biri bir kez düşüyor, bir daha kullanılmıyor. izin ise tekrar kullanılabilir: A onu bırakınca B alıyor, B de bırakınca üçüncü bir çağıran gelseydi o da alabilirdi. CountDownLatch bu yüzden “bir kez olan bir şeyi bekle” sorusuna, Semaphore “aynı anda en çok kaç tarafın içeride olacağını sınırla” sorusuna karşılık veriyor — burada sınır bire ayarlandığı için Semaphore bir kilide çok benziyor, ama izin sayısı birden fazla olsaydı aynı anda birden fazla çağıranın içeri girmesine izin verirdi; bu, synchronized’ın hiçbir zaman veremeyeceği bir esneklik.

Yazarken Kopyalama: Gezinen Ödemez, Yazan Öder

  • EZ14 — Dört anlık görüntü, listeye eklenen her ögeden önce alınıyor; her görüntü kendi alındığı andaki boyutu görüyor ve bu boyut daha sonraki eklemelerden hiç etkilenmiyor.

CopyOnWriteArrayList bir yazma işleminde (add, remove, set) mevcut diziyi olduğu gibi bırakıp yeni bir dizi üretiyor ve iç referansı o yeni diziye çeviriyor. Bu yüzden yazmadan önce alınmış bir yineleyici, kendi aldığı dizide donuyor — sonraki yazmalar o yineleyiciyi hiç etkilemiyor, üstüne bir istisna da düşürmüyor.

// YazarkenKopyalama.java — gezinen taraf odemez, yazan taraf her degisiklikte yeni bir dizi uretir
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;

public class YazarkenKopyalama {
    public static void main(String[] args) {
        CopyOnWriteArrayList<String> liste = new CopyOnWriteArrayList<>();
        List<Iterator<String>> anlikGoruntuler = new ArrayList<>();

        anlikGoruntuler.add(liste.iterator());
        liste.add("a");
        anlikGoruntuler.add(liste.iterator());
        liste.add("b");
        anlikGoruntuler.add(liste.iterator());
        liste.add("c");
        anlikGoruntuler.add(liste.iterator());

        int i = 0;
        for (Iterator<String> it : anlikGoruntuler) {
            int sayim = 0;
            while (it.hasNext()) { it.next(); sayim++; }
            System.out.println("anlik goruntu " + i + " gordugu oge sayisi: " + sayim);
            i++;
        }
        System.out.println("son liste boyutu: " + liste.size());
    }
}
anlik goruntu 0 gordugu oge sayisi: 0
anlik goruntu 1 gordugu oge sayisi: 1
anlik goruntu 2 gordugu oge sayisi: 2
anlik goruntu 3 gordugu oge sayisi: 3
son liste boyutu: 3

Dört yineleyici de liste.add çağrılmadan önce alınıyor, ama hepsi döngüden sonra gezinilerek okunuyor — üçüncü ekleme yapıldığında ilk üç yineleyici çoktan var. Buna rağmen hiçbiri sonraki eklemeleri görmüyor: sıfırıncı görüntü hep 0, birinci hep 1 diyor. Dört farklı boyut gözlemlemiş olmak, dört ayrı dizinin var olduğunun dolaylı kanıtı — gezinen taraf hiçbir şey ödemiyor çünkü kendi aldığı diziyi hiç paylaşmıyor, yazan taraf öder çünkü her add çağrısı liste.size()’ın son değerine kadar büyüyen bir diziyi baştan kopyalıyor. Üç add çağrısı üç yeni dizi üretiyor; ilk boş dizi de sayılırsa ölçüm boyunca gözlemlenen farklı boyutların sayısı dört.

Bedelin nereye düştüğü, bu yapının ne zaman tercih edildiğini de belirliyor. Gezinme çok, yazma seyrekse (örneğin bir dinleyici listesi neredeyse hiç değişmeden sürekli dolaşılıyorsa) yazma başına bir kopyalama bedeli kabul edilebilir, çünkü ödeyen taraf zaten seyrek çalışıyor. Yazma sık, gezinme seyrekse aynı bedel her seferinde tam bir kopyalamaya dönüşür ve bu artık kabul edilebilir olmayabilir; o durumda ConcurrentHashMap’in aşağıda ölçülen deseni — hiçbir tarafın tam kopya çıkarmadığı desen — daha uygun düşer. Bu ders iki yapı arasında bir hız karşılaştırması yapmıyor, yalnız hangi tarafın ne ödediğini sayıyor; seçim, o ödemenin hangi tarafta daha ucuz olduğuna bakan bir tasarım kararıdır.

Eşzamanlı Koleksiyonlarda Yazan Kim

  • EZ15 — Gezinme sırasında yapılan yazma, ayrı bir iş parçacığında ve join() ile tamamlanmış olarak gerçekleşiyor; sıra çizelgeleyiciye bırakılmıyor, ne zaman yazılacağı döngünün içinde açıkça seçiliyor.

ConcurrentHashMap’in erken hata veren yineleyiciden farkı ve anlık kopya üzerinden gezinme davranışı M08/K03 Standart Kütüphane ve Akışlar kursunda zaten kuruldu; bu ders o sınıfları yeniden tanıtmıyor, yalnız kendi sorusunu ekliyor: gezinme sırasında bir yazma olduğunda, o yazmayı hangi iş parçacığı yapıyor?

// EszamanliHarita.java — gezinme sirasinda yazma istisna vermiyor, yazan is parcacigi etiketle belli
import java.util.concurrent.ConcurrentHashMap;

public class EszamanliHarita {
    public static void main(String[] args) throws InterruptedException {
        ConcurrentHashMap<String, Integer> harita = new ConcurrentHashMap<>();
        harita.put("a", 1);
        harita.put("b", 2);

        int gorulen = 0;
        for (var giris : harita.entrySet()) {
            gorulen++;
            if (gorulen == 1) {
                Thread yazan = new Thread(() -> {
                    harita.put("c", 3);
                    System.out.println("yazan: olusturulan is parcacigi");
                });
                yazan.start();
                yazan.join();
            }
        }
        System.out.println("gezinme istisna vermeden bitti, ziyaret edilen giris sayisi=" + gorulen);
        System.out.println("gezinme sonrasi boyut: " + harita.size());
    }
}
yazan: olusturulan is parcacigi
gezinme istisna vermeden bitti, ziyaret edilen giris sayisi=3
gezinme sonrasi boyut: 3

Yazma, oluşturulan bir iş parçacığında ve gezinme henüz sürerken (gorulen == 1’de, döngü daha ilk girişteyken) çalışıyor; join() bu yazmanın gezinme devam etmeden önce tamamlanmış olmasını garanti ediyor. Gezinme hiçbir istisna vermeden bitiyor — ArrayList’in erken hata veren yineleyicisi aynı kurulumda ConcurrentModificationException fırlatırdı, bu davranış zaten önceki kursta ölçüldü. Bu koşumda eklenen üçüncü giriş gezinmeye de yansıyor, ama bu, ConcurrentHashMap’in garanti ettiği bir şey değil — yineleyici yazma anından önceki ya da sonraki bir görüntüyü gösterebilir, ikisi de sözleşmeye uygun; garanti edilen tek şey istisna atılmamasıdır.

yazan: olusturulan is parcacigi satırı bu dersin kendi eklediği soruyu yanıtlıyor. Önceki kursta ölçülen davranış tek bir iş parçacığı içinde bile geçerliydi — bir yineleyici alındıktan sonra aynı iş parçacığının yapısı değiştirmesi de erken hata verirdi. Burada değiştiren taraf ayrı bir iş parçacığı, ve etiketten görüldüğü gibi bu oluşturulan bir iş parçacığı, gezinen main’in kendisi değil. ConcurrentHashMap’in sözleşmesi hangi iş parçacığının yazdığını hiç sormuyor — kimin yazdığından bağımsız olarak istisna atmıyor; kimliği bu derste yalnız izlemek için ekleniyor, sözleşmenin bir parçası değil.

Sınırlayıcı Ölçüm: Tek Değişkenin Atomikliği

Bu bölümde yeni bir kod koşturulmuyor. AtomicInteger bir tek değişkene atomiklik verir; o değişkeni okuyup değiştiren her işlem bölünemez bir bütün olarak görünür. Ama bir program iki atomik değişkeni birlikte, aralarındaki bir ilişkiyi koruyarak güncellemek isterse (örneğin bir toplamı bir sayaca hep eşit tutmak isterse), bu ilişki AtomicInteger’ın verdiği garantinin dışında kalır — her değişken kendi içinde bölünmez, ikisi birlikte bölünebilir. Bu, M01/K05 İşletim Sistemi Kavramları kursunda kurulan oku-artır-yaz kavramının bir başka görünümüdür ve burada da koşturularak değil, modellenerek anlatılıyor: iki atomik değişkenin arasına bir iş parçacığının girip yalnızca birini güncellediği bir an her zaman mümkündür, ve bu anın gerçekten oluşup oluşmadığını koşumdan göstermek ortama bağlı bir sonuç üretirdi.

Aynı sınır CopyOnWriteArrayList ve ConcurrentHashMap için de geçerlidir: her ikisi de kendi iç tutarlılıklarını korur — bir add çağrısı hiçbir zaman yarım kalmış bir dizi bırakmaz, bir put çağrısı haritayı hiçbir zaman tutarsız bir ara durumda göstermez — ama bu, aynı koleksiyon üzerinde çalışan birden fazla işlemin birlikte oluşturduğu daha büyük bir kuralı garanti etmez. Bir listeye önce bir öge ekleyip sonra o ögeye bağlı ikinci bir alanı başka bir haritaya yazan kod, iki ayrı eşzamanlı koleksiyonu birlikte tutarlı tutma sorumluluğunu kendi üstlenir; yardımcı tipler yalnız kendi sınırları içindeki bütünlüğü garanti eder.

Özet

  • AtomicInteger’ın karşılaştır-ve-ata döngüsü tamamen çağıranın kod akışında kalır; hiçbir yeni iş parçacığı ya da kilit devreye girmez, yalnız okuma-hesapla-dene tekrarlanır.
  • CountDownLatch ve Semaphore’da bekleyen taraf kendi kendini serbest bırakamaz; sayacı düşüren ya da izni bırakan her zaman öteki taraftır.
  • CopyOnWriteArrayList’te gezinen taraf hiçbir bedel ödemez, çünkü kendi aldığı diziyi hiçbir yazmayla paylaşmaz; yazan taraf her değişiklikte yeni bir dizi üretir.
  • ConcurrentHashMap gezinme sırasında yapılan bir yazmaya istisna atmaz; yazmayı hangi iş parçacığının yaptığı programın kendi kurgusundan bilinir, ama yeni girişin gezinmeye yansıyıp yansımayacağı garanti edilmez.
  • İki atomik değişkeni birlikte tutarlı tutmak AtomicInteger’ın garantisinin dışındadır; bu sınır koşturularak değil, kural olarak anlatılır.

Sonraki Adım

Bu dört yardımcı tip de kilidi elle tutma işini üstlendi — KarsilastirVeAta hiç kilit kullanmadı, Nobetci izni ve mandalı elle almaktan kurtardı, YazarkenKopyalama ile EszamanliHarita bir kilit olmadan tutarlılık sağladı. Ama görevi kimin koşturacağını hâlâ çağıran seçiyor: Nobetci‘deki A ve B’yi de, EszamanliHarita’daki yazanı da new Thread ile elle kuran ve start() çağıran çağıranın kendisiydi. Sıradaki konu bu seçimi bir servise devrediyor ve aynı soruyu ona soruyor: bir görev bir havuza verildiğinde, onu havuzun kendi iş parçacığı mı koşturuyor, yoksa görevi veren taraf mı — ve bu seçim, havuzun o anki doluluğuna göre nasıl değişiyor?

İlerlemeni kaydetmek ve not almak için Giriş yap

Notlarım

Not almak için giriş yapmalısın.

Aramak için yazmaya başlayın.

↑↓ Esc gezin · aç · kapat