개발 공부/Java & Spring

19. 쓰레드의 동기화

빵다희 2023. 3. 9. 12:04
쓰레드의 동기화
 : 하나의 쓰레드가 진행중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것.
* 사전에 알아야할 개념 
* 잠금(락,lock) : 모든 객체가 한개씩 갖고 있으며, 어떤 쓰레드가 lock을 가지면 다른 쓰레드들은 lock을 얻을 때까지 기다린다.
* 임계영역(critical section) : 공유 데이터를 사용하는 코드 영역, lock을 획득한 하나의 쓰레드가 코드를 수행할 수 있는 영역

 

synchronized를 이용한 동기화
: 두 가지 방법있는데, 공통점은 lock의 획득과 반납이 모두 자동적으로 이루어진다는 것이다. 그래서 사용자는 임계영역만 설정해주면 된다.

1. 메서드 전체를 임계영역으로 지정

: 쓰레드는 synchronized메서드가  호출된 시점부터 해당 메서드가 포함된 객체의 lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반환하다. 

public synchronized void calcSum(){
    // ... -> 임계영역(critical section)
}

2. 특정한 영역을 임계영역으로 지정 (synchronized블럭)

: 메서드 내의 코드 일부를 블럭{}으로 감싸고 블럭 앞에 'synchronized(참조변수)'를 붙이는 것인데, 참조변수는 락을 걸고자 하는 객체이다.  

이 블럭의 영역 안으로 들어가면서부터 쓰레드는 지정된 객체의 lock을 얻게되고, 이 블럭을 벗어나면 lock을 반납한다.

임계영역은 멀티쓰레드의 프로그램의 성능을 좌우하기 때문에 메서드 전체에 락을 거는 것보다 synchronized블럭으로 임계영역을 최소화하는게 보다 효율적이다.

synchronized(객체의 참조변수) {
    // ... -> 임계 영역(critical section)
}

* synchronized를 이용한 동기화의 예시

: 계좌에서 1초마다 임의의 돈을 빼가는 예시

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

class Account {
    private int balance = 1000;
    public int getBalance(){
        return balance;
    }
    public synchronized void withdraw(int money){
        if(balance >= money){
            try {
                Thread.sleep(1000);
            }catch (InterruptedException e){}
            balance -= money;
        }
    }
}

class RunnableEx22 implements Runnable{
    Account acc = new Account();
    @Override
    public void run() {
        while(acc.getBalance() > 0){
            // 100, 200, 300중의 한 값을 임의로 선택해서 출금(withdraw)
            int money = (int)(Math.random() * 3 + 1) * 100;
            acc.withdraw(money);
            System.out.println("balance:"+acc.getBalance());
        }
    }
}

* 실행결과

balance:700
balance:600
balance:400
balance:200
balance:0
balance:0

* account 클래스의 withdraw메소드에 synchronized가 없었다면 

한 쓰레드가 if문의 조건식을 통과하고 출금하기 바로 직전에 바로 다른 쓰레드가 끼어들어서 출금을 하고 

그 이후에 조건식을 통과했던 쓰레드가 다시 lock을 잡아 출금을 진행해 balance의 값이 0인데도 출금이 되어버리는, 의도와는 다른 실행결과가 나올 수 있다. 

* account 클래스의 balance의 접근제어자가 private인 이유 : 

만약 private가 아니면, 외부에서 직접 접근할 수 있기 때문에 아무리 동기화를 해도 이 값의 변경을 막을 길이 없기 때문이다. 

 

wait()과 notify()
 : synchronized로 동기화를 구현할때 특정 쓰레드가 락을 보유한 채로 오래 있어서 다른 작업들이 원할히 진행이 안될 수도 있다. 
이런 케이스를 개선하기 위해 고안된 것이 바로 wait()와 notify()이다. 
동기화 블록(synchronized블록)내에서만 사용할 수 있다. 

💡wait()

wait() 가 호출되면, 실행 중이던 쓰레드는 락을 반납하고 해당 객체의 waiting pool(대기실)에서 기다린다. 
wait(매개변수) :  매개변수가 있는 wait()은 지정된 시간동안만 기다린다. 지정된 시간이 지난 후에 자동적으로 notify()가 호출되는 것과 같다.

💡notify()

notify()가 호출되면 해당 객체의 waiting pool(대기실)에 있던 모든 쓰레드 중에서 임의의 쓰레드가 다시 락을 얻어
중단했던 작업을 진행할 수 있게된다.
notifyAll()은 기다리고 있는 모든 쓰레드에게 통보를 한다. 하지만 lock을 얻을 수 있는 것은 하나의 쓰레드이고 lock을 얻지못한 쓰레드는 다시 lock을 기다린다. 
waiting pool은 객체마다 존재하는 것이므로 notifyAll()이 호출된다고 해서 모든 객체의 waiting pool에 있는 쓰레드가 깨워지는 것은 아니다. notifyAll()이 호출된 객체의 waiting pool에 대기중인 쓰레드만 해당된다.

※ 기아 현상과 경쟁 상태 

기아(starvation)현상 : 한 쓰레드가 계속 통지를 받지 못하고 오랫동안 기다리게 되는 현상.
                                    해결하기 위해서는 notifyAll()을 사용해 모든 쓰레드에게 통지를 줘야한다.
경쟁 상태(race condition) : 여러 쓰레드가 lock을 얻기 위해 서로 경쟁하는 것.
                                             경쟁 상태를 해결하려면 특정 쓰레드를 구별하여 통지하는 것이 필요하다.

 

