Tasarım Kalıpları – VI: Singleton (Tek Nesne) – III ve Double-Checked Locking (devam)

Bir önceki yazıda tek nesne yani singleton tasarım kalıbının, sonradan yükleme ile çok kanallı halde etkin bir şekilde nasıl kullanılacağını ele aldık ve “singleton == null” kontrolünü iki defa yaparak double-checked locking kalıbını elde etmiştik. Bu koda ulaşırken kendimizi hakikatten bir mühendis gibi hissetmiştik ve elde ettiğimiz kod da son derece çekiciydi. Ama size şimdi bununla ilgili başka bir şey söylemek istiyorum, lütfen sıkı durun: “double-checked locking kalıbı, zekicedir ama çalışmaz!” Evet yanlış duymadınız, JavaWorld dergisinde 8 Şubat 2001 yılında yayınlanan yazıda double-checked locking yaklaşımının gerçekte her zaman doğru çalışmayabileceği gerçeği geniş bir şekilde açıklandı. Aslında Java’nın o anki bellek yönetim modeline göre bu kalıbın bu haliyle çalışmayacağı daha önce güçlü bir grup bilim insanı tarafından “The “Double-Checked Locking is Broken” Declaration” başlığıyla açıklanmıştı. Hatta bu açıklamada ismi bulunan Bill Pugh, burada, içinde bu konunun da dahil olduğu, Java’nın bellek modeliyle ilgili pek çok bilgiyi ve referansı tutan bir sayfa da hazırlamıştı.

Kısaca açıklamak gerekirse, double-checked locking kalıbındaki problem, genel olarak bellek yapılarından kaynaklanıyor. Biraz gaz ve toz bulutundan başlarsak, bizler genelde bilgisayarın klasik Von Neumann modeline göre, her şeyin kodda belirttiğimiz sırada yapılacağını varsayıyoruz. Bu durumda herşeyin yazdığımız gibi ardışıl (sequential) bir şekilde olduğunu düşünüyoruz. Halbuki işletim sistemleri, derleyiciler, JVM gibi çalışma zamanı yapıları, bizim yazdığımız kodu daha etkin çalıştırmak için hemen her türlü taklayı daha formal ifadeyle iyileştirmeyi (optimization) yaparlar. Hatta bu türden etkin kod çalıştırma gayretleri donanım seviyesine kadar çoktan inmiş ve işlemcilerin (CPU) yapılarıyla, değişik paralelleştirme (pipelining) ve tahmin etme (branch prediction) vb. teknikleri kullanılarak daha etkin çalışma zamanları oluşturulmaya çalışılmıştır. (Bu konu tabi olarak bilgisayar mimarisinin (computer architecture) detaylarıdır.) Örneğin “A a = new A()” ifadesinde biz yapılandırıcı çağrısının her zaman atamadan önce yapıldığını farz ederiz ama gerçekte durum böyle olmayabilir. Sistem önce nesne için bellekte yer ayırıp sonra atamayı yapabilir ve yapılandırıcı çağrısını en sona bırakabilir. Bu durumda kısa bir süre için de olsa “a” referansı henüz yapılandırıcı çağrısı yapılmamış bir nesne görecektir. Bu durumda da nesne, değişkenleriyle ilgili tutarsız, en iyi durumda varsayılan değerlere sahip durumda olacaktır.

Yukarıdaki durum, birden fazla işlemcinin, birden fazla çekirdeğin, bir işlemci ya da çekirdekte birden fazla donanım kanalının (hardware thread) ve nihayetinde işletim sistemi ve JVM içinde farklı kanalların olduğu ortamlarda daha da karmaşık bir hal alır. Bu açıdan var olan bellek modelleri (memory models), tüm bu farklı yapılar arasında bellek uyumunu (memory synchronization) sağlamak yerine, en basit yolu tercih edip, temelde herhangi bir uyumlaştırma yapmayıp, uyumlaştırma isteklerini, gelecek işaretlere bağlamıştır. Aksi taktirde, hiç de gereği yokken örneğin birden fazla işlemcinin ya da  bir çok kanalın belleklerini uyumlaştırmaya çalışmak son derece pahalı işlerdir. Sistemler yukarıda da dediğim gibi genelde herhangi bir uyumlaştırma isteği ya da işareti olmadan en basit haliyle işleri yapmaya devam etme eğiliminde olurlar.

Şimdi biraz daha Java’ya özgü açıklamaya girişirsek, Java’nın bellek yapısınından bahsetmemiz gerekir. Çünkü Java, tüm bu farklı yapıların değişik davranışlarını, farklılıkları giderecek şekilde ve yeknesak bir çatı altında bize sunduğundan, onun da bir bellek modeli olması beklenir. Java bellek modelinde, “önce olur” anlamına gelen “happens-before” yaklaşımı vardır. Yani hangi durumlarda olayalr arasıdna öncelik-sonralık ilişkisinin olduğu belirlenmiştir. Bu belirlemler altyapının, var olan kodla ilgili tekrar sıralama gibi inisiyatifler almasını önler. Örneğin, bir nesnenin finalizer bloğunun çalışması, muhakkak o nesnenin yapılandırıcısının bitmesinden sonra olabilir. (Java’nın bellek modeli hakkında daha geniş bilgi için, aynı zamanda Java World’deki yazının da yazarı olan Brian Goetz’in “Java Concurrency in Practice” isimli kitabının 16. bölümü olan Java Memory Model’i okumanızı tavziye ederim. Bu konuda okuması zor olmakla birlikte Java SE speclerinin “Threads and Locks” bölümlerinin Java Memorry Model ile ilgili kısımları da düşünülebilir.)

