쌩로그

[JAVA] 김영한의 실전 자바 고급 1편 - Se09. 생산자 소비자 문제1 본문

Language/JAVA

[JAVA] 김영한의 실전 자바 고급 1편 - Se09. 생산자 소비자 문제1

.쌩수. 2025. 3. 30. 15:46
반응형

목차

  1. 포스팅 개요
  2. 본론
     2-1. 생산자 소비자 문제 - 소개
     2-2. 생산자 소비자 문제 - 예제1 코드
     2-3. 생산자 소비자 문제 - 예제1 분석 - 생산자 우선
     2-4. 생산자 소비자 문제 - 예제1 분석 - 소비자 우선
     2-5. 생산자 소비자 문제 - 예제2 코드
     2-6. 생산자 소비자 문제 - 예제2 분석
     2-7. Object - wait, notify - 예제 3 코드
     2-8. Object - wait, notify - 예제 3 분석 - 생산자 우선
     2-9. Object - wait, notify - 예제 3 분석 - 소비자 우선
     2-10. Object - wait, notify - 한계
  3. 요약

1. 포스팅 개요

해당 포스팅은 김영한의 실전 자바 고급 1편 Section 9의 생산자 소비자 문제1 에 대한 학습 내용이다.

학습 레포 URL : https://github.com/SsangSoo/inflearn-holyeye-java-adv1 (해당 레포는 완강시 public으로 전환 예정이다.)

2. 본론

2-1. 생산자 소비자 문제 - 소개

생산자 소비자 문제는 멀티스레드 프로그래밍에서 자주 등장하는 동시성 문제 중 하나로, 여러 스레드가 동시에 데이터를 생산하고 소비하는 상황을 다룬다.

멀티스레드의 핵심을 제대로 이해하려면 반드시 생산자 소비자 문제를 이해하고, 올바른 해결 방안도 함께 알아두어야 한다.
생산자 소비자 문제를 제대로 이해하면 멀티스레드를 제대로 이해했다고 볼 수 있다.
그 만큼 중요한 내용이다.

프린터 예제 - 그림

기본 개념

  • 생산자(Producer): 데이터를 생성하는 역할을 한다. 예를 들어, 파일에서 데이터를 읽어오거나 네트워크에서 데 이터를 받아오는 스레드가 생산자 역할을 할 수 있다.
    • 앞서 프린터 예제에서 사용자의 입력을 프린터 큐에 전달하는 스레드가 생산자의 역할이다.
  • 소비자(Consumer): 생성된 데이터를 사용하는 역할을 한다. 예를 들어, 데이터를 처리하거나 저장하는 스레드가 소비자 역할을 할 수 있다.
    • 앞서 프린터 예제에서 프린터 큐에 전달된 데이터를 받아서 출력하는 스레드가 소비자 역할이다.
  • 버퍼(Buffer): 생산자가 생성한 데이터를 일시적으로 저장하는 공간이다. 이 버퍼는 한정된 크기를 가지며, 생산자와 소비자가 이 버퍼를 통해 데이터를 주고받는다.
    • 앞서 프린터 예제에서 프린터 큐가 버퍼 역할이다.

문제 상황

  • 생산자가 너무 빠를 때: 버퍼가 가득 차서 더 이상 데이터를 넣을 수 없을 때까지 생산자가 데이터를 생성한다. 버퍼가 가득 찬 경우 생산자는 버퍼에 빈 공간이 생길 때까지 기다려야 한다.
  • 소비자가 너무 빠를 때: 버퍼가 비어서 더 이상 소비할 데이터가 없을 때까지 소비자가 데이터를 처리한다. 버퍼가 비어있을 때 소비자는 버퍼에 새로운 데이터가 들어올 때까지 기다려야 한다.

생산자 소비자 문제 - 비유

비유 1: 레스토랑 주방과 손님

  • 생산자(Producer): 주방 요리사
  • 소비자(Consumer): 레스토랑 손님
  • 버퍼(Buffer): 준비된 음식이 놓이는 서빙 카운터
    • 요리사(생산자)는 음식을 준비하고 서빙 카운터(버퍼)에 놓는다.
    • 손님(소비자)은 서빙 카운터에서 음식을 가져가서 먹는다.
    • 만약 서빙 카운터가 가득 차면 요리사는 새로운 음식을 준비하기 전에 공간이 생길 때까지 기다려야 한다.
    • 반대로, 서빙 카운터가 비어 있으면 손님은 새로운 음식이 준비될 때까지 기다려야 한다.

비유 2: 음료 공장과 상점

  • 생산자(Producer): 음료 공장
  • 소비자(Consumer): 상점
  • 버퍼(Buffer): 창고
    • 음료 공장(생산자)은 음료를 생산하고 창고(버퍼)에 보관한다.
    • 상점(소비자)은 창고에서 음료를 가져와 판매한다.
    • 만약 창고가 가득 차면 공장은 새로운 음료를 생산하기 전에 공간이 생길 때까지 기다려야 한다.
    • 반대로, 창고가 비어 있으면 상점은 새로운 음료가 창고에 들어올 때까지 기다려야 한다.

이 문제는 다음 두 용어로 불린다. 참고로 둘다 같은 뜻이다.

  • 생산자 소비자 문제(producer-consumer problem): 생산자 소비자 문제는, 생산자 스레드와 소비자 스레드가 특정 자원을 함께 생산하고, 소비하면서 발생하는 문제이다.
  • 한정된 버퍼 문제(bounded-buffer problem): 이 문제는 결국 중간에 있는 버퍼의 크기가 한정되어 있기 때문에 발생한다. 따라서 한정된 버퍼 문제라고도 한다.

예제를 통해서 생산자 소비자 문제가 왜 발생하는지, 그리고 어떤 해결 방안들이 있는지 예제 코드를 통해서 알아보자.
쉬운 문제가 아니므로 최대한 점진적으로 천천히 단계적으로 알아보자.

2-2. 생산자 소비자 문제 - 예제1 코드

생산자 소비자 문제를 이해하기 위한 예제를 만들어보자.

package thread.bounded;  

public interface BoundedQueue {  

    void put(String data);  

    String take();  
}
  • BoundedQueue : 버퍼 역할을 하는 큐의 인터페이스이다.
  • put(data) : 버퍼에 데이터를 보관한다. (생산자 스레드가 호출하고, 데이터를 생산한다.)
  • take() : 버퍼에 보관된 값을 가져간다. (소비자 스레드가 호출하고, 데이터를 소비한다.)
package thread.bounded;  

import java.util.ArrayDeque;  
import java.util.Queue;  

import static util.MyLogger.log;  

public class BoundedQueueV1 implements BoundedQueue {  

    private final Queue<String> queue = new ArrayDeque<>();  
    private final int max;  

    public BoundedQueueV1(final int max) {  
        this.max = max;  
    }  

    @Override  
    public synchronized void put(final String data) {  
        if(queue.size() == max) {  
            log("[put] 큐가 가득 참, 버림 : " + data);  
            return;        }  
        queue.offer(data);  
    }  

    @Override  
    public synchronized String take() {  
        if(queue.isEmpty()) {  
            return null;  
        }  

        return queue.poll();  
    }  

    @Override  
    public String toString() {  
        return queue.toString();  
    }  
}
  • BoundedQueueV1 : 한정된 버퍼 역할을 하는 가장 단순한 구현체이다. 이후에 버전이 점점 올라가면서 코드를 개선한다.
  • Queue , ArrayDeque : 데이터를 중간에 보관하는 버퍼로 큐( Queue )를 사용한다. 구현체로는 ArrayDeque 를 사용한다.
  • int max : 한정된(Bounded) 버퍼이므로, 버퍼에 저장할 수 있는 최대 크기를 지정한다.
  • put() : 큐에 데이터를 저장한다. 큐가 가득 찬 경우 더는 데이터를 보관할 수 없으므로 데이터를 버린다.
  • take() : 큐의 데이터를 가져간다. 큐에 데이터가 없는 경우 null 을 반환한다.
  • toString() : 버퍼 역할을 하는 queue 정보를 출력한다.

해결된 의문점
synchronized 로 선언된 두 개의 메서드가 있다.
예를 들어, A, B가 있다고 했을 때 "하나의 쓰레드가 A를 호출하면, A외엔 다른 쓰레드는 A에 접근할 수 없지만, B 메서드에는 접근가능하고, B 메서드에 접근한 쓰레드 외에는 B 메서드에 접근할 수 없겠네?" 라고 생각했다.

"그러면 만약에 A 에서 어떤 인스턴스 변수를 수정할 때 B 메서드에서 그 인스턴스 변수를 읽어들이게 되어도 동시성 문제가 발생하지 않을까..??" 하고 나중에 알아봐야겠다고 했는데,

synchronized 가 붙으면 접근하는 쓰레드가 해당 인스턴스의 락을 얻어야 실행한다.
그리고 인스턴스마다 하나의 락을 가진다.

즉, 정리하자면, synchronized 로 선언된 메서드가 여러 개 있다 할지라도 결국은 인스턴스한테 락은 하나이기 때문에 하나의 쓰레드만 해당 인스턴스에 접근이 가능하고, 락을 반납해야 다른 쓰레드가 다른 synchronized 메서드에 접근할 수 있다.

주의!
원칙적으로 toString() 에도 synchronized 를 적용해야 한다. 그래야 toString() 을 통한 조회 시점에도 정확한 데이터를 조회할 수 있다.
하지만 이 부분이 이번 설명의 핵심이 아니고, 또 예제 코드를 단순하게 유지하기 위해 여기서는 toString()synchronized 를 사용하지 않는다.

임계 영역
여기서 핵심 공유 자원은 바로 queue(ArrayDeque) 이다.
여러 스레드가 접근할 예정이므로 synchronized 를 사용해서 한 번에 하나의 스레드만 put() 또는 take() 를 실행할 수 있도록 안전한 임계 영역을 만든다.

  • 예) put(data) 을 호출할 때 queue.size()max 가 아니어서, queue.offer() 를 호출하려고 한다. 그런데 호출하기 직전에 다른 스레드에서 queue 에 데이터를 저장해서 queue.size()max 로 변할 수 있다!
package thread.bounded;  

import static util.MyLogger.log;  

public class ProducerTask implements Runnable {  

    private BoundedQueue queue;  
    private String request;  

    public ProducerTask(BoundedQueue queue, String request) {  
        this.queue = queue;  
        this.request = request;  
    }  

    @Override  
    public void run() {  
        log("[생산 시도] " + request + " -> " + queue);  
        queue.put(request);  
        log("[생산 완료] " + request + " -> " + queue);  
    }  
}
  • ProducerTask : 데이터를 생성하는 생성자 스레드가 실행하는 클래스, Runnable 을 구현한다.
  • 스레드를 실행하면, queue.put(request) 을 호출해서 전달된 데이터( request )를 큐에 보관한다.
package thread.bounded;  

import static util.MyLogger.log;  

public class ConsumerTask implements Runnable {  

    private BoundedQueue queue;  

    public ConsumerTask(final BoundedQueue queue) {  
        this.queue = queue;  
    }  

    @Override  
    public void run() {  
        log("[소비 시도]     ? <- " + queue);  
        String data = queue.take();  
        log("[소비 완료] " + data + " <- " + queue);  
    }  
}
  • ConsumerTask : 데이터를 소비하는 소비자 스레드가 실행하는 클래스, Runnable 을 구현한다.
  • 스레드를 실행하면, queue.take() 를 호출해서 큐의 데이터를 가져와서 소비한다.
package thread.bounded;  

import java.util.ArrayList;  
import java.util.List;  

import static util.MyLogger.log;  
import static util.ThreadUtils.sleep;  

public class BoundedMain {  

    public static void main(String[] args) {  
        // 1. BoundedQueue 선택  
        BoundedQueueV1 queue = new BoundedQueueV1(2);  

        // 2. 생산자, 소비자 실행 순서 선택, 반드시 하나만 선택!  
        producerFirst(queue); // 생산자 먼저 실행  
        // consumerFirst(queue); // 소비자 먼저 실행  
    }  


    private static void producerFirst(BoundedQueueV1 queue) {  
        log("== [생산자 먼저 실행] 시작," + queue.getClass().getSimpleName() + "==");  
        List<Thread> threads = new ArrayList<>();  
        startProducer(queue, threads);  
        printAllState(queue, threads);  
        startConsumer(queue, threads);  
        printAllState(queue, threads);  
        log("== [생산자 먼저 실행] 종료," + queue.getClass().getSimpleName() + "==");  
    }  

    private static void consumerFirst(BoundedQueueV1 queue) {  
        log("== [소비자 먼저 실행] 시작," + queue.getClass().getSimpleName() + "==");  
        List<Thread> threads = new ArrayList<>();  
        startConsumer(queue, threads);  
        printAllState(queue, threads);  
        startProducer(queue, threads);  
        printAllState(queue, threads);  
        log("== [소비자 먼저 실행] 종료," + queue.getClass().getSimpleName() + "==");  
    }  


    private static void printAllState(BoundedQueueV1 queue, List<Thread> threads) {  
        System.out.println();  
        log("현재 상태 출력, 큐 데이터: " + queue);  
        for(Thread thread : threads) {  
            log(thread.getName() + ": " + thread.getState());  
        }  
    }  

    private static void startProducer(BoundedQueueV1 queue, List<Thread> threads) {  
        System.out.println();  
        log("생산자 시작");  
        for(int i = 1; i <= 3; i++) {  
            Thread producer = new Thread(new ProducerTask(queue, "data" + i), "producer" + i);  
            threads.add(producer);  
            producer.start();  
            sleep(100);  
        }  
    }  

