JAVA

[ Java ] - 프로세스와 쓰레드 - 02)

algml0703 2023. 10. 17. 23:44
반응형

  쓰레드의 동기화 방법

 

1. synchronized 사용

임계 영역으로 설정하려는 부분에 synchronized를 사용하면 되며, 메서드에 적용할 수 도 있고, 특정 영역으로 축소하여 적용할 수 있습니다. 이때 임계 영역을 너무 넓게 설정하는 경우 프로그램의 성능이 저하될 수 있기 때문에 임계 영역을 최소화하는 것이 좋습니다. synchronized가 붙은 메서드가 호출된 후 해당 작업이 종료되기 이전까지 해당 임계 영역에 다른 쓰레드가 접근하는 것을 허용하지 않습니다. 위의 코드를 아래와 같이 수정할 수 있습니다.

public class ThreadEx21 {
    public static void main(String[] args){
        Runnable r = new RunnableEx21();
        new Thread(r).start();
        new Thread(r).start();

    }
}

class Account {
    private int balance = 1000;
    public int getBalance() {
        return balance;
    }
    // 메서드 앞에 synchronized를 붙여 해당 메서드를 임계 영역으로 설정 ----
    public synchronized void withdraw(int money) {
        // ...
    }
}

class RunnableEx21 implements Runnable {
    Account acc = new Account();
    public void run () {
        // ...
    }
}

 

wait()  /  notify()

임계 영역을 설정하여, 락을 걸어 특정 영역에 한 쓰레드만 접근할 수 있도록 하는 방식은 잘못하면 두 개 이상의 쓰레드가 각각 상대가 가진 자원을 기다리며 작업을 진행하지 못하고 무한한 대기 상태에 빠지는 데드락과 같은 문제를 발생시킬 수 있습니다. 때문에 이를 방지하기 위해wait()와 notify()를 활용할 수 있습니다.

가장 먼저 wait()의 경우 쓰레드가 synchronized를 통한 임계 영역의 코드를 수행하다가 작업의 계속적인 진행이 어려운 경우에 사용하여, 락을 반납하고 대기 상태로 만들어, 다른 쓰레드가 해당 임계 영역의 락을 얻어 작업을 수행할 수 있도록 합니다.wait()를 호출한 쓰레드는 waiting pool에서 해당 임계영역의 락을 다시 얻기를 기다리게 됩니다. 해당 임계 영역의 락을 얻어 작업을 수행하던 쓰레드가 작업을 완료한 경우 notify()를 호출하여 waiting pool에 작업이 완료되었음을 알리는데 이때 waiting pool에 대기 중이던 쓰레드들 중 임의의 하나의 쓰레드에만 알리기 때문에 이전의 쓰레드가 반드시 다시 락을 획득하는 것은 아닙니다. 반면에 notifyAll()의 경우 waiting pool의 모든 쓰레드에 작업이 완료되었음을 알려주기는 하나 이 때에도 대기 중인 쓰레드 전체에 통지하는 것에 불과할 뿐 락을 얻을 수 있는 쓰레드는 오직 하나의 쓰레드입니다.

* wait() notify() notifyAll()은 모두 synchronized 블록 내에서만 사용 가능합니다.

ex) 사용 예시 코드

class ExCustomer implements Runnable {
    private ExTable table;
    private String food;

    ExCustomer(ExTable table, String food) {
        this.table = table;
        this.food = food;
    }

    @Override
    public void run() {
        while(true) {
            try {
                Thread.sleep(100);
            }catch (InterruptedException e) {}
            String name = Thread.currentThread().getName();
            table.remove(food);
            System.out.println(name + " ate a " +food);
        }
    }
}

class ExCook implements Runnable {
    private ExTable table;

    ExCook(ExTable table) {
        this.table = table;
    }
    @Override
    public void run() {
        while(true) {
            int idx = (int)(Math.random() * table.dishNum());
            table.add(table.dishNames[idx]);
            try {
                Thread.sleep(10);
            }catch (InterruptedException e) {}
        }
    }
}
class ExTable {
    String[] dishNames = {"donut", "donut", "burger"};
    final int MAX_FOOD = 6;
    private ArrayList<String> dishes = new ArrayList<>();

    public synchronized void add(String dish) {
        while(dishes.size() >= MAX_FOOD){
            String name = Thread.currentThread().getName();
            System.out.println(name + "is waiting.");
            try {
                wait(); // wait()호출 해당 부분에서 쓰레드는 대기 상태가 된다. ---------------------
                Thread.sleep(500);
            }catch (InterruptedException e) {}
        }
        dishes.add(dish);
        notify(); // 작업이 완료되었음을 알림 -------------------------
        System.out.println("Dishes: " +dishes);
    }