Java bellek modelinde happens-before ilişkisini sağlayan yapılardan ikisi ise synchronized ve volatile anahtar kelimeleridir. Malum, synchronized anahtar kelimesi, farklı kanallar arasında serilik (serialization) sağlar, dolayısıyla bu kelimeyle tanımlanan kod alanları aynı anda birden fazla kanal tarafından çalıştırılamazlar. Ayrıca, Java’nın bellek modeline göre, synchronized bloğa giren her kanal, önce bu blokta kilitlenmiş bütün değişkenlerin değerlerini ana bellekten okur ve sonrasında bloktaki kodu çalıştırır. Benzer şekilde synchronized bloktan çıkan kanal da, çıkmadan hemen önce, blokta değerini değiştirdiği her değişkenin yeni değerini ana belleğe yazar ki bu korunaklı bloğa girecek bir sonraki kanal, tüm değişkenlerin değerlerini tutarlı bir şekilde görebilsin. Bu mekanizma, kanalın yerel (local) belleği ile JVM’in ana belleği arasındaki haberleşmedir. Ancak bu şekilde bir kanalın, synchronized blokta yaptığı değişiklikler bir başka kanal tarafından doğru bir şekilde görülebilir. Benzer şekilde volatile olan bir değişkenin değerini değiştirmek muhakkak olarak o değişkenin en son değerinin okunmasından sonra olmalıdır. Eğer değişkeninizi volatile olarak tanımlamazsanız, diğer kanalların bu değişkene yaptığı değişiklikleri görmeyebilirsiniz. Bu anlamda volatile anahtar kelimesi, kod bloku seviyesinde değil ama değişken seviyesinde bir serilik sağlar.

Double-checked locking kalıbındaki problem ise, basitçe synchronized blokta kilitlenen nesnenin sınıf nesnesi yani ThreadSafeLazySingleton.class olması, oluşturulan singleton nesnesinin ise kilitlenmemesidir. Bu durumda JVM, derleyicinin üretmiş olduğu bytecodeun çalıştırılmasında, bytecodeların sırasıyla ilgili öyle değişiklikler yapabilir ki singleton nesnesi, diğer kanallara tutarsız bir şekilde görünebilir. Yukarıda bahsettiğim açıklamada, o yıllarda ki 96-97lerden bahsediyoruz, çok kullanılan bir JIT olan Symantec JIT’nin double-checked locking kalıbı için ürettiği makina kodu vardır ve bu kod, yukarıda bahsettiğim işlerde sıralamayı değiştirmekte ve o yazıda da bahsedildiği gibi yapılandırıcı çağrısı en sona kalmaktadır. Bu da bazı kanalların, henüz oluşturulmamış nesneyi gösteren referansa yani singleton referansına ulaşması anlamına gelecektir.

Nihayetinde, bir JVM’de çalışan bütün kanalların varsayılan durumda birbirleriyle sürekli olarak haberleşmesi çok ciddi karmaşıklık ve performans problemi getirecektir. Bu yüzden kodu yazan kişi, kanallar arasındaki muhtemel bu tür problemlerin olması önlemek üzere JVM’e belli işaretler vermesi gerekir. Yukarıda bahsettiğimiz gibi, synchronized ve volatile anahtar kelimeleri bu işaretlerdendir. Dolayısıyla, singleton referansına yapılan değişiklikleri diğer kanalların aynen görmesini istiyorsak, bu referansı volatile olarak tanımlamalıyız. volatile anahtar kelimesi, kendisiyle nitelenen değişkenin değerinin, bir yerlerde cachlenmek yerine daima ana bellekten okunmasını ve kendisiyle ilgili değer atama ve okuma gibi işlerin sırasının değiştirilmeden yapılmasını sağlar. Burada hatırlatmam gereken bir diğer husus da volatile anahtar kelimesinin Java’da en başından bu yana olmasına rağmen, “happens before” ilişkisini sağlaması Java SE 5 ile olmasıdır. Dolayısıyla volatile anahtar kelimesinin kullanılarak, kalıbın bu şekilde bu doğru olarak çalışması için, kullandığımız Java SE sürümünün 5 (JDK 1.5) ve sonrası olması gereklidir.

Bu açıklamadan sonra tek nesne kalıbımızın, sonradan yüklemeli haliyle, double-checked kalıbıyla (ve kanal uyutmasını kaldırdığımızdaki) gerçekleştirilmesi şöyle olacaktır:

package org.javaturk.dp.pattern.gof.creational.singleton;

public class ThreadSafeTrueLazySingleton {

	private static volatile ThreadSafeTrueLazySingleton singleton;

	private static int count;
	private String name;

	private ThreadSafeTrueLazySingleton() {
		name = "ThreadSafeTrueLazySingleton" + count;
		count++;
	}

	public static ThreadSafeTrueLazySingleton getInstance() {
		if (singleton == null) {
			synchronized (ThreadSafeTrueLazySingleton.class) {
				if (singleton == null) {
					singleton = new ThreadSafeTrueLazySingleton();
				}
			}
		}
		return singleton;
	}

	public void printName() {
		System.out.println(name);
	}
}

Tek nesne tasarım kalıbıyla ilgili sonraki yazımızda da kullanım alanlarından ve eleştirilerinden bahsedelim.

Bu yazı toplam 1260 defa görüntülenmiştir.