    private static void startConsumer(BoundedQueueV1 queue, List<Thread> threads) {  
        System.out.println();  
        log("소비자 시작");  
        for(int i = 1; i <= 3; i++) {  
            Thread consumer = new Thread(new ConsumerTask(queue), "consumer" + i);  
            threads.add(consumer);  
            consumer.start();  
            sleep(100);  
        }  
    }  
}

프로그램을 실행하는 코드이다.
여기서는 큐와 생산자 소비자의 실행 순서를 선택할 수 있어야 한다.

1. BoundedQueue 선택
BoundedQueue queue = new BoundedQueueV1(2);

  • BoundedQueue 의 구현체를 선택해서 생성한다. 이후에 점점 버전업 된 BoundedQueue 의 구현체로 변경할 예정이다.
  • 버퍼의 크기는 2 를 사용한다. 따라서 버퍼에는 데이터를 2개 까지만 보관할 수 있다.
    • 만약 생산자가 2개를 넘어서는 데이터를 추가로 저장하려고 하면 문제가 발생한다.
    • 반대로 버퍼에 데이터가 없는데, 소비자가 데이터를 가져갈 때도 문제가 발생한다.

2. 생산자, 소비자 실행 순서 선택, 반드시 하나만 선택!

producerFirst(queue); // 생산자 먼저 실행  
// consumerFirst(queue); // 소비자 먼저 실행
  • 이 두 코드중에 하나만 선택해서 실행해야 한다. 그렇지 않으면, 예상치 못한 오류가 발생할 수 있다.
  • 생산자가 먼저 실행되는 경우, 소비자가 먼저 실행되는 경우를 나누어서 다양한 예시를 보여주기 위해 이렇게 만들었다.

주의! 반드시 생산자, 소비자 실행 순서 선택에서 두 코드중에 하나만 선택해서 실행해야 한다. 생산자 먼저 실행을 선택 하면 소비자 먼저 실행 부분은 주석으로 처리해야 한다!

producerFirst 코드 분석

private static void producerFirst(BoundedQueueV1 queue) {  
    log("== [생산자 먼저 실행] 시작," + queue.getClass().getSimpleName() + "==");  
    List<Thread> threads = new ArrayList<>();  
    startProducer(queue, threads);  
    printAllState(queue, threads);  
    startConsumer(queue, threads);  
    printAllState(queue, threads);  
    log("== [생산자 먼저 실행] 종료," + queue.getClass().getSimpleName() + "==");  
}
  • threads : 스레드의 결과 상태를 한꺼번에 출력하기 위해 생성한 스레드를 보관해둠
  • startProducer : 생산자 스레드를 3개 만들어서 실행한다. 참고로 이해를 돕기 위해 0.1초의 간격으로 sleep 을 주면서 순차적으로 실행한다. 이렇게 하면 producer1 -> producer2 -> producer3 순서로 실행되는 것을 확인할 수 있다.
  • printAllState : 모든 스레드의 상태를 출력한다. 처음에는 producer 스레드들만 만들어졌으므로 해당 스 레드들만 출력한다.
  • startConsumer : 소비자 스레드를 3개 만들어서 실행한다. 참고로 이해를 돕기 위해 0.1초의 간격으로 sleep 을 주면서 순차적으로 실행한다. 이렇게 하면 consumer1 -> consumer2 -> consumer3 순서로 실행되는 것을 확인할 수 있다.
  • printAllState : 모든 스레드의 상태를 출력한다. 이때는 생산자 소비자 스레드 모두 출력한다.

여기서 핵심은 스레드를 0.1초 단위로 쉬면서 순서대로 실행한다는 점이다.

  • 생산자 먼저인 producerFirst 를 호출하면
    • producer1 -> producer2 -> producer3 -> consumer1 -> consumer2 -> consumer3 순서로 실행된다.
  • 소비자 먼저인 consumerFirst 를 호출하면
    • consumer1 -> consumer2 -> consumer3 -> producer1 -> producer2 -> producer3 순서로 실행된다.

참고로 여기서는 이해를 돕기 위해 이렇게 순서대로 실행했다.
실제로는 동시에 실행될 것이다.

생산자 먼저 실행
실행 순서를 분석하기 전에 우선 다음과 같이 생산자 먼저 실행해서 제대로 작동하는지 확인해보자.

// 1. BoundedQueue 선택  
BoundedQueueV1 queue = new BoundedQueueV1(2);  

// 2. 생산자, 소비자 실행 순서 선택, 반드시 하나만 선택!  
producerFirst(queue); // 생산자 먼저 실행  
// consumerFirst(queue); // 소비자 먼저 실행

실행 결과 - BoundedQueueV1, 생산자 먼저 실행

13:19:44.012 [     main] == [생산자 먼저 실행] 시작,BoundedQueueV1==

13:19:44.013 [     main] 생산자 시작
13:19:44.025 [producer1] [생산 시도] data1 -> []
13:19:44.026 [producer1] [생산 완료] data1 -> [data1]
13:19:44.123 [producer2] [생산 시도] data2 -> [data1]
13:19:44.123 [producer2] [생산 완료] data2 -> [data1, data2]
13:19:44.228 [producer3] [생산 시도] data3 -> [data1, data2]
13:19:44.229 [producer3] [put] 큐가 가득 참, 버림 : data3
13:19:44.229 [producer3] [생산 완료] data3 -> [data1, data2]

13:19:44.333 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
13:19:44.334 [     main] producer1: TERMINATED
13:19:44.334 [     main] producer2: TERMINATED
13:19:44.335 [     main] producer3: TERMINATED

13:19:44.335 [     main] 소비자 시작
13:19:44.337 [consumer1] [소비 시도]     ? <- [data1, data2]
13:19:44.338 [consumer1] [소비 완료] data1 <- [data2]
13:19:44.441 [consumer2] [소비 시도]     ? <- [data2]
13:19:44.441 [consumer2] [소비 완료] data2 <- []
13:19:44.546 [consumer3] [소비 시도]     ? <- []
13:19:44.546 [consumer3] [소비 완료] null <- []

13:19:44.653 [     main] 현재 상태 출력, 큐 데이터: []
13:19:44.653 [     main] producer1: TERMINATED
13:19:44.653 [     main] producer2: TERMINATED
13:19:44.654 [     main] producer3: TERMINATED
13:19:44.654 [     main] consumer1: TERMINATED
13:19:44.654 [     main] consumer2: TERMINATED
13:19:44.654 [     main] consumer3: TERMINATED
13:19:44.655 [     main] == [생산자 먼저 실행] 종료,BoundedQueueV1==

참고: 모든 로그의 실행 순서는 각 환경마다 다를 수 있다. 그리고 여러분의 이해를 돕기 위해 실행시간이 같은 일 부 로그는 순서를 조정했다.

소비자 먼저 실행

이번에는 소비자를 먼저 실행해서 제대로 작동하는지 확인해보자.

// 1. BoundedQueue 선택  
BoundedQueueV1 queue = new BoundedQueueV1(2);  

// 2. 생산자, 소비자 실행 순서 선택, 반드시 하나만 선택!  
//producerFirst(queue); // 생산자 먼저 실행  
consumerFirst(queue); // 소비자 먼저 실행
  • 주의! 나머지 하나는 반드시 주석처리해야 한다!

실행 결과 - BoundedQueueV1, 소비자 먼저 실행

13:20:01.779 [     main] == [소비자 먼저 실행] 시작,BoundedQueueV1==

13:20:01.780 [     main] 소비자 시작
13:20:01.785 [consumer1] [소비 시도]     ? <- []
13:20:01.793 [consumer1] [소비 완료] null <- []
13:20:01.901 [consumer2] [소비 시도]     ? <- []
13:20:01.901 [consumer2] [소비 완료] null <- []
13:20:02.006 [consumer3] [소비 시도]     ? <- []
13:20:02.006 [consumer3] [소비 완료] null <- []

13:20:02.113 [     main] 현재 상태 출력, 큐 데이터: []
13:20:02.114 [     main] consumer1: TERMINATED
13:20:02.114 [     main] consumer2: TERMINATED
13:20:02.114 [     main] consumer3: TERMINATED

13:20:02.114 [     main] 생산자 시작
13:20:02.116 [producer1] [생산 시도] data1 -> []
13:20:02.117 [producer1] [생산 완료] data1 -> [data1]
13:20:02.219 [producer2] [생산 시도] data2 -> [data1]
13:20:02.219 [producer2] [생산 완료] data2 -> [data1, data2]
13:20:02.326 [producer3] [생산 시도] data3 -> [data1, data2]
13:20:02.327 [producer3] [put] 큐가 가득 참, 버림 : data3
13:20:02.327 [producer3] [생산 완료] data3 -> [data1, data2]

13:20:02.431 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
13:20:02.431 [     main] consumer1: TERMINATED
13:20:02.432 [     main] consumer2: TERMINATED
13:20:02.432 [     main] consumer3: TERMINATED
13:20:02.432 [     main] producer1: TERMINATED
13:20:02.432 [     main] producer2: TERMINATED
13:20:02.433 [     main] producer3: TERMINATED
13:20:02.433 [     main] == [소비자 먼저 실행] 종료,BoundedQueueV1==

2-3. 생산자 소비자 문제 - 예제1 분석 - 생산자 우선

그림을 통해 생산자 소비자 문제를 알아보자. 먼저 앞서 만든 예제1의 실행 순서를 하나씩 분석해보자.

  • p1 : producer1 생산자 스레드를 뜻한다.
  • c1 : consumer1 소비자 스레드를 뜻한다.
  • 임계 영역은 synchronized 를 적용한 영역을 뜻한다. 스레드가 이 영역에 들어가려면 모니터 락( lock )이 필요하다.
  • 참고
    • BoundedQueue 의 버전 정보는 생략
    • 스레드가 처음부터 모두 생성되어 있는 것은 아니지만, 모두 그려둠.

BoundedQueueV1 - 생산자 먼저 실행 분석

실행 결과 - BoundedQueueV1, 생산자 먼저 실행

13:32:08.299 [     main] == [생산자 먼저 실행] 시작,BoundedQueueV1==

13:32:08.300 [     main] 생산자 시작
13:32:08.310 [producer1] [생산 시도] data1 -> []
13:32:08.311 [producer1] [생산 완료] data1 -> [data1]
13:32:08.420 [producer2] [생산 시도] data2 -> [data1]
13:32:08.420 [producer2] [생산 완료] data2 -> [data1, data2]
13:32:08.526 [producer3] [생산 시도] data3 -> [data1, data2]
13:32:08.527 [producer3] [put] 큐가 가득 참, 버림 : data3
13:32:08.527 [producer3] [생산 완료] data3 -> [data1, data2]

13:32:08.635 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
13:32:08.636 [     main] producer1: TERMINATED
13:32:08.636 [     main] producer2: TERMINATED
13:32:08.637 [     main] producer3: TERMINATED

13:32:08.637 [     main] 소비자 시작
13:32:08.639 [consumer1] [소비 시도]     ? <- [data1, data2]
13:32:08.640 [consumer1] [소비 완료] data1 <- [data2]
13:32:08.746 [consumer2] [소비 시도]     ? <- [data2]
13:32:08.746 [consumer2] [소비 완료] data2 <- []
13:32:08.852 [consumer3] [소비 시도]     ? <- []
13:32:08.852 [consumer3] [소비 완료] null <- []

13:32:08.959 [     main] 현재 상태 출력, 큐 데이터: []
13:32:08.959 [     main] producer1: TERMINATED
13:32:08.960 [     main] producer2: TERMINATED
13:32:08.960 [     main] producer3: TERMINATED
13:32:08.961 [     main] consumer1: TERMINATED
13:32:08.963 [     main] consumer2: TERMINATED
13:32:08.963 [     main] consumer3: TERMINATED
13:32:08.963 [     main] == [생산자 먼저 실행] 종료,BoundedQueueV1==

참고: 모든 로그의 실행 순서는 각 환경마다 다를 수 있다. 그리고 여러분의 이해를 돕기 위해 실행시간이 같은 일 부 로그는 순서를 조정했다.

생산자 스레드 실행 시작

13:32:08.299 [     main] == [생산자 먼저 실행] 시작,BoundedQueueV1==

13:32:08.300 [     main] 생산자 시작
13:32:08.310 [producer1] [생산 시도] data1 -> []
13:32:08.311 [producer1] [생산 완료] data1 -> [data1]
13:32:08.420 [producer2] [생산 시도] data2 -> [data1]
13:32:08.420 [producer2] [생산 완료] data2 -> [data1, data2]
13:32:08.526 [producer3] [생산 시도] data3 -> [data1, data2]
13:32:08.527 [producer3] [put] 큐가 가득 참, 버림 : data3
  • p3data3 을 큐에 저장하려고 시도한다.
  • 하지만 큐가 가득 차 있기 때문에 더는 큐에 데이터를 추가할 수 없다. 따라서 put() 내부에서 data3 은 버린다.

데이터를 버리지 않는 대안
data3 을 버리지 않는 대안은, 큐에 빈 공간이 생길 때 까지 p3 스레드가 기다리는 것이다.
언젠가는 소비자 스레드가 실행되어서 큐의 데이터를 가져갈 것이고, 큐에 빈 공간이 생기게 된다.
이때 큐에 데이터를 보관하는 것이다.
그럼 어떻게 기다릴 수 있을까?
단순하게 생각하면 생산자 스레드가 반복문을 사용해서 큐에 빈 공간이 생기는지 주기적으로 체크한 다음에, 만약 빈 공간이 없다면 sleep() 을 짧게 사용해서 잠시 대기하고, 깨어난 다음에 다시 반복문에서 큐의 빈 공간을 체크하는 식으로 구현하면 될 것 같다.