    public void remove(String dishName) {
        synchronized (this) {
            String name = Thread.currentThread().getName();
            while(dishes.size() ==0) {
                System.out.println(name =" is waiting");
                try {
                    wait();// wait()호출 해당 부분에서 쓰레드는 대기 상태가 된다. ---------------------
                    Thread.sleep(500);
                }catch (InterruptedException e) {}
            }
            while(true) {
                for (int i=0; i< dishes.size(); i++) {
                    if(dishName.equals(dishes.get(i))) {
                        dishes.remove(i);
                        notify();// 작업이 완료되었음을 알림 -------------------------
                        return;
                    }
                }

                try {
                    System.out.println(name + " is waiting");
                    wait();// wait()호출 해당 부분에서 쓰레드는 대기 상태가 된다. ---------------------
                    Thread.sleep(500);
                }catch (InterruptedException e) {}
            }
        }
    }
    public int dishNum(){return dishNames.length;}
}

public class ThreadWaitEx3 {
    public static void main(String[] args) throws Exception{
        ExTable table = new ExTable();
        new Thread(new ExCook(table), "COOK1").start();
        new Thread(new ExCustomer(table, "donut"), "CUST1").start();
        new Thread(new ExCustomer(table, "burger"), "CUST2").start();
        Thread.sleep(2000);
        System.exit(0);
    }
}

 

2. Lock 클래스 활용

* Lock 클래스 종류

ReentrantLock
ReentrantReadWriteLock
StampedLock

ex) StampedLock

StampedLock의 경우 java 8에서 새롭게 도입된 것으로 lock을 획득하거나 해제할 때 스탬프(long 타입의 정수값)를 사용합니다. StampedLock은 기본적으로 쓰기락, 읽기락, 낙관적읽기가 존재합니다. 읽기락의 경우 여러 쓰레드가 동시에 작업할 수 있는 반면, 쓰기 락의 경우 쓰기락을 중복적으로 획득하거나, 쓰기락과 읽기락은 함께 작업할 수 없습니다. 즉 읽기락이 작업 중인 경우 쓰기락은 읽기락의 작업이 완료될 때까지 대기 상태가 됩니다. 반면 낙관적 읽기가 작업중인 상황에서 쓰기락이 작업을 수행하는 경우 낙관적 읽기 락은 유효성 검사를 통해 데이터의 변경 여부를 확인하고, 데이터가 변경된 경우 락이 해제되고, 다시 읽기 락을 획득하여 작업을 새롭게 수행하게 됩니다. 낙관적읽기에서 유효성 여부를 검사하기 위해 사용하는 것이 바로 validate()입니다. 아래는 예시 코드입니다.

public class ExStampedLock {
    private double x = 0.0;
    private double y = 0.0;

    private final StampedLock lock = new StampedLock();

    public void writeCoordinate(double newX, double newY) {
        long stamp = lock.writeLock(); // 쓰기 락 획득
        try {
            x = newX;
            y = newY;
        } finally {
            lock.unlockWrite(stamp); // 쓰기 락 해제
        }
    }

    public double readDistanceFromOrigin() {
        long stamp = lock.tryOptimisticRead(); // 낙관적 읽기 시도
        double currentX = x;
        double currentY = y;

        if (!lock.validate(stamp)) { // 낙관적 읽기 유효성 검사
            // -> 유효하지 않은 경우 다시 읽기 락 획득
            stamp = lock.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                 lock.unlockRead(stamp); // 읽기 락 해제
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }

    public static void main(String[] args) {
        ExStampedLock example = new ExStampedLock();
        example.writeCoordinate(1.0, 2.0);
        double distance = example.readDistanceFromOrigin();
        System.out.println("Distance from origin: " + distance);
    }
}

ReentrantLock과 ReentrantReadWriteLock은 기본적인 사용법이 유사합니다. ReentrantLock을 기준으로 설명하면, 임계영역을 lock()과 unlock()으로 설정하며, isLocked()를 통해 락이 잠겼는지 확인합니다. lock을 설정하고 해제하지 않는 경우 다른 쓰레드에서 해당 자원에 접근할 수 없게 되고, 리소스 누수 문제 등이 발생할 수 있기 때문에 try{}finally{} 로 하여 반드시 락설정후 해제하는 부분을 설정해주어야 합니다.

    private ReentrantLock lock;
    public void ex() {
        lock.lock();
        try {
            // 임계 영역
        }finally {
            lock.unlock();
        }
    }

 

*ReentrantLock 공식문서   https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/locks/ReentrantLock.html

*ReentrantReadWriteLock 공식문서   https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/locks/ReentrantReadWriteLock.html

 

출처

책 : 자바의 정석

https://github.com/castello/javajungsuk3

반응형