[자바의 정석] 13. 쓰레드 - synchronized, wait와 notify

jyleever·2023년 2월 5일
0

자바의 정석

목록 보기
4/12
post-thumbnail

임계 영역과 락

멀티 쓰레드 프로세스의 경우 여러 쓰레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게 된다.
따라서 한 쓰레드가 특정 작업을 끝마치기 전까지 다른 쓰레드에 방해받지 않도록 하는 것이 필요하다.
그래서 도입된 개념이 임계영역(critical section)잠금(lock)이다.

  1. 공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정
  2. 공유 데이터가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있도록 함
  3. 그리고 해당 쓰레드가 임계 영역 내의 모든 코드를 수행하고 벗어나서 lock을 반납해야만
  4. 다른 쓰레드가 반납한 lock을 획득하여 임계 영역의 코드를 수행할 수 있게 한다.

이처럼 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못 하도록 막는 것을 쓰레드의 동기화(synchronization) 이라고 한다.

자바에서는 다음과 같이 쓰레드의 동기화를 지원한다.

  • synchronized 블럭
  • java.util.concurrent.locks
  • java.util.concurrent.atomic

synchronized를 이용한 동기화

이 키워드는 임계 영역을 설정하는 데 사용된다.

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

public synchronized void calcSum(){
	//..
}

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

2. 특정한 영역을 임계 영역으로 지정

synchronized(객체의 참조변수){
	//..
}

메서드 내의 코드 일부를 블럭으로 감싸고, 블럭 앞에 synchronized(참조변수)를 붙이는 것인데
이 때 참조변수는 락을 걸고자 하는 객체를 참조하는 것이어야 한다.
이 블럭을 synchronized블럭이라고 부르며, 이 블럭의 영역 안으로 들어가면서부터 쓰레드는 지정된 객체의 lock을 얻게 되고, 이 블럭을 벗어나면 lock을 반납한다.

모든 객체는 lock을 하나씩 가지고 있으며 해당 객체의 lock을 가지고 있는 쓰레드만 임계 영역의 코드를 수행할 수 있다.
임계 영역은 멀티 쓰레드 프로그램의 성능을 좌우하기 때문에, 가능하면 메서드 전체에 락을 거는 것보다 synchronized블럭으로 임계 영역을 최소화해서 보다 효율적인 프로그램이 되도록 노력해야 한다.

한 쓰레드에 의해서 먼저 synchronized메서드/블럭이 호출되면, 이 메서드/블럭이 종료되어 lock이 반남될 때까지 다른 쓰레드는 특정 블럭을 호출하더라도 대기 상태에 머물게 된다.

  • 예제
class Account{
	private int balance = 1000; // private으로 해야 동기화가 의미가 있다.
    ...
	public synchronized void withdraw(int money){
		if(balance >= money){
    		try{ Thread.sleep(1000); } catch(InterruptException e) {}
        	balance -= money;
    	}
	} // withdraw
}

여기서 주의할 점은 동기화 시 사용하는 변수를 private으로 선언했다는 점.
private이 아닌 변수는 외부에서 직접 접근할 수 있기 때문에 아무리 동기화를 해도 이 값의 변경을 막을 길이 없다. synchronized를 이용한 동기화는 지정된 영역의 코드를 한 번에 하나의 쓰레드가 수행하는 것을 보장하는 것일 뿐이기 떄문이다.

wait()와 notify()

특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 하는 것도 중요하다. 다른 쓰레드들이 해당 객체의 락을 기다리느라 다른 작업들도 원활히 진행되지 않는 문제가 발생할 수 있기 때문.
이러한 상황을 개선하기 위해 고안된 것이 바로 wait()notify()

1, 동기화된 임계 영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면
2. 일단 wait() 호출하여 쓰레드가 락을 반납하고 기다리게 함
3. 그러면 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행
4. 나중에 작업을 진행할 수 있는 상황이 되면 notify() 호출
5. 작업을 중단했던 쓰레드가 다시 락을 얻어 작업 진행

  • 오래 기다린 쓰레드가 락을 얻는다는 보장이 없다.
  • wait()가 호출되면, 실행 중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다린다.
  • notify()가 호출되면, 해당 객체의 대기실에 있던 모든 쓰레드 중에 임의의 쓰레드만 통지를 받는다.
  • notifyAll()은 기다리고 있는 모든 쓰레드에게 통보를 하지만, 그래도 lock을 얻을 수 있는 것은 하나의 쓰레드다. 따라서 나머지 쓰레드는 통보를 받긴 했지만 lock을 얻지 못 하면 다시 lock을 기다리는 신세가 된다.
    이 때 waiting pool은 객체마다 존재하는 것이므로, notifyAll()이 호출된다고 해서 모든 객체의 waiting pool에 있는 쓰레드가 깨워지는 것은 아니다. notifyAll()이 호출된 객체의 wating pool에 대기 중인 쓰레드만 해당된다는 것을 기억하자.