이후에 BoundedQueueV2 에서 이 방식으로 개선한다..

13:32:08.527 [producer3] [생산 완료] data3 -> [data1, data2]

생산자 스레드 실행 완료

13:32:08.635 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
13:32:08.636 [     main] producer1: TERMINATED
13:32:08.636 [     main] producer2: TERMINATED
13:32:08.637 [     main] producer3: TERMINATED

소비자 스레드 실행 시작

13:32:08.637 [     main] 소비자 시작
13:32:08.639 [consumer1] [소비 시도]     ? <- [data1, data2]
13:32:08.640 [consumer1] [소비 완료] data1 <- [data2]
13:32:08.746 [consumer2] [소비 시도]     ? <- [data2]
13:32:08.746 [consumer2] [소비 완료] data2 <- []
13:32:08.852 [consumer3] [소비 시도]     ? <- []
  • c3 는 큐에서 데이터를 획득하려고 한다.
  • 하지만 큐에 데이터가 없기 때문에 데이터를 획득할 수 없다.
    • 따라서 대신에 null 을 반환한다.

큐에 데이터가 없다면 기다리자
소비자 입장에서 큐에 데이터가 없다면 기다리는 것도 대안이다.
null 을 받지 않는 대안은, 큐에 데이터가 추가될 때 까지 c3 스레드가 기다리는 것이다.
언젠가는 생산자 스레드가 실행되어서 큐에 데이터를 추가할 것이다.
물론 생산자 스레드가 계속해서 데이터를 생산한다는 가정이 필요하다.

그럼 어떻게 기다릴 수 있을까?
단순하게 생각하면 소비자 스레드가 반복문을 사용해서 큐에 데이터가 있는지 주기적으로 체크한 다음에, 만약 데이터가 없다면 sleep() 을 짧게 사용해서 잠시 대기하고, 깨어난 다음에 다시 반복문에서 큐에 데이터가 있는지 체크하는 식으로 구현하면 될 것 같다.
BoundedQueueV2 에서 이 방식으로 개선한다.

생각해보면 큐에 데이터가 없는 상황은 앞서 큐의 데이터가 가득찬 상황과 비슷하다.
한정된 버퍼(Bounded buffer) 문제는 이렇듯 버퍼에 데이터가 가득 찬 상황에 데이터를 생산해서 추가할 때도 문제가 발생하고, 큐에 데이터가 없는데 데이터를 소비할 때도 문제가 발생한다.

13:32:08.852 [consumer3] [소비 완료] null <- []

소비자 스레드 실행 완료

13:32:08.959 [     main] 현재 상태 출력, 큐 데이터: []
13:32:08.959 [     main] producer1: TERMINATED
13:32:08.960 [     main] producer2: TERMINATED
13:32:08.960 [     main] producer3: TERMINATED
13:32:08.961 [     main] consumer1: TERMINATED
13:32:08.963 [     main] consumer2: TERMINATED
13:32:08.963 [     main] consumer3: TERMINATED
13:32:08.963 [     main] == [생산자 먼저 실행] 종료,BoundedQueueV1==

결과적으로 버퍼가 가득차서 p3 가 생산한 data3 은 버려졌다.
그리고 c3 가 데이터를 조회하는 시점에 버퍼는 비어있어서 데이터를 받지 못하고 null 값을 받았다.
스레드가 대기하며 기다릴 수 있다면 p3 가 생산한 data3c3 가 받을 수도 있었을 것이다.

2-4. 생산자 소비자 문제 - 예제1 분석 - 소비자 우선

BoundedQueueV1 - 소비자 먼저 실행 분석

이번에는 반대로 소비자를 먼저 실행하고 그 결과를 분석해보자.

실행 결과 - BoundedQueueV1, 소비자 먼저 실행

13:37:35.970 [     main] == [소비자 먼저 실행] 시작,BoundedQueueV1==

13:37:35.972 [     main] 소비자 시작
13:37:35.978 [consumer1] [소비 시도]     ? <- []
13:37:35.984 [consumer1] [소비 완료] null <- []
13:37:36.080 [consumer2] [소비 시도]     ? <- []
13:37:36.080 [consumer2] [소비 완료] null <- []
13:37:36.187 [consumer3] [소비 시도]     ? <- []
13:37:36.189 [consumer3] [소비 완료] null <- []

13:37:36.297 [     main] 현재 상태 출력, 큐 데이터: []
13:37:36.298 [     main] consumer1: TERMINATED
13:37:36.298 [     main] consumer2: TERMINATED
13:37:36.298 [     main] consumer3: TERMINATED

13:37:36.299 [     main] 생산자 시작
13:37:36.302 [producer1] [생산 시도] data1 -> []
13:37:36.302 [producer1] [생산 완료] data1 -> [data1]
13:37:36.404 [producer2] [생산 시도] data2 -> [data1]
13:37:36.404 [producer2] [생산 완료] data2 -> [data1, data2]
13:37:36.510 [producer3] [생산 시도] data3 -> [data1, data2]
13:37:36.511 [producer3] [put] 큐가 가득 참, 버림 : data3
13:37:36.511 [producer3] [생산 완료] data3 -> [data1, data2]

13:37:36.620 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
13:37:36.620 [     main] consumer1: TERMINATED
13:37:36.622 [     main] consumer2: TERMINATED
13:37:36.622 [     main] consumer3: TERMINATED
13:37:36.622 [     main] producer1: TERMINATED
13:37:36.622 [     main] producer2: TERMINATED
13:37:36.622 [     main] producer3: TERMINATED
13:37:36.623 [     main] == [소비자 먼저 실행] 종료,BoundedQueueV1==

실행 전

소비자 스레드 실행 시작

13:37:35.970 [     main] == [소비자 먼저 실행] 시작,BoundedQueueV1==

13:37:35.972 [     main] 소비자 시작
13:37:35.978 [consumer1] [소비 시도]     ? <- []
13:37:35.984 [consumer1] [소비 완료] null <- []

소비자 스레드 실행 완료

13:37:36.080 [consumer2] [소비 시도]     ? <- []
13:37:36.080 [consumer2] [소비 완료] null <- []
13:37:36.187 [consumer3] [소비 시도]     ? <- []
13:37:36.189 [consumer3] [소비 완료] null <- []
13:37:36.297 [     main] 현재 상태 출력, 큐 데이터: []
13:37:36.298 [     main] consumer1: TERMINATED
13:37:36.298 [     main] consumer2: TERMINATED
13:37:36.298 [     main] consumer3: TERMINATED

큐에 데이터가 없으므로 null 을 반환한다. 결과적으로 c1 , c2 , c3 모두 데이터를 받지 못하고 종료된다.
언젠가 생산자가 데이터를 넣어준다고 가정해보면 c1 , c2 , c3 스레드는 큐에 데이터가 추가될 때 까지 기다리는 것도 방법이다. (이 부분은 뒤에서 구현한다.)

생산자 스레드 실행 시작

13:37:36.299 [     main] 생산자 시작
13:37:36.302 [producer1] [생산 시도] data1 -> []
13:37:36.302 [producer1] [생산 완료] data1 -> [data1]
13:37:36.404 [producer2] [생산 시도] data2 -> [data1]
13:37:36.404 [producer2] [생산 완료] data2 -> [data1, data2]
13:37:36.510 [producer3] [생산 시도] data3 -> [data1, data2]
13:37:36.511 [producer3] [put] 큐가 가득 참, 버림 : data3
  • p3 의 경우 큐에 데이터가 가득 차서 data3 을 포기하고 버린다.
  • 소비자가 계속해서 큐의 데이터를 가져간다고 가정하면, p3 스레드는 기다리는 것도 하나의 방법이다.
13:37:36.511 [producer3] [생산 완료] data3 -> [data1, data2]

생산자 스레드 실행 완료

13:37:36.620 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
13:37:36.620 [     main] consumer1: TERMINATED
13:37:36.622 [     main] consumer2: TERMINATED
13:37:36.622 [     main] consumer3: TERMINATED
13:37:36.622 [     main] producer1: TERMINATED
13:37:36.622 [     main] producer2: TERMINATED
13:37:36.622 [     main] producer3: TERMINATED
13:37:36.623 [     main] == [소비자 먼저 실행] 종료,BoundedQueueV1==

문제점

  • 생산자 스레드 먼저 실행의 경우 p3 가 보관하는 data3 은 버려지고, c3 는 데이터를 받지 못한다. ( null 을 받는다.)
  • 소비자 스레드 먼저 실행의 경우 c1 , c2 , c3 는 데이터를 받지 못한다.( null 을 받는다.) 그리고 p3 가 보관하는 data3 은 버려진다.

예제는 단순하게 설명하기 위해 생산자 스레드 3개, 소비자 스레드 3개를 한 번만 실행했지만, 실제로 이런 생산자 소비자 구조는 보통 계속해서 실행된다.
레스토랑에 손님은 계속 찾아오고, 음료 공장은 계속해서 음료를 만들어낸다.
쇼핑몰이라면 고객은 계속해서 주문을 한다.

  • 버퍼가 가득 찬 경우: 생산자 입장에서 버퍼에 여유가 생길 때 까지 조금만 기다리면 되는데, 기다리지 못하고, 데이터를 버리는 것은 아쉽다.
  • 버퍼가 빈 경우: 소비자 입장에서 버퍼에 데이터가 채워질 때 까지 조금만 기다리면 되는데, 기다리지 못하고, null 데이터를 얻는 것은 아쉽다.

문제의 해결 방안은 단순하다. 앞서 설명한 것 처럼 스레드가 기다리면 되는 것이다! 그럼 기다리도록 구현해보자.

2-5. 생산자 소비자 문제 - 예제2 코드

이번에는 각 상황에 맞추어 스레드가 기다리도록 해보자.

package thread.bounded;  

import java.util.ArrayDeque;  
import java.util.Queue;  

import static util.MyLogger.log;  
import static util.ThreadUtils.sleep;  

public class BoundedQueueV2 implements BoundedQueue {  

    private final Queue<String> queue = new ArrayDeque<>();  
    private final int max;  

    public BoundedQueueV2(final int max) {  
        this.max = max;  
    }  

    @Override  
    public synchronized void put(final String data) {  
        while (queue.size() == max) {  
            log("[put] 큐가 가득 참, 셍신지 대기");  
            sleep(1000);  
        }  
        queue.offer(data);  
    }  

    @Override  
    public synchronized String take() {  
        while(queue.isEmpty()) {  
            log("[take] 큐에 데이터가 없음, 소비자 대기");  
            sleep(1000);  
        }  
        return queue.poll();  
    }  

    @Override  
    public String toString() {  
        return queue.toString();  
    }  
}

put(data) - 데이터를 버리지 않는 대안
data3 을 버리지 않는 대안은, 큐가 가득 찾을 때, 큐에 빈 공간이 생길 때 까지, 생산자 스레드가 기다리면 된다.
언젠가는 소비자 스레드가 실행되어서 큐의 데이터를 가져갈 것이고, 그러면 큐에 데이터를 넣을 수 있는 공간이 생기게 된다.
그럼 어떻게 기다릴 수 있을까?
여기서는 생산자 스레드가 반복문을 사용해서 큐에 빈 공간이 생기는지 주기적으로 체크한다.
만약 빈 공간이 없다면 sleep() 을 사용해서 잠시 대기하고, 깨어난 다음에 다시 반복문에서 큐의 빈 공간을 체크하는 식으로 구현했다.

take() - 큐에 데이터가 없다면 기다리자
소비자 입장에서 큐에 데이터가 없다면 기다리는 것도 대안이다.
큐에 데이터가 없을 때 null 을 받지 않는 대안은, 큐에 데이터가 추가될 때 까지 소비자 스레드가 기다리는 것이다.
언젠가는 생산자 스레드가 실행되어서 큐의 데이터를 추가할 것이고, 큐에 데이터가 생기게 된다.
물론 생산자 스레드가 계속해서 데이터를 생산한다는 가정이 필요하다.
그럼 어떻게 기다릴 수 있을까?
여기서는 소비자 스레드가 반복문을 사용해서 큐에 데이터가 있는지 주기적으로 체크한 다음에, 만약 데이터가 없다면 sleep() 을 사용해서 잠시 대기하고, 깨어난 다음에 다시 반복문에서 큐에 데이터가 있는지 체크하는 식으로 구현했다.

BoundedMain - BoundedQueueV2를 사용하도록 변경

public class BoundedMain {  

    public static void main(String[] args) {  
        // 1. BoundedQueue 선택  
        //BoundedQueue queue = new BoundedQueueV1(2);  
        BoundedQueue queue = new BoundedQueueV2(2);  

        // 2. 생산자, 소비자 실행 순서 선택, 반드시 하나만 선택!  
        producerFirst(queue); // 생산자 먼저 실행  
        //consumerFirst(queue); // 소비자 먼저 실행  
    }
}
  • BoundedQueueV2 를 사용하도록 변경하자.
  • 생산자 먼저 실행하도록 주석을 변경하자.

실행 결과 - BoundedQueueV2, 생산자 먼저 실행

14:11:13.376 [     main] == [생산자 먼저 실행] 시작,BoundedQueueV2==