Lock과 Condition을 이용한 동기화
: Lock과 Condition을 이용하면 wait() & notify()로는 불가능한 선별적인 통지가 가능하다.
lock클래스를 이용하여 동기화를 할 수 있다. 
synchronized블럭과는 달리 수동으로 lock을 잠그고 해제해야한다.

* Lock클래스의 종류 

클래스 설명
ReentrantLock 가장 일반적인 배타 Lock. 
특정 조건에서 Lock을 풀고 나중에 다시 lock을 얻음으로서 임계영역으로 돌아와 이후의 작업을 수행할 수 있다.
ReentrantReadWriteLock 읽기에는 공유적이나(여러쓰레드가 읽어도 문제없음), 쓰기에는 배타적인 Lock(lock을 반납받아야 쓰기 가능).
StampedLock lock을 걸거나 해지할 때 '스탬프(long타입의 정수값)'을 사용한다.
ReentrantReadWriteLock에 낙관적인 lock의 기능을 추가하였다.
* 낙관적인 lock
무조건 읽기 lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기lock을 건다.

 

ReentrantLock 클래스의 사용

* 생성자

ReentrantLock()
ReentrantLock(boolean fair) 
// 값이 true면 가장 오래기다린 쓰레드가 lock을 가져갈 수 있게 한다.
// 일반보다 성능이 떨어져서 잘 사용되지 않는다.

* 메소드

메소드 설명
void lock() lock을 잠근다.
void unlock() lock을 해지한다.
boolean isLocked() lock이 잠겼는지 확인한다.
boolean tryLock() 호출했을때 다른 쓰레드에 의해 lock이 걸려있으면 lock을 얻으려고 기다리지 않는다.
lock을 얻으면 true를 반환하고,  얻지 못하면 false를 반환한다.
boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException 호출했을때 다른 쓰레드에 의해 lock이 걸려있으면 지정된 시간만큼만 기다린다. 
lock을 얻으면 true를 반환하고,  얻지 못하면 false를 반환한다.
lock을 얻으려고 기다리는 도중에 interrupt()에 의해 작업을 취소할 수 있도록 코드를 작성할 수 있다.

* 임계영역 설정 

// 1. synchronized 블럭
 synchronized(lock){
    // 임계영역 
}

// 2. ReentrantLock의 생성자
lock.lock();
try{
// 임계영역
} finally { // -> 예외발생이나 return문으로 빠져나가는 경우가 있기 때문에 unlock()은 try-finally문으로 감싸는 것이 일반적이다. 
    lock.unlock(); // lock푸는 걸 잊지않도록 주의.
}
Condition
: 특정 쓰레드를 위한 condition을 만들어서 각각의 waiting pool에서 기다리도록 할 수 있다.
그러므로 wait() & notify()로는 불가능했던 선별적인 통지가 가능하다.

* 사용방법 - condition은 이미 생성된 lock으로 부터 newCondition()을 호출해서 생성한다.

//lock을 생성
private ReentrantLock lock = new ReentrantLock();

// lock으로 condition을 생성
private Condition cond1 = lock.newCondition();  // 특정 쓰레드를 위해 컨디션을 생성할 수 있다.
private Condition cond2 = lock.newCondition();

* 메소드 

condition 메소드 wait() & notify() 설명
void await()
void awaitUninterruptibly()
void wait() 락을 반납하고 대기실에서 대기.
boolean await(long time, TimeUnit unit)
long awaitNanos(long nanosTimeout)
boolean awaitUntil(Date deadline)
void wait(long timeout) 락을 반납하고 지정된 시간만큼만 대기
void signal() void notify() 대기중이던 임의의 쓰레드가 락을 얻어 작업을 수행
void signalAll() void notifyAll() waiting pool에 대기하던 모든 쓰레드에게 통지. 그 중에 하나가 락을 얻어 작업을 수행
// add 메소드를 실행하는 쓰레드 cook이 있다고 가정.
// remove 메소드를 실행하는 쓰레드 cust가 있다고 가정 

//lock을 생성
private ReentrantLock lock = new ReentrantLock();

// lock으로 condition을 생성
private Condition forCook = lock.newCondition();  // cook 쓰레드를 위해 컨디션을 생성
private Condition forCust = lock.newCondition();  // cust 쓰레드를 위해 컨디션을 생성

public void add(){
   lock.lock(); // 임계영역 
    try{
      forCook.wait(); // cook쓰레드를 기다리게 한다.
      forCust.signal(); // 기다리고 있는 cust쓰레드를 깨운다.
    }finally{
      lock.unlock; // 임계영역 해제 
    }
}

public void remove(){
   lock.lock(); // 임계영역 
    try{
      forCust.wait(); // cust쓰레드를 기다리게 한다.
      forCook.signal(); // 기다리고 있는 cook쓰레드를 깨운다.
    }finally{
      lock.unlock; // 임계영역 해제 
    }
}
// 이렇게 선별적으로 쓰레드를 기다리게하고 통지하면 '기아 현상'이나 '경쟁상태'가 개선된다.

 

728x90
반응형

'개발 공부 > Java & Spring' 카테고리의 다른 글

21. fork & join 프레임워크  (0) 2023.03.09
20. volatile  (0) 2023.03.09
18. 애너테이션(annotation)  (0) 2023.02.15
17. 열거형(enums)  (0) 2023.02.12
16. 해싱과 HashMap  (0) 2023.01.18