Bilgisayar kullanıcıları doğal olarak kullandıkları bilgisayarın aynı anda birden fazla iş yaptığını düşünürler. İnternette geziniyorken veya klavye tuşlarına basıldığında arka planda çalan şarkının kesintiye uğraması pekçok kişi tarafından rahatsız edici bulunacaktır. Bu durum sadece farklı programların aynı anda çalışabilmesi için değil tek başına bir programın aynı anda birden fazla iş yapabilmesi için de geçerlidir. Tek çekirdekli bir işlemci fetch(Memory den bir sonraki komutun alınması) - decode(Komutun çözümlenmesi) - execute(Komutun çalıştırılması) döngüsünde -pipelining ile tek bir çevrimde fetch, decode ve execute işlemleri aynı anda yapılabilir- bir işlemci zamanında(CPU Cycle) tek bir komut çalıştırabilir(execute cycle). Eğer son çalıştırılan komut neticesinde uzun sürecek bir işlem yapılıyorsa(Sabit diske yazma, ağ bağlantısı kurma gibi..) boşta kalan işlemci başka programlara ait komutları çalıştırabilir, veya işlemci zamanı tüm processler arasında adil olarak dağıtabilir. Bu şekilde kullanıcı aynı anda birden fazla program kullanabilir. Son yıllarda kişisel bilgisayarlarda kullanılmaya başlanan çok çekirdekli işlemciler ise aynı anda birden fazla komut işleme olanağı verir ve bu şekilde bir iş birden fazla ayrı parçaya(task) bölünebilir. Java her ne kadar threadlerin eş zamanlı programlamlanmasına olanak vereceği iddaasıyla yola çıkmış olsa da, versiyon 5 e kadar eşzamanlılık desteği oldukça zayıf kalmıştır.
Processler ve threadler eşzamanlı programlamanın temel iki birimini oluşturur. Bir program sıralı komutlardan oluşur ve bu komutları çalıştıran öğeye process denir. Thread’ler processler gibi komutlara çalışacak ortamı sağlarlar ancak processlerden farklı olarak processlerin içerisinde yer alırlar, aynı memory alanını paylaşırlar ve bir processin ihtiyacı olan kaynağın çok daha azı ile oluşturulabilirler. Java’da eşzamanlılık ortak kaynakları kullanan threadlerin bu ortaklıktan kaynaklanabilecek hataları engellemeye yönelik senkronizasyon işini kapsar. Uygun senkronizasyon koda dahil edilmez ise threadler birbirlerinin bir değişken üzerinde yaptığı değişiklikleri kaybedebilirler veya tüm bunların dışında bir thread belli bir işe başlamadan önce başka bir threadin bir işi bitirmesine ihtiyaç duyabilir. Tüm bu senkronizasyon işlerinin yapılması için Java dili içerisinde ayrılmış keywordler bulunur. Bunun yanında java.util.concurrent paketi içerisinde senkronizasyon problemlerine çözümler sağlayan APIler bulunmaktadır.
Bir programın thread-safe(Threadlerin eşzamanlılığından kaynaklanabilecek sorunların olmadığı program) olduğunu söyleyebilmek için aşağıdaki özelliklerden birine sahip olması gerekmektedir:
- Uygulama içerisinde threadler oluşturulmuyorsa ya da diğer bir deyişle process içinde sadece bir thread bulunuyorsa herhangi bir senkronizasyon planı yapmaya gerek yoktur. Diğer yandan, kullanılan 3rd party frameworklerin thread yaratıyor olabilme olasılığı göz önünde bulundurulmalıdır.
- Bir sınıfın anlık durumunu(state) sınıf değişkenleri(class variables) belirler. Threadler tarafından bu durum bilgisi paylaşılmaktadır ve senkronizasyon da durum belirten değişkenlere erişimi koordine eder. Yazılan bir sınıfta hiç sınıf değişkeni yok ise(yani durum belirtmiyorsa) o sınıfın doğal olarak thread-safe olduğu söylenebilir.
- Sınıf değişkeninin değeri nesnenin yaratılması aşamasında(constructor içinde) atanır ve yaşam süresi boyunca hiç değiştirilmezse bu değişkenle ilgili senkronizasyon endişesi taşımaya gerek kalmaz.
Durum değişkenleri değiştirilemeyen sınıflar üretmek için(immutable classes):
- Tüm alanları final ve private tanımlanmalı, alanları değiştiren “setter” methodları bulunmamalı ve direk olarak bu alanlar diğer sınıflara kullandırılmamalı. Direk olarak dönülmesi gereken bir alan var ise bu alanın kopyası(defensive copy) oluşturulmalı ve bu kopyaya ait referans dönülmelidir.
- Başka sınıfların bu sınıfa ait methodları override etmesi engellenmeli(final sınıf veya private constructor ile yapılabilir)
- Construction sırasında this referansı başka nesnelere verilmemeli.
- Değişebilen sınıflara ait(mutable classes) referanslar bulundurulmamalı, gerekiyorsa kopyaları oluşturulmalıdır.
- Değişen durum değişkenlerine direk olarak ulaşım engellenmeli, bu değişkenler ulaşımı koordine etmekten sorumlu bir sınıf içerisinde barındırılmalıdır.(encapsulation) Durum değişkenlerinin tamamı thread-safe sınıflar tarafından yönetilirse yazılan programın thread-safe olduğu söylenebilir.
Gerekli senkronizasyonun bulunmadığı bir program çalışıyormuş gibi görünebilir; ancak bu noktada programın doğru sonuç üretebilmesi için zamanlama önemlidir. Bunun nedeni; programlama dilinde tek bir satırlık kodun aslında birden fazla komut içermesi ve bu işlemin atomik olmamasıdır. Birden fazla işlem tek bir işlem gibi çalıştırıldığında atomiklik sağlanır. Aşağıdaki kod parçacığında unsafeCount++ tek bir işlem gibi gözüksede üç ayrı parçadan oluşmaktadır. Bunlar sırasıyla Fetch(O anki değer okunur), Increment(Değer 1 arttırılır) ve Write(Yeni değer yazılır.) şeklindedir. Burada ortaya çıkabilecek sorun aşağıda gösterilmiştir.
Başlangıçta 8 olan değeri her iki threadde 8 olarak okuyor ve sonuçta 9 olarak yazıyor. Neticede 1 artımlık değer kaybolmuş oluyor. Bu durumdan kaçınmak için işlemin atomik hale getirilmesi gerekir. Aşağıdaki kod parçacığında increment metodu Java dilinin içerisinde yer alan synhronized keywordü ile atomik hale getirilmiştir. Synchronized keywordü içerisinde kalan blok aynı anda iki thread tarafından çalıştırılamaz. Bir thread synhronized bloğuna girdiğinde bu bloğa gelen diğer threadler bloğu çalıştıran thread bloktan çıkana kadar bekler.(intrinsic lock)
public class AtomicServlet implements Servlet{
private long unsafeCount = 0;
private long lockingCount = 0;
private final AtomicLong nonblockingCount = new AtomicLong(0);
public synchronized void increment(){lockingCount++;}//intrinsic lock
public void service(ServletRequest req, ServletResponse resp) {
unsafeCount++; // Bu işlem atomik değil!
increment(); //İşlem süresince diğer threadler bekler
unblockingCount.incrementAndGet(); //atomiktir ve diğer threadler beklemez
}
}
Kod içerisinde en verimli kullanım AtomicLong sınıfıdır. Çünkü AtomicLong sınıfı increment() metodundan farklı olarak diğer threadleri bekletmez. Bu modern işlemcilerin sahip olduğu bir özelliğin Java 5 tarafından implementasyonu ile sağlanmaktadır. Lock mekanizması kullanılacak ise bloğun içinde çalışan kodun uzun sürecek(I/O, network işlemleri gibi..) bir işlem barındırmamasına dikkat edilmelidir; çünkü bu durum diğer threadlerin gereksiz yere beklemelerine ve sonuç olarak programın performansının düşük olmasına veya deadlocklara sebep olabilir. Deadlock durumunu engellemek için intrinsic lock yerine Lock sınıfları(java.util.concurrent.locks paketi içerisinde) kullanılabilir. Lock sınıfları intrinsic locktan farklı olarak lock istendiği anda veya belirtilen belli bir süre içerisinde hazır değilse lock isteğinden vazgeçilebilir. Ancak intrinsic lockta bloktan çıkınca lock kaldırılıyorken Lock objeleri kullanıldığında bu iş kod içerisinde yapılmalıdır. (try-finally bloğu ile)
try { //Lock nesnesinin kullanımı synchronized(lock) {//intrinsic lock
locked = myLock.tryLock(); /*Diğer threadler locku kullanamaz
Bloktan çıkana kadar diğer
} finally { *threadleri bekletir*/
if(locked) { }//lock kaldırılır
myLock.unlock();
}
}
Beklemeye neden olabilecek metodlar(blocking methods) InterruptedException fırlatırlar. Normal süreç içerisinde bir threadin çalışması başka threadler tarafından engellenemez; ancak bir thread başka bir threadin yaptığı işi bırakmasını isteyebilir. Örneğin Thread sınıfına ait sleep ve wait methodları blocking method olarak nitelendirilirler ve ait oldukları Thread interrupt edildiğinde InterruptedException fırlatırlar. Yazdığınız beklemeye neden olabilecek bir method uzun sürebilecek bir işlem üzerinde çalışıyor ise belli aralıklarla Threadin isInterrupted metodu ile interrupt edilip edilmediği kontrol edilmelidir.
public void interrupt1() throws InterruptedException{//Blocking method
try {
/*If you call the deprecated method stop, it wont immediately stop, it will quit after sleep method executes */
Thread.sleep(5000); /*It is a blocking method and throws InterruptedExce**/
} catch (InterruptedException e) {
throw e;
}
}
public void interrupt2() throws InterruptedException{//Blocking method
while(notCompleted ){
workOnSomeIOTask();
if(Thread.currentThread().isInterrupted()){
throw new InterruptedException(“Stop!”);
}
}
}
public void howToInterruptThread(){
Thread t = getThread();
t.interrupt();
}
Verinin threadler arasında paylaşılması ile ilgili diğer bir problem bir değişkendeki değişikliğin tüm threadler tarafından görülememesi ile ilgili yaşanmaktadır(visibility). Bu performansı arttırmak için bazı değerlerin önbellekte tutulmasından kaynaklanır. Sonuç olarak bir değişkeni sadece bir thread değiştiriyor ve diğer tüm threadler bu değişkeni okuyor olsa dahi bu paylaşılan değişken için senkronizasyon gereklidir. Tüm threadlerin değişkenin son değerini gördüğünden emin olmak için değişkene yazma ve okuma aynı lock üzerinden gerçekleştirilmelidir veya değişken volatile keywordü ile tanımlanabilir. Volatile JVMe ilgili değişkenin paylaşıldığını ve değişikliklerin tüm threadler tarafından görülebilmesi gerektiğini bildirir. Ancak volatile keywordü atomikliği sağlamaz.
Java 5 versiyonu threadlerin zamanlamasını kontrol etmeyi sağlayan bazı sınıfları bulundurmaktadır.(synchronizers) Bir synchronizer sınıfı kendisini çağıran threadin çalışmasına devam edip etmesi ile ilgili durum bilgisini içerir.
Semaphore sınıfları sınırlı sayıda olan kaynağa erişimi kontrol etmekte kullanılır. Semaphore sınıfı mevcut kaynak sayısını bilir(counter) ve bir thread kaynak erişimi için izin istediğinde bu değeri 1 azaltır, thread kaynağı bıraktığında 1 arttırır. Sayı 0 olduğunda kullanımda olan bir kaynak bırakılana kadar kaynak isteyen threadler bekletilir.
public void semaphore() throws InterruptedException{ //May be interrupted
Semaphore sem = new Semaphore(1);
sem.acquire();
/** Since we have 1 permit no other thread can execute doSomething
* method until current thread releases the permit*/
doSomethind();
sem.release();
}
Latchler bitiş durumuna ulaşana kadar await methodunu çağıran threadleri bekletir. Örneğin; CountDownLatch içerisinde barındırdığı counter 0 a ulaştığında bitiş durumuna ulaşır ve beklettiği threadleri bırakır.
public void latch(int nThreads) throws InterruptedException {
final CountDownLatch start = new CountDownLatch(1);
final CountDownLatch end = new CountDownLatch(nThreads);
for (int i = 0; i < nThreads; i++) {
Thread t = new Thread() {
public void run() {
try {
start.await(); // waits until all the threads are started
doSomethind();
} catch (InterruptedException e) {
} finally {
end.countDown();
}
}
};
t.start();
}
start.countDown(); //All the created threads start execution
end.await(); //Main thread waits until all the created threads finish their task
}
Barrier olarak adlandırılan sınıflar belli sayıda threadin aynı noktaya ulaşmasını bekler. Bitiş durumuna ulaşıldığında tüm threadler aynı noktadan çalışmaya başlar. Bir thread bekleme sürecinde interrupt edilirse beklemede olan diğer threadler BrokenBarrierException fırlatır.
public void barrier(int nThreads) throws InterruptedException{
final CyclicBarrier bar = new CyclicBarrier(nThreads);
for (int i = 0; i < nThreads; i++) {
Thread t = new Thread() {
public void run() {
try {
bar.await(); //Waits until nThread calls await method
//And all the threads will call doSomething method at
//the same time!
doSomethind();
} catch (InterruptedException e) {
return;
} catch (BrokenBarrierException e) {
//Called if a thread is interrupted
return;
}
}
};
t.start();
}
}
Eşzamanlı programlarda yaşanabilecek temel sorunlar paylaşılan dataya ulaşımdan kaynaklanan performans problemleri ve deadlocklar ile gerekli senkronizasyonun bulunmamasından kaynaklanan yanlış sonuç üretimidir. Ayrıca çok fazla thread üretimi -işlemci threadler arasında sürekli geçiş yapmaya ihtiyaç duyacağından dolayı- performansın düşmesine neden olabilir. Bu durumdan kaçınmak için Thread pool kullanılabilir. Bir thread pool içerisinde her zaman çalışan belirli sayıda thread vardır. Aşağıdaki kodda thread pool 10 adet thread içerebilir. Bu nedenle 500 thread aynı anda yaratılmaz. Böylece sistemin kaldırabileceği yük sınırının aşılmadığı garanti altına alınır.
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 500; i++) {
Runnable worker = new SomeRunnable();
executor.execute(worker);
}
Thread-safe program tasarlamak tamamen değişen durum değişkenleri ile alakalı olduğundan mümkün olduğu kadar az değişebilir durum değişkeni(mutable state variable) tanımlanması programın thread-safe tasarımını daha kolay hale getirecektir. Değişmeyen durum değişkenleri her zaman final keywordü ile tanımlanmalıdır. Böylece kod içerisinde değişkenin değiştirilmediğinden emin olunur ve kodu okuyan programcılar için dökümantasyon sağlar. Değişebilir durum değişkeni tanımlamak gerektiğinde bu değişkenler direk ulaşıma açık olmamalı ve bu değişkenlere ulaşımın koordinasyonu bir sınıfın sorumluluğunda olmalıdır. Diğer bir deyişle değişen durum değişkenleri bir sınıf içerisinde encapsulate edilmelidir.