14:11:13.377 [     main] 생산자 시작
14:11:13.390 [producer1] [생산 시도] data1 -> []
14:11:13.390 [producer1] [생산 완료] data1 -> [data1]
14:11:13.485 [producer2] [생산 시도] data2 -> [data1]
14:11:13.485 [producer2] [생산 완료] data2 -> [data1, data2]
14:11:13.593 [producer3] [생산 시도] data3 -> [data1, data2]
14:11:13.594 [producer3] [put] 큐가 가득 참, 셍신지 대기

14:11:13.699 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
14:11:13.700 [     main] producer1: TERMINATED
14:11:13.700 [     main] producer2: TERMINATED
14:11:13.700 [     main] producer3: TIMED_WAITING

14:11:13.700 [     main] 소비자 시작
14:11:13.703 [consumer1] [소비 시도]     ? <- [data1, data2]
14:11:13.807 [consumer2] [소비 시도]     ? <- [data1, data2]
14:11:13.908 [consumer3] [소비 시도]     ? <- [data1, data2]

14:11:14.010 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
14:11:14.011 [     main] producer1: TERMINATED
14:11:14.011 [     main] producer2: TERMINATED
14:11:14.011 [     main] producer3: TIMED_WAITING
14:11:14.011 [     main] consumer1: BLOCKED
14:11:14.012 [     main] consumer2: BLOCKED
14:11:14.012 [     main] consumer3: BLOCKED
14:11:14.012 [     main] == [생산자 먼저 실행] 종료,BoundedQueueV2==
14:11:14.603 [producer3] [put] 큐가 가득 참, 셍신지 대기
14:11:15.616 [producer3] [put] 큐가 가득 참, 셍신지 대기
14:11:16.630 [producer3] [put] 큐가 가득 참, 셍신지 대기
14:11:17.644 [producer3] [put] 큐가 가득 참, 셍신지 대기
14:11:18.648 [producer3] [put] 큐가 가득 참, 셍신지 대기
// ...반복

그런데 실행 결과를 보면 뭔가 이상하다.

문제 - 생산자 먼저 실행의 경우
producer3 이 종료되지 않고 계속 수행되고, consumer1 , consumer2 , consumer3BLOCKED 상태가 된다.

참고: 만약 실행 결과가 지금 내용과 다르고 특히 "현재 상태 출력"과 그 이후 부분이 나오지 않는다면 toString() 에 있는 synchronized 를 제거해야 한다.
원칙적으로 toString() 에도 synchronized 를 적용해야 한다.
그래야 toString() 을 통한 조회 시점에도 모니터 락이 걸리며 정확한 데이터를 조회할 수 있다.
하지만 이 부분이 이번 설명의 핵심이 아니고, 또 예제 코드를 단순하게 유지하기 위해 여기서는 toString()synchronized 를 사용하지 않겠다.
왜 결과에 차이가 나는지는 이후에 설명하는 내용에서 자연스럽게 이해가 될 것이다.

실행 결과 - BoundedQueueV2, 소비자 먼저 실행

14:17:18.670 [     main] == [소비자 먼저 실행] 시작,BoundedQueueV2==

14:17:18.671 [     main] 소비자 시작
14:17:18.676 [consumer1] [소비 시도]     ? <- []
14:17:18.677 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:17:18.778 [consumer2] [소비 시도]     ? <- []
14:17:18.884 [consumer3] [소비 시도]     ? <- []

14:17:18.985 [     main] 현재 상태 출력, 큐 데이터: []
14:17:18.991 [     main] consumer1: TIMED_WAITING
14:17:18.992 [     main] consumer2: BLOCKED
14:17:18.992 [     main] consumer3: BLOCKED

14:17:18.992 [     main] 생산자 시작
14:17:18.994 [producer1] [생산 시도] data1 -> []
14:17:19.095 [producer2] [생산 시도] data2 -> []
14:17:19.195 [producer3] [생산 시도] data3 -> []

14:17:19.297 [     main] 현재 상태 출력, 큐 데이터: []
14:17:19.297 [     main] consumer1: TIMED_WAITING
14:17:19.298 [     main] consumer2: BLOCKED
14:17:19.298 [     main] consumer3: BLOCKED
14:17:19.298 [     main] producer1: BLOCKED
14:17:19.298 [     main] producer2: BLOCKED
14:17:19.298 [     main] producer3: BLOCKED
14:17:19.299 [     main] == [소비자 먼저 실행] 종료,BoundedQueueV2==
14:17:19.685 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:17:20.692 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:17:21.695 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:17:22.698 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:17:23.712 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기

여기서도 실행 결과가 이상하다.

문제 - 소비자 먼저 실행의 경우
소비자 먼저 실행의 경우 consumer1 이 종료되지 않고 계속 수행된다.
그리고 나머지 모든 스레드가 BLOCKED 상태가 된다.

뭔가 세상이 멈춘 것 같다! 왜 이런 문제가 발생했을까?

2-6. 생산자 소비자 문제 - 예제2 분석

BoundedQueueV2 - 생산자 먼저 실행 분석

실행 결과 - BoundedQueueV2, 생산자 먼저 실행

14:19:28.904 [     main] == [생산자 먼저 실행] 시작,BoundedQueueV2==

14:19:28.905 [     main] 생산자 시작
14:19:28.917 [producer1] [생산 시도] data1 -> []
14:19:28.918 [producer1] [생산 완료] data1 -> [data1]
14:19:29.014 [producer2] [생산 시도] data2 -> [data1]
14:19:29.014 [producer2] [생산 완료] data2 -> [data1, data2]
14:19:29.121 [producer3] [생산 시도] data3 -> [data1, data2]
14:19:29.121 [producer3] [put] 큐가 가득 참, 셍신지 대기

14:19:29.227 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
14:19:29.231 [     main] producer1: TERMINATED
14:19:29.231 [     main] producer2: TERMINATED
14:19:29.232 [     main] producer3: TIMED_WAITING

14:19:29.232 [     main] 소비자 시작
14:19:29.234 [consumer1] [소비 시도]     ? <- [data1, data2]
14:19:29.336 [consumer2] [소비 시도]     ? <- [data1, data2]
14:19:29.436 [consumer3] [소비 시도]     ? <- [data1, data2]

14:19:29.538 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
14:19:29.538 [     main] producer1: TERMINATED
14:19:29.538 [     main] producer2: TERMINATED
14:19:29.538 [     main] producer3: TIMED_WAITING
14:19:29.539 [     main] consumer1: BLOCKED
14:19:29.539 [     main] consumer2: BLOCKED
14:19:29.539 [     main] consumer3: BLOCKED
14:19:29.539 [     main] == [생산자 먼저 실행] 종료,BoundedQueueV2==
14:19:30.132 [producer3] [put] 큐가 가득 참, 셍신지 대기
14:19:31.136 [producer3] [put] 큐가 가득 참, 셍신지 대기
14:19:32.144 [producer3] [put] 큐가 가득 참, 셍신지 대기
14:19:33.158 [producer3] [put] 큐가 가득 참, 셍신지 대기

생산자 스레드 실행 시작

14:19:28.904 [     main] == [생산자 먼저 실행] 시작,BoundedQueueV2==

14:19:28.905 [     main] 생산자 시작
14:19:28.917 [producer1] [생산 시도] data1 -> []
14:19:28.918 [producer1] [생산 완료] data1 -> [data1]
14:19:29.014 [producer2] [생산 시도] data2 -> [data1]
14:19:29.014 [producer2] [생산 완료] data2 -> [data1, data2]
14:19:29.121 [producer3] [생산 시도] data3 -> [data1, data2]
14:19:29.121 [producer3] [put] 큐가 가득 참, 셍신지 대기
  • 생산자 스레드인 p3 는 임계 영역에 들어가기 위해 먼저 락을 획득한다.
  • 큐에 data3 을 저장하려고 시도한다. 그런데 큐가 가득 차있다.
  • p3sleep(1000) 을 사용해서 잠시 대기한다. 이때 RUNNABLE -> TIMED_WAITING 상태가 된다.
  • 이때 반복문을 사용해서 1초마다 큐에 빈 자리가 있는지 반복해서 확인한다.
    • 빈 자리가 있다면 큐에 데이터를 입력하고 완료된다.
    • 빈 자리가 없다면 sleep() 으로 잠시 대기한 다음 반복문을 계속해서 수행한다.
      • 1초마다 한 번씩 체크하 기 때문에 "큐가 가득 참, 생산자 대기"라는 메시지가 계속 출력될 것이다.

여기서 핵심은 p3 스레드가 락을 가지고 있는 상태에서, 큐에 빈 자리가 나올 때 까지 대기한다는 점이다.

14:19:29.227 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
14:19:29.231 [     main] producer1: TERMINATED
14:19:29.231 [     main] producer2: TERMINATED
14:19:29.232 [     main] producer3: TIMED_WAITING

소비자 스레드 실행 시작

14:19:29.232 [     main] 소비자 시작
14:19:29.234 [consumer1] [소비 시도]     ? <- [data1, data2]

무한 대기 문제

  • c1 이 임계 영역에 들어가기 위해 락을 획득하려 한다.
  • 그런데 락이 없다! 왜냐하면 p3 가 락을 가지고 임계 영역에 이미 들어가 있기 때문이다. p3 가 락을 반납하기 전까지는 c1 은 절대로 임계 영역(여기서는 synchronized )에 들어갈 수 없다!
  • 여기서 심각한 무한 대기 문제가 발생한다.
    • p3 가 락을 반납하려면 -> 소비자 스레드인 c1 이 먼저 작동해서 큐의 데이터를 가져가야 한다.
    • 소비자 스레드인 c1 이 락을 획득 하려면 -> 생산자 스레드인 p3 가 먼저 락을 반납해야 한다.
  • p3 는 락을 반납하지 않고, c1 은 큐의 데이터를 가져갈 수 없다.
  • 지금 상태면 p3 는 절대로 락을 반납할 수 없다.
    • 왜냐하면 락을 반납하려면 c1 이 먼저 큐의 데이터를 소비해야 한다.
    • 그래야 p3 가 큐에 data3 을 저장하고 임계 영역을 빠져나가며 락을 반납할 수 있다.
    • 그런데 p3 가 락을 가지고 임계 영역 안에 있기 때문에, 임계 영역 밖의 c1 은 락을 획득할 수 없으므로, 큐에 접근하지 못하고 무한 대기한다.
  • 결과적으로 소비자 스레드인 c1 은 p3 가 락을 반납할 때 까지 BLOCKED 상태로 대기한다.
14:19:29.336 [consumer2] [소비 시도]     ? <- [data1, data2]
  • c2 도 마찬가지로 락을 얻을 수 없으므로 BLOCKED 상태로 대기한다.
14:19:29.436 [consumer3] [소비 시도]     ? <- [data1, data2]
  • c3 도 마찬가지로 락을 얻을 수 없으므로 BLOCKED 상태로 대기한다.

소비자 스레드 실행 완료

14:19:29.538 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
14:19:29.538 [     main] producer1: TERMINATED
14:19:29.538 [     main] producer2: TERMINATED
14:19:29.538 [     main] producer3: TIMED_WAITING
14:19:29.539 [     main] consumer1: BLOCKED
14:19:29.539 [     main] consumer2: BLOCKED
14:19:29.539 [     main] consumer3: BLOCKED
14:19:29.539 [     main] == [생산자 먼저 실행] 종료,BoundedQueueV2==
14:19:30.132 [producer3] [put] 큐가 가득 참, 셍신지 대기
14:19:31.136 [producer3] [put] 큐가 가득 참, 셍신지 대기
14:19:32.144 [producer3] [put] 큐가 가득 참, 셍신지 대기
14:19:33.158 [producer3] [put] 큐가 가득 참, 셍신지 대기

결과적으로 c1 , c2 , c3 는 모두 락을 획득하기 위해 BLOCKED 상태로 대기한다.
p3 는 1초마다 한 번씩 깨어나서 큐의 상태를 확인한다.
그런데 본인이 락을 가지고 있기 때문에 다른 스레드가 임계 영역 안에 들어오는 것이 불가능하다.
따라서 다른 스레드는 임계 영역 안에 있는 큐에 접근조차 할 수 없다.
결국 p3 는 절대로 비워지지 않는 큐를 계속 확인하게 된다.
그리고 [put] 큐가 가득 참, 생산자 대기 를 1초마다 계속 출력한다.
결국 이런 상태가 무한하게 지속된다.

BoundedQueueV2 - 소비자 먼저 실행 분석

실행 결과 - BoundedQueueV2, 소비자 먼저 실행

14:25:53.661 [     main] == [소비자 먼저 실행] 시작,BoundedQueueV2==

14:25:53.663 [     main] 소비자 시작
14:25:53.669 [consumer1] [소비 시도]     ? <- []
14:25:53.670 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:25:53.771 [consumer2] [소비 시도]     ? <- []
14:25:53.878 [consumer3] [소비 시도]     ? <- []

14:25:53.979 [     main] 현재 상태 출력, 큐 데이터: []
14:25:53.984 [     main] consumer1: TIMED_WAITING
14:25:53.985 [     main] consumer2: BLOCKED
14:25:53.985 [     main] consumer3: BLOCKED

14:25:53.985 [     main] 생산자 시작
14:25:53.988 [producer1] [생산 시도] data1 -> []
14:25:54.088 [producer2] [생산 시도] data2 -> []
14:25:54.189 [producer3] [생산 시도] data3 -> []

14:25:54.289 [     main] 현재 상태 출력, 큐 데이터: []
14:25:54.289 [     main] consumer1: TIMED_WAITING
14:25:54.289 [     main] consumer2: BLOCKED
14:25:54.290 [     main] consumer3: BLOCKED
14:25:54.290 [     main] producer1: BLOCKED
14:25:54.290 [     main] producer2: BLOCKED
14:25:54.290 [     main] producer3: BLOCKED
14:25:54.291 [     main] == [소비자 먼저 실행] 종료,BoundedQueueV2==
14:25:54.677 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:25:55.686 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:25:56.686 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:25:57.700 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:25:58.713 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기

소비자 스레드 실행 시작

14:25:53.661 [     main] == [소비자 먼저 실행] 시작,BoundedQueueV2==

14:25:53.663 [     main] 소비자 시작
14:25:53.669 [consumer1] [소비 시도]     ? <- []
14:25:53.670 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
  • 소비자 스레드인 c1 은 임계영역에 들어가기 위해 락을 획득한다.
  • c1 은 큐의 데이터를 획득하려 하지만, 데이터가 없다.
  • c1sleep(1000) 을 사용해서 잠시 대기한다. 이때 RUNNABLE -> TIMED_WAITING 상태가 된다.
  • 이때 반복문을 사용해서 1초마다 큐에 데이터가 있는지 반복해서 확인한다.
    • 데이터가 있다면 큐의 데이터를 가져오고 완료된다.
    • 데이터가 없다면 반복문을 계속해서 수행한다. 1초마다 한 번 "큐에 데이터가 없음, 소비자 대기"라는 메시지가 출력될 것이다.
14:25:53.771 [consumer2] [소비 시도]     ? <- []
14:25:53.878 [consumer3] [소비 시도]     ? <- []

14:25:53.979 [     main] 현재 상태 출력, 큐 데이터: []
14:25:53.984 [     main] consumer1: TIMED_WAITING
14:25:53.985 [     main] consumer2: BLOCKED
14:25:53.985 [     main] consumer3: BLOCKED

무한 대기 문제

  • c2 , c3 가 임계 영역에 들어가기 위해 락을 획득하려 한다.
  • 그런데 락이 없다! 왜냐하면 c1 이 락을 가지고 임계 영역에 들어가 있기 때문이다. c1 이 락을 반납하기 전까지 는 c2 ,c3 는 절대로 임계 영역(여기서는 synchronized )은 들어갈 수 없다!
  • 여기서 심각한 무한 대기 문제가 발생한다.
  • c1 이 락을 반납하지 않기 때문에 c2 , c3BLOCKED 상태가 된다.

생산자 스레드 실행 시작

14:25:53.985 [     main] 생산자 시작
14:25:53.988 [producer1] [생산 시도] data1 -> []
14:25:54.088 [producer2] [생산 시도] data2 -> []
14:25:54.189 [producer3] [생산 시도] data3 -> []

무한 대기 문제

  • p1 , p2 , p3 가 임계 영역에 들어가기 위해 락을 획득하려 한다.
  • 그런데 락이 없다! 왜냐하면 c1 이 락을 가지고 임계 영역에 들어가 있기 때문이다.
    • c1 이 락을 반납하기 전까지 는 p1 , p2 , p3 는 절대로 임계 영역(여기서는 synchronized )은 들어갈 수 없다!
  • 여기서 심각한 무한 대기 문제가 발생한다.
    • c1 이 락을 반납하려면 -> 생산자 스레드인 p1 , p2 , p3 가 먼저 작동해서 큐의 데이터를 추가해야 한다.
    • 생산자 스레드( p1 , p2 , p3 )가 락을 획득하려면 -> 소비자 스레드인 c1 이 먼저 락을 반납해야 한다.
  • c1 은 락을 반납하지 않고, p1 은 큐에 데이터를 추가할 수 없다. (물론 p2 , p3 도 포함이다.)
  • 지금 상태면 c1 은 절대로 락을 반납할 수 없다. 왜냐하면 락을 반납하려면 p1 이 먼저 큐의 데이터를 추가해야 한다.
    • 그래야 c1 이 큐에서 데이터를 획득하고 임계 영역을 빠져나가며 락을 반납할 수 있다.
    • 그런데 c1 이 락을 가지고 임계 영역 안에 있기 때문에, 임계 영역 밖의 p1 은 락을 획득할 수 없으므로, 큐에 접근하지 못하고 무한 대기한다.
  • 결과적으로 생산자 스레드인 p1c1 이 락을 반납할 때 까지 BLOCKED 상태로 대기한다.
14:25:54.289 [     main] 현재 상태 출력, 큐 데이터: []
14:25:54.289 [     main] consumer1: TIMED_WAITING
14:25:54.289 [     main] consumer2: BLOCKED
14:25:54.290 [     main] consumer3: BLOCKED
14:25:54.290 [     main] producer1: BLOCKED
14:25:54.290 [     main] producer2: BLOCKED
14:25:54.290 [     main] producer3: BLOCKED
14:25:54.291 [     main] == [소비자 먼저 실행] 종료,BoundedQueueV2==
14:25:54.677 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:25:55.686 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:25:56.686 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:25:57.700 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:25:58.713 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:25:59.723 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기

결과적으로 c1 을 제외한 모든 스레드가 락을 획득하기 위해 BLOCKED 상태로 대기한다.
c1 은 1초마다 한 번씩 깨어나서 큐의 상태를 확인한다.
그런데 본인이 락을 가지고 있기 때문에 다른 스레드는 임계 영역에 들어오는 것이 불가능하고, 큐에 접근조차 할 수 없다. 따라서 [take] 큐에 데이터가 없음, 소비자 대기 를 1초마다 계속 출력한다.
결국 이런 상태가 무한하게 지속된다.

정리
버퍼가 비었을 때 소비하거나, 버퍼가 가득 찾을 때 생산하는 문제를 해결하기 위해, 단순히 스레드가 잠깐 기다리면 될 것이라 생각했는데, 문제가 더 심각해졌다.
생각해보면 결국 임계 영역 안에서 락을 가지고 대기하는 것이 문제이다.
이것은 마치 열쇠를 가진 사람이 안에서 문을 잠궈버린 것과 같다.
그래서 다른 스레드가 임계 영역안에 접근조차 할 수 없 는 것이다.

여기서 잘 생각해보면, 락을 가지고 임계 영역안에 있는 스레드가 sleep() 을 호출해서 잠시 대기할 때는 아무일도 하지 않는다.
그렇다면 이렇게 아무일도 하지 않고 대기하는 동안 잠시 다른 스레드에게 락을 양보하면 어떨까?
그러면 다른 스레드가 버퍼에 값을 채우거나 버퍼의 값을 가져갈 수 있을 것이다.
그러면 락을 가진 스레드도 버퍼에서 값을 획득 하거나 값을 채우고 락을 반납할 수 있을 것이다.

예를 들어 락을 가진 소비자 스레드가 임계 영역 안에서 버퍼의 값을 획득하기를 기다린다고 가정하자.
버퍼에 값이 없으면 값이 채워질 때 까지 소비자 스레드는 아무일도 하지 않고 대기해야 한다.
어차피 아무일도 하지 않으므로, 이때 잠시 락을 다른 스레드에게 빌려주는 것이다.
락을 획득한 생산자 스레드는 이때 버퍼에 값을 채우고 락을 반납한다.
버퍼에 값이 차면 대기하던 소비자 스레드가 다시 락을 획득한 다음에 버퍼의 값을 가져가고 락을 반납하는 것이다.

이 설명이 잘 이해가 되지 않아도 괜찮다. 바로 다음에 예제를 통해서 천천히 알아볼 것인데 여기서는 다음 딱 한가지만 생각하면 된다.
"락을 가지고 대기하는 스레드가 대기하는 동안 다른 스레드에게 락을 양보할 수 있다면, 이 문제를 쉽게 풀 수 있다."

자바의 Object.wait() , Object.noitfy() 를 사용하면 락을 가지고 대기하는 스레드가 대기하는 동안 다른 스레드에게 락을 양보할 수 있다.

2-7. Object - wait, notify - 예제 3 코드

자바는 처음부터 멀티스레드를 고려하며 탄생한 언어다.
앞서 설명한 synchronized 를 사용한 임계 영역 안에서 락을 가지고 무한 대기하는 문제는 흥미롭게도 Object 클래스에 해결 방안이 있다.
Object 클래스는 이런 문제를 해결할 수 있는 wait() , notify() 라는 메서드를 제공한다.
Object 는 모든 자바 객체의 부모이기 때문에, 여기 있는 기능들은 모두 자바 언어의 기본 기능이라 생각하면 된다.

wait(), notify() 설명

  • Object.wait()
    • 현재 스레드가 가진 락을 반납하고 대기( WAITING )한다.
    • 현재 스레드를 대기( WAITING ) 상태로 전환한다.
      • 이 메서드는 현재 스레드가 synchronized 블록이나 메서드에서 락을 소유하고 있을 때만 호출할 수 있다.
    • 호출한 스레드는 락을 반납하고, 다른 스레드가 해당 락을 획득할 수 있도록 한다.
    • 이렇게 대기 상태로 전환된 스레드는 다른 스레드가 notify() 또는 notifyAll() 을 호출할 때까지 대기 상태를 유지한다.
  • Object.notify()
    • 대기 중인 스레드 중 하나를 깨운다.
    • 이 메서드는 synchronized 블록이나 메서드에서 호출되어야 한다.
      • 깨운 스레드는 락을 다시 획득할 기회를 얻게 된다.
      • 만약 대기 중인 스레드가 여러 개라면, 그 중 하나만이 깨워지게 된다.
  • Object.notifyAll()
    • 대기 중인 모든 스레드를 깨운다.
    • 이 메서드 역시 synchronized 블록이나 메서드에서 호출되어야 하며, 모든 대기 중인 스레드가 락을 획득할 수 있는 기회를 얻게 된다.
      • 이 방법은 모든 스레드를 깨워야 할 필요가 있는 경우에 유용하다.

wait() , notify() 메서드를 적절히 사용하면, 멀티스레드 환경에서 발생할 수 있는 문제를 효율적으로 해결할 수 있다.
이 기능을 활용해서 스레드가 락을 가지고 임계 영역안에서 무한 대기하는 문제를 해결해보자.

예제3 코드 작성

package thread.bounded;  

import java.util.ArrayDeque;  
import java.util.Queue;  

import static util.MyLogger.log;  
import static util.ThreadUtils.sleep;  

public class BoundedQueueV3 implements BoundedQueue {  

    private final Queue<String> queue = new ArrayDeque<>();  
    private final int max;  

    public BoundedQueueV3(final int max) {  
        this.max = max;  
    }  

    @Override  
    public synchronized void put(final String data) {  
        while (queue.size() == max) {  
            log("[put] 큐가 가득 참, 생산자 대기");  
            try {  
                wait(); // RUNNABLE -> WAITING, 락 반납  
                log("[put] 생산자 깨어남");  
            } catch (InterruptedException e) {  
                throw new RuntimeException(e);  
            }  
        }  
        queue.offer(data);  
        log("[put] 생산자 데이터 저장, notify() 호출");  
        notify(); // 대시 쓰레드, WAIT -> BLOCKED  
    }  

    @Override  
    public synchronized String take() {  
        while(queue.isEmpty()) {  
            log("[take] 큐에 데이터가 없음, 소비자 대기");  
            try {  
                wait();  
                log("[take] 소비자 깨어남");  
            } catch (InterruptedException e) {  
                throw new RuntimeException(e);  
            }  
        }  

        String data = queue.poll();  
        log("[take] 소비자 데이터 획득, notify() 호출");  
        notify();   // 대기 스레드, WAIT -> BLOCKED  
        return data;  
    }  

    @Override  
    public String toString() {  
        return queue.toString();  
    }  
}

앞서 작성한 sleep() 코드는 제거하고 대신에 Object.wait() 를 사용하자.
Object 는 모든 클래스의 부모이므 로 자바의 모든 객체는 해당 기능을 사용할 수 있다.

put(data) - wait(), notify()

  • synchronized 를 통해 임계 영역을 설정한다. 생산자 스레드는 락 획득을 시도한다.
  • 락을 획득한 생산자 스레드는 반복문을 사용해서 큐에 빈 공간이 생기는지 주기적으로 체크한다.
    • 만약 빈 공간이 없다면 Object.wait() 을 사용해서 대기한다.
    • 참고로 대기할 때 락을 반납하고 대기한다. 그리고 대기 상태에 서 깨어나면, 다시 반복문에서 큐의 빈 공간을 체크한다.
  • wait() 를 호출해서 대기하는 경우 RUNNABLE -> WAITING 상태가 된다.
  • 생산자가 데이터를 큐에 저장하고 나면 notify() 를 통해 저장된 데이터가 있다고 대기하는 스레드에 알려주어야 한다.
    • 예를 들어서 큐에 데이터가 없어서 대기하는 소비자 스레드가 있다고 가정하자.
    • 이때 notify() 를 호 출하면 소비자 스레드는 깨어나서 저장된 데이터를 획득할 수 있다.

take() - wait(), notify()

  • synchronized 를 통해 임계 영역을 설정한다. 소비자 스레드는 락 획득을 시도한다.
  • 락을 획득한 소비자 스레드는 반복문을 사용해서 큐에 데이터가 있는지 주기적으로 체크한다.
    • 만약 데이터가 없다면 Object.wait() 을 사용해서 대기한다.
    • 참고로 대기할 때 락을 반납하고 대기한다.
    • 그리고 대기 상태에서 깨어나면, 다시 반복문에서 큐에 데이터가 있는지 체크한다.
  • 대기하는 경우 RUNNABLE -> WAITING 상태가 된다.
  • 소비자가 데이터를 획득하고 나면 notify() 를 통해 큐에 저장할 여유 공간이 생겼다고, 대기하는 스레드에게 알려주어야 한다.
    • 예를 들어서 큐에 데이터가 가득 차서 대기하는 생산자 스레드가 있다고 가정하자.
    • 이때 notify() 를 호출하면 생산자 스레드는 깨어나서 데이터를 큐에 저장할 수 있다.

