Daha önce Java ile asenkron programlama yazımda nasıl bir thread yaratabileceğimizi ve onu nasıl idare edebileceğimizden bahsetmiştim. Bu yazımda multi-thread programlamanın en büyük sorunlarından biri olan Race condition (yarış hali) sorununun üstesinden gelmeyi öğreneceğiz.

Bu durum iki veya daha fazla thread’in yanlış sırada çalışması sonucu birbirlerinin verilerinin üzerine yazması sonucu oluşur. Ve genellikle işlem sonrası yanlış bir sonuca ulaşılır.

Bu durumun üzerinden Mutex (Mutual Exclusion) ile gelinir. Bu sayede atomik gerçekleşmesi gereken (herhangi bir şekilde bölünmemesi gereken) işlemler çakıştığında threadler birbirini beklerler. Ancak yanlış kullanımı deadlock (threadlerin birbirlerini sonsuza kadar bloklaması/beklemesi durumu) ile sonuçlanabilir.

Bu durumları simule edelim.

1- Race-Condition

Öncelikle sorunu inceleyelim. Aşağıdaki kod parçasına bakarsanız iki adet thread tanımlandığını göreceksiniz. Bunlardan ilki (thread1) zero değişkenini 100000 kere bir arttırıyor. İkinci thread (thread2) zero değişkenini 100000 kere bir azaltıyor.

Sonucunda zero değişkeninin sıfır olmasını bekleyeceğimiz bu program bekleneni çoğunlukla sağlamayacaktır. alacağınız değerler +/- 100000 arası değişkenlik gösterecektir.

public class Main {
  private static int zero;

  public static void main(String[] args) throws InterruptedException {
    Thread thread1 = new Thread(new Runnable() {
      @Override
      public void run() {
        for (int i = 0; i < 100000; i++)
          zero++;
      }
    });
    Thread thread2 = new Thread(new Runnable() {
      @Override
      public void run() {
        for (int i = 0; i < 100000; i++)
          zero--;
      }
    });

    thread1.start();
    thread2.start();

    thread1.join();
    thread2.join();

    System.out.println("Zero: " + zero);
  }
}

Tam olarak yüzleştiğimiz sorun bu. Peki bu sorunun üstesinden nasıl geleceğiz?

2- Thread senkronizasyon / Mutex

Bu sorunun birden fazla çözüm yolu var. Bunlardan bir tanesi işlemi atomik kılmak için ortak bir nesneyi kitlemek. Ve işte örneği.

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Main {
  private static int zero;
  private static Lock lock = new ReentrantLock();

  public static void main(String[] args) throws InterruptedException {
    Thread thread1 = new Thread(new Runnable() {
      @Override
      public void run() {
        for (int i = 0; i < 100000; i++) {
          lock.lock();
            zero++;
          lock.unlock();
        }
      }
    });
    Thread thread2 = new Thread(new Runnable() {
      @Override
      public void run() {
        for (int i = 0; i < 100000; i++) {
          lock.lock();
            zero--;
          lock.unlock();
        }
      }
    });

    thread1.start();
    thread2.start();

    thread1.join();
    thread2.join();

    System.out.println("Zero: " + zero);
  }
}

Bu basit uygulamayı çalıştırdığınızda sonucun her zaman doğru değer olan sıfır’a eşit olduğunu göreceksiniz. Ancak burada hata yapmak mümkün. Herhangi bir unlock satırını silip ve programın asla bitmediğini görebilirsiniz. İşte buna deadlock denir.

Lock arayüzü birçok durumda ihtiyacımız olan çalışan thread üzerinde daha fazla kontrol sağlar. Ancak bu extra kontrole ihtiyacımız olmayan durumlarda alternatiflerimiz var. Deadlock sorundan kaçınmamızı sağlayan aşağıdaki yapı daha güvenli çalışır. En azından unlock’u unutma ihtimalimizi ortadan kaldırır.

public class Main {
  private static int zero;
  private static Object lock = new Object();

  public static void main(String[] args) throws InterruptedException {
    Thread thread1 = new Thread(new Runnable() {
      @Override
      public void run() {
        for (int i = 0; i < 100000; i++) {
          synchronized (lock) {
            zero++;
          }
        }
      }
    });
    Thread thread2 = new Thread(new Runnable() {
      @Override
      public void run() {
        for (int i = 0; i < 100000; i++) {
          synchronized (lock) {
            zero--;
          }
        }
      }
    });

    thread1.start();
    thread2.start();

    thread1.join();
    thread2.join();

    System.out.println("Zero: " + zero);
  }
}

Benzer şekilde synchronized methodlarda kullanabiliriz. Bu şekilde bir obje kitlemek yerine üzerinde bulunduğu mevcut objeyi kitlemiş oluruz. Ek bir obje gereksinimimiz ortadan kalkar. İşte örneği…

public class Main {
  private static int zero;

  public static void main(String[] args) throws InterruptedException {
    Thread thread1 = new Thread(new Runnable() {
      @Override
      public void run() {
        for (int i = 0; i < 100000; i++) {
          increase();
        }
      }
    });
    Thread thread2 = new Thread(new Runnable() {
      @Override
      public void run() {
        for (int i = 0; i < 100000; i++) {
          decrease();
        }
      }
    });

    thread1.start();
    thread2.start();

    thread1.join();
    thread2.join();

    System.out.println("Zero: " + zero);
  }

  private static synchronized void increase() {
    zero++;
  }

  private static synchronized void decrease() {
    zero--;
  }
}

Farklı durumlarda ihtiyaçlarımıza göre bu metotlardan uygulamamız için uygun olanını uygulamamızda kullanırız.

Bu yazımında sonuna geldim. Sonraki yazımda görüşmek üzere.. Hepinize keyifli kodlamalar…