생산자 소비자 문제 (Producer-Consumer Problem)
- 생산자 소비자 문제는 멀티 스레드 프로그래밍에서 자주 등장하는 동시성 문제로 동시에 데이터를 생산하고 소비하는 상황을 다룬다.
- 생산자 스레드와 소비자 스레드가 특정 자원을 함께 생산하고 소비하면서 발생하는 문제이다.
- 중간 버퍼의 크기가 한정되어 있기에 발생하므로 한정된 버퍼 문제(Bounded-Buffer Problem)이라고도 불린다.
- 기본 개념
- 생산자: 데이터를 생산하는 역할로 파일에서 데이터를 읽어오거나 네트워크에서 데이터를 받아오는 스레드가 생산자 역할을 할 수 있다.
- 소비자: 생성된 데이터를 사용하는 역할로 데이터를 처리하거나 저장하는 스레드가 소비자 역할을 할 수 있다.
- 버퍼: 생산자가 생성한 데이터를 일시적으로 저장하는 공간으로 한정된 크기를 가지며 생산자와 소비자가 버퍼를 통해 데이터를 주고 받는다.
- 문제 상황
- 생산자가 너무 빠를 때 - 버퍼가 가득차서 더이상 데이터를 넣을 수 없을 때까지 생산자가 데이터를 생성해 버퍼에 빈 공간이 생길 때까지 기다려야 한다.
- 소비자가 너무 빠를 때 - 버퍼에 더이상 소비할 데이터가 없을 때까지 소비자가 데이터를 처리해 버퍼에 새로운 데이터가 들어올 때까지 기다려야한다.
- 생산자 소비자 문제에서는 버퍼가 가득찬 상태에서 생산하는 경우와 버퍼가 비어있는 상태에서 소비하는 경우에 문제가 발생한다.
- 해결 방안은 버퍼가 가득 찼다면 생산자는 버퍼에 공간이 생길 때까지 기다렸다가 생산을 하면 되고 버퍼가 비었다면 소비자는 버퍼가 채워질 때까지 기다렸다가 소비를 하면 된다.
- 임계 영역 안에서 락을 가지고 대기하면서 문제가 발생한다.
- 생산 메소드와 소비 메소드가 동일한 락을 사용하면 두 메소드는 동시에 실행될 수 없다.
동시에 실행되지 않으면 버퍼가 가득차서 생산을 기다리는 경우나, 버퍼가 비어서 소비를 기다리는 경우에 한 쪽에서 락을 반납하지 않고 계속 가지고 있기에 나머지 스레드는 임계 영역 밖에서 계속 락을 기다리는 무한 대기 상황이 발생한다. - 락을 가지고 대기하는 스레드는 아무일도 하지 않기 때문에 다른 스레드에게 락을 양보함으로 무한 대기 문제를 해결할 수 있다.
- 생산 메소드와 소비 메소드가 동일한 락을 사용하면 두 메소드는 동시에 실행될 수 없다.
Object.wait() / Object.notify()
- Object.wait() : 현재 스레드가 가진 락을 반납하고 대기한다. WAITING 상태
- 현재 스레드가 synchronized 메소드 / 블록에서 락을 소유하고 있을 때만 호출할 수 있다.
호출한 스레드는 락을 반납하고 다른 스레드가 락을 획득할 수 있도록 한다. - 다른 스레드가 notify(), notifyAll()을 호출할 때까지 WAITING 상태를 유지한다.
- wait() 호출 시 해당 스레드는 RUNNABLE 상태 -> WAITING 상태가 된다.
- 현재 스레드가 synchronized 메소드 / 블록에서 락을 소유하고 있을 때만 호출할 수 있다.
- Object.notify() : 대기 중인 스레드 하나를 깨운다.
- synchronized 메소드 / 블록에서 호출되어야 하며 notify()로 깨운 쓰레드는 다시 락을 획득할 기회를 얻는다.
- 여러 스레드가 WAITING 상태라면 그 중 하나만 깨워지고 어떤 스레드가 깨워질지는 모른다.
- notify() 호출 시 WAITING 상태인 스레드 중 하나가 BLOCKED 상태가 된다.
- notify()로 깨어나면 해당 스레드는 여전히 임계 영역안에 있고 임계 영역안에서는 락이 필요하기에 락을 기다리는 BLOCKED 상태가 되며 이후 락을 얻어서 RUNNABLE 상태가 되면 wait() 이후의 코드부터 실행된다.
- Object.notifyAll() : 대기 중인 모든 스레드를 깨운다.
- synchronized 메소드 / 블록에서 호출되어야 하며 WAITING 상태의 모든 스레드가 락을 획득할 수 있는 기회를 얻는다.
- wait()에 의해 대기 상태에 빠진 스레드는 notify()를 사용해야 깨울 수 있다.
- 스레드 대기 집합(wait set)
- 대기 집합 : synchronized 임계 영역 안에서 wait()을 호출해서 대기 상태에 들어간 스레드를 관리하는 것
- 모든 객체는 각자의 대기 집합과 모니터 락을 가지고 있다.
- 락과 대기 집합은 한 쌍으로 사용되며 락을 획득한 객체의 대기 집합을 사용해야한다.
- 대기 집합 : synchronized 임계 영역 안에서 wait()을 호출해서 대기 상태에 들어간 스레드를 관리하는 것
- notify()는 대기 중인 스레드를 깨울 때 선택할 수 없고 임의의 스레드가 선택된다.
- notify()는 대상 스레드를 지정할 수 없다는 단점을 통해 생산자 소비자 문제에서 버퍼가 꽉찬 상황에서 생산자가 소비자를 깨우는 것이 아닌 또 다른 생산자를 깨워서 CPU만 낭비시키고 다시 대기 상태로 돌아갈 수도 있고 버퍼가 빈 상황에서 소비자가 생산자가 아닌 소비자를 깨워서 다시 대기 상태로 돌아갈 수 도 있다.
- 좌측 그림 예시와 같이 버퍼가 꽉찬 상황에서 생산자가 소비자를 깨워야 버퍼 내에 데이터를 소비하고 깨운 생산자가 다시 버퍼 안에 데이터를 생산할 수 있다.
- 우측 그림 예시와 같이 버퍼가 꽉찬 상황에서 생산자가 생산자를 깨우면 버퍼에는 데이터가 꽉 차 있기에 생산자는 다시 대기 상태에 들어가게 되는 비효율적인 상황이 발생하게 된다.
- notify(), wait()의 한계
- 같은 종류의 스레드를 깨울 때 비효율이 발생한다.
- 스레드 기아 문제가 발생한다.
- 어떤 스레드가 깨어날 지 알 수 없기 때문에 특정 스레드는 실행 기회를 얻지 못하는 최악의 상황이 발생할 수 있다.
- 스레드 기아 문제를 해결하기 위한 방안으로는 notifyAll()을 호출해서 다 BLOCKED 상태로 만들어서 같은 종류의 스레드는 다시 대기 집합으로 들어가게 하고 다른 종류의 스레드는 실행할 수 있도록 하는 것이다.
- notifyAll()을 사용하더라도 비효율은 막지 못한다.
나는 notify() 한계점의 해결 방안으로 모니터 락과 대기 집합은 객체마다 가지고 있다고 했으니 소비자와 생산자를 서로 다른 객체를 통해 각자의 대기 집합으로 관리하며 나중에 서로 필요한 대기 집합에서 대기 중인 스레드를 깨우면 좋지않을까? 라는 생각이 들었다.
웬일로 내 생각과 유사했다.. 실제 해결방안도 ReentrantLock 을 사용해 대기 집합을 따로 관리하는 방법이다.
앞으로도 강의 듣고 따라하는 것에만 국한되지 말고 스스로 생각하고 고민하는 시간도 충분히 가지면 좋을 것 같다.
Lock Condition
- Lock 인터페이스의 구현체인 ReentrantLock에서도 wait(), notify() 기능을 수행하도록 할 수 있다.
- Condition : ReentrantLock을 사용하는 스레드가 대기하는 대기 집합의 역할을 한다.
- await() : Object.wait()과 기능이 유사하며 지정한 condition에 현재 스레드를 대기 상태로 보관하는데 ReentrantLock에서 획득한 락을 반납하고 대기 상태로 보관된다.
- signal() : Object.notify()와 기능이 유사하고 지정한 condition에서 대기 중인 스레드 하나를 깨우며 해당 스레드는 condition에서 빠져나온다.
private final Lock lock = new ReentrantLock();
private final Condition producerCond = lock.newCondition();
private final Condition consumerCond = lock.newCondition();
- lock 하나에 여러 대기 집합 (condition)을 만들 수 있다.
- 두 대기 집합 모두 같은 lock을 사용하기에 synchronized 때와 마찬가지로 동시에 임계 영역에 진입할 수 없다.
- 각각의 대기 집합을 만듦으로 서로 다른 종류의 원하는 스레드를 깨울 수 있게 되었다.
- Object.notify() vs Condition.signal()
- Object.notify()
- 대기 중인 스레드 중 임의의 스레드 하나를 깨우기에 깨어나는 순서는 정의되어 있지 않다.
- synchronized 블록 내에서 모니터 락을 가지고 있는 스레드가 호출해야 한다.
- Condition.signal()
- 대기 중인 스레드 중 하나를 깨우며 Queue 구조이기에 보통 FIFO 순서로 깨운다.
- ReentrantLock을 가지고 있는 스레드가 호출해야 한다.
- Object.notify()
스레드 대기 상태
- synchronized의 대기 상태
- 락 획득 대기
- BLOCKED 상태로 대기
- synchronized를 시작할 때 락이 없으면 대기
- 다른 스레드가 synchronized를 빠져나갈 때 대기가 풀리며 락 획득을 시도
- wait() 대기
- WAITING 상태로 대기
- wait()을 호출했을 때 스레드 대기 집합에서 대기
- 다른 스레드가 notify()를 호출했을 때 대기가 풀리며 락 획득을 시도한다.
- 락 획득 대기
- BLOCKED 상태로 대기하는 스레드들 락 대기 집합에서 관리된다.
- 스레드 대기 집합에서 WAITING 상태로 대기하다가 notify()로 깨어나더라도 락 대기 집합에서 BLOCKED 상태로 락 획득을 기다려야하는 2중 감옥과 같은 구조
- synchronized를 사용할 때 자바의 모든 객체는 멀티스레드와 임계 영역을 다루기 위해 내부에 3가지 요소가 존재한다.
- 모니터 락
- 락 대기 집합 (모니터 락 대기 집합, BLOCKED 상태의 스레드를 관리하는 집합)
- 스레드 대기 집합 (WAITING 상태의 스레드를 관리하는 집합)
- ReentrantLock 대기 상태
- ReentrantLock 락 획득 대기
- ReentrantLock의 대기 큐에서 관리
- WAITING 상태로 락 획득 대기
- lock.lock()을 호출했을 때 락이 없으면 대기
- 다른 스레드가 lock.unlock()을 호출했을 때 대기가 풀리며 락 획득을 시도하며 락 획득 시 대기 큐를 빠져나간다.
- await() 대기
- condition.await()을 호출했을 때 condition 객체의 스레드 대기 공간에서 관리
- WAITING 상태로 대기
- 다른 스레드가 condition.signal()을 호출했을 때 condition 객체의 스레드 대기 공간에서 빠져나간다.
- ReentrantLock 락 획득 대기
- synchronized 대기 / ReentrantLock 대기의 차이점
- ReentrantLock은 lock이나 condition을 직접 생성해야하지만 스레드 대기 집합을 분리할 수 있다.
- synchronized는 락 대기 중에 BLOCKED 상태지만 ReentrantLock은 락 대기 중에 WAITING 상태이다.
BlockingQueue
- BlockingQueue : 자바에서 생산자 소비자 문제를 해결하기 위해 java.util.concurrent.BlockingQueue라는 인터페이스와 구현체들을 제공한다.
- BlockingQueue는 스레드를 차단할 수 있는 큐로 특별한 멀티스레드 자료 구조를 제공한다.
- 데이터 추가 차단 - 큐가 가득차면 데이터 추가 작업 put()을 시도하는 스레드는 공간이 생길 때까지 차단된다.
- 데이터 획득 차단 - 큐가 비어있으면 획득 작업 take()를 시도하는 스레드는 큐에 데이터가 들어올 때까지 차단된다.
- BlockingQueue 구현체
- ArrayBlockingQueue : 배열 기반으로 구현되어 있으며 버퍼의 크기가 고정되어 있다.
- LinkedBlockingQueue : 링크 기반으로 구현되어 있으며 버퍼의 크기를 고정할 수도, 무한하게 사용할 수도 있다.
- BlockingQueue의 기능
Operation | Throws Exception | Special Value | Blocks | Times Out |
Insert (추가) | add(e) | offer(e) | put(e) | offer(e, time, unit) |
Remove (제거) | remove() | poll() | take() | poll(time, unit) |
Examine (조회) | element() | peek() | x | x |
- 큐가 가득 찼을 경우 예외를 던지는 메소드 (Throws Exception), 대기하지 않고 즉시 값을 반환하는 메소드 (Special Value), 대기하는 메소드 (Blocks), 특정 시간만큼 대기하는 메소드(Times Out)를 제공한다.
- BlockingQueue의 모든 대기, 시간 대기 메소드는 인터럽트를 제공한다.
- 내부에 생산자 스레드 대기 집합과 소비자 스레드 대기 집합이 나눠져 있다.
- ReentrantLock으로 구현되어 있다.
출처 : [인프런 김영한 실전 자바 - 고급편]
김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런
김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?
www.inflearn.com
'Java > [인프런 김영한 실전 자바 - 고급편]' 카테고리의 다른 글
[인프런 김영한 실전 자바 - 고급편] 동시성 컬렉션 (1) | 2024.08.07 |
---|---|
[인프런 김영한 실전 자바 - 고급편] CAS - 동기화와 원자적 연산 (0) | 2024.08.06 |
[인프런 김영한 실전 자바 - 고급편] concurrent.Lock (0) | 2024.08.02 |
[인프런 김영한 실전 자바 - 고급편] 동기화 - synchronized (0) | 2024.08.02 |
[인프런 김영한 실전 자바 - 고급편] 메모리 가시성 (0) | 2024.08.02 |