wait() 로 대기 상태에 빠진 스레드는 notify() 를 사용해야 깨울 수 있다.
생산자는 생산을 완료하면 notify() 로 대기하는 스레드를 깨워서 생산된 데이터를 가져가게 하고, 소비자는 소비를 완료하면 notify() 로 대기하는 스레드를 깨워서 데이터를 생산하라고 하면 된다.
여기서 중요한 핵심은 wait() 를 호출해서 대기 상태에 빠질 때 락을 반납하고 대기 상태에 빠진다는 것이다.
대기 상태에 빠지면 어차피 아무일도 하지 않으므로 락도 필요하지 않다.

BoundedMain - BoundedQueueV3를 사용하도록 변경

//BoundedQueue queue = new BoundedQueueV2(2);        
BoundedQueue queue = new BoundedQueueV3(2);  

실행 결과 - BoundedQueueV3, 생산자 먼저 실행

14:47:49.966 [     main] == [생산자 먼저 실행] 시작,BoundedQueueV3==

14:47:49.967 [     main] 생산자 시작
14:47:49.980 [producer1] [생산 시도] data1 -> []
14:47:49.980 [producer1] [put] 생산자 데이터 저장, notify() 호출
14:47:49.980 [producer1] [생산 완료] data1 -> [data1]
14:47:50.083 [producer2] [생산 시도] data2 -> [data1]
14:47:50.083 [producer2] [put] 생산자 데이터 저장, notify() 호출
14:47:50.083 [producer2] [생산 완료] data2 -> [data1, data2]
14:47:50.189 [producer3] [생산 시도] data3 -> [data1, data2]
14:47:50.189 [producer3] [put] 큐가 가득 참, 생산자 대기

14:47:50.296 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
14:47:50.297 [     main] producer1: TERMINATED
14:47:50.297 [     main] producer2: TERMINATED
14:47:50.297 [     main] producer3: WAITING

14:47:50.297 [     main] 소비자 시작
14:47:50.299 [consumer1] [소비 시도]     ? <- [data1, data2]
14:47:50.300 [consumer1] [take] 소비자 데이터 획득, notify() 호출
14:47:50.300 [producer3] [put] 생산자 깨어남
14:47:50.300 [producer3] [put] 생산자 데이터 저장, notify() 호출
14:47:50.300 [consumer1] [소비 완료] data1 <- [data2]
14:47:50.300 [producer3] [생산 완료] data3 -> [data2, data3]
14:47:50.404 [consumer2] [소비 시도]     ? <- [data2, data3]
14:47:50.404 [consumer2] [take] 소비자 데이터 획득, notify() 호출
14:47:50.405 [consumer2] [소비 완료] data2 <- [data3]
14:47:50.510 [consumer3] [소비 시도]     ? <- [data3]
14:47:50.510 [consumer3] [take] 소비자 데이터 획득, notify() 호출
14:47:50.510 [consumer3] [소비 완료] data3 <- []

14:47:50.617 [     main] 현재 상태 출력, 큐 데이터: []
14:47:50.617 [     main] producer1: TERMINATED
14:47:50.617 [     main] producer2: TERMINATED
14:47:50.618 [     main] producer3: TERMINATED
14:47:50.618 [     main] consumer1: TERMINATED
14:47:50.618 [     main] consumer2: TERMINATED
14:47:50.618 [     main] consumer3: TERMINATED
14:47:50.619 [     main] == [생산자 먼저 실행] 종료,BoundedQueueV3==

실행 결과 - BoundedQueueV3, 소비자 먼저 실행

14:48:19.549 [     main] == [소비자 먼저 실행] 시작,BoundedQueueV3==

14:48:19.551 [     main] 소비자 시작
14:48:19.555 [consumer1] [소비 시도]     ? <- []
14:48:19.555 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:48:19.658 [consumer2] [소비 시도]     ? <- []
14:48:19.658 [consumer2] [take] 큐에 데이터가 없음, 소비자 대기
14:48:19.765 [consumer3] [소비 시도]     ? <- []
14:48:19.765 [consumer3] [take] 큐에 데이터가 없음, 소비자 대기

14:48:19.875 [     main] 현재 상태 출력, 큐 데이터: []
14:48:19.879 [     main] consumer1: WAITING
14:48:19.880 [     main] consumer2: WAITING
14:48:19.880 [     main] consumer3: WAITING

14:48:19.880 [     main] 생산자 시작
14:48:19.883 [producer1] [생산 시도] data1 -> []
14:48:19.883 [producer1] [put] 생산자 데이터 저장, notify() 호출
14:48:19.884 [consumer1] [take] 소비자 깨어남
14:48:19.884 [producer1] [생산 완료] data1 -> [data1]
14:48:19.884 [consumer1] [take] 소비자 데이터 획득, notify() 호출
14:48:19.884 [consumer2] [take] 소비자 깨어남
14:48:19.884 [consumer1] [소비 완료] data1 <- []
14:48:19.884 [consumer2] [take] 큐에 데이터가 없음, 소비자 대기
14:48:19.997 [producer2] [생산 시도] data2 -> []
14:48:19.997 [producer2] [put] 생산자 데이터 저장, notify() 호출
14:48:19.998 [consumer3] [take] 소비자 깨어남
14:48:19.998 [consumer3] [take] 소비자 데이터 획득, notify() 호출
14:48:19.998 [producer2] [생산 완료] data2 -> [data2]
14:48:19.998 [consumer3] [소비 완료] data2 <- []
14:48:19.998 [consumer2] [take] 소비자 깨어남
14:48:19.999 [consumer2] [take] 큐에 데이터가 없음, 소비자 대기
14:48:20.103 [producer3] [생산 시도] data3 -> []
14:48:20.104 [producer3] [put] 생산자 데이터 저장, notify() 호출
14:48:20.104 [producer3] [생산 완료] data3 -> [data3]
14:48:20.104 [consumer2] [take] 소비자 깨어남
14:48:20.104 [consumer2] [take] 소비자 데이터 획득, notify() 호출
14:48:20.105 [consumer2] [소비 완료] data3 <- []

14:48:20.209 [     main] 현재 상태 출력, 큐 데이터: []
14:48:20.210 [     main] consumer1: TERMINATED
14:48:20.210 [     main] consumer2: TERMINATED
14:48:20.211 [     main] consumer3: TERMINATED
14:48:20.211 [     main] producer1: TERMINATED
14:48:20.211 [     main] producer2: TERMINATED
14:48:20.211 [     main] producer3: TERMINATED
14:48:20.212 [     main] == [소비자 먼저 실행] 종료,BoundedQueueV3==

참고: 모든 로그의 실행 순서는 각 환경마다 다를 수 있다. 그리고 여러분의 이해를 돕기 위해 실행시간이 같은 일 부 로그는 순서를 조정했다

2-8. Object - wait, notify - 예제 3 분석 - 생산자 우선

BoundedQueueV3 - 생산자 먼저 실행 분석

실행 결과 - BoundedQueueV3, 생산자 먼저 실행

14:47:49.966 [     main] == [생산자 먼저 실행] 시작,BoundedQueueV3==

14:47:49.967 [     main] 생산자 시작
14:47:49.980 [producer1] [생산 시도] data1 -> []
14:47:49.980 [producer1] [put] 생산자 데이터 저장, notify() 호출
14:47:49.980 [producer1] [생산 완료] data1 -> [data1]
14:47:50.083 [producer2] [생산 시도] data2 -> [data1]
14:47:50.083 [producer2] [put] 생산자 데이터 저장, notify() 호출
14:47:50.083 [producer2] [생산 완료] data2 -> [data1, data2]
14:47:50.189 [producer3] [생산 시도] data3 -> [data1, data2]
14:47:50.189 [producer3] [put] 큐가 가득 참, 생산자 대기

14:47:50.296 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
14:47:50.297 [     main] producer1: TERMINATED
14:47:50.297 [     main] producer2: TERMINATED
14:47:50.297 [     main] producer3: WAITING

14:47:50.297 [     main] 소비자 시작
14:47:50.299 [consumer1] [소비 시도]     ? <- [data1, data2]
14:47:50.300 [consumer1] [take] 소비자 데이터 획득, notify() 호출
14:47:50.300 [producer3] [put] 생산자 깨어남
14:47:50.300 [producer3] [put] 생산자 데이터 저장, notify() 호출
14:47:50.300 [consumer1] [소비 완료] data1 <- [data2]
14:47:50.300 [producer3] [생산 완료] data3 -> [data2, data3]
14:47:50.404 [consumer2] [소비 시도]     ? <- [data2, data3]
14:47:50.404 [consumer2] [take] 소비자 데이터 획득, notify() 호출
14:47:50.405 [consumer2] [소비 완료] data2 <- [data3]
14:47:50.510 [consumer3] [소비 시도]     ? <- [data3]
14:47:50.510 [consumer3] [take] 소비자 데이터 획득, notify() 호출
14:47:50.510 [consumer3] [소비 완료] data3 <- []

14:47:50.617 [     main] 현재 상태 출력, 큐 데이터: []
14:47:50.617 [     main] producer1: TERMINATED
14:47:50.617 [     main] producer2: TERMINATED
14:47:50.618 [     main] producer3: TERMINATED
14:47:50.618 [     main] consumer1: TERMINATED
14:47:50.618 [     main] consumer2: TERMINATED
14:47:50.618 [     main] consumer3: TERMINATED
14:47:50.619 [     main] == [생산자 먼저 실행] 종료,BoundedQueueV3==

참고: 모든 로그의 실행 순서는 각 환경마다 다를 수 있다. 그리고 여러분의 이해를 돕기 위해 실행시간이 같은 일 부 로그는 순서를 조정했다.

생산자 스레드 실행 시작

14:47:49.966 [     main] == [생산자 먼저 실행] 시작,BoundedQueueV3==

14:47:49.967 [     main] 생산자 시작

스레드 대기 집합(wait set)

  • synchronized 임계 영역 안에서 Object.wait() 를 호출하면 스레드는 대기( WAITING ) 상태에 들어간다. 이렇게 대기 상태에 들어간 스레드를 관리하는 것을 대기 집합(wait set)이라 한다.
    • 참고로 모든 객체는 각자의 대기 집합을 가지고 있다.
  • 모든 객체는 락(모니터 락)과 대기 집합을 가지고 있다. 둘은 한 쌍으로 사용된다. 따라서 락을 획득한 객체의 대기 집합을 사용해야 한다. 여기서는 BoundedQueue(x001) 구현 인스턴스의 락과 대기 집합을 사용한다.
    • synchronized 를 메서드에 적용하면 해당 인스턴스의 락을 사용한다.
      • 여기서는 BoundedQueue(x001) 의 구현체이다.
    • wait() 호출은 앞에 this 를 생략할 수 있다.
      • this 는 해당 인스턴스를 뜻한다.
      • 여기서는 BoundedQueue(x001) 의 구현체이다.
14:47:49.980 [producer1] [생산 시도] data1 -> []
14:47:49.980 [producer1] [put] 생산자 데이터 저장, notify() 호출
  • p1 이 락을 획득하고 큐에 데이터를 저장한다.
  • 큐에 데이터가 추가 되었기 때문에 스레드 대기 집합에 이 사실을 알려야 한다.
  • notify() 를 호출하면 스레드 대기 집합에서 대기하는 스레드 중 하나를 깨운다.
  • 현재 대기 집합에 스레드가 없으므로 아무일도 발생하지 않는다. 만약 소비자 스레드가 대기 집합에 있었다면 깨 어나서 큐에 들어있는 데이터를 소비했을 것이다.
14:47:49.980 [producer1] [생산 완료] data1 -> [data1]
14:47:50.083 [producer2] [생산 시도] data2 -> [data1]
14:47:50.083 [producer2] [put] 생산자 데이터 저장, notify() 호출
14:47:50.083 [producer2] [생산 완료] data2 -> [data1, data2]
  • p2 도 큐에 데이터를 저장하고 생산을 완료한다.
14:47:50.189 [producer3] [생산 시도] data3 -> [data1, data2]
14:47:50.189 [producer3] [put] 큐가 가득 참, 생산자 대기
  • p3 가 데이터를 생산하려고 하는데, 큐가 가득 찼다. wait() 를 호출한다.

생산자 스레드 실행 완료

  • wait() 를 호출하면
    • 락을 반납한다.
    • 스레드의 상태가 RUNNABLE -> WAITING 로 변경된다.
    • 스레드 대기 집합에서 관리된다.
  • 스레드 대기 집합에서 관리되는 스레드는 이후에 다른 스레드가 notify() 를 통해 스레드 대기 집합에 신호를 주면 깨어날 수 있다.
14:47:50.296 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
14:47:50.297 [     main] producer1: TERMINATED
14:47:50.297 [     main] producer2: TERMINATED
14:47:50.297 [     main] producer3: WAITING

소비자 스레드 실행 시작