wait()와 notify()는 특정 객체에 대한 것이므로 Object클래스에 정의되어있다.

void wait()
void wait(long timeout)
void wait(long timeout, int nanos)
void notify()
void notifyAll()

synchronized, wait & notify 예시

p.772 ~
p. 772 코드

  • 발생할 수 있는 예외
    ConcurrentModificationException
    한 쓰레드가 객체에 접근했을 때 다른 쓰레드에서도 접근했으므로 발생하는 예외
    IndexOutOfBoundsException
    한 쓰레드가 객체를 제거하려고 할 때 다른 쓰레드에서 먼저 접근해서 제거해버려 발생하는 예외
    IndexOutOfBoundsException

synchronized

이런 예외들이 발생하는 이유는 여러 쓰레드가 한 객체를 공유하는데도 동기화를 하지 않았기 때문 -> 동기화가 필요한 부분에 synchronized를 걸어준다

하지만 해당 부분에 synchronized 를 걸어주어도 원하는 대로 동작하지 않을 수 있다.

예를 들어, 손님 쓰레드가 eat 해야 하는 상황에서 table이 비어있을 때,
요리사 쓰레드가 table 객체를 add 해주어야 손님 쓰레드가 eat할 수 있는 상황이지만,
현재 table 객체에 대한 lock을 손님 쓰레드가 갖고 있으므로,
요리사 쓰레드가 table 객체에 접근할 수가 없기 때문에
손님 쓰레드는 한없이 기다리고 있는 상황인 것이다.

wait & notify

이렇게 한 쓰레드가 객체의 lock을 쥐고 한없이 기다리는 상황이 발생하면 다른 쓰레드가 그 문제를 해결하기 위해 객체에 접근하려고 해도 객체의 lock을 얻을 수 없어서 불가능한 문제가 발생한다.
이럴 때 사용하는 것이 바로 wait() & notify()

손님 쓰레드가 lock을 쥐고 기다리는 게 아니라,
wait()로 lock을 풀고 기다리다가,
음식이 추가되면 notify()로 통보를 받고
다시 lock을 얻어서 나머지 작업을 진행하게 할 수 있다.

그런데 여기에도 문제가 있다.
table 객체의 waiting pool에 요리사 쓰레드와 손님 쓰레드가 같이 기다린다는 것
그래서 notify가 호출되었을 때, 요리사 쓰레드와 손님 쓰레드 중에서 누가 통지받을지 알 수 없다.
notify()는 그저 waiting pool에서 대기 중인 쓰레드 중에서 하나를 임의로 선택하여 통지할 뿐, 특정 쓰레드를 선택해서 통지하지 않기 때문!

wait & notify 문제점 - 기아 현상과 경쟁 상태

(1) 기아 현상
이렇게 통지 받아야 되는 쓰레드가 계속 통지 받지 못 하고 오랫동안 기다리게 되는 현상을 기아(startvation) 현상 이라고 한다.
이 현상을 막으려면 notify() 대신 notifyAll()을 사용해야 한다.
일단 모든 쓰레드에 통지를 하면, 손님 쓰레드는 다시 waiting pool에 들어가더라도 요리사 쓰레드는 결국 lock을 얻어서 작업을 진행할 수 있기 때문

하지만 또 생각해야할 것이 있다.
notifyAll()로 요리사 쓰레드의 기아 현상은 막았지만, 손님 쓰레드까지 통지를 받아서 불필요하게 요리사 쓰레드와 lock을 얻기 위해 경쟁하게 된다.

(2) 경쟁 상태
이처럼 여러 쓰레드가 lock을 얻기 위해 서로 경쟁하는 것을 경쟁 상태(race condition)이라고 한다.
그리고 이 경쟁 상태를 개선하기 위해서는 쓰레드를 구별해서 통지하는 것이 필요한데, 이를 위해 Lock과 Condition을 이용하게 된다.

0개의 댓글