14:47:50.297 [     main] 소비자 시작
14:47:50.299 [consumer1] [소비 시도]     ? <- [data1, data2]
14:47:50.300 [consumer1] [take] 소비자 데이터 획득, notify() 호출
  • 소비자 스레드가 데이터를 획득했기 때문에 큐에 데이터를 보관할 빈자리가 생겼다.
  • 소비자 스레드는 notify() 를 호출해서 스레드 대기 집합에 이 사실을 알려준다.
  • 스레드 대기 집합은 notify() 신호를 받으면 대기 집합에 있는 스레드 중 하나를 깨운다.
  • 그런데 대기 집합에 있는 스레드가 깨어난다고 바로 작동하는 것은 아니다.
    • 깨어난 스레드는 여전히 임계 영역 안에 있다.
  • 임계 영역에 있는 코드를 실행하려면 먼저 락이 필요하다.
    • p3 는 대기 집합에서는 나가지만 여전히 임계 영역에 있으므로 락을 획득하기 위해 BLOCKED 상태로 대기한다.
    • 당연한 이야기지만 임계 영역 안에서 2개의 스레드가 실행되면 큰 문제가 발생한다!
    • 임계 영역 안에서는 락을 가지고 있는 하나의 스레드만 실행 되어야 한다.
      • p3 : WAITING -> BLOCKED
  • 참고로 이때 임계 영역의 코드를 처음으로 돌아가서 실행하는 것은 아니다.
    • 대기 집합에 들어오게 된 wait() 를 호출한 부분 부터 실행된다.
    • 락을 획득하면wait() 이후의 코드를 실행한다.
14:47:50.300 [consumer1] [소비 완료] data1 <- [data2]
  • c1 은 데이터 소비를 완료하고 락을 반납하고 임계 영역을 빠져나간다.
14:47:50.300 [producer3] [put] 생산자 깨어남
14:47:50.300 [producer3] [put] 생산자 데이터 저장, notify() 호출
  • p3 가 락을 획득한다.
    • BLOCKED -> RUNNABLE
    • wait() 코드에서 대기했기 때문에 이후의 코드를 실행한다.
    • data3 을 큐에 저장한다.
    • notify() 를 호출한다.
      • 데이터를 저장했기 때문에 혹시 스레드 대기 집합에 소비자가 대기하고 있다면 소비자를 하나 깨워야 한다.
      • 물론 지금은 대기 집합에 스레드가 없기 때문에 아무 일도 일어나지 않는다.
14:47:50.300 [producer3] [생산 완료] data3 -> [data2, data3]

소비자 스레드 실행 완료


14:47:50.404 [consumer2] [소비 시도]     ? <- [data2, data3]
14:47:50.404 [consumer2] [take] 소비자 데이터 획득, notify() 호출
14:47:50.405 [consumer2] [소비 완료] data2 <- [data3]
14:47:50.510 [consumer3] [소비 시도]     ? <- [data3]
14:47:50.510 [consumer3] [take] 소비자 데이터 획득, notify() 호출
14:47:50.510 [consumer3] [소비 완료] data3 <- []
  • c2 , c3 를 실행한다. 데이터가 있으므로 둘다 데이터를 소비하고 완료한다.
  • 둘다 notify() 를 호출하지만 대기 집합에 스레드가 없으므로 아무일도 발생하지 않는다.
14:47:50.617 [     main] 현재 상태 출력, 큐 데이터: []
14:47:50.617 [     main] producer1: TERMINATED
14:47:50.617 [     main] producer2: TERMINATED
14:47:50.618 [     main] producer3: TERMINATED
14:47:50.618 [     main] consumer1: TERMINATED
14:47:50.618 [     main] consumer2: TERMINATED
14:47:50.618 [     main] consumer3: TERMINATED
14:47:50.619 [     main] == [생산자 먼저 실행] 종료,BoundedQueueV3==

정리

wait() , notify() 덕분에 스레드가 락을 놓고 대기하고, 또 대기하는 스레드를 필요한 시점에 깨울 수 있었다.
생산자 스레드가 큐가 가득차서 대기해도, 소비자 스레드가 큐의 데이터를 소비하고 나면 알려주기 때문에, 최적의 타이밍에 깨어나서 데이터를 생산할 수 있었다.
덕분에 최종 결과를 보면 p1 , p2 , p3 는 모두 데이터를 정상 생산하고, c1 , c2 , c3 는 모두 데이터를 정상 소비할 수 있었다.
다음에는 반대로 소비자를 먼저 실행해보자.

2-9. Object - wait, notify - 예제 3 분석 - 소비자 우선

BoundedQueueV3 - 소비자 먼저 실행 분석

실행 결과 - BoundedQueueV3, 소비자 먼저 실행

14:48:19.549 [     main] == [소비자 먼저 실행] 시작,BoundedQueueV3==

14:48:19.551 [     main] 소비자 시작
14:48:19.555 [consumer1] [소비 시도]     ? <- []
14:48:19.555 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:48:19.658 [consumer2] [소비 시도]     ? <- []
14:48:19.658 [consumer2] [take] 큐에 데이터가 없음, 소비자 대기
14:48:19.765 [consumer3] [소비 시도]     ? <- []
14:48:19.765 [consumer3] [take] 큐에 데이터가 없음, 소비자 대기

14:48:19.875 [     main] 현재 상태 출력, 큐 데이터: []
14:48:19.879 [     main] consumer1: WAITING
14:48:19.880 [     main] consumer2: WAITING
14:48:19.880 [     main] consumer3: WAITING

14:48:19.880 [     main] 생산자 시작
14:48:19.883 [producer1] [생산 시도] data1 -> []
14:48:19.883 [producer1] [put] 생산자 데이터 저장, notify() 호출
14:48:19.884 [consumer1] [take] 소비자 깨어남
14:48:19.884 [producer1] [생산 완료] data1 -> [data1]
14:48:19.884 [consumer1] [take] 소비자 데이터 획득, notify() 호출
14:48:19.884 [consumer2] [take] 소비자 깨어남
14:48:19.884 [consumer1] [소비 완료] data1 <- []
14:48:19.884 [consumer2] [take] 큐에 데이터가 없음, 소비자 대기
14:48:19.997 [producer2] [생산 시도] data2 -> []
14:48:19.997 [producer2] [put] 생산자 데이터 저장, notify() 호출
14:48:19.998 [consumer3] [take] 소비자 깨어남
14:48:19.998 [consumer3] [take] 소비자 데이터 획득, notify() 호출
14:48:19.998 [producer2] [생산 완료] data2 -> [data2]
14:48:19.998 [consumer3] [소비 완료] data2 <- []
14:48:19.998 [consumer2] [take] 소비자 깨어남
14:48:19.999 [consumer2] [take] 큐에 데이터가 없음, 소비자 대기
14:48:20.103 [producer3] [생산 시도] data3 -> []
14:48:20.104 [producer3] [put] 생산자 데이터 저장, notify() 호출
14:48:20.104 [producer3] [생산 완료] data3 -> [data3]
14:48:20.104 [consumer2] [take] 소비자 깨어남
14:48:20.104 [consumer2] [take] 소비자 데이터 획득, notify() 호출
14:48:20.105 [consumer2] [소비 완료] data3 <- []

14:48:20.209 [     main] 현재 상태 출력, 큐 데이터: []
14:48:20.210 [     main] consumer1: TERMINATED
14:48:20.210 [     main] consumer2: TERMINATED
14:48:20.211 [     main] consumer3: TERMINATED
14:48:20.211 [     main] producer1: TERMINATED
14:48:20.211 [     main] producer2: TERMINATED
14:48:20.211 [     main] producer3: TERMINATED
14:48:20.212 [     main] == [소비자 먼저 실행] 종료,BoundedQueueV3==

참고: 모든 로그의 실행 순서는 각 환경마다 다를 수 있다. 그리고 여러분의 이해를 돕기 위해 실행시간이 같은 일 부 로그는 순서를 조정했다.

소비자 스레드 실행 시작

14:48:19.549 [     main] == [소비자 먼저 실행] 시작,BoundedQueueV3==

14:48:19.551 [     main] 소비자 시작
14:48:19.555 [consumer1] [소비 시도]     ? <- []
14:48:19.555 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기

소비자 스레드 실행 완료

14:48:19.658 [consumer2] [소비 시도]     ? <- []
14:48:19.658 [consumer2] [take] 큐에 데이터가 없음, 소비자 대기
14:48:19.765 [consumer3] [소비 시도]     ? <- []
14:48:19.765 [consumer3] [take] 큐에 데이터가 없음, 소비자 대기
  • 큐에 데이터가 없기 때문에 c1 , c2 ,c3 모두 스레드 대기 집합에서 대기한다.
  • 이후에 생산자가 큐에 데이터를 생산하면 notify() 를 통해 이 스레드들을 하나씩 깨워서 데이터를 소비할 수 있을 것이다.
14:48:19.875 [     main] 현재 상태 출력, 큐 데이터: []
14:48:19.879 [     main] consumer1: WAITING
14:48:19.880 [     main] consumer2: WAITING
14:48:19.880 [     main] consumer3: WAITING

생산자 스레드 실행 시작

14:48:19.880 [     main] 생산자 시작
14:48:19.883 [producer1] [생산 시도] data1 -> []
14:48:19.883 [producer1] [put] 생산자 데이터 저장, notify() 호출
  • p1 은 락을 획득하고, 큐에 데이터를 생산한다. 큐에 데이터가 있기 때문에 소비자를 하나 깨울 수 있다.
    • notify() 를 통해 스레드 대기 집합에 이 사실을 알려준다.
  • notify() 를 받은 스레드 대기 집합은 스레드 중에 하나를 깨운다.
  • 여기서 c1 , c2 , c3 중에 어떤 스레드가 깨어날까? 정답은 "예측할 수 없다"이다.
    • 어떤 스레드가 깨워질지는 JVM 스펙에 명시되어 있지 않다.
    • 따라서 JVM 버전 환경등에 따라서 달라진다.
  • 그런데 대기 집합에 있는 스레드가 깨어난다고 바로 작동하는 것은 아니다.
    • 깨어난 스레드는 여전히 임계 영역 안에 있다.
  • 임계 영역에 있는 코드를 실행하려면 먼저 락이 필요하다.
    • 대기 집합에서는 나가지만 여전히 임계 영역에 있으므로 락을 획득하기 위해 BLOCKED 상태로 대기한다.
    • c1 : WAITING -> BLOCKED
14:48:19.884 [producer1] [생산 완료] data1 -> [data1]
14:48:19.884 [consumer1] [take] 소비자 깨어남
14:48:19.884 [consumer1] [take] 소비자 데이터 획득, notify() 호출
  • c1 은 락을 획득하고, 임계 영역 안에서 실행되며 데이터를 획득한다.
  • c1 이 데이터를 획득했으므로 큐에 데이터를 넣을 공간이 있다는 것을 대기 집합에 알려준다.
    • 만약 대기 집합에 생산자 스레드가 대기하고 있다면 큐에 데이터를 넣을 수 있을 것이다.
  • c1notify() 로 스레드 대기 집합에 알렸지만, 생산자 스레드가 아니라 소비자 스레드만 있다.
  • 따라서 의도와는 다르게 소비자 스레드인 c2 가 대기 상태에서 깨어난다.
    • 물론 대기 집합에 있는 어떤 스레드가 깨어날지는 알 수 없다. 여기서는 c2 가 깨어난다고 가정한다.
    • 심지어 생산자와 소비자 스레드가 함께 대기 집합에 있어도 어떤 스레드가 깨어날지는 알 수 없다.
14:48:19.884 [consumer1] [소비 완료] data1 <- []
  • c1 은 작업을 완료한다.
  • c1c2 를 깨웠지만, 문제가 하나 있다.
    • 바로 큐에 데이터가 없다는 점이다.
14:48:19.884 [consumer2] [take] 소비자 깨어남
14:48:19.884 [consumer2] [take] 큐에 데이터가 없음, 소비자 대기
  • c2 는 락을 획득하고, 큐에 데이터를 소비하려고 시도 한다. 그런데 큐에는 데이터가 없다.
  • 큐에 데이터가 없기 때문에, c2 는 결국 wait() 를 호출해서 대기 상태로 변하며 다시 대기 집합에 들어간다.
  • 이처럼 소비자인 c1 이 같은 소비자인 c2 를 깨우는 것은 상당히 비효율적이다.
  • c1 입장에서 c2 를 깨우게 되면 아무 일도 하지 않고 그냥 다시 스레드 대기 집합에 들어갈 수 있다.
    • 결과적으로 CPU만 사용하고, 아무 일도 하지 않은 상태로 다시 대기 상태가 되어버린다.
  • 그렇다고 c1 이 스레드 대기 집합에 있는 어떤 스레드를 깨울지 선택할 수는 없다.
    • notify() 는 스레드 대기 집합에 있는 스레드 중 임의의 하나를 깨울 뿐이다.
  • 물론 이것이 비효율적이라는 것이지 문제가 되는 것은 아니다.
    • 결과에는 문제가 없다.
    • 가끔씩 약간 돌아서 갈 뿐이다.
14:48:19.997 [producer2] [생산 시도] data2 -> []
14:48:19.997 [producer2] [put] 생산자 데이터 저장, notify() 호출
  • p2 가 락을 획득하고 데이터를 저장한 다음에 notify() 를 호출한다.
    • 데이터가 있으므로 소비자 스레드가 깨어 난다면 데이터를 소비할 수 있다
  • 스레드 대기 집합에 있는 c3 가 깨어난다.
    • 참고로 어떤 스레드가 깨어날 지는 알 수 없다.
  • c3 는 임계 영역 안에 있으므로 락을 획득하기 위해 대기( BLOCKED )한다.
14:48:19.998 [producer2] [생산 완료] data2 -> [data2]
14:48:19.998 [consumer3] [take] 소비자 깨어남
14:48:19.998 [consumer3] [take] 소비자 데이터 획득, notify() 호출
  • c3 는 락을 획득하고 BLOCKED -> RUNNABLE 상태가 된다.
  • c3 는 데이터를 획득한 다음에 notify() 를 통해 스레드 대기 집합에 알린다.
    • 큐에 여유 공간이 생겼기 때문에 생산자 스레드가 대기 중이라면 데이터를 생산할 수 있다.
  • 생산자 스레드를 깨울 것으로 기대하고, notify() 를 호출했지만 스레드 대기 집합에는 소비자인 c2 만 존재한다.
  • c2 가 깨어나지만 임계 영역 안에 있으므로 락을 기다리는 BLOCKED 상태가 된다.
14:48:19.998 [consumer3] [소비 완료] data2 <- []
14:48:19.998 [consumer3] [take] 소비자 깨어남
14:48:19.998 [consumer3] [take] 소비자 데이터 획득, notify() 호출
  • c2 가 락을 획득하고, 큐에서 데이터를 획득하려 하지만 데이터가 없다.
  • c2 는 다시 wait() 를 호출해서 대기( WAITING ) 상태에 들어가고, 다시 대기 집합에서 관리된다.
  • 물론 c2 의 지금 이 사이클은 CPU 자원만 소모하고 다시 대기 집합에 들어갔기 때문에 비효율적이다.
  • 만약 소비자인 c3 입장에서 생산자, 소비자 스레드를 선택해서 깨울 수 있다면, 소비자인 c2 를 깨우지는 않았을 것이다.
    • 하지만 notify() 는 이런 선택을 할 수 없다.
14:48:20.103 [producer3] [생산 시도] data3 -> []
14:48:20.104 [producer3] [put] 생산자 데이터 저장, notify() 호출
  • p3 가 데이터를 저장하고 notify() 를 통해 스레드 대기 집합에 알린다.
  • 스레드 대기 집합에는 소비자 c2 가 있으므로 생산한 데이터를 잘 소비할 수 있다.
14:48:20.104 [producer3] [생산 완료] data3 -> [data3]
14:48:20.104 [consumer2] [take] 소비자 깨어남
14:48:20.104 [consumer2] [take] 소비자 데이터 획득, notify() 호출
14:48:20.105 [consumer2] [소비 완료] data3 <- []

생산자 스레드 실행 완료

14:48:20.209 [     main] 현재 상태 출력, 큐 데이터: []
14:48:20.210 [     main] consumer1: TERMINATED
14:48:20.210 [     main] consumer2: TERMINATED
14:48:20.211 [     main] consumer3: TERMINATED
14:48:20.211 [     main] producer1: TERMINATED
14:48:20.211 [     main] producer2: TERMINATED
14:48:20.211 [     main] producer3: TERMINATED
14:48:20.212 [     main] == [소비자 먼저 실행] 종료,BoundedQueueV3==

정리
최종 결과를 보면 p1 , p2 , p3 는 모두 데이터를 정상 생산하고, c1 , c2 , c3 는 모두 데이터를 정상 소비할 수 있었다.
하지만 소비자인 c1 이 같은 소비자인 c2 , c3 를 깨울 수 있었다.
이 경우 큐에 데이터가 없을 가능성이 있다.
이때는 깨어난 소비자 스레드가 CPU 자원만 소모하고 다시 대기 집합에 들어갔기 때문에 비효율적이다.

만약 소비자인 c1 입장에서 생산자, 소비자 스레드를 선택해서 깨울 수 있다면, 소비자인 c2 를 깨우지는 않았을 것이다.
예를 들어서 소비자는 생산자만 깨우고, 생산자는 소비자만 깨울 수 있다면 더 효율적으로 작동할 수 있을 것 같다.
하지만 notify() 는 이런 선택을 할 수 없다.
물론 이것이 비효율적이라는 것이지 결과에는 아무런 문제가 없다. 약간 돌아서 갈 뿐이다.

2-10. Object - wait, notify - 한계

지금까지 살펴본 Object.wait() , Object.notify() 방식은 스레드 대기 집합 하나에 생산자, 소비자 스레드를 모두 관리한다.
그리고 notify() 를 호출할 때 임의의 스레드가 선택된다.
따라서 앞서 살펴본 것 처럼 큐에 데이터가 없는 상황에 소비자가 같은 소비자를 깨우는 비효율이 발생할 수 있다.
또는 큐에 데이터가 가득 차있는데 생산자가 같은 생산자를 깨우는 비효율도 발생할 수 있다.
이 문제를 다시 한번 정리해보자.

비효율 - 생산자 실행 예시

다음과 같은 상황을 가정하겠다.

  • 큐에 dataX 가 보관되어 있다.
  • 스레드 대기 집합에는 다음 스레드가 대기하고 있다.
    • 소비자: c1 , c2 , c3
    • 생산자: p1 , p2 , p3
  • p0 스레드가 data0 생산을 시도한다.
  • p0 스레드가 실행되면서 data0 를 큐에 저장한다.
    • 이때 큐에 데이터가 가득 찬다.
  • notify() 를 통해 대기 집합의 스레드를 하나 깨운다.
  • 만약 notify() 의 결과로 소비자 스레드가 깨어나게 되면 소비자 스레드는 큐의 데이터를 획득하고, 완료된다.
  • 만약 notify() 의 결과로 생산자 스레드를 깨우게 되면, 이미 큐에 데이터는 가득 차 있다.
  • 따라서 데이터를 생산하지 못하고 다시 대기 집합으로 이동하는 비효율이 발생한다.

비효율 - 소비자 실행 예시

이번에는 반대의 경우로 소비자 c0 를 실행해보자.

  • c0 스레드가 실행되고 data0 를 획득한다.
  • 이제 큐에 데이터는 비어있게 된다.
  • c0 스레드는 notify() 를 호출한다.
  • 스레드 대기 집합에서 소비자 스레드가 깨어나면 큐에 데이터가 없기 때문에 다시 대기 집합으로 이동하는 비효율이 발생한다.
  • 스레드 대기 집합에서 생산자 스레드가 깨어나면 큐에 데이터를 저장하고 완료된다.

같은 종류의 스레드를 깨울 때 비효율이 발생한다.

이 내용을 통해서 알 수 있는 사실은 생산자가 같은 생산자를 깨우거나, 소비자가 같은 소비자를 깨울 때 비효율이 발생 할 수 있다는 점이다.
생산자가 소비자를 깨우고, 반대로 소비자가 생산자를 깨운다면 이런 비효율은 발생하지 않는다.

스레드 기아(thread starvation)

notify() 의 또 다른 문제점으로는 어떤 스레드가 깨어날 지 알 수 없기 때문에 발생할 수 있는 스레드 기아 문제가 있다.

  • notify() 가 어떤 스레드를 깨울지는 알 수 없다. 최악의 경우 c1 ~ c5 스레드가 반복해서 깨어날 수 있다.
    • c1 이 깨어나도 큐에 소비할 데이터가 없다. 따라서 다시 스레드 대기 집합에 들어간다.
    • notify() 로 다시 깨우는데 어떤 스레드를 깨울지 알 수 없다.
      • 따라서 c1 ~ c5 스레드가 반복해서 깨어날 수 있다.
  • p1 은 실행 순서를 얻지 못하다가 아주 나중에 깨어날 수도 있다.
  • 이렇게 대기 상태의 스레드가 실행 순서를 계속 얻지 못해서 실행되지 않는 상황을 스레드 기아(starvation) 상태라 한다.
  • 물론 p1 이 가장 먼저 실행될 수도 있다.

이런 문제를 해결하는 방법 중에 notify() 대신에 notifyAll() 을 사용하는 방법이 있다.

notifyAll()

notifyAll() 을 사용하면 스레드 대기 집합에 있는 모든 스레드를 한번에 다 깨울 수 있다.

  • 데이터를 획득한 c0 스레드가 notifyAll() 을 호출한다.
  • 대기 집합에 있는 모든 스레드가 깨어난다.
  • 모든 스레드는 다 임계 영역 안에 있다. 따라서 락을 먼저 획득해야 한다.
  • 락을 획득하지 못하면 BLOCKED 상태가 된다.
  • 만약 c1 이 먼저 락을 먼저 획득한다면 큐에 데이터가 없으므로 다시 스레드 대기 집합에 들어간다.
  • c2 ~ c5 모두 마찬가지이다.
  • 따라서 p1 이 가장 늦게 락 획득을 시도해도, c1 ~ c5 는 모두 스레드 대기 집합에 들어갔으므로 결과적으로 p1 만 남게 되고, 결국 락을 획득하게 된다.
  • p1 은 락을 획득하고, 데이터를 생성한 다음에 notifyAll() 을 호출하고 실행을 완료할 수 있다.
  • 참고로 반대의 경우도 같은 스레드 기아 문제가 발생할 수 있기 때문에 notifyAll() 을 호출한다.

결과적으로 notifyAll() 을 사용해서 스레드 기아 문제는 막을 수 있지만, 비효율을 막지는 못한다.

정리

지금까지 만든 BoundedQueue 의 구현체들을 간단하게 정리하면,

BoundedQueueV1

  • 단순한 큐 자료 구조이다. 스레드를 제어할 수 없기 때문에, 버퍼가 가득 차거나, 버퍼에 데이터가 없는 한정된 버퍼 상황에서 문제가 발생한다.
  • 버퍼가 가득 찬 경우: 생산자의 데이터를 버린다.
  • 버퍼에 데이터가 없는 경우: 소비자는 데이터를 획득할 수 없다. ( null )

BoundedQueueV2

  • 앞서 발생한 문제를 해결하기 위해 반복문을 사용해서 스레드를 대기하는 방법을 적용했다.
    • 하지만 synchronized 임계 영역 안에서 락을 들고 대기하기 때문에, 다른 스레드가 임계 영역에 접근할 수 없는 문제가 발생했다.
  • 결과적으로 나머지 스레드는 모두 BLOCKED 상태가 되고, 자바 스레드 세상이 멈추는 심각한 문제가 발생했다.

BoundedQueueV3

  • synchronized 와 함께 사용할 수 있는 wait() , notify() , notifyAll() 을 사용해서 문제를 해결했다.
    • wait() 를 사용하면 스레드가 대기할 때, 락을 반납하고 대기한다.
    • 이후에 notify() 를 호출하면 스레드가 깨어나면서 락 획득을 시도한다.
    • 이때 락을 획득하면 RUNNABLE 상태가 되고, 락을 획득하지 못하면 락 획득을 대기하는 BLOCKED 상태가 된다.
  • 이렇게 해서 스레드를 제어하는 큐 자료 구조를 만들 수 있었다. 생산자 스레드는 버퍼가 가득차면 버퍼에 여유가 생길 때 까지 대기한다.
    • 소비자 스레드는 버퍼에 데이터가 없으면 버퍼에 데이터가 들어올 때 까지 대기한다.
  • 이런 구현 덕분에 단순한 자료 구조를 넘어서 스레드까지 제어할 수 있는 자료 구조를 완성했다.
  • 이 방식의 단점은 스레드가 대기하는 대기 집합이 하나이기 때문에, 원하는 스레드를 선택해서 깨울 수 없다는 문 제가 있었다.
    • 예를 들어서 생산자는 데이터를 생산한 다음 대기하는 소비자를 깨워야 하는데, 대기하는 생산자를 깨울 수 있다.
    • 따라서 비효율이 발생한다. 물론 이렇게 해도 비효율이 있을 뿐 로직은 모두 정상 작동한다.

지금까지 자바 synchronizedObject.wait() , Object.notify() , Object.notifyAll() 을 사용해서 생산자 소비자 문제를 해결해보았다.

이 기술을 사용한 덕분에 생산자는 큐에 데이터가 가득 차 있어도, 큐에 데이터를 저장할 공간이 생길 때 까지 대기할 수 있었다.
소비자도 큐에 데이터가 없어도, 큐에 데이터가 들어올 때 까지 대기할 수 있었다. 결과적으로 버리는 데이터 없이 안전하게 데이터를 큐에 보관하고 또 소비할 수 있었다.

하지만 이 방법은 일부 비효율이 발생했다.

생산자 스레드는 데이터를 생성하고, 대기중인 소비자 스레드에게 알려주어야 한다.
소비자 스레드는 데이터를 소비하고, 대기중인 생산자 스레드에게 알려주어야 한다.
하지만 스레드 대기 집합은 하나이고 이 안에 생산자 스레드와 소비자 스레드가 함께 대기한다.
그리고 notify() 는 원하는 목표를 지정할 수 없었다.
물론 notifyAll() 을 사용할 수 있지만, 원하지 않는 모든 스레드까지 모두 깨어난다.
이런 문제를 해결하려면 어떻게 해야할까?

(다음 챕터에서 알 수 있다.)

3. 요약

생산자 - 소비자 문제를 직면하면서 Objectnotify(), notifyAll(), wait() 로 스레드를 깨우거나 대기하게 하면서 생산자-소비자 문제를 해결했다.

하지만, 비효율이 발생한다.
생산자가 생산자를 깨울 수도 있고, 소비자는 소비자를 깨울 수도 있다.

생산자는 소비자를
소비자는 생산자를 깨울 수 있도록 해야하는데, 이를 다음 챕터에서 알아볼 수 있을 거 같다.

728x90
Comments