728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

저번 포스팅에서 다뤘던 생산자 소비자 문제의 두번째 포스팅이다. 이 포스팅에선 어떻게 저번 포스팅에 말했던 문제를 해결하는지를 알아보자. 우선 저번 포스팅에서 말했던 문제는 생산자가 생산자를 깨워버릴 수 있고, 소비자가 소비자를 깨워버릴 수 있다는 문제였다. 그렇게 되면 결국 깨어난 스레드는 아무것도 하지 못하고 다시 기다리는 상태로 돌아가야 한다. 

 

Lock Condition

이제, synchronized를 사용하지 않겠다. 이전에 synchronized의 단점을 극복하기 위해 사용했던 ReentrantLock을 사용하면 이 문제에서도 역시나 도움을 준다. 

 

어떻게 도움을 주냐? 기존의 문제는 생산자가 생산자를 깨울 가능성이 있고 반대로 소비자가 소비자를 깨울 가능성이 있기 때문에 비효율이 발생하는 것이다. 그리고 그 근본 원인은? 스레드 대기 집합이 딱 하나이기 때문이다. 이 스레드 대기 집합은 모든 객체가 기본으로 가지고 있다. 모니터 락과 synchronized와 같이 사용되는 것이다. 

 

그럼 스레드 대기 집합을 둘로 나누면 된다. 생산자용 대기 집합과 소비자용 대기 집합으로. 그리고 깨울땐 소비자라면 생산자용 스레드 대기 집합에 알리면 되고, 생산자라면 소비자용 스레드 대기 집합에 알리면 된다!

 

우선, 대기 집합을 분리하기 전에 앞에서 사용했던 synchronized, wait(), notify()를 통해 작성한 코드를 Lock 인터페이스와 ReentrantLock 구현체를 이용해서 다시 구현해보자!

 

BoundedQueueV4

package thread.bounded;

import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import static util.MyLogger.log;

public class BoundedQueueV4 implements BoundedQueue {

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

    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();

    public BoundedQueueV4(int max) {
        this.max = max;
    }

    @Override
    public void put(String data) {
        lock.lock();


        try {
            while (queue.size() == max) {
                log("[put] 큐가 가득 찼습니다. 생산자는 대기합니다.");
                try {
                    condition.await();
                    log("[put] 생산자 깨어남");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            queue.offer(data);
            log("[put] 생산자 데이터 저장, notify() 호출");
            condition.signal();
        } finally {
            lock.unlock();
        }
    }

    @Override
    public String take() {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                log("[take] 큐에 데이터가 없습니다. 소비자는 대기합니다");
                try {
                    condition.await();
                    log("[take] 소비자 깨어남");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            String data = queue.poll();
            log("[take] 소비자 데이터 획득, notify() 호출");
            condition.signal();
            return data;
        } finally {
            lock.unlock();
        }
    }

    @Override
    public String toString() {
        return queue.toString();
    }
}
  • synchronized 대신 Lock lock = new ReentrantLock을 사용한다.
  • Condition이 등장한다. 이 ConditionReentrantLock을 사용하는 스레드가 대기하는 스레드 대기 공간이다.
  • lock.newCondition() 메서드를 호출하면 스레드 대기 공간이 만들어진다. Lock(ReentrantLock)의 스레드 대기 공간은 이렇게 만들 수가 있다.
  • 참고로 synchronized, 모니터 락, wait(), notify()에서 사용한 스레드 대기 공간은 모든 객체 인스턴스가 내부에 기본으로 가지고 있다. 반면에 Lock(ReentrantLock)을 사용하는 경우 이렇게 스레드 대기 공간을 직접 만들어서 사용해야 한다.

condition.await()

Object.wait()과 유사한 기능이다. 지정한 condition에 현재 스레드를 대기(WAITING)상태로 보관한다. 이때 ReentrantLock에서 획득한 락을 반납하고 대기 상태로 condition에 보관된다.

 

condition.signal()

Object.notify()와 유사한 기능이다. 지정한 condition에서 대기중인 스레드를 하나 깨운다. 깨어난 스레드는 condition에서 빠져나온다. 

 

Lock lock = new ReentrantLock()

 

이 그림에서 locksynchronized에서 사용하는 객체 내부에 모니터 락이 아니라, ReentrantLock 락을 뜻한다. ReentrantLock은 내부에 락과, 락 획득을 대기하는 스레드를 관리하는 대기 큐가 있다. 

 

이 그림에서 스레드 대기 공간은 synchronized에서 사용하는 스레드 대기 공간이 아니라, 다음 코드를 뜻한다.

Condition condition = lock.newCondition()

ReentrantLock을 사용하면, condition이 스레드 대기 공간이다. 

 

지금까지는 synchronized, wait(), notify()를 사용한 이전 코드와 거의 비슷하다. 아직 생산자용, 소비자용 스레드 대기 공간을 따로 분리하지 않았기 때문에 기존 방식과 같다고 보면 된다. 다만 구현을 synchronized로 했는가 아니면 ReentrantLock을 사용해서 했는가에 차이가 있을 뿐이다. 

 

이대로 실행해보면 실행결과는 기존과 똑같을 것이다. 이제 스레드 대기 공간을 생산자용과 소비자용으로 분리해보자!

 

생산자, 소비자 대기 공간 분리

이런 그림을 만들어보자! 생각보다 엄청 간단하다.

 

BoundedQueueV5

package thread.bounded;

import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import static util.MyLogger.log;

public class BoundedQueueV5 implements BoundedQueue {

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

    private final Lock lock = new ReentrantLock();
    private final Condition producerCond = lock.newCondition();
    private final Condition consumerCond = lock.newCondition();

    public BoundedQueueV5(int max) {
        this.max = max;
    }

    @Override
    public void put(String data) {
        lock.lock();

        try {
            while (queue.size() == max) {
                log("[put] 큐가 가득 찼습니다. 생산자는 대기합니다.");
                try {
                    producerCond.await();
                    log("[put] 생산자 깨어남");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            queue.offer(data);
            log("[put] 생산자 데이터 저장, notify() 호출");
            consumerCond.signal();
        } finally {
            lock.unlock();
        }
    }

    @Override
    public String take() {
        lock.lock();

        try {
            while (queue.isEmpty()) {
                log("[take] 큐에 데이터가 없습니다. 소비자는 대기합니다");
                try {
                    consumerCond.await();
                    log("[take] 소비자 깨어남");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            String data = queue.poll();
            log("[take] 소비자 데이터 획득, notify() 호출");
            producerCond.signal();
            return data;
        } finally {
            lock.unlock();
        }
    }

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

 

여기서 핵심은 lock.newCondition()을 두 번 호출해서 ReentrantLock을 사용하는 스레드 대기 공간을 2개 만드는 것이다.

private final Lock lock = new ReentrantLock();
private final Condition producerCond = lock.newCondition();
private final Condition consumerCond = lock.newCondition();

Condition 분리

  • producerCond: 생산자를 위한 스레드 대기 공간
  • consumerCond: 소비자를 위한 스레드 대기 공간

이렇게 하면 생산자 스레드, 소비자 스레드를 정확하게 나누어 관리하고 깨울 수 있다!

@Override
public void put(String data) {
    lock.lock();

    try {
        while (queue.size() == max) {
            log("[put] 큐가 가득 찼습니다. 생산자는 대기합니다.");
            try {
                producerCond.await();
                log("[put] 생산자 깨어남");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
        queue.offer(data);
        log("[put] 생산자 데이터 저장, notify() 호출");
        consumerCond.signal();
    } finally {
        lock.unlock();
    }
}
  • put(data) 메서드는 결국 생산자가 큐에 데이터를 생산해내는 것이다. 그렇다면 이 메서드에서 만약 큐가 가득찼다면 어디로 들어가면 될까? producerCond 안으로 들어가면 된다. 그래서 producerCond.await()을 호출한다.
  • 그리고 큐가 가득차지 않아서 데이터를 잘 넣었다면 누굴 깨우면 될까? 소비자 스레드를 깨우면 된다. 그래서 consumerCond.signal()을 호출한다.
@Override
public String take() {
    lock.lock();

    try {
        while (queue.isEmpty()) {
            log("[take] 큐에 데이터가 없습니다. 소비자는 대기합니다");
            try {
                consumerCond.await();
                log("[take] 소비자 깨어남");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
        String data = queue.poll();
        log("[take] 소비자 데이터 획득, notify() 호출");
        producerCond.signal();
        return data;
    } finally {
        lock.unlock();
    }
}
  • take() 메서드는 소비자 스레드가 사용하는 메서드이다. 만약 큐에 데이터가 비었다면 어디로 들어가면 될까? 소비자용 스레드 대기 공간으로 들어가면 된다. 그래서 consumerCond.await()을 호출한다.
  • 큐에 데이터가 있어서 데이터를 잘 소비했다면 누굴 깨우면 될까? 생산자 스레드를 깨우면 된다. 그래서 producerCond.signal()을 호출한다.

 

이렇게 굉장히 간단하게 딱 필요한 스레드 대기 공간에 알리고, 자기가 들어갈 곳을 잘 들어갈 수 있게 됐다. 그리고 실제로 이 코드로 실행해보면 비효율은 하나도 발생하지 않는다! 왜냐!? 생산자 스레드는 소비자 스레드만을 깨우고, 소비자 스레드는 생산자 스레드만을 깨우기 때문에. 

 

실행 결과 로그는 따로 작성하지 않겠다. 직접 실행해보면 될 것 같다.

한번 이 큰 그림을 그림으로 하나씩 분석해보자. 

 

참고로, 지금 그림을 가지고 하는 설명은 위 예제 실행 결과와 살짝 다르다. 이해하기 쉽게 조금 변형했다고 보면 된다

생산자 먼저 실행

  • c1, c2, c3는 소비자 스레드 전용 대기 공간(consumerCond)에 대기중이다.
  • p1, p2, p3는 생산자 스레드 전용 대기 공간(producerCond)에 대기중이다.
  • 큐에 데이터가 비어있다.
  • 생산자인 p0 스레드가 실행 예정이다.

  • p0 스레드는 ReentrantLock의 락을 획득하고 큐에 데이터를 보관한다.
  • 생산자 스레드가 큐에 데이터를 보관했기 때문에, 소비자 스레드가 가져갈 데이터가 추가되었다. 따라서 소비자 대기 공간(consumerCond)에 signal()을 통해 알려준다.

  • 소비자 스레드 중에 하나가 깨어난다. c1이 깨어났다고 가정하자.
  • c1은 락 획득까지 잠시 대기하다가 이후에 p0가 반납한 ReentrantLock의 락을 획득한다. 그리고 큐의 데이터를 획득한 다음에 완료된다.

 

소비자 먼저 실행

이 설명 역시 예제 결과와 살짝 다르다.

  • c1, c2, c3는 소비자 스레드 전용 대기 공간(consumerCond)에 대기중이다.
  • p1, p2, p3는 생산자 스레드 전용 대기 공간(producerCond)에 대기중이다.
  • 큐에 데이터가 가득 차 있다.
  • 소비자인 c0 스레드가 실행 예정이다.

  • c0 스레드는 ReentrantLock의 락을 획득하고 큐에 있는 데이터를 획득한다.
  • 큐에 데이터를 획득했기 때문에, 큐에 데이터를 생산할 수 있는 빈 공간이 생겼다. 생산자 대기 공간(producerCond)에 signal()을 통해 알려준다.

  • 생산자 스레드 중에 하나가 깨어난다. p3가 깨어났다고 가정하자.
  • p3는 이후에 c0가 반납한 ReentrantLock의 락을 획득하고, 큐의 데이터를 저장한 다음에 완료된다.

Object.notify() vs Condition.signal()

  • Object.notify()
    • 대기 중인 스레드 중 임의의 하나를 선택해 깨운다. 스레드가 깨어나는 순서는 보장되어 있지 않으며, JVM 구현에 따라 다르다. 보통은 먼저 들어온 스레드가 먼저 수행되지만 구현에 따라 다를 수 있다.
    • synchronized 블록 내에서 모니터 락을 가지고 있는 스레드가 호출해야 한다.
  • Condition.signal()
    • 대기중인 스레드 중 하나를 깨우며, 일반적으로는 FIFO 순서로 깨운다. 이 부분은 자바 버전과 구현에 따라 달라질 수 있지만, 보통 Condition의 구현은 Queue 구조를 사용하기 때문에 FIFO 순서로 깨운다. 
    • ReentrantLock의 락을 가지고 있는 스레드가 호출해야 한다.

 

스레드의 대기

사실 지금까지 얘기하지 않고 있던 부분이 하나 있다.

이제는 synchronized, ReentrantLock의 대기 상태에 대해 이야기할 차례가 됐다.

먼저 synchronized의 대기 상태부터 얘기해보자면, 잘 생각해보면 2가지 단계의 대기 상태가 존재한다.

 

synchronized 대기

  • 대기1: 락 획득 대기
    • BLOCKED 상태로 모니터 락 획득 대기
    • synchronized를 시작할 때 락이 없으면 대기
    • 다른 스레드가 synchronized를 빠져나갈 때 모니터 락 획득 시도
  • 대기2: wait() 대기
    • WAITING 상태로 대기
    • wait()을 호출했을 때 스레드 대기 집합에서 대기
    • 다른 스레드가 notify()를 호출했을 때 빠져나감

  • 소비자 스레드: c1, c2, c3
  • 생산자 스레드: p1, p2, p3

  • 소비자 스레드 c1, c2, c3가 먼저 동시에 실행된다고 가정하자.
  • 소비자 스레드 c1이 가장 먼저 락을 획득한다.
  • c2, c3는 락 획득을 대기하며 BLOCKED 상태가 된다. 

c2, c3는 락 획득을 시도하지만, 모니터 락이 없기 때문에 락을 대기하며 BLOCKED 상태가 된다. c1은 나중에 락을 반납할 것이다. 그러면 c2, c3 중에 하나가 락을 획득해야 한다. 그런데 잘 생각해보면 락을 기다리는 c2, c3도 어딘가에서 관리가 되어야 한다. 그래야 락이 반환되었을 때 자바가 c2, c3중에 하나를 선택해서 락을 제공할 수 있다. 예를 들어서 List, Set, Queue와 같은 자료구조에 관리가 되어야 한다. 그림에서는 c2, c3가 단순히 BLOCKED 상태로 변경만 되었다. 그래서 관리되는 것처럼 보이지는 않는다. 

 

사실은 BLOCKED 상태의 스레드도 자바 내부에서 따로 관리된다. 다음 그림을 보자.

  • 이 그림은 이전 그림과 같은 상태를 좀 더 자세히 그린 그림이다.
  • 그림을 보면 락 대기 집합이라는 곳이 있다. 이곳은 락을 기다리는 BLOCKED 상태의 스레드들을 관리한다.
  • 락 대기 집합은 자바 내부에 구현되어 있기 때문에 모니터 락과 같이 개발자가 확인하기는 어렵다.
  • 여기서는 BLOCKED 상태의 스레드 c2, c3가 관리된다.
  • 언젠가 c1이 락을 반납하면 락 대기 집합에서 관리되는 스레드 중 하나가 락을 획득한다. 

락 대기 집합이 지금에서야 나온 이유는,

단순하게 설명하기 위해 BLOCKED 상태에서 사용하는 락 대기 집합을 일부러 얘기하지 않았다. 지금쯤이면 이 내용을 말해도 이해하는데 어려움이 없을 것이다. 

 

  • c1이 큐에 데이터가 없으므로 wait()을 호출하고 스레드 대기 집합에 들어간다. 

  • c1은 락을 반납 후 스레드 대기 집합에 들어가고 WAITING 상태가 된다.

  • 이후에 락이 반납됐으니 락을 기다리고있는 BLOCKED 상태의 스레드 중 하나가 락을 획득한다. 어차피 c2, c3 둘 다 소비자 스레드이기 때문에 락을 획득하고 임계 영역에 들어와도 아무것도 하지 못한채 wait()을 호출하고 둘 다 스레드 대기 집합으로 들아간 모습이다.

  • p1이 락을 획득하고 데이터를 저장한 다음 notify()를 호출하여 스레드 대기 집합에 이 사실을 알린다.

  • 스레드 대기 집합에 있는 c1이 스레드 대기 집합을 빠져나간다.
  • 하지만 아직 끝난 것이 아니다. 락을 얻지 못한 상태이니까 BLOCKED 상태가 된다. 그리고 락을 기다리는 BLOCKED 상태의 스레드들은 락 대기 집합에서 대기한다.

그러니까 사실 2번의 대기 과정이 있고 대기 장소가 있는것이다. 그래서 이 그림이 된다.

  • c1BLOCKED 상태에서 락을 얻을 때까지 락 대기 집합에서 대기한다. 
  • 드디어 p1이 락을 반납한다.

  • 락이 반납되면 락 대기 집합에 있는 스레드 중 하나가 락을 획득한다. 여기서는 c1이 획득한다.
  • c1은 드디어 락 대기 집합까지 탈출하고 임계 영역을 수행할 수 있다.

 

정리를 하자면, 

자바의 모든 객체 인스턴스는 멀티 스레드와 임계 영역을 다루기 위해 내부에 3가지 기본 요소를 가진다.

  • 모니터 락
  • 락 대기 집합(모니터 락 대기 집합)
  • 스레드 대기 집합

여기서 락 대기 집합이 1차 대기소이고, 스레드 대기 집합이 2차 대기소라 생각하면 된다. 2차 대기소에 들어간 스레드는 2차, 1차 대기소를 모두 빠져나와야 임계 영역을 수행할 수 있다. 이 3가지 요소는 서로 맞물려 들어간다.

  • synchronized를 사용한 임계 영역에 들어가려면 모니터 락이 필요하다.
  • 모니터 락이 없으면 락 대기 집합에 들어가서 BLOCKED 상태로 락을 기다린다.
  • 모니터 락을 반납하면 락 대기 집합에 있는 스레드 중 하나가 락을 획득하고 BLOCKED → RUNNABLE 상태가 된다. 
  • wait()을 호출해서 스레드 대기 집합에 들어가기 위해선 모니터 락이 필요하다.
  • 스레드 대기 집합에 들어가면 모니터 락을 반납한다.
  • 스레드가 notify()를 호출하면 스레드 대기 집합에 있는 스레드 중 하나가 스레드 대기 집합을 빠져나온다. 그리고 모니터 락 획득을 시도한다. 
    • 모니터 락을 획득하면 임계 영역을 수행한다.
    • 모니터 락을 획득하지 못하면 락 대기 집합에 들어가서 BLOCKED 상태로 락을 기다린다.

 

여기까지가 synchronized를 사용한 스레드 대기의 전체 과정이다. 그럼 ReentrantLock은 크게 다를까? 똑같다. 살짝살짝만 다른 부분이 있고 이 메커니즘은 똑같다고 보면 된다.

ReentrantLock 대기

  • 대기1: ReentrantLock 락 획득 대기
    • ReentrantLock의 대기 큐에서 관리
    • WAITING 상태로 락 획득 대기
    • lock.lock()을 호출 했을 때 락이 없으면 대기
    • 다른 스레드가 lock.unlock()을 호출 했을 때 락 획득을 시도, 락을 획득하면 대기 큐를 빠져나감
  • 대기2: await() 대기
    • condition.await()을 호출 했을 때, condition 객체의 스레드 대기 공간에서 관리
    • WAITING 상태로 대기
    • 다른 스레드가 condition.signal()을 호출 했을 때 condition 객체의 스레드 대기 공간에서 빠져나감

 

ReentrantLocksynchronized마찬가지로 대기소가 2단계로 되어 있다. 2단계 대기소인 condition 객체의 스레드 대기 공간을 빠져나간다고 바로 실행되는 것이 아니다. 임계 영역 안에서는 항상 락이 있는 하나의 스레드만 실행될 수 있다. 여기서는 ReentrantLock의 락을 획득해야 RUNNABLE 상태가 되면서 그 다음 코드를 실행할 수 있다. 락을 획득하지 못하면 WAITING 상태로 락을 획득할 때 까지 ReentrantLock의 대기 큐에서 대기한다. 

 

차이점이라고 하면, synchronized는 락 대기 집합에서 스레드들은 BLOCKED 상태라는 거고 ReentrantLock의 대기 큐에서 스레드들은 WAITING 상태라는 것이다. 그래서, 이런 사소한 차이가 있고 결국 대기소는 둘 다 2개가 있다라는 점이다. 물론 ReentrantLock을 사용하면 더 유연하고 많은 장점이 있다. 무한 대기를 하지 않아도 되는 점과 공정성을 해결할 수 있다는 것과 스레드 대기 집합을 여러개로 분리할 수 있다는 것이다.

 

중간 정리

여기까지 잘 익혔다면, 진짜 어지간한 멀티 스레드 환경과 흐름을 이해했다고 봐도 무방하다. 실무에서도 사용할 수 있는 정도의 수준이라고 말할 수 있다. 사실 이 예제는 이미 자바가 만들어 둔 java.util.concurrent.BlockingQueue를 사용했으면 되는 문제였다. 정확히 이 한정된 버퍼에 더 채울 수 있는 공간이 없으면 데이터 추가를 공간이 생길 때까지 차단해주고, 한정된 버퍼에 데이터가 없으면 데이터가 생길 때까지 데이터를 소비하는 것을 차단해주는 큐이다. 

 

근데, 하나씩 풀어가면서 왜 이런 상황이 발생하고, 왜 생산자 스레드 입장에서 데이터가 비워질때까지(한정된 버퍼에 데이터를 넣을 공간이 생길때까지) 단순히 기다리기만 한다면 다른 스레드들이 BLOCKED 상태에서 빠져나오지 못하는지, synchronized, wait(), notify()를 사용해야 하고 이것들이 가진 한계가 무엇이길래 ReentrantLock, Condition을 사용하는지 이해해야 BlockingQueue라는 이미 잘 만들어진 큐를 사용할 자격이 생기는 것이다. 

 

이제 위 내용을 다 이해했다. 그럼 이미 만들어놓은 기능인 BlockingQueue를 한번 사용해보자!

 

BlockingQueue

자바는 생산자 소비자 문제를 해결하기 위해 java.util.concurrent.BlockingQueue라는 특별한 멀티스레드 자료 구조를 제공한다.

이것은 이름 그대로 스레드를 차단(Blocking)할 수 있는 큐다.

 

  • 데이터 추가 차단: 큐가 가득 차면 데이터 추가 작업을 시도하는 스레드는 공간이 생길때까지 차단된다.
  • 데이터 획득 차단: 큐가 비어 있으면 획득 작업을 시도하는 스레드는 큐에 데이터가 들어올 때까지 차단된다.

BlockingQueue는 인터페이스이고, 다음과 같은 다양한 기능을 제공한다.

package java.util.concurrent;

public interface BlockingQueue<E> extends Queue<E> {
    boolean add(E e);
    boolean offer(E e);
    void put(E e) throws InterruptedException;
    boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException;
    E take() throws InterruptedException;
    E poll(long timeout, TimeUnit unit) throws InterruptedException;
    boolean remove(Object o);
	
    ...
}

주요 메서드만 정리했다.

  • 데이터 추가 메서드: add(), offer(), put(), offer(타임아웃)
  • 데이터 획득 메서드: take(), poll(타임아웃), remove()
  • Queue를 상속 받는다. 큐를 상속 받았기 때문에 추가로 큐의 기능들도 사용할 수 있다.

보면 데이터 추가와 획득에서 메서드가 굉장히 많다는 것을 알 수 있다. 이게 왜 이렇게 여러개가 있는지도 이후에 설명한다.

 

BlockingQueue 인터페이스의 대표적인 구현체

  • ArrayBlockingQueue: 배열 기반으로 구현되어 있고, 버퍼의 크기가 고정되어 있다.
  • LinkedBlockingQueue: 링크 기반으로 구현되어 있고, 버퍼의 크기를 고정할 수도, 무한하게 사용할 수도 있다.

이제 BlockingQueue를 사용하도록 기존 코드를 변경해보자.

 

BoundedQueueV6_1

package thread.bounded;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class BoundedQueueV6_1 implements BoundedQueue {

    private final BlockingQueue<String> queue;

    public BoundedQueueV6_1(int max) {
        this.queue = new ArrayBlockingQueue<String>(max);
    }

    @Override
    public void put(String data) {
        try {
            queue.put(data);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public String take() {
        try {
            return queue.take();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public String toString() {
        return queue.toString();
    }
}
  • ArrayBlockingQueue를 사용한다.
  • BlockingQueue.put(data): 데이터를 추가한다.
  • BlockingQueue.take(): 데이터를 뽑아온다.

여기서 BlockingQueue.put(data) 메서드를 한번 어떻게 구현했는지 봐보자.

다음 코드가 java.util.concurrent.BlockingQueue를 구현한 ArrayBlockingQueueput() 메서드이다.

public void put(E e) throws InterruptedException {
    Objects.requireNonNull(e);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == items.length)
            notFull.await();
        enqueue(e);
    } finally {
        lock.unlock();
    }
}
  • 앞에서 만들어본 BoundedQueueV5와 굉장히 유사하게 생긴것을 알 수 있다. 다른건 lock.lock()이 아니라 lock.lockInterruptibly()를 사용했다 정도의 차이가 있다.

"어? signal()은 호출 안 하는데요!?" → enqueue(e)에 있다.

private void enqueue(E e) {
    final Object[] items = this.items;
    items[putIndex] = e;
    if (++putIndex == items.length) putIndex = 0;
    count++;
    notEmpty.signal();
}

 

그러니까 결론은 자바에서 제공하는 멀티 스레드 용 자료구조가 이제 눈에 들어온다는 것이다.

이제 이 BoundedQueueV6_1를 사용해서 실행해보면 같은 결과를 얻을것이다.

 

BlockingQueue 기능 설명

아까 보니까 데이터를 추가하는 것도 많은 메서드가 있던것을 알 수 있었다. 왜 그럴까? 실무에서 멀티스레드를 사용할땐 응답성이 중요하다. 예를 들어, 대기 상태에 있어도 고객이 중지 요청을 하거나, 또는 너무 오래 대기한 경우 포기하고 빠져나갈 수 있는 방법이 필요하다.

 

생산자가 무언가 데이터를 생산하는데, 버퍼가 빠지지 않아서 너무 오래 대기해야 한다면 무한정 기다리는 것보다는 작업을 포기하고 사용자에게는 "죄송합니다. 현재 시스템에 문제가 있습니다. 나중에 다시 시도해주세요."라고 하는 것이 더 나은 선택일 것이다.

 

즉, 멀티스레드 세상에서는 정말 여러 상황과 결론이 만들어질 수 있다는 얘기고 그렇기에 이 BlockingQueue는 각 상황에 맞는 다양한 메서드를 제공하고 있다.

 

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)

 

  • Throws Exception (대기 시 예외를 터트림)
    • add(e): 지정된 요소를 큐에 추가하며, 큐가 가득 차면 IllegalStateException 예외를 던진다.
    • remove(): 큐에서 요소를 제거하며 반환한다. 큐가 비어 있으면 NoSuchElementException 예외를 던진다.
  • Special Value (대기 시 즉시 반환)
    • offer(e): 지정된 요소를 큐에 추가하려고 시도하며, 큐가 가득 차면 false를 반환한다.
    • poll(): 큐에서 요소를 제거하고 반환한다. 큐가 비어 있으면 null을 반환한다.
  • Blocks (대기)
    • put(e): 지정된 요소를 큐에 추가할 때까지 대기한다. 큐가 가득 차면 공간이 생길 때까지 대기한다.
    • take(): 큐에서 요소를 제거하고 반환한다. 큐가 비어 있으면 요소가 준비될 때까지 대기한다.
  • Times Out (시간만큼만 대기)
    • offer(e, time, unit): 지정된 요소를 큐에 추가하려고 시도하며, 지정된 시간 동안 큐가 비워지기를 기다리다가 시간이 초과되면 false를 반환한다.
    • poll(time, unit): 큐에서 요소를 제거하고 반환한다. 큐에 요소가 없다면 지정된 시간 동안 요소가 준비되기를 기다렸다가 시간이 초과되면 null을 반환한다.

참고로, BlockingQueue의 모든 대기, 시간 대기 메서드는 인터럽트를 제공한다. 대기하는 put(e), take()는 바로 위에서 예제로 직접 만들어보았다. 나머지도 하나씩 코드로 확인해보자.

 

BlockingQueue 즉시 반환

BoundedQueueV6_2

package thread.bounded;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

import static util.MyLogger.log;

public class BoundedQueueV6_2 implements BoundedQueue {

    private final BlockingQueue<String> queue;

    public BoundedQueueV6_2(int max) {
        this.queue = new ArrayBlockingQueue<String>(max);
    }

    @Override
    public void put(String data) {
        boolean result = queue.offer(data);
        log("저장 시도 결과 = " + result);
    }

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

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

실행결과

2024-07-25 18:04:39.998 [     main] == [생산자 먼저 실행] 시작, BoundedQueueV6_2 ==

2024-07-25 18:04:40.001 [     main] 생산자 시작
2024-07-25 18:04:40.010 [producer1] [생산 시도] data1 -> []
2024-07-25 18:04:40.010 [producer1] 저장 시도 결과 = true
2024-07-25 18:04:40.011 [producer1] [생산 완료] data1 -> [data1]
2024-07-25 18:04:40.111 [producer2] [생산 시도] data2 -> [data1]
2024-07-25 18:04:40.111 [producer2] 저장 시도 결과 = true
2024-07-25 18:04:40.111 [producer2] [생산 완료] data2 -> [data1, data2]
2024-07-25 18:04:40.215 [producer3] [생산 시도] data3 -> [data1, data2]
2024-07-25 18:04:40.215 [producer3] 저장 시도 결과 = false
2024-07-25 18:04:40.216 [producer3] [생산 완료] data3 -> [data1, data2]

2024-07-25 18:04:40.320 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
2024-07-25 18:04:40.321 [     main] producer1: TERMINATED
2024-07-25 18:04:40.321 [     main] producer2: TERMINATED
2024-07-25 18:04:40.321 [     main] producer3: TERMINATED

2024-07-25 18:04:40.321 [     main] 소비자 시작
2024-07-25 18:04:40.322 [consumer1] [소비 시도]     ? <- [data1, data2]
2024-07-25 18:04:40.322 [consumer1] [소비 완료] data1 <- [data2]
2024-07-25 18:04:40.427 [consumer2] [소비 시도]     ? <- [data2]
2024-07-25 18:04:40.427 [consumer2] [소비 완료] data2 <- []
2024-07-25 18:04:40.530 [consumer3] [소비 시도]     ? <- []
2024-07-25 18:04:40.530 [consumer3] [소비 완료] null <- []

2024-07-25 18:04:40.631 [     main] 현재 상태 출력, 큐 데이터: []
2024-07-25 18:04:40.631 [     main] producer1: TERMINATED
2024-07-25 18:04:40.632 [     main] producer2: TERMINATED
2024-07-25 18:04:40.632 [     main] producer3: TERMINATED
2024-07-25 18:04:40.632 [     main] consumer1: TERMINATED
2024-07-25 18:04:40.633 [     main] consumer2: TERMINATED
2024-07-25 18:04:40.633 [     main] consumer3: TERMINATED
2024-07-25 18:04:40.634 [     main] == [생산자 먼저 실행] 종료, BoundedQueueV6_2 ==

 

실행 결과를 보면 생산 시 큐가 꽉찼을때 저장 시도 결과가 false가 찍히는 것을 알 수 있고, 데이터 소비 시 데이터가 없는 경우 null을 반환했음을 알 수 있다.

 

BlockingQueue 시간 대기

BoundedQueueV6_3

package thread.bounded;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;

import static util.MyLogger.log;

public class BoundedQueueV6_3 implements BoundedQueue {

    private final BlockingQueue<String> queue;

    public BoundedQueueV6_3(int max) {
        this.queue = new ArrayBlockingQueue<String>(max);
    }

    @Override
    public void put(String data) {
        try {
            boolean result = queue.offer(data, 1, TimeUnit.NANOSECONDS);
            log("저장 시도 결과 = " + result);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public String take() {
        try {
            return queue.poll(2, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

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

실행 결과

2024-07-25 18:08:09.956 [     main] == [생산자 먼저 실행] 시작, BoundedQueueV6_3 ==

2024-07-25 18:08:09.959 [     main] 생산자 시작
2024-07-25 18:08:09.968 [producer1] [생산 시도] data1 -> []
2024-07-25 18:08:09.969 [producer1] 저장 시도 결과 = true
2024-07-25 18:08:09.969 [producer1] [생산 완료] data1 -> [data1]
2024-07-25 18:08:10.069 [producer2] [생산 시도] data2 -> [data1]
2024-07-25 18:08:10.069 [producer2] 저장 시도 결과 = true
2024-07-25 18:08:10.069 [producer2] [생산 완료] data2 -> [data1, data2]
2024-07-25 18:08:10.172 [producer3] [생산 시도] data3 -> [data1, data2]
2024-07-25 18:08:10.173 [producer3] 저장 시도 결과 = false
2024-07-25 18:08:10.173 [producer3] [생산 완료] data3 -> [data1, data2]

2024-07-25 18:08:10.278 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
2024-07-25 18:08:10.278 [     main] producer1: TERMINATED
2024-07-25 18:08:10.278 [     main] producer2: TERMINATED
2024-07-25 18:08:10.278 [     main] producer3: TERMINATED

2024-07-25 18:08:10.278 [     main] 소비자 시작
2024-07-25 18:08:10.279 [consumer1] [소비 시도]     ? <- [data1, data2]
2024-07-25 18:08:10.279 [consumer1] [소비 완료] data1 <- [data2]
2024-07-25 18:08:10.381 [consumer2] [소비 시도]     ? <- [data2]
2024-07-25 18:08:10.381 [consumer2] [소비 완료] data2 <- []
2024-07-25 18:08:10.486 [consumer3] [소비 시도]     ? <- []

2024-07-25 18:08:10.591 [     main] 현재 상태 출력, 큐 데이터: []
2024-07-25 18:08:10.592 [     main] producer1: TERMINATED
2024-07-25 18:08:10.592 [     main] producer2: TERMINATED
2024-07-25 18:08:10.592 [     main] producer3: TERMINATED
2024-07-25 18:08:10.592 [     main] consumer1: TERMINATED
2024-07-25 18:08:10.593 [     main] consumer2: TERMINATED
2024-07-25 18:08:10.593 [     main] consumer3: TIMED_WAITING
2024-07-25 18:08:10.594 [     main] == [생산자 먼저 실행] 종료, BoundedQueueV6_3 ==
2024-07-25 18:08:12.488 [consumer3] [소비 완료] null <- []

 

저장 시도 시 기다리는 시간을 1 나노초로 했기 때문에 그냥 뭐 안 기다리는 수준으로 기다리게 설정했다. 그러니까 실행 결과를 보면 저장 시도 결과는 false가 출력되고 저장을 마지막에 하지 못했으니 소비도 마지막 스레드는 할 수 없어 null이 반환됐다. 만약 기다리는 시간을 넉넉하게 잡고 awaitNanos(timeout)으로 잠시 대기하게 한 후 소비자 스레드가 들어와서 큐에 데이터를 소비해서 공간이 생긴후에도 지정한 시간을 지나지 않았다면 아마 데이터가 추가도, 데이터 소비도 정상적으로 될 것이다.

 

아, 참고로 여기서 awaitNanos(timeout)은 그냥 내가 이렇게 사용하자가 아니라 실제로 BlockingQueueoffer(e, time, unit) 메서드에서 사용하고 있는 것이다. 아래 실제 구현 코드 참고!
public boolean offer(E e, long timeout, TimeUnit unit)
    throws InterruptedException {

    Objects.requireNonNull(e);
    long nanos = unit.toNanos(timeout);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == items.length) {
            if (nanos <= 0L)
                return false;
            nanos = notFull.awaitNanos(nanos);
        }
        enqueue(e);
        return true;
    } finally {
        lock.unlock();
    }
}

 

BlockingQueue 예외 터트리기

BoundedQueueV6_4

package thread.bounded;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;

import static util.MyLogger.log;

public class BoundedQueueV6_4 implements BoundedQueue {

    private final BlockingQueue<String> queue;

    public BoundedQueueV6_4(int max) {
        this.queue = new ArrayBlockingQueue<String>(max);
    }

    @Override
    public void put(String data) {
        queue.add(data);
    }

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

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

실행결과

2024-07-25 18:14:03.964 [     main] == [생산자 먼저 실행] 시작, BoundedQueueV6_4 ==

2024-07-25 18:14:03.968 [     main] 생산자 시작
2024-07-25 18:14:03.978 [producer1] [생산 시도] data1 -> []
2024-07-25 18:14:03.978 [producer1] [생산 완료] data1 -> [data1]
2024-07-25 18:14:04.073 [producer2] [생산 시도] data2 -> [data1]
2024-07-25 18:14:04.073 [producer2] [생산 완료] data2 -> [data1, data2]
2024-07-25 18:14:04.176 [producer3] [생산 시도] data3 -> [data1, data2]
Exception in thread "producer3" java.lang.IllegalStateException: Queue full
	at java.base/java.util.AbstractQueue.add(AbstractQueue.java:98)
	at java.base/java.util.concurrent.ArrayBlockingQueue.add(ArrayBlockingQueue.java:329)
	at thread.bounded.BoundedQueueV6_4.put(BoundedQueueV6_4.java:19)
	at thread.bounded.ProducerTask.run(ProducerTask.java:18)
	at java.base/java.lang.Thread.run(Thread.java:1595)

2024-07-25 18:14:04.276 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
2024-07-25 18:14:04.277 [     main] producer1: TERMINATED
2024-07-25 18:14:04.277 [     main] producer2: TERMINATED
2024-07-25 18:14:04.277 [     main] producer3: TERMINATED

2024-07-25 18:14:04.277 [     main] 소비자 시작
2024-07-25 18:14:04.278 [consumer1] [소비 시도]     ? <- [data1, data2]
2024-07-25 18:14:04.278 [consumer1] [소비 완료] data1 <- [data2]
2024-07-25 18:14:04.378 [consumer2] [소비 시도]     ? <- [data2]
2024-07-25 18:14:04.378 [consumer2] [소비 완료] data2 <- []
2024-07-25 18:14:04.483 [consumer3] [소비 시도]     ? <- []
Exception in thread "consumer3" java.util.NoSuchElementException
	at java.base/java.util.AbstractQueue.remove(AbstractQueue.java:117)
	at thread.bounded.BoundedQueueV6_4.take(BoundedQueueV6_4.java:24)
	at thread.bounded.ConsumerTask.run(ConsumerTask.java:16)
	at java.base/java.lang.Thread.run(Thread.java:1595)

2024-07-25 18:14:04.583 [     main] 현재 상태 출력, 큐 데이터: []
2024-07-25 18:14:04.583 [     main] producer1: TERMINATED
2024-07-25 18:14:04.584 [     main] producer2: TERMINATED
2024-07-25 18:14:04.584 [     main] producer3: TERMINATED
2024-07-25 18:14:04.584 [     main] consumer1: TERMINATED
2024-07-25 18:14:04.584 [     main] consumer2: TERMINATED
2024-07-25 18:14:04.584 [     main] consumer3: TERMINATED
2024-07-25 18:14:04.585 [     main] == [생산자 먼저 실행] 종료, BoundedQueueV6_4 ==

 

실행 결과를 보면 데이터를 추가할 때 공간이 없으면 IllegalStateException을, 데이터 소비할 때 소비할 데이터가 없으면 NoSuchElementException을 발생시키고 있음을 알 수 있다. 

참고로, 지금 V6_1, V6_2, V6_3, V6_4 모두 BoundedQueue 인터페이스를 구현해서 만들고 있는데 이래야만 가능한게 아니라 기존에 작성한 코드들(producerFirst, consumerFirst, ...)이 전부 BoundedQueue를 의존하고 있기 때문에 이 인터페이스를 구현한 구현체를 만들어서 그 안에서 BlockingQueue를 사용하는 식으로 만든거고 그게 아니라면 그냥 바로 BlockingQueue를 사용해도 상관없다!

 

정리

이렇듯, 기존에 아주 아주 잘 만들어진 BlockingQueue를 사용하면 훨씬 더 다양한 상황을 더 유연하게 대처할 수 있음을 알게됐다. 

무작정 기다릴수도, 정해진 시간만큼만 기다릴수도, 아예 바로 결과를 반환할수도, 예외를 터트릴수도 있다. 그리고 내부가 어떻게 구현됐는지도 이제 이해할 수 있는 레벨이 됐다!

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

생산자 소비자 문제

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

 

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

 

 

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

 

그럼 이게 왜 문제가 된다는 것일까?

문제 상황

  • 생산자가 너무 빠를 때: 버퍼가 가득 차서 더 이상 데이터를 넣을 수 없을 때까지 생산자가 데이터를 생성한다. 버퍼가 가득 찬 경우 생산자는 버퍼에 빈 공간이 생길 때까지 기다려야 한다.
  • 소비자가 너무 빠를 때: 버퍼가 비어서 더 이상 소비할 데이터가 없을 때까지 소비자가 데이터를 처리한다. 버퍼가 비어있을 때 소비자는 버퍼에 새로운 데이터가 들어올 때까지 기다려야 한다.
예를 들면, 초밥집에 가서 주방장님이 초밥을 하나씩 만들어서 서빙 카운터에 초밥을 하나씩 올려 놓는데 더 이상 올려 놓을 곳이 없어 초밥을 만들지 못하는 경우가 생산자가 너무 빠른 경우이고, 반대로 서빙 카운터에 초밥을 내려놓자마자 손님이 초밥을 다 먹어버려서 음식이 나올때까지 기다려야 하는 경우가 소비자가 너무 빠른 경우이다. 이때 서빙 카운터는 버퍼라고 볼 수 있다.

 

이 문제는 다음 두 용어로도 불린다.

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

 

이 내용에 대해 예제를 만들어보고 직접 눈으로 보면서 이해해보자!

생산자 소비자 문제 - 예제 코드

BoundedQueue

package thread.bounded;

public interface BoundedQueue {
    void put(String data);

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

BoundedQueueV1

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(int max) {
        this.max = max;
    }

    @Override
    public synchronized void put(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 정보를 출력한다.
주의! 원칙적으로는 toString()에도 synchronized를 적용해야 맞다. 그래야 toString()을 통한 조회 시점에도 정확한 데이터를 조회할 수 있다. 예를 들어, toString()을 호출하는 시점에 모니터 락을 사용하지 않으면 다른 스레드가 put을 하는 동시에 이 toString()을 호출해서 실제 데이터와 다른 데이터가 출력될 수 있으니까. 그러나, 예제 코드를 단순하게 유지하고 목적에 부합한 결과를 출력하기 위해 의도적으로 synchronized를 넣지 않았다.

 

임계 영역

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

 

ProducerTask

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)를 큐에 보관한다.

ConsumerTask

package thread.bounded;

import static util.MyLogger.log;

public class ConsumerTask implements Runnable {

    private BoundedQueue queue;

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

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

 

BoundedMain

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 선택
        BoundedQueue queue = new BoundedQueueV1(2);

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

    private static void producerFirst(BoundedQueue 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(BoundedQueue 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 startProducer(BoundedQueue 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(BoundedQueue 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);
        }
    }

    private static void printAllState(BoundedQueue queue, List<Thread> threads) {
        System.out.println();
        log("현재 상태 출력, 큐 데이터: " + queue);
        for (Thread thread : threads) {
            log(thread.getName() + ": " + thread.getState());
        }
    }
}
  • 조금 길지만 하나씩 살펴보면, 우선 BoundedQueue를 구현한 특정 구현체를 선택하는 queue가 있다. V1이기 때문에 최대 사이즈를 생성자에 넣어준다. 
  • 생산자, 소비자의 실행 순서를 선택할 수 있어야 한다. 이건 의도적으로 생산자를 먼저 실행하거나 소비자를 먼저 실행하도록 구현했다. (producerFirst(queue), consumerFirst(queue))
    • 이렇게 만들어 두었으면 반드시 둘 중 하나만 사용해서 실행해야 한다. 의도적으로 소비자 또는 생산자가 먼저 실행될 수 있도록 만들었다.
private static void producerFirst(BoundedQueue 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, startConsumer에 파라미터로 전달되어 각 메서드에서 만들어지는 생산자 스레드와 소비자 스레드가 담기게 된다.
  • startProducer: 생산자 스레드를 만들어서 실행하는 메서드이다.
  • printAllState: 모든 스레드의 상태를 출력한다.
  • startConsumer: 소비자 스레드를 만들어서 실행하는 메서드이다.
private static void consumerFirst(BoundedQueue 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() + " ==");
}
  • producerFirst 메서드와 완전히 동일하고 먼저 실행되는 메서드가 startProducer가 아니라 startConsumer라는 차이만 있다.
private static void startProducer(BoundedQueue 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);
    }
}
  • 3개의 생산자 스레드를 만들고 각 스레드들을 실행한다. 로그 출력을 조금 알아보기 쉽게 하기 위해 sleep(100)을 추가했다. 의도적으로 추가한 것이고 실제 업무라면 없는게 맞다. 이렇게 잠시 0.1초 동안 대기하게 만들어 두면 로그로 producer1 → producer2producer3 순으로 이쁘게 출력될 것이다.
  • 만들어지는 각각의 생산자 스레드 모두 파라미터로 넘어온 threads에 추가된다.
private static void startConsumer(BoundedQueue 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);
    }
}
  • startProducer와 완전히 동일한 코드이지만 생산자 스레드를 만드는 게 아니라 소비자 스레드를 만들어 실행한다.
private static void printAllState(BoundedQueue queue, List<Thread> threads) {
    System.out.println();
    log("현재 상태 출력, 큐 데이터: " + queue);
    for (Thread thread : threads) {
        log(thread.getName() + ": " + thread.getState());
    }
}
  • 파라미터로 넘어온 threads에 담긴 모든 스레드(생산자, 소비자)의 상태를 출력한다.
  • 파라미터로 넘어온 queue의 상태를 출력한다.

 

생산자 먼저 실행하는 코드로 실행

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

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

실행 결과

2024-07-24 13:14:14.019 [     main] == [생산자 먼저 실행] 시작, BoundedQueueV1 ==

2024-07-24 13:14:14.023 [     main] 생산자 시작
2024-07-24 13:14:14.033 [producer1] [생산 시도] data1 -> []
2024-07-24 13:14:14.033 [producer1] [생산 완료] data1 -> [data1]
2024-07-24 13:14:14.129 [producer2] [생산 시도] data2 -> [data1]
2024-07-24 13:14:14.129 [producer2] [생산 완료] data2 -> [data1, data2]
2024-07-24 13:14:14.229 [producer3] [생산 시도] data3 -> [data1, data2]
2024-07-24 13:14:14.230 [producer3] [put] 큐가 가득 찼습니다. 버립니다.data3
2024-07-24 13:14:14.230 [producer3] [생산 완료] data3 -> [data1, data2]

2024-07-24 13:14:14.330 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
2024-07-24 13:14:14.330 [     main] producer1: TERMINATED
2024-07-24 13:14:14.331 [     main] producer2: TERMINATED
2024-07-24 13:14:14.331 [     main] producer3: TERMINATED

2024-07-24 13:14:14.331 [     main] 소비자 시작
2024-07-24 13:14:14.332 [consumer1] [소비 시도]     ? <- [data1, data2]
2024-07-24 13:14:14.333 [consumer1] [소비 완료] data1 <- [data2]
2024-07-24 13:14:14.437 [consumer2] [소비 시도]     ? <- [data2]
2024-07-24 13:14:14.438 [consumer2] [소비 완료] data2 <- []
2024-07-24 13:14:14.539 [consumer3] [소비 시도]     ? <- []
2024-07-24 13:14:14.539 [consumer3] [소비 완료] null <- []

2024-07-24 13:14:14.643 [     main] 현재 상태 출력, 큐 데이터: []
2024-07-24 13:14:14.643 [     main] producer1: TERMINATED
2024-07-24 13:14:14.643 [     main] producer2: TERMINATED
2024-07-24 13:14:14.644 [     main] producer3: TERMINATED
2024-07-24 13:14:14.644 [     main] consumer1: TERMINATED
2024-07-24 13:14:14.644 [     main] consumer2: TERMINATED
2024-07-24 13:14:14.645 [     main] consumer3: TERMINATED
2024-07-24 13:14:14.645 [     main] == [생산자 먼저 실행] 종료, BoundedQueueV1 ==

 

소비자 먼저 실행하는 코드로 실행

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

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

실행 결과

2024-07-24 13:15:15.838 [     main] == [소비자 먼저 실행] 시작, BoundedQueueV1 ==

2024-07-24 13:15:15.840 [     main] 소비자 시작
2024-07-24 13:15:15.845 [consumer1] [소비 시도]     ? <- []
2024-07-24 13:15:15.850 [consumer1] [소비 완료] null <- []
2024-07-24 13:15:15.950 [consumer2] [소비 시도]     ? <- []
2024-07-24 13:15:15.950 [consumer2] [소비 완료] null <- []
2024-07-24 13:15:16.052 [consumer3] [소비 시도]     ? <- []
2024-07-24 13:15:16.053 [consumer3] [소비 완료] null <- []

2024-07-24 13:15:16.157 [     main] 현재 상태 출력, 큐 데이터: []
2024-07-24 13:15:16.158 [     main] consumer1: TERMINATED
2024-07-24 13:15:16.158 [     main] consumer2: TERMINATED
2024-07-24 13:15:16.159 [     main] consumer3: TERMINATED

2024-07-24 13:15:16.159 [     main] 생산자 시작
2024-07-24 13:15:16.160 [producer1] [생산 시도] data1 -> []
2024-07-24 13:15:16.160 [producer1] [생산 완료] data1 -> [data1]
2024-07-24 13:15:16.265 [producer2] [생산 시도] data2 -> [data1]
2024-07-24 13:15:16.266 [producer2] [생산 완료] data2 -> [data1, data2]
2024-07-24 13:15:16.367 [producer3] [생산 시도] data3 -> [data1, data2]
2024-07-24 13:15:16.367 [producer3] [put] 큐가 가득 찼습니다. 버립니다.data3
2024-07-24 13:15:16.368 [producer3] [생산 완료] data3 -> [data1, data2]

2024-07-24 13:15:16.471 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
2024-07-24 13:15:16.471 [     main] consumer1: TERMINATED
2024-07-24 13:15:16.471 [     main] consumer2: TERMINATED
2024-07-24 13:15:16.472 [     main] consumer3: TERMINATED
2024-07-24 13:15:16.472 [     main] producer1: TERMINATED
2024-07-24 13:15:16.472 [     main] producer2: TERMINATED
2024-07-24 13:15:16.473 [     main] producer3: TERMINATED
2024-07-24 13:15:16.473 [     main] == [소비자 먼저 실행] 종료, BoundedQueueV1 ==

 

이 결과에 대해 자세히 분석해보자!

 

생산자 소비자 문제 - 생산자 우선 결과 분석

  • p1: producer1 생산자 스레드를 뜻한다.
  • c1: consumer1 소비자 스레드를 뜻한다.
  • 임계 영역은 synchronized 영역을 뜻한다. 스레드가 이 영역에 들어가려면? 모니터 락(lock)이 필요하다.
  • 물론 스레드가 처음부터 모두 생성되어 있는 것은 아니지만, 모두 그려두고 시작한다.

  • 먼저 생산자 스레드가 하나씩 생성되면서 큐에 데이터를 하나씩 추가한다. 이 데이터를 추가하는 부분은 임계 영역이므로 모니터 락이 필요하다.

  • producer1, producer2 스레드가 모두 큐에 데이터를 넣고 TERMINATED 상태가 되었다. 이제 producer3 스레드가 데이터를 큐에 넣으려고 시도하지만 최대 크기가 2이므로 더 이상 데이터를 넣지 못하고 버리게 된다. 

이 상태의 시점의 로그는 다음 부분이다.

2024-07-24 13:14:14.330 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
2024-07-24 13:14:14.330 [     main] producer1: TERMINATED
2024-07-24 13:14:14.331 [     main] producer2: TERMINATED
2024-07-24 13:14:14.331 [     main] producer3: TERMINATED

 

이제 소비자 스레드를 하나씩 만들면서 데이터를 소비한다.

  • consumer1, consumer2 스레드가 모두 한번씩 실행되면서 큐에 있는 데이터를 소비했다. 이제 consumer3이 실행될 차례다.

  • consumer3이 데이터를 소비하려고 했지만 데이터가 없다. 아무것도 가져올 수 없다. 아무런 데이터도 얻지 못한 채 쓸쓸히 돌아가게 된다.

  • 이 시점이 마지막 시점이다. 결과적으로 버퍼가 가득차서 p3 스레드가 생성한 데이터는 버려졌고, c3 스레드는 버퍼에 아무런 데이터도 없기 때문에 어떠한 데이터도 얻지 못한 채 돌아갔다. 이 부분에서 문제점이 보인다. 만약 이걸 해결하려면 가장 간단한 대안으로는 기다리는 대안이 있을 것 같다. p3 입장에서는 버퍼에 남는 공간이 생길 때 까지 기다렸다가 데이터를 넣고, c3 입장에서는 버퍼에 데이터가 생길 때 까지 기다렸다가 생기면 데이터를 가져오는 식으로 말이다.

생산자 소비자 문제 - 소비자 우선 결과 분석

이번엔 반대로 소비자 먼저 실행한 결과를 분석해보자.

  • c1, c2, c3 순으로 스레드가 큐에서 데이터를 가져오려고 시도하지만, 데이터가 아무것도 없다. 결국 모든 소비자 스레드는 빈털털이로 돌아오게 된다.

  • 이제 생산자 스레드가 하나씩 생성되면서 큐에 데이터를 넣는다.

  • 마지막 p3 스레드는 역시나 버퍼에 데이터가 꽉 차있기 때문에 데이터를 넣지 못하고 버리게 된다.

 

  • 이게 마지막 시점의 모습이다. 부분에서도 역시나 문제점이 많다. 소비자 스레드는 모든 스레드가 다 데이터를 가지지 못했고, 생산자 스레드는 결국 p3 스레드는 여전히 데이터를 추가하지 못했다.

 

총평을 내려보자면, 

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

 

결국 생산자 소비자 문제는 이런 결과를 발생시킨다. 어디 한쪽이 너무 빠르면 뭐가 됐건 문제가 생기고 그 근본 원인은 사실 버퍼의 사이즈다. 버퍼가 가득 찬 경우는 생산자 입장에서 버퍼에 여유가 생길 때 까지 조금만 기다리면 되는데 기다리지 못하고 데이터를 버리는 것이 아쉽고, 버퍼가 빈 경우는 소비자 입장에서 버퍼에 데이터가 채워질 때 까지 조금만 기다리면 되는데 기다리지 못하고 데이터를 못 얻는게 아쉽다. 

 

예제 변경 - 대기하는 코드로 바꿔보기

가장 간단한 해결 방법은 기다리는 것이다! 한번 기다리게 해서 문제를 해결해보자!

 

BoundedQueueV2

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(int max) {
        this.max = max;
    }

    @Override
    public synchronized void put(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();
    }
}
  • BoundedQueueV2를 만들고 여기서 put(data), take() 메서드에서 데이터가 꽉차거나 없다면 빠져나가는게 아니라 기다리는 것이다.
  • 그래서, put(String data)를 보면 큐의 사이즈가 꽉 찼다면 1초 정도 대기한 후 다시 확인해보는 것이다. 데이터가 빠져나갈 때까지.
  • 또한, take()도 큐에 아무런 데이터가 없다면 1초 정도 대기한 후 다시 확인해보는 것이다. 데이터가 들어올 때까지.

 

이렇게 하면 과연 잘 될까? 생산자 우선 코드로 실행해보자.

실행결과

2024-07-24 14:12:07.563 [     main] == [생산자 먼저 실행] 시작, BoundedQueueV2 ==

2024-07-24 14:12:07.565 [     main] 생산자 시작
2024-07-24 14:12:07.573 [producer1] [생산 시도] data1 -> []
2024-07-24 14:12:07.574 [producer1] [생산 완료] data1 -> [data1]
2024-07-24 14:12:07.674 [producer2] [생산 시도] data2 -> [data1]
2024-07-24 14:12:07.674 [producer2] [생산 완료] data2 -> [data1, data2]
2024-07-24 14:12:07.777 [producer3] [생산 시도] data3 -> [data1, data2]
2024-07-24 14:12:07.777 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.

2024-07-24 14:12:07.881 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
2024-07-24 14:12:07.882 [     main] producer1: TERMINATED
2024-07-24 14:12:07.882 [     main] producer2: TERMINATED
2024-07-24 14:12:07.882 [     main] producer3: TIMED_WAITING

2024-07-24 14:12:07.883 [     main] 소비자 시작
2024-07-24 14:12:07.884 [consumer1] [소비 시도]     ? <- [data1, data2]
2024-07-24 14:12:07.985 [consumer2] [소비 시도]     ? <- [data1, data2]
2024-07-24 14:12:08.087 [consumer3] [소비 시도]     ? <- [data1, data2]

2024-07-24 14:12:08.188 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
2024-07-24 14:12:08.189 [     main] producer1: TERMINATED
2024-07-24 14:12:08.189 [     main] producer2: TERMINATED
2024-07-24 14:12:08.190 [     main] producer3: TIMED_WAITING
2024-07-24 14:12:08.190 [     main] consumer1: BLOCKED
2024-07-24 14:12:08.190 [     main] consumer2: BLOCKED
2024-07-24 14:12:08.191 [     main] consumer3: BLOCKED
2024-07-24 14:12:08.191 [     main] == [생산자 먼저 실행] 종료, BoundedQueueV2 ==
2024-07-24 14:12:08.778 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:12:09.783 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:12:10.787 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:12:11.791 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.

 

실행 결과를 봤더니 뭔가 이상하다. 분명히 기다리는 코드를 작성했고, 소비자 스레드가 버퍼에서 데이터를 빼가기만 하면 넣을 수 있을 것 같은데 소비자 스레드는 작동하지 않고 있다. 왜 그럴까? p3 스레드가 모니터 락을 가지고 안 놔주고 있기 때문이다.

 

그리고 take(), put() 모두 synchronized 메서드이기 때문에 이 메서드에 진입하려면 모니터 락이 필요하다. 그 결과아주 심각한 무한대기 현상이 발생한다.

 

p3 입장에선 소비자 스레드 하나만이라도 버퍼에서 데이터를 가져가면 내가 데이터를 추가하고 빠져나올 수 있는데 내가 가지고 있는 락 때문에 어떤 소비자든 생산자든 어떤 스레드도 접근이 불가능한 것이다. 

 

그럼 소비자 우선 코드로 실행하면 어떻게 될까? 가관이다.

2024-07-24 14:15:52.311 [     main] == [소비자 먼저 실행] 시작, BoundedQueueV2 ==

2024-07-24 14:15:52.313 [     main] 소비자 시작
2024-07-24 14:15:52.318 [consumer1] [소비 시도]     ? <- []
2024-07-24 14:15:52.319 [consumer1] [take] 큐에 데이터가 없습니다. 소비자는 대기합니다
2024-07-24 14:15:52.419 [consumer2] [소비 시도]     ? <- []
2024-07-24 14:15:52.522 [consumer3] [소비 시도]     ? <- []

2024-07-24 14:15:52.626 [     main] 현재 상태 출력, 큐 데이터: []
2024-07-24 14:15:52.632 [     main] consumer1: TIMED_WAITING
2024-07-24 14:15:52.633 [     main] consumer2: BLOCKED
2024-07-24 14:15:52.633 [     main] consumer3: BLOCKED

2024-07-24 14:15:52.633 [     main] 생산자 시작
2024-07-24 14:15:52.635 [producer1] [생산 시도] data1 -> []
2024-07-24 14:15:52.739 [producer2] [생산 시도] data2 -> []
2024-07-24 14:15:52.839 [producer3] [생산 시도] data3 -> []

2024-07-24 14:15:52.944 [     main] 현재 상태 출력, 큐 데이터: []
2024-07-24 14:15:52.945 [     main] consumer1: TIMED_WAITING
2024-07-24 14:15:52.945 [     main] consumer2: BLOCKED
2024-07-24 14:15:52.945 [     main] consumer3: BLOCKED
2024-07-24 14:15:52.945 [     main] producer1: BLOCKED
2024-07-24 14:15:52.945 [     main] producer2: BLOCKED
2024-07-24 14:15:52.946 [     main] producer3: BLOCKED
2024-07-24 14:15:52.946 [     main] == [소비자 먼저 실행] 종료, BoundedQueueV2 ==
2024-07-24 14:15:53.322 [consumer1] [take] 큐에 데이터가 없습니다. 소비자는 대기합니다
2024-07-24 14:15:54.323 [consumer1] [take] 큐에 데이터가 없습니다. 소비자는 대기합니다
2024-07-24 14:15:55.323 [consumer1] [take] 큐에 데이터가 없습니다. 소비자는 대기합니다
2024-07-24 14:15:56.329 [consumer1] [take] 큐에 데이터가 없습니다. 소비자는 대기합니다

 

c1 스레드가 버퍼에 데이터가 아무것도 없기 때문에 데이터가 들어올때까지 기다리고 있어서 락을 놔주고 있지 않다. 그 결과 어떤 스레드도 접근이 불가능하다. 그래서 c1 스레드를 제외한 모든 스레드가 다 BLOCKED 상태가 돼버렸다.

 

"어? 그럼 sleep()말고 yield()를 사용해서 다른 스레드에게 양보하면 되지 않아요?"

 

진짜 좋은 생각이다. 한번 그렇게 해볼까? 다음 코드를 보자.

@Override
public synchronized void put(String data) {
    while (queue.size() == max) {
        log("[put] 큐가 가득 찼습니다. 생산자는 대기합니다.");
        // sleep(1000);
        Thread.yield();
    }
    queue.offer(data);
}

 

이번엔 yield()를 사용해서 욕심 부리지 말고 다른 스레드에게 양보하자! 과연 잘 될까?

생산자 스레드 먼저 실행 코드로 실행해보면 다음과 같은 결과를 얻는다.

...
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
2024-07-24 14:18:59.595 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.
...

 

결국 같은 현상이다. 대신 1초의 대기 시간이 사라지니까 너무 빠르게 출력이 된 것 뿐이다. 왜 같은 현상이 일어날까?

Thread.yield()synchronized의 락을 반납하는게 아니다.

그저 다른 스레드에게 CPU 사용을 양보하는 것 뿐이지 synchronized의 락은 여전히 본인이 가지고 있다. 언제까지? synchronized 블록이 끝날 때까지. 그래서 결국 다른 스레드들은 같은 모니터 락이 필요한 synchronized 메서드나 블록에 접근하지 못하게 된다.

 

 

그럼 이 생산자 소비자 문제는 도대체 어떻게 해결할까?

양보하는게 맞다! 근데 양보를 할 때 락도 양보하면 된다. 

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

 

Object.wait(), Object.notify()

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

 

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

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

 

BoundedQueueV3

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(int max) {
        this.max = max;
    }

    @Override
    public synchronized void put(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();
    }
}
  • 이제 V3이다. 앞서 사용한 sleep() 코드는 제거하고, Object.wait()을 사용하자. Object는 모든 클래스의 부모이므로 자바의 모든 객체는 해당 기능을 사용할 수 있다.
@Override
public synchronized void put(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
}
  • synchronized를 통해 임계 영역을 설정한다. 생산자 스레드는 락 획득을 시도한다.
  • 락을 획득한 생산자 스레드는 반복문을 통해서 큐에 빈 공간이 생기는지 주기적으로 체크한다. 만약, 빈 공간이 없다면 Object.wait()을 사용해서 대기한다. 참고로 대기할 때 락을 반납하고 대기한다. 그리고 대기 상태에서 깨어나면 다시 반복문에서 큐의 빈 공간을 체크한다.
  • wait()을 호출해서 대기하는 경우 RUNNABLE → WAITING 상태가 된다.
  • 생산자가 데이터를 큐에 저장하고 나면 notify()를 통해 대기하는 스레드에게 저장된 데이터가 있다고 알려주어야 한다. 예를 들어서 큐에 데이터가 없어서 대기하는 소비자 스레드가 있다고 가정하자. 이때 notify()를 호출하면 소비자 스레드는 깨어나서 저장된 데이터를 가져갈 수 있다.
@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;
}
  • synchronized를 통해 임계 영역을 설정한다. 소비자 스레드는 락 획득을 시도한다.
  • 락을 획득한 소비자 스레드는 반복문을 사용해서 큐에 데이터가 있는지 주기적으로 체크한다. 만약, 데이터가 없다면 Object.wait()을 사용해서 대기한다. 참고로 대기할 때 락을 반납하고 대기한다. 그리고 대기 상태에서 깨어나면, 다시 반복문에서 큐에 데이터가 있는지 체크한다. 
  • 대기하는 경우 RUNNABLE → WAITING 상태가 된다.
  • 소비자가 데이터를 획득하고 나면 notify()를 통해 대기하는 생산자 스레드에게 큐에 저장할 여유 공간이 생겼다고 알려주어야 한다. 예를 들어, 큐에 데이터가 꽉차서 데이터를 넣지 못해 대기하는 생산자 스레드가 있다고 가정하자. 이때 notify()를 호출하면 생산자 스레드는 깨어나서 저장된 데이터를 획득할 수 있다. 

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

 

V3로 변경하고 생산자 먼저 실행 코드로 변경 하기

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

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

 

실행 결과

2024-07-24 15:29:08.277 [     main] == [생산자 먼저 실행] 시작, BoundedQueueV3 ==

2024-07-24 15:29:08.279 [     main] 생산자 시작
2024-07-24 15:29:08.287 [producer1] [생산 시도] data1 -> []
2024-07-24 15:29:08.287 [producer1] [put] 생산자 데이터 저장, notify() 호출
2024-07-24 15:29:08.288 [producer1] [생산 완료] data1 -> [data1]
2024-07-24 15:29:08.384 [producer2] [생산 시도] data2 -> [data1]
2024-07-24 15:29:08.384 [producer2] [put] 생산자 데이터 저장, notify() 호출
2024-07-24 15:29:08.384 [producer2] [생산 완료] data2 -> [data1, data2]
2024-07-24 15:29:08.487 [producer3] [생산 시도] data3 -> [data1, data2]
2024-07-24 15:29:08.487 [producer3] [put] 큐가 가득 찼습니다. 생산자는 대기합니다.

2024-07-24 15:29:08.590 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
2024-07-24 15:29:08.591 [     main] producer1: TERMINATED
2024-07-24 15:29:08.591 [     main] producer2: TERMINATED
2024-07-24 15:29:08.592 [     main] producer3: WAITING

2024-07-24 15:29:08.592 [     main] 소비자 시작
2024-07-24 15:29:08.593 [consumer1] [소비 시도]     ? <- [data1, data2]
2024-07-24 15:29:08.593 [consumer1] [take] 소비자 데이터 획득, notify() 호출
2024-07-24 15:29:08.593 [producer3] [put] 생산자 깨어남
2024-07-24 15:29:08.594 [consumer1] [소비 완료] data1 <- [data2]
2024-07-24 15:29:08.594 [producer3] [put] 생산자 데이터 저장, notify() 호출
2024-07-24 15:29:08.594 [producer3] [생산 완료] data3 -> [data2, data3]
2024-07-24 15:29:08.696 [consumer2] [소비 시도]     ? <- [data2, data3]
2024-07-24 15:29:08.697 [consumer2] [take] 소비자 데이터 획득, notify() 호출
2024-07-24 15:29:08.697 [consumer2] [소비 완료] data2 <- [data3]
2024-07-24 15:29:08.797 [consumer3] [소비 시도]     ? <- [data3]
2024-07-24 15:29:08.798 [consumer3] [take] 소비자 데이터 획득, notify() 호출
2024-07-24 15:29:08.798 [consumer3] [소비 완료] data3 <- []

2024-07-24 15:29:08.897 [     main] 현재 상태 출력, 큐 데이터: []
2024-07-24 15:29:08.898 [     main] producer1: TERMINATED
2024-07-24 15:29:08.898 [     main] producer2: TERMINATED
2024-07-24 15:29:08.898 [     main] producer3: TERMINATED
2024-07-24 15:29:08.899 [     main] consumer1: TERMINATED
2024-07-24 15:29:08.899 [     main] consumer2: TERMINATED
2024-07-24 15:29:08.900 [     main] consumer3: TERMINATED
2024-07-24 15:29:08.900 [     main] == [생산자 먼저 실행] 종료, BoundedQueueV3 ==

 

V3로 변경하고 소비자 먼저 실행 코드로 변경 하기

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

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

 

실행 결과 

2024-07-24 15:30:27.418 [     main] == [소비자 먼저 실행] 시작, BoundedQueueV3 ==

2024-07-24 15:30:27.421 [     main] 소비자 시작
2024-07-24 15:30:27.425 [consumer1] [소비 시도]     ? <- []
2024-07-24 15:30:27.425 [consumer1] [take] 큐에 데이터가 없습니다. 소비자는 대기합니다
2024-07-24 15:30:27.528 [consumer2] [소비 시도]     ? <- []
2024-07-24 15:30:27.529 [consumer2] [take] 큐에 데이터가 없습니다. 소비자는 대기합니다
2024-07-24 15:30:27.629 [consumer3] [소비 시도]     ? <- []
2024-07-24 15:30:27.629 [consumer3] [take] 큐에 데이터가 없습니다. 소비자는 대기합니다

2024-07-24 15:30:27.731 [     main] 현재 상태 출력, 큐 데이터: []
2024-07-24 15:30:27.736 [     main] consumer1: WAITING
2024-07-24 15:30:27.736 [     main] consumer2: WAITING
2024-07-24 15:30:27.736 [     main] consumer3: WAITING

2024-07-24 15:30:27.737 [     main] 생산자 시작
2024-07-24 15:30:27.738 [producer1] [생산 시도] data1 -> []
2024-07-24 15:30:27.738 [producer1] [put] 생산자 데이터 저장, notify() 호출
2024-07-24 15:30:27.738 [consumer1] [take] 소비자 깨어남
2024-07-24 15:30:27.739 [producer1] [생산 완료] data1 -> [data1]
2024-07-24 15:30:27.739 [consumer1] [take] 소비자 데이터 획득, notify() 호출
2024-07-24 15:30:27.739 [consumer2] [take] 소비자 깨어남
2024-07-24 15:30:27.739 [consumer1] [소비 완료] data1 <- []
2024-07-24 15:30:27.739 [consumer2] [take] 큐에 데이터가 없습니다. 소비자는 대기합니다
2024-07-24 15:30:27.840 [producer2] [생산 시도] data2 -> []
2024-07-24 15:30:27.841 [producer2] [put] 생산자 데이터 저장, notify() 호출
2024-07-24 15:30:27.841 [consumer3] [take] 소비자 깨어남
2024-07-24 15:30:27.841 [producer2] [생산 완료] data2 -> [data2]
2024-07-24 15:30:27.841 [consumer3] [take] 소비자 데이터 획득, notify() 호출
2024-07-24 15:30:27.842 [consumer2] [take] 소비자 깨어남
2024-07-24 15:30:27.842 [consumer3] [소비 완료] data2 <- []
2024-07-24 15:30:27.842 [consumer2] [take] 큐에 데이터가 없습니다. 소비자는 대기합니다
2024-07-24 15:30:27.942 [producer3] [생산 시도] data3 -> []
2024-07-24 15:30:27.942 [producer3] [put] 생산자 데이터 저장, notify() 호출
2024-07-24 15:30:27.942 [producer3] [생산 완료] data3 -> [data3]
2024-07-24 15:30:27.942 [consumer2] [take] 소비자 깨어남
2024-07-24 15:30:27.943 [consumer2] [take] 소비자 데이터 획득, notify() 호출
2024-07-24 15:30:27.943 [consumer2] [소비 완료] data3 <- []

2024-07-24 15:30:28.047 [     main] 현재 상태 출력, 큐 데이터: []
2024-07-24 15:30:28.047 [     main] consumer1: TERMINATED
2024-07-24 15:30:28.048 [     main] consumer2: TERMINATED
2024-07-24 15:30:28.048 [     main] consumer3: TERMINATED
2024-07-24 15:30:28.049 [     main] producer1: TERMINATED
2024-07-24 15:30:28.049 [     main] producer2: TERMINATED
2024-07-24 15:30:28.049 [     main] producer3: TERMINATED
2024-07-24 15:30:28.050 [     main] == [소비자 먼저 실행] 종료, BoundedQueueV3 ==

 

로그만 보면 결국 잘 저장하고 잘 사용한것 같아 보인다. 근데 로그만으로는 이해하기 쉽지 않다. 그림으로 하나씩 분석해보자!

 

wait(), notify() 생산자 우선 분석

  • 우선 못보던게 하나 생겼다. 스레드 대기 집합
  • 스레드 대기 집합 (wait set)
    • synchronized 임계 영역 안에서 Object.wait()을 호출하면 스레드는 대기 상태에 들어간다. 이렇게 대기 상태에 들어간 스레드를 관리하는 것을 대기 집합이라 한다. 참고로 모든 객체는 각자 자기의 대기 집합과 모니터 락을 가지고 있다. 그리고 이 둘은 한 쌍으로 사용된다. 따라서 락을 획득한 객체의 대기 집합을 사용해야 한다. 여기서는 BoundedQueue(x001) 구현 인스턴스의 락과 대기 집합을 사용한다. 
      • synchronized를 메서드에 적용하면 해당 인스턴스의 락을 사용한다. 여기서는 BoundedQueue(x001)의 구현체이다.
      • wait() 호출은 앞에 this를 생략할 수 있다. this는 해당 인스턴스를 뜻한다. 여기서는 BoundedQueue(x001)의 구현체이다.

 

이제 순서대로 흐름을 분석해보자!

  • p1이 락을 획득하고 큐에 데이터를 저장한다.
  • 큐에 데이터가 추가 되었기 때문에 스레드 대기 집합에 이 사실을 알려야 한다. (코드 흐름이 그렇다)
  • notify()를 호출하면 스레드 대기 집합에서 대기하는 스레드 중 하나를 깨운다.
  • 현재 대기 집합에 스레드가 없으므로 아무일도 발생하지 않는다. 만약 소비자 스레드가 대기 집합에 있었다면 깨어나서 큐에 들어있는 데이터를 소비했을 것이다.

  • p1은 할일을 다 끝내고 락을 반납한다. 

  • p2도 큐에 데이터를 저장하고 생산을 완료했다.

  • p3가 데이터를 생산하려고 하는데, 큐가 가득 찼다. wait()을 호출한다.

  • wait()을 호출하면 락을 반납한다.
  • wait()을 호출하면 스레드의 상태가 RUNNABLE → WAITING으로 변경된다.
  • wait()을 호출하면 스레드 대기 집합에서 관리된다.
  • 스레드 대기 집합에서 관리되는 스레드는 이후에 다른 스레드가 notify()를 통해 스레드 대기 집합에 신호를 주면 깨어날 수 있다.

  • 이제 소비자 스레드들이 움직일 차례가 됐다.

  • c1이 데이터를 획득했다. 그래서 큐에 데이터를 보관할 빈자리 생겼다.
  • c1notify()를 호출한다 (코드 흐름이 그렇다)
  • 스레드 대기 집합에 있는 p3를 깨운다.

  • 스레드 대기 집합은 notify() 신호를 받으면 대기 집합에 있는 스레드 중 하나를 깨운다.
  • 그런데 대기 집합에 있는 스레드가 깨어난다고 바로 작동하는 것이 아니다. 왜냐하면 이 스레드는 여전히 임계 영역 안에 있기 때문이다.
  • 임계 영역에 있는 코드를 실행하려면 가장 먼저 락이 필요하다. p3는 대기 집합에서는 나가지만 여전히 임계 영역에 있으므로 락을 획득하기 위해 BLOCKED 상태로 대기한다. p3: WAITING → BLOCKED 
  • 참고로 이때 임계 영역의 코드를 처음으로 돌아가서 실행하는 게 아니다. 대기 집합에 들어오게 된 wait()을 호출한 부분부터 다시 실행된다. 락을 획득하면 wait() 이후의 코드를 실행한다. 

  • c1은 데이터 소비를 완료하고 락을 반납하고 임계 영역을 빠져나간다.

  • p3가 락을 획득한다.
    • BLOCKED → RUNNABLE
    • wait() 코드에서 대기했기 때문에 이후의 코드를 실행한다.
    • data3을 큐에 저장한다.
    • notify()를 호출한다. 데이터를 저장했기 때문에 혹시 스레드 대기 집합에 소비자가 대기하고 있다면 소비자를 하나 깨워줘야 한다. 물론 지금은 대기 집합에 스레드가 없기 때문에 아무일도 일어나지 않는다.

  • p3는 락을 반납하고 임계 영역을 빠져나간다.
  • 이제 c2, c3가 하나씩 데이터를 원래대로 가져갈 것이다.

  • c2, c3가 실행됐고 데이터가 있으므로 둘 다 데이터를 소비하고 완료한다.
  • 둘 다 notify()를 호출했지만 대기 집합에 스레드가 없으므로 아무일도 일어나지 않는다.

wait(), notify() 덕분에 스레드가 락을 놓고 대기하고, 또 대기하는 스레드를 필요한 시점에 깨울 수 있었다. 생산자 스레드가 큐가 가득차서 대기해도 소비자 스레드가 큐의 데이터를 소비하고 나면 알려주기 때문에 최적의 타이밍에 깨어나서 데이터를 생산할 수 있었다. 

덕분에 최종 결과를 보면 p1, p2, p3 모두 데이터를 정상 생산하고 c1, c2, c3 모두 데이터를 정상 소비할 수 있었다. 

 

wait(), notify() 소비자 우선 분석

  • 이제 소비자 우선 코드로 시작해보자. 최초의 상태이다.

  • c1이 락을 얻고 임계 영역에 들어왔지만, 데이터가 없다. wait()을 호출하고 대기 집합에 대기하게 된다.

  • 큐에 데이터가 없기 때문에 c1, c2, c3 모두 스레드 대기 집합에서 대기하게 된다.
  • 이후에 생산자가 큐에 데이터를 생산하면 notify()를 통해 이 스레드들을 하나씩 깨워서 데이터를 소비할 수 있을것이다.

  • p1은 락을 획득하고 큐에 데이터를 생산한다. 큐에 데이터가 있기 때문에 소비자를 하나 깨울 수 있다. notify()를 통해 스레드 대기 집합에 이 사실을 알려준다.

  • notify()를 받은 스레드 대기 집합은 스레드 중에 하나를 깨운다.
  • 여기서 c1, c2, c3 중에 어떤 스레드가 깨어날지는 알 수 없다.
    • 어떤 스레드가 깨워질지는 JVM 스펙에 명시되어 있지 않다. 따라서 JVM 버전 및 환경에 따라 달라진다.
  • 그런데 대기 집합에 있는 스레드가 깨어난다고 바로 작동하는 것은 아니다. 깨어난 스레드는 여전히 임계 영역 안에 있다.
  • 임계 영역 안에 있는 코드를 실행하려면 먼저 락이 필요하다. 대기 집합에서는 나가지만 여전히 임계 영역 안에 있으므로 락을 획득하기 위해 BLOCKED 상태로 대기한다. WAITING → BLOCKED

  • p1이 락을 반납하고 임계 영역에서 나간다.
  • c1은 락을 획득한다.

  • c1은 락을 획득하고 임계 영역 안에서 실행되며 데이터를 획득한다.
  • c1이 데이터를 획득했으므로 큐에 데이터를 넣을 공간이 있다는 것을 대기 집합에 알려준다. 만약 대기 집합에 생산자 스레드가 대기하고 있다면 큐에 데이터를 넣을 수 있을 것이다.

  • c1notify()로 스레드 대기 집합에 알렸지만, 생산자 스레드가 아니라 소비자 스레드만 있다. 따라서 의도와는 다르게 소비자 스레드인 c2가 대기상태에서 깨어난다. (물론 대기 집합에 있는 어떤 스레드가 깨어날지는 알 수 없다. 여기서는 c2가 깨어난다고 가정한다. 심지어 생산자와 소비자 스레드가 함께 대기 집합에 있어도 어떤 스레드가 깨어날지는 알 수 없다.)

  • c1은 작업을 완료한다.
  • c1c2를 깨웠지만, 문제가 있다. 바로 큐에 데이터가 없다는 것이다.
  • c2는 락을 획득하고 큐에 데이터를 소비하려고 시도한다. 그런데 큐에 데이터가 없으므로 c2는 결국 wait()을 호출해서 다시 대기 상태로 변하며 대기 집합에 들어간다.

  • 이처럼 소비자인 c1이 같은 소비자인 c2를 깨우는것은 상당히 비효율적이다.
  • c1 입장에서 c2를 깨우게 되면 아무 일도 하지 않고 그냥 다시 스레드 대기 집합에 들어갈 수 있다. 결과적으로 CPU만 사용하고, 아무 일도 하지 않은 상태로 다시 대기 상태가 되어버린다.
  • 그렇다고 c1이 스레드 대기 집합에 있는 어떤 스레드를 깨울지 선택할 수는 없다. notify()는 스레드 대기 집합에 있는 스레드 중 임의의 하나를 깨울뿐이다.
  • 물론 이게 비효율적이라는 것이지 문제가 되는 것은 아니다. 결과에는 아무런 문제가 없다. 살짝 돌아갈 뿐이다.

  • p2가 락을 획득하고, 데이터를 저장한 다음에 notify()를 호출한다. 데이터가 있으므로 소비자 스레드가 깨어난다면 데이터를 소비할 수 있다.

  • 스레드 대기 집합에 있는 c3가 깨어난다. 참고로 어떤 스레드가 깨어날지는 알 수 없다.
  • c3는 임계 영역 안에 있으므로 락을 획득하기 위해 대기(BLOCKED) 한다.

  • p2가 작업을 끝마치고 락을 반납하고 나간다. 
  • c3는 락을 획득하고 BLOCKED → RUNNABLE 상태가 된다.
  • c3는 데이터를 획득한 다음에 notify()를 통해 스레드 대기 집합에 알린다. 큐에 여유 공간이 생겼기 때문에 생산자 스레드가 대기 중이라면 데이터를 생산할 수 있다.

  • notify()를 호출했지만, 스레드 대기 집합에는 소비자인 c2만 존재한다. 
  • c2가 깨어나지만 임계 영역 안에 있으므로 락을 기다리는 BLOCKED 상태가 된다.

  • c3는 락을 반납하고 임계 영역을 나간다.
  • c2가 락을 획득하고, 큐에서 데이터를 획득하려 하지만 데이터가 없다.
  • c2는 다시 wait()을 호출해서 대기(WAITING)상태에 들어가고, 다시 대기 집합에서 관리된다.

  • 물론 c2의 지금 이 사이클은 CPU 자원만 소모하고 다시 대기 집합에 들어갔기 때문에 비효율적이다.
  • 만약 소비자인 c3 입장에서 생산자, 소비자 스레드를 선택해서 깨울 수 있다면, 소비자인 c2를 깨우지는 않았을 것이다. 하지만 notify()는 이런 선택을 할 수 없다.

  • p3가 락을 얻고 데이터를 저장한다. notify()를 통해 스레드 대기 집합에 알린다.
  • 스레드 대기 집합에는 소비자 c2가 있으므로 생산한 데이터를 잘 소비할 수 있다.

  • c2notify()를 통해 깨어나고 BLOCKED 상태로 대기하고 있다가 락을 획득하면 큐에 데이터를 잘 소비해서 임계영역을 빠져나오고 종료된다.

정리를 하자면

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

 

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

 

비효율적이지만 문제는 없다. 좀 돌아갈 뿐이다. 

 

Object - wait(), notify() 한계

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

 

  • 다음과 같은 상황을 연출해보자.
  • 큐에 dataX가 보관되어 있다.
  • 스레드 대기 집합에는 다음 스레드가 대기하고 있다.
    • 소비자: c1, c2, c3
    • 생산자: p1, p2, p3
  • p0 스레드가 data0 생산을 시도한다.

  • p0 스레드가 실행되면서 data0을 큐에 저장한다. 이때 큐에 데이터가 가득찬다.
  • notify()를 통해 대기 집합의 스레드를 하나 깨운다.

  • 만약, notify()의 결과로 소비자 스레드가 깨어나게 되면 소비자 스레드는 큐의 데이터를 획득하고 완료된다.

  • 그러나 notify()의 결과로 생산자 스레드를 깨우게 되면, 이미 큐에 데이터는 가득 차 있다. 따라서 데이터를 생산하지 못하고 다시 대기 집합으로 이동하는 비효율이 발생한다.

 

이번엔 반대의 경우로 소비자에 대해서도 이야기 해보자. 아래와 같은 상황이 있다.

  • c0 스레드가 실행되고 data0을 획득한다.
  • 이제 큐에 데이터는 비어있게 된다.
  • c0 스레드는 notify()를 호출한다.

  • 스레드 대기 집합에서 소비자 스레드가 깨어나면 큐에 데이터가 없기 때문에 다시 대기 집합으로 이동하는 비효율이 발생한다.

 

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

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

 

또 하나의 문제가 있다. 바로 스레드 기아(thread starvation) 문제점이다.

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

  • notify()가 어떤 스레드를 깨울지는 알 수 없다. 최악의 경우 c1 - c5 스레드가 반복해서 깨어날 수 있다.
    • c1 - c5 스레드가 깨어나도 큐에 소비할 데이터가 없다. 따라서 다시 스레드 대기 집합에 들어간다.
    • notify()로 다시 깨우는데 어떤 스레드를 깨울지 알 수 없다. 따라서 c1 - c5 스레드가 반복해서 깨어날 수 있다.
  • 이렇게 대기 상태의 스레드가 실행 순서를 계속 얻지 못해서 실행되지 않는 상황을 스레드 기아 상태라 한다. 
  • 여기서 깨어나야 할 이상적인 스레드는 바로 생산자 스레드인 p1이다. 

이 스레드 기아를 해결하는 방법 중 하나인 notifyAll()이 있다. 

 

notifyAll()

이 메서드는 스레드 대기 집합에 있는 모든 스레드를 한번에 다 깨울 수 있다.

  • 데이터를 획득한 c0 스레드가 notifyAll()을 호출한다.

  • 대기 집합에 있는 모든 스레드가 깨어난다.
  • 모든 스레드는 다 임계 영역 안에 있다. 따라서 먼저 락을 획득해야 한다.
  • 락을 획득하지 못하면 BLOCKED 상태에서 머무르게 된다.
  • 만약, c1이 먼저 락을 획득한다면 큐에 데이터가 없으므로 다시 스레드 대기 집합에 들어간다.
  • c2 - c5도 마찬가지다.
  • 따라서, p1이 가장 늦게 락 획득을 시도해도 c1 - c5 모두 스레드 대기 집합에 들어가있으므로 결과적으로 p1만 남게되고 결국 락을 획득하게 된다.

 

그러나, 이 경우에 스레드 기아 문제를 해결한다 하더라도 비효율은 해결하지 못한다. 결국 가장 좋은 방법은 소비자는 생산자를, 생산자는 소비자를 깨우는 방법이다. 

 

정리

  • 생산자 - 소비자 문제란?
    • 생산자가 너무 빠를 경우 버퍼에 데이터가 꽉 차서 더 이상 데이터를 생산해낼 수 없다.
    • 소비자가 너무 빠를 경우 버퍼에 남은 데이터가 없어 데이터를 소비할 수 없다.

이 문제를 해결하는 방법은 생산자의 경우 버퍼가 빈 공간이 생길때까지 기다리는 것이고, 소비자의 경우 버퍼에 데이터가 생길때까지 기다리는 것이다. 결국 기다리는 것이다.

 

그러나, 단순히 기다릴 순 없다. 왜냐하면 임계 영역은 딱 하나의 스레드만 작업할 수 있게 설계되었다. 아무리 특정 스레드가 하루종일 기다린다해도 본인이 락을 들고 있는 상태에서 놔주지 않으면 다른 스레드는 진입할 수 없다.

 

그래서 단순히 기다린 게 아니라 락을 반납하고 기다린다.

이때 사용할 수 있는 것이 Object.wait()이다. 이 wait(), notify(), notifyAll()synchronized와 같이 사용할 수 있다. 

 

wait()으로 락을 반납하고 해당 스레드는 스레드 대기 집합에 들어간다. 그리고 락을 반납했으니 다른 스레드가 진입할 수 있게 된다. 다른 스레드가 작업을 다 마치고 notify()로 스레드 대기 집합에 알린다. 그럼 스레드 대기 집합에 있는 임의의 스레드 하나가 튀어나온다.

그땐 스레드는 BLOCKED 상태이다. 아직 락을 얻지 못한 상태이니까. 그리고 최종적으로 락을 반납하고 스레드가 나가면 튀어나온 스레드가 이 락을 얻어 작업을 진행할 수 있게 됐다. 

 

근본적인 생산자 소비자 문제를 해결했지만, 여기서 파생되는 비효율이 발생했다.

생산자가 생산자를 계속 깨우거나, 소비자가 소비자를 계속 깨우면 의미없이 CPU 자원만 소모하고 아무것도 할 수 없다. 

 

즉, 가장 좋은 방법은 깨울때 생산자는 소비자를, 소비자는 생산자를 깨우는 게 가장 좋은 방법이다. 이 방법을 다음 포스팅에서 알아보자!

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

LockSupport

LockSupport 라는 것을 배우기 전에 먼저 다시 복습을 해보자. synchronized는 자바 1.0부터 제공되는 매우 편리한 기능이지만, 다음과 같은 한계가 있다.

  • 무한대기: BLOCKED 상태의 스레드는 락이 풀릴 때 까지 무한 대기한다.
    • 특정 시간까지만 대기하는 타임아웃도 없다.
    • 중간에 인터럽트도 못한다.
  • 공정성: 락이 돌아왔을 때 BLOCKED 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할 지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜기간 락을 획득하지 못할 수 있다.

결국, 더 유연하고 더 세밀한 제어가 가능한 방법들이 필요하게 되었다. 이런 문제를 해결하기 위해 자바 1.5부터 java.util.concurrent 라는 동시성 문제 해결을 위한 라이브러리 패키지가 추가된다. 

 

이 라이브러리에는 수 많은 클래스가 있지만, 가장 기본이 되는 LockSupport에 대해서 먼저 알아보자. LockSupport를 사용하면 synchronized의 가장 큰 단점인 무한 대기 문제를 해결할 수 있다.

 

LockSupport 기능

LockSupport는 스레드를 WAITING 상태로 변경한다. WAITING 상태는 누가 깨워주기 전까지는 계속 대기한다. 그리고 CPU 스케쥴링에 들어가지 않는다. LockSupport의 대표적인 기능은 다음과 같다.

  • park(): 스레드를 WAITING 상태로 변경한다.
  • parkNanos(nanos): 스레드를 나노초 동안만 TIMED_WAITING 상태로 변경한다. 지정한 나노초가 지나면 TIMED_WAITING 상태에서 빠져나오고 RUNNABLE 상태로 변경된다.
  • unpark(thread): WAITING 상태의 대상 스레드를 RUNNABLE 상태로 변경한다.

 

LockSupportMainV1

package thread.sync.lock;

import java.util.concurrent.locks.LockSupport;

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

public class LockSupportMainV1 {
    public static void main(String[] args) {
        Thread thread1 = new Thread(new ParkTest(), "Thread-1");
        thread1.start();

        sleep(100);
        log("Thread-1 state: " + thread1.getState());

        log("main -> unpark(Thread-1)");
        LockSupport.unpark(thread1);
        // thread1.interrupt();
    }

    static class ParkTest implements Runnable {
        @Override
        public void run() {
            log("park 시작");
            LockSupport.park();
            log("park 종료, state " + Thread.currentThread().getState());
            log("인터럽트 상태: " + Thread.currentThread().isInterrupted());
        }
    }
}

실행결과

2024-07-22 20:57:28.683 [ Thread-1] park 시작
2024-07-22 20:57:28.765 [     main] Thread-1 state: WAITING
2024-07-22 20:57:28.766 [     main] main -> unpark(Thread-1)
2024-07-22 20:57:28.766 [ Thread-1] park 종료, state RUNNABLE
2024-07-22 20:57:28.772 [ Thread-1] 인터럽트 상태: false
  • main 스레드가 Thread-1start()하면 Thread-1RUNNABLE 상태가 된다.
  • Thread-1LockSupport.park()를 호출한다. Thread-1RUNNABLE → WAITING 상태가 되면서 대기한다.
  • main 스레드가 Thread-1unpark()로 깨운다. Thread-1은 대기 상태에서 실행 가능 상태로 변한다.
    • WAITING → RUNNABLE 상태로 변한다.

이처럼, LockSupport는 특정 스레드를 WAITING 상태로 또 RUNNABLE 상태로 변경할 수 있다.

그런데, 대기 상태로 바꾸는 LockSupport.park()는 매개변수가 없는데, 실행 가능 상태로 바꾸는 LockSupport.unpark(thread1)은 왜 특정 스레드를 지정하는 매개변수가 있을까? 왜냐하면 실행 중인 스레드는 LockSupport.park()를 호출해서 스스로 대기 상태에 빠질 수 있지만, 대기 상태의 스레드는 자신의 코드를 실행할 수 없기 때문이다. 따라서 외부 스레드의 도움을 받아야 깨어날 수 있다.

 

인터럽트를 사용해서 WAITING → RUNNABLE로 바꾸기

WAITING 상태의 스레드에 인터럽트가 발생하면 WAITING 상태에서 RUNNABLE 상태로 변하면서 깨어난다.

 

위 코드를 딱 이렇게 변경해보자.

//LockSupport.unpark(thread1);
thread1.interrupt();

 

실행결과

2024-07-22 21:04:04.277 [ Thread-1] park 시작
2024-07-22 21:04:04.357 [     main] Thread-1 state: WAITING
2024-07-22 21:04:04.357 [     main] main -> unpark(Thread-1)
2024-07-22 21:04:04.357 [ Thread-1] park 종료, state RUNNABLE
2024-07-22 21:04:04.362 [ Thread-1] 인터럽트 상태: true

 

실행 결과를 보면 스레드가 RUNNABLE 상태로 깨어난 것을 확인할 수 있다. 그리고 해당 스레드의 인터럽트의 상태도 true인 것을 확인할 수 있다. 이처럼 WAITING 상태의 스레드는 인터럽트를 걸어서 중간에 깨울 수 있다.

 

시간 대기

이번에는 스레드를 특정 시간 동안만 대기하는 parkNanos(nanos)를 호출해보자.

  • parkNanos(nanos): 스레드를 나노초 동안만 TIMED_WAITING 상태로 변경한다. 지정한 나노초가 지나면 TIMED_WAITING 상태에서 빠져나와서 RUNNABLE 상태로 변경된다.
  • 참고로 밀리초 동안만 대기하는 메서드는 없다. parkUntil(밀리초)라는 메서드가 있는데, 이 메서드는 특정 에포크(Epoch) 시간에 맞추어 깨어나는 메서드이다. 정확한 미래의 에포크 시점을 지정해야 한다.

LockSupportMainV2

package thread.sync.lock;

import java.util.concurrent.locks.LockSupport;

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

public class LockSupportMainV2 {
    public static void main(String[] args) {
        Thread thread1 = new Thread(new ParkTest(), "Thread-1");
        thread1.start();

        sleep(100);
        log("Thread-1 state: " + thread1.getState());

    }

    static class ParkTest implements Runnable {
        @Override
        public void run() {
            log("park 시작");
            LockSupport.parkNanos(2_000_000_000); // 2초
            log("park 종료, state " + Thread.currentThread().getState());
            log("인터럽트 상태: " + Thread.currentThread().isInterrupted());
        }
    }
}
  • 여기서 스레드를 깨우기 위한 unpark()를 사용하지 않는다.
  • parkNanos(나노초)를 사용하면 지정한 시간 이후에 스레드가 알아서 일어난다.
  • 1초 = 1000밀리초(ms)
  • 1밀리초 = 1,000,000 나노초(ns)
  • 2초 = 2,000,000,000 나노초(ns)

BLOCKED vs WAITING

WAITING 상태에 특정 시간까지만 대기하는 기능이 포함된 것이 TIMED_WAITING이다. 여기서는 둘을 묶어서 WAITING 상태라 표현하겠다. 

 

인터럽트

  • BLOCKED 상태는 인터럽트가 걸려도 대기 상태를 빠져나오지 못한다. 여전히 BLOCKED 상태이다.
  • WAITING, TIMED_WAITING 상태는 인터럽트가 걸리면 대기상태를 빠져나온다. 그래서 RUNNABLE 상태로 변한다.

용도

  • BLOCKED 상태는 자바의 synchronized에서 락을 획득하기 위해 대기할 때 사용된다.
  • WAITING, TIMED_WAITING 상태는 스레드가 특정 조건이나 시간 동안 대기할 때 발생하는 상태이다.
  • WAITING 상태는 다양한 상황에서 사용된다. 예를 들어, Thread.join(), LockSupport.park(), Object.wait()과 같은 메서드 호출 시 WAITING 상태가 된다.
  • TIMED_WAITING 상태는 Thread.sleep(ms), Object.wait(long timeout), Thread.join(long millis), LockSupport.parkNanos(nanos) 등과 같은 시간 제한이 있는 대기 메서드를 호출할 때 발생한다.

BLOCKED, WAITING, TIMED_WAITING 상태 모두 스레드가 대기하며, 실행 스케쥴링에 들어가지 않기 때문에 CPU 입장에서 보면 실행하지 않는 비슷한 상태이다. 

  • BLOCKED 상태는 synchronized에서만 사용하는 특별한 대기 상태라고 이해하면 된다.
  • WAITING, TIMED_WAITING 상태는 범용적으로 활용할 수 있는 대기 상태라고 이해하면 된다.

 

LockSupport 정리

LockSupport를 사용하면 스레드를 WAITING, TIMED_WAITING 상태로 변경할 수 있고, 또 인터럽트를 받아서 스레드를 깨울 수도 있다. 이런 기능들을 잘 활용하면 synchronized의 단점인 무한 대기 문제를 해결할 수 있을 것 같다.

 

synchronized의 단점

  • 무한 대기: BLOCKED 상태의 스레드는 락이 풀릴 때 까지 무한으로 대기한다.
    • 특정 시간까지만 대기하는 타임아웃 X  parkNanos(nanos)를 사용하면 특정 시간까지만 대기할 수 있게 된다.
    • 중간에 인터럽트 X  park(), parkNanos(nanos)는 인터럽트를 걸 수 있다.

이처럼 LockSupport를 활용하면, 무한 대기하지 않는 락 기능을 만들 수 있다. 물론 그냥 되는게 아니라 LockSupport를 활용해서 안전한 임계 영역을 만드는 어떤 기능을 개발해야 한다. 그렇지만! 이런 기능을 직접 만드는 건 너무 어렵다. 예를 들어 스레드 10개를 동시에 실행했는데, 그 중에 딱 한 개의 스레드만 락을 가질 수 있도록 락 기능을 만들어야 한다. 그리고 나머지 9개의 스레드가 대기해야 하는데 어떤 스레드가 대기하고 있는지 알 수 있는 자료구조도 필요하다. 그래야 이후에 대기 중인 스레드를 찾아서 깨울 수 있다. 여기서 끝이 아니고 대기 중인 스레드 중에 어떤 스레드를 깨울지에 대한 우선순위 결정도 필요하다.

 

즉, LockSupport는 사용할만 한 게 못된다. synchronized를 사용하는 게 날 것 같다. 하지만! 자바는 Lock 인터페이스와 ReentrantLock 이라는 구현체로 이런 기능들을 이미 다 구현해두었다. ReentrantLockLockSupport를 활용해서 synchronized의 단점을 극복하면서도 매우 편리하게 임계 영역을 다룰 수 있는 다양한 기능을 제공한다. 즉, 이 녀석을 사용하면 된다.

 

 

ReentrantLock

자바는 1.0부터 존재한 synchronizedBLOCKED 상태를 통한 임계 영역 관리의 한계를 극복하기 위해 자바 1.5부터 Lock 인터페이스와 ReentrantLock 구현체를 제공한다. 

 

synchronized 단점

  • 무한대기: BLOCKED 상태의 스레드는 락이 풀릴 때 까지 무한 대기한다.
    • 특정 시간까지만 대기하는 타임아웃 X
    • 중간에 인터럽트 X
  • 공정성: 락이 돌아왔을 때 BLOCKED 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할 지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜기간 락을 획득하지 못할 수 있다.

Lock 인터페이스

public interface Lock {
     void lock();
     void lockInterruptibly() throws InterruptedException;
     boolean tryLock();
     boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
     void unlock();
     Condition newCondition();
}

Lock 인터페이스는 동시성 프로그래밍에서 쓰이는 안전한 임계 영역을 위한 락을 구현하는데 사용된다.

Lock 인터페이스는 다음과 같은 메서드들을 제공하는데 대표적인 구현체는 ReentrantLock이 있다.

 

  • void lock()
    • 락을 획득한다. 만약 다른 스레드가 이미 락을 획득했다면, 락이 풀릴 때 까지 현재 스레드는 대기(WAITING)한다. 이 메서드는 인터럽트에 응답하지 않는다.
주의! 여기서 사용하는 락은 객체 내부에 있는 모니터 락이 아니다! Lock 인터페이스와 ReentrantLock이 제공하는 기능이다. 모니터 락과 BLOCKED 상태는 synchronized에서만 사용된다.
  • void lockInterruptibly()
    • 락 획득을 시도하되, 다른 스레드가 인터럽트 할 수 있도록 한다. 만약, 다른 스레드가 이미 락을 획득했다면, 현재 스레드는 락을 획득할 때 까지 대기한다. 대기 중에 인터럽트가 발생하면 InterruptedException이 발생하며 락 획득을 포기한다.
  • boolean tryLock()
    • 락 획득을 시도하고, 즉시 성공 여부를 반환한다. 만약 다른 스레드가 이미 락을 획득했다면, false를 반환하고 그렇지 않으면 락을 획득하고 true를 반환한다.
  • boolean tryLock(long time, TimeUnit unit)
    • 주어진 시간동안 락 획득을 시도한다. 주어진 시간 안에 락을 획득하면 true를 반환한다. 주어진 시간이 지나도 락을 획득하지 못한 경우 false를 반환한다. 이 메서드는 대기 중 인터럽트가 발생하면 InterruptedException이 발생하며 락 획득을 포기한다.
  • void unlock()
    • 락을 해제한다. 락을 해제하면 락 획득을 대기 중인 스레드 중 하나가 락을 획득할 수 있게 된다.
    • 락을 획득한 스레드가 호출해야 하며, 그렇지 않으면 IllegalMonitorStateException이 발생할 수 있다.
  • Condition newCondition()
    • Condition 객체를 생성하여 반환한다. Condition 객체는 락과 결합되어 사용되며, 스레드가 특정 조건을 기다리거나 신호를 받을 수 있도록 한다. 이는 Object 클래스의 wait, notify, notifyAll 메서드와 유사한 역할을 한다. 참고로 이 부분은 뒤에서 자세히 다루겠다.

 

이 메서드들을 사용하면 고수준의 동기화 기법을 구현할 수 있다. Lock 인터페이스는 synchronized 블록보다 더 많은 유연성을 제공하며, 특히 락을 특정 시간 만큼만 시도하거나 인터럽트 가능한 락을 사용할 때 유용하다. 이 메서드들을 보면 알겠지만 다양한 메서드들을 통해 synchronized의 단점인 무한 대기 문제도 깔끔하게 해결할 수 있다.

 

참고로, lock() 메서드는 인터럽트에 응하지 않는다고 되어있다. 이 메서드의 의도는 인터럽트가 발생해도 무시하고 락을 기다리는 것이다. 앞서 대기(WAITING) 상태의 스레드에 인터럽트가 발생하면 대기 상태를 빠져나온다고 했다. 그런데 lock() 메서드의 설명을 보면 WAITING 상태인데 인터럽트에 응하지 않는다고 되어 있다. 어떻게 된 것일까? lock()을 호출해서 락을 얻기 위해 대기중인 스레드에 인터럽트가 발생하면 순간 대기 상태를 빠져나오는 것은 맞다. 그래서 아주 짧지만 WAITINGRUNNABLE이 된다. 그런데 lock() 메서드 안에서 해당 스레드를 다시 WAITING 상태로 강제로 변경해버린다. 이런 원리로 인터럽트를 무시하는 것이다. 참고로 인터럽트가 필요하면 lockInterruptibly()를 사용하면 된다. 새로운 Lock은 개발자에게 다양한 선택권을 제공한다.

 

공정성

Lock 인터페이스가 제공하는 다양한 기능 덕분에 synchronized의 단점인 무한 대기 문제가 해결되었다. 그런데 공정성에 대한 문제가 남아 있다.

 

synchronized의 단점

  • 공정성: 락이 돌아왔을 때 BLOCKED 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할 지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜기간 락을 획득하지 못할 수 있다.

Lock 인터페이스의 대표적인 구현체로 ReentrantLock이 있는데, 이 클래스는 스레드가 공정하게 락을 얻을 수 있는 모드를 제공한다.

private final Lock unFairLock = new ReentrantLock(); // 비공정 모드 락
private final Lock fairLock = new ReentrantLock(true); // 공정 모드 락

ReentrantLock 락은 공정성 모드와 비공정 모드로 설정할 수 있으며, 이 두 모드는 락을 획득하는 방식에서 차이가 있다.

 

비공정 모드

비공정 모드는 ReentrantLock의 기본 모드이다. 이 모드에서는 락을 먼저 요청한 스레드가 락을 먼저 획득한다는 보장이 없다. 락을 풀었을 때 대기 중인 스레드 중 아무나 락을 획득할 수 있다. 이는 락을 빨리 획득할 수 있지만, 특정 스레드가 장기간 락을 획득하지 못할수도 있다. 

 

비공정 모드 특징

  • 성능 우선: 락을 획득하는 속도가 빠르다.
  • 선점 가능: 새로운 스레드가 기존 대기 스레드보다 먼저 락을 획득할 수 있다.
  • 기아 현상 가능성: 특정 스레드가 계속해서 락을 획득하지 못할 수 있다.

공정 모드

생성자에서 true를 전달하면 된다. 공정 모드는 락을 요청한 순서대로 스레드가 락을 획득할 수 있게 한다. 이는 먼저 대기한 스레드가 먼저 락을 획득하게 되어 스레드 간 공정성을 보장한다. 그러나 이로 인해 성능이 저하될 수 있다. 

 

공정 모드 특징

  • 공정성 보장: 대기 큐에서 먼저 대기한 스레드가 락을 먼저 획득한다.
  • 기아 현상 방지: 모든 스레드가 언젠가 락을 획득할 수 있게 보장한다.
  • 성능 저하: 락을 획득하는 속도가 느려질 수 있다.

정리를 하자면, Lock 인터페이스와 ReentrantLock 구현체를 이용하면 synchronized 단점인 무한 대기와 공정성 문제를 모두 해결할 수 있다!

 

ReentrantLock 활용

이제 이 ReentrantLock을 직접 이용해보자! 앞에 사용했던 예제 BankAccountV3를 가지고 해보자.

 

BankAccountV4

package thread.sync;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

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

public class BankAccountV4 implements BankAccount {

    private int balance;

    private final Lock lock = new ReentrantLock();

    public BankAccountV4(int initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        lock.lock();

        try {
            log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
            if (balance < amount) {
                log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
                return false;
            }

            log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
            sleep(1000);
            balance -= amount;
            log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);

            log("거래 종료:");
            return true;
        } finally {
            lock.unlock();
        }
    }

    @Override
    public int getBalance() {
        lock.lock();
        try {
            return balance;
        } finally {
            lock.unlock();
        }
    }
}
  • Lock lock = new ReentrantLock()을 사용하도록 선언했다.
  • 임계영역을 안전하게 처리하는 부분에서 synchronized(this) 대신에 lock.lock()을 사용해서 락을 건다. 이 지점에서 다른 스레드도 락을 얻기 위해 시도했을 때 이미 락을 누가 가져갔다면, 대기(WAITING)상태로 빠진다.
  • lock() ↔ unlock() 사이는 안전한 임계영역이 된다.
  • 임계 영역이 끝나면 반드시! 락을 반납해야 한다. 그렇지 않으면 대기하는 스레드가 락을 얻지 못한다.
    • 따라서 어떤 예외가 터지더라도 어떤 예측 불가능한 상황이 발생하더라도 락을 반납할 수 있도록 try - finally 구문에서 finally 블록안에 락을 반납하는 lock.unlock()을 호출한다.
주의! 여기서 사용하는 락은 객체 내부에 있는 모니터 락이 아니다! Lock 인터페이스와 ReentrantLock이 제공하는 기능이다! 모니터 락과 BLOCKED 상태는 synchronized에서만 사용된다.

 

이제 이 BankAccountV4를 사용해보자!

BankMain

package thread.sync;

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

public class BankMain {

    public static void main(String[] args) throws InterruptedException {
        BankAccountV4 account = new BankAccountV4(1000);

        Thread t1 = new Thread(new WithdrawTask(account, 800), "T1");
        Thread t2 = new Thread(new WithdrawTask(account, 800), "T2");

        t1.start();
        t2.start();

        sleep(500);
        log("t1 state: " + t1.getState());
        log("t2 state: " + t2.getState());

        t1.join();
        t2.join();

        log("최종 잔액: " + account.getBalance());
    }
}

실행결과

2024-07-23 17:46:13.130 [       T2] 거래 시작: BankAccountV4
2024-07-23 17:46:13.130 [       T1] 거래 시작: BankAccountV4
2024-07-23 17:46:13.141 [       T2] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-23 17:46:13.141 [       T2] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-23 17:46:13.604 [     main] t1 state: WAITING
2024-07-23 17:46:13.604 [     main] t2 state: TIMED_WAITING
2024-07-23 17:46:14.143 [       T2] [출금 완료] 출금액: 800, 잔액: 200
2024-07-23 17:46:14.144 [       T2] 거래 종료:
2024-07-23 17:46:14.145 [       T1] [검증 시작] 출금액: 800, 잔액: 200
2024-07-23 17:46:14.146 [       T1] [검증 실패] 출금액: 800, 잔액: 200
2024-07-23 17:46:14.153 [     main] 최종 잔액: 200

정확한 계산 결과가 반영됐다. 중간에 t1 스레드는 WAITING이고 t2 스레드는 TIMED_WAITING이다. 이 t1WAITING인 이유는 t2 스레드가 먼저 임계 영역에 진입했기 때문에 락을 얻지 못해 t1은 기다리는 상태이고, t2 스레드는 로직중에 sleep()에 걸려있을 때 찍힌 TIMED_WAITING이다.

 

실행 결과를 자세히 분석해보자!

  • t1, t2가 출금을 시작한다. 여기서는 t1이 약간 먼저 실행된다고 가정하겠다.
  • ReentrantLock 내부에는 락과 락을 얻지 못해 대기하는 스레드를 관리하는 대기 큐가 존재한다.
  • 여기서 이야기하는 락은 객체 내부에 있는 모니터 락이 아니다! ReentrantLock이 제공하는 기능이다.

  • t1: ReentrantLock에 있는 락을 획득한다.
  • 락을 획득하는 경우 RUNNABLE 상태가 유지되고, 임계 영역의 코드를 실행할 수 있다.

  • t1: 임계 영역의 코드를 실행한다.

  • t2: ReentrantLock에 있는 락 획득을 시도한다. 하지만 락이 없다.

  • t2: 락을 획득하지 못하면 WAITING 상태가 되고, 대기 큐에서 관리된다.
    • LockSupport.park()가 내부에서 호출된다.
  • 참고로 tryLock(long time, TimeUnit unit)과 같은 시간 대기 기능을 사용하면 TIMED_WAITING 상태가 되고 대기 큐에서 관리된다.

  • t1: 임계 영역의 수행을 완료했다. 이 때 잔액은 balance=200이 된다.

  • t1: 임계 영역을 수행하고 나면 lock.unlock()을 호출한다.
    • t1: 락을 반납한다.
    • t1: 대기 큐의 스레드를 하나 깨운다. LockSupport.unpark(thread)가 내부에서 호출된다.
    • t2: RUNNABLE 상태가 되면서 깨어난 스레드는 락 획득을 시도한다.
      • 이때 락을 획득하면 lock.lock()을 빠져나오면서 대기 큐에서도 제거된다.
      • 이때 락을 획득하지 못하면 다시 대기 상태가 되면서 대기 큐에 유지된다.
      • 참고로 락 획득을 시도하는 잠깐 사이에 새로운 스레드가 먼저 락을 가져갈 수 있다.
      • 공정 모드의 경우 대기 큐에 먼저 대기한 스레드가 먼저 락을 가져간다.

  • t2: 락을 획득한 t2 스레드는 RUNNABLE 상태로 임계 영역을 수행한다.

  • t2: 잔액(200)이 출금액(800)보다 적으므로 검증에 실패한다. 따라서 return false가 호출된다.
  • 이때 finally 구문이 있으므로 finally 구문으로 이동한다.

  • t2: lock.unlock()을 호출해서 락을 반납하고, 대기 큐의 스레드를 하나 깨우려고 시도한다. 대기 큐에 스레드가 없으므로 이때는 깨우지 않는다.

  • 종료된다.
참고로, volatile을 사용하지 않아도 Lock을 사용할 때 접근하는 변수의 메모리 가시성 문제는 해결된다. 모든 동기화를 돕는 기술을 사용하면 알아서 메모리 가시성 문제는 해결된다. (synchronized, LockSupport, ...)

 

ReentrantLock 활용 2 - 대기 중단

바로 위에서 해봤던건 락을 얻을때까지 대기하는 lock()을 사용했다. 시간도 정해지지 않았고 인터럽트도 먹지 않는 방법이다.

이번에는 tryLock(), tryLock(long time, TimeUnit unit) 메서드들을 사용해서 한번만 시도해보고 얻을 수 있으면 얻고 그렇지 않으면 얻는걸 포기하는 방법과 주어진 시간까지만 시도해보는 방법을 알아보자.

 

boolean tryLock()
  • 락 획득을 시도하고, 즉시 성공 여부를 반환한다. 만약 다른 스레드가 이미 락을 획득했다면 false를 반환하고, 그렇지 않으면 락을 획득하고 true를 반환한다.
boolean tryLock(long time, TimeUnit unit)
  • 주어진 시간동안 락 획득을 시도한다. 주어진 시간안에 락을 획득하면 true를 반환한다. 주어진 시간이 지나도 락을 획득하지 못한 경우 false를 반환한다. 이 메서드는 대기 중 인터럽트가 발생하면 InterruptedException이 발생하며 락 획득을 포기한다.
아무런 파라미터도 받지 않는 tryLock()은 인터럽트 예외가 발생할 수가 없다. 즉시 결과를 반환하기 때문에 기다리는 시간이 존재하지 않으니까!

 

BankAccountV5

package thread.sync;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

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

public class BankAccountV5 implements BankAccount {

    private int balance;

    private final Lock lock = new ReentrantLock();

    public BankAccountV5(int initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        if (!lock.tryLock()) {
            log("[진입 실패] 이미 처리중인 작업이 있습니다.");
            return false;
        }

        try {
            log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
            if (balance < amount) {
                log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
                return false;
            }

            log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
            sleep(1000);
            balance -= amount;
            log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
        } finally {
            lock.unlock();
        }

        log("거래 종료:");
        return true;
    }

    @Override
    public int getBalance() {
        lock.lock();
        try {
            return balance;
        } finally {
            lock.unlock();
        }
    }
}
  • lock.tryLock()을 사용한다. 락을 획득할 수 없으면 바로 포기하고 대기하지 않는다. 락을 획득하지 못하면 false를 반환한다.

실행결과

2024-07-24 11:21:56.984 [       T2] 거래 시작: BankAccountV5
2024-07-24 11:21:56.984 [       T1] 거래 시작: BankAccountV5
2024-07-24 11:21:56.987 [       T1] [진입 실패] 이미 처리중인 작업이 있습니다.
2024-07-24 11:21:56.992 [       T2] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-24 11:21:56.993 [       T2] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-24 11:21:57.465 [     main] t1 state: TERMINATED
2024-07-24 11:21:57.466 [     main] t2 state: TIMED_WAITING
2024-07-24 11:21:57.996 [       T2] [출금 완료] 출금액: 800, 잔액: 200
2024-07-24 11:21:57.997 [       T2] 거래 종료:
2024-07-24 11:21:58.001 [     main] 최종 잔액: 200
  • t2: 먼저 락을 획득하고 임계 영역을 수행한다.
  • t1: 락이 없다는 것을 확인하고 lock.tryLock()에서 즉시 빠져나온다. 이때 false가 반환된다.
  • t1: "[진입 실패] 이미 처리중인 작업이 있습니다."를 출력하고 false를 반환하면서 메서드를 종료한다.
  • t2: 임계 영역의 수행을 완료하고 거래를 종료한다. 마지막으로 락을 반납한다.

 

이번에는 시간을 사용해서 특정 시간만 기다리는 예제를 살펴보자.

BankAccountV6

package thread.sync;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

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

public class BankAccountV6 implements BankAccount {

    private int balance;

    private final Lock lock = new ReentrantLock();

    public BankAccountV6(int initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        try {
            if (!lock.tryLock(500, TimeUnit.MILLISECONDS)) {
                log("[진입 실패] 이미 처리중인 작업이 있습니다.");
                return false;
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        try {
            log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
            if (balance < amount) {
                log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
                return false;
            }

            log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
            sleep(1000);
            balance -= amount;
            log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
        } finally {
            lock.unlock();
        }

        log("거래 종료:");
        return true;
    }

    @Override
    public int getBalance() {
        lock.lock();
        try {
            return balance;
        } finally {
            lock.unlock();
        }
    }
}
  • lock.tryLock(500, TimeUnit.MILLISECONDS): 락이 없을 때 락을 대기할 시간을 지정한다. 해당 시간이 지나도 락을 얻지 못하면 false를 반환하면서 해당 메서드를 빠져나온다. 여기서는 0.5초로 설정했다.
  • 스레드의 상태는 대기하는 동안 TIMED_WAITING 상태가 되고, 대기 상태를 빠져나오면 RUNNABLE이 된다.

실행결과

2024-07-24 11:27:10.788 [       T1] 거래 시작: BankAccountV6
2024-07-24 11:27:10.788 [       T2] 거래 시작: BankAccountV6
2024-07-24 11:27:10.800 [       T1] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-24 11:27:10.800 [       T1] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-24 11:27:11.267 [     main] t1 state: TIMED_WAITING
2024-07-24 11:27:11.267 [     main] t2 state: TIMED_WAITING
2024-07-24 11:27:11.293 [       T2] [진입 실패] 이미 처리중인 작업이 있습니다.
2024-07-24 11:27:11.801 [       T1] [출금 완료] 출금액: 800, 잔액: 200
2024-07-24 11:27:11.802 [       T1] 거래 종료:
2024-07-24 11:27:11.805 [     main] 최종 잔액: 200
  • t1: 먼저 락을 획득하고 임예 영역을 수행한다.
  • t2: lock.tryLock(500, TimeUnit.MILLISECONDS)를 호출하고 락 획득을 시도한다. 락이 없으므로 0.5초 대기한다. 
    • 이때 t2TIMED_WAITING이 된다. 
    • 내부에서는 LockSupport.parkNanos(nanos)가 호출된다.
  • t2: 대기 시간인 0.5초간 락을 획득하지 못했다. lock.tryLock(500, TimeUnit.MILLISECONDS)에서 즉시 빠져나온다. 이때 false가 반환된다. 
    • 스레드는 TIMED_WAITING → RUNNABLE이 된다.
  • t2: "[진입 실패] 이미 처리중인 작업이 있습니다."를 출력하고 false를 반환하면서 메서드를 종료한다.
  • t1: 임계 영역의 수행을 완료하고 거래를 종료한다. 마지막으로 락을 반납한다.

 

정리

자바 1.5에서 등장한 Lock 인터페이스와 ReentrantLock 구현체 덕분에 synchronized의 단점인 무한 대기공정성 문제를 극복하고, 또 더욱 유연하고 세밀한 스레드 제어가 가능하게 되었다. 다음 포스팅엔 정말 중요한 생산자 소비자 문제에 대해 알아보자! 어떻게 보면 이 부분이 멀티스레드의 핵심이라고 볼 수 있다!

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

멀티 스레드를 사용할 때 가장 주의해야 할 점은, 같은 자원(리소스)에 여러 스레드가 동시에 접근할 때 발생하는 동시성 문제이다. 참고로 여러 스레드가 접근하는 자원을 공유 자원이라고 한다. 대표적인 공유 자원은 인스턴스의 필드(멤버 변수)이다.

 

멀티 스레드를 사용할 때는 이런 공유 자원에 대한 접근을 적절하게 동기화해서 동시성 문제가 발생하지 않게 방지하는 것이 중요하다.

동시성 문제가 어떤 것인지 이해하기 위해 간단한 은행 출금 예제를 하나 만들어보자.

 

BankAccount

package thread.sync;

public interface BankAccount {
    boolean withdraw(int amount);

    int getBalance();
}
  • BankAccount 인터페이스이다. 앞으로 이 인터페이스의 구현체를 점진적으로 발전시키면서 문제를 해결할 예정이다.
  • withdraw(amount): 계좌의 돈을 출금한다. 출금할 금액을 매개변수로 받는다.
    • 계좌의 잔액이 출금할 금액보다 많다면 출금에 성공하고, true를 반환한다.
    • 계좌의 잔액이 출금할 금액보다 적다면 출금에 실패하고, false를 반환한다.
  • getBalance(): 계좌의 잔액을 반환한다.

BankAccountV1

package thread.sync;

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

public class BankAccountV1 implements BankAccount {

    private int balance;

    public BankAccountV1(int initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
        if (balance < amount) {
            log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
            return false;
        }

        log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
        sleep(1000);
        balance -= amount;
        log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);

        log("거래 종료:");
        return true;
    }

    @Override
    public int getBalance() {
        return balance;
    }
}
  • BankAccountV1BankAccount 인터페이스를 구현한다.
  • 생성자를 통해 계좌의 초기 잔액을 저장한다.
  • int balance: 계좌의 잔액 필드
  • withdraw(amount): 검증과 출금 2가지 단계로 나누어진다.
    • 검증 단계: 출금액과 잔액을 비교한다. 만약 출금액이 잔액보다 많다면 문제가 있으므로 검증에 실패하고 false를 반환한다.
    • 출금 단계: 검증에 통과하면 잔액이 출금액보다 많으므로 출금할 수 있다. 잔액에서 출금액을 빼고 출금을 완료하면 성공이라는 의미의 true를 반환한다.
  • getBalance(): 잔액을 반환한다.

WithdrawTask

package thread.sync;

public class WithdrawTask implements Runnable {

    private BankAccount account;
    private int amount;

    public WithdrawTask(BankAccount account, int amount) {
        this.account = account;
        this.amount = amount;
    }

    @Override
    public void run() {
        account.withdraw(amount);
    }
}
  • 츌금을 담당하는 Runnable 구현체이다. 생성 시 출금할 계좌(account)와 출금할 금액(amount)를 저장해둔다.
  • run()을 통해 스레드가 출금을 실행한다.

 

BankMain

package thread.sync;

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

public class BankMain {

    public static void main(String[] args) throws InterruptedException {
        BankAccountV1 account = new BankAccountV1(1000);

        Thread t1 = new Thread(new WithdrawTask(account, 800), "T1");
        Thread t2 = new Thread(new WithdrawTask(account, 800), "T2");

        t1.start();
        t2.start();

        sleep(500);
        log("t1 state: " + t1.getState());
        log("t2 state: " + t2.getState());

        t1.join();
        t2.join();

        log("최종 잔액: " + account.getBalance());
    }
}
  • new BankAccountV1(1000)을 통해 초기 잔액을 1000원으로 설정한다.
  • main 스레드는 t1, t2 스레드를 만든다. 만든 스레드들은 같은 계좌에 각각 800원의 출금을 시도한다.
  • main 스레드는 join()을 사용해서 t1, t2 스레드가 출금을 완료한 이후에 최종 잔액을 확인한다.

  • 각각의 스레드의 스택에서 run()이 실행된다.
  • t1 스레드는 WithdrawTask(x002) 인스턴스의 run()을 호출한다.
  • t2 스레드는 WithdrawTask(x003) 인스턴스의 run()을 호출한다.
  • 스택 프레임의 this에는 호출한 메서드의 인스턴스 참조가 들어있다. (쉽게 말해 각각의 스레드에 스택 하나씩 부여받는데 그 스레드들은 모두 run()을 호출한다. 그 run()의 인스턴스(WithdrawTask)를 참조로 가지고 있단 소리다.)
  • 두 스레드는 같은 계좌(x001)에 대해서 출금을 시도한다. 

  • t1 스레드의 run()에서 withdraw()를 실행한다.
  • 거의 동시에 t2 스레드의 run()에서 withdraw()를 실행한다.
  • t1, t2 스레드는 같은 BankAccount(x001) 인스턴스의 withdraw() 메서드를 호출한다.
  • 따라서 두 스레드는 같은 BankAccount(x001) 인스턴스에 접근하고 또 x001 인스턴스에 있는 잔액(balance)필드도 함께 사용한다.
  • 마찬가지로 호출한 메서드(withdraw())의 인스턴스(BankAccount(x001))를 this로 참조하고 있다.

실행결과

2024-07-19 22:56:47.129 [       T2] 거래 시작: BankAccountV1
2024-07-19 22:56:47.129 [       T1] 거래 시작: BankAccountV1
2024-07-19 22:56:47.144 [       T1] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-19 22:56:47.144 [       T2] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-19 22:56:47.145 [       T1] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-19 22:56:47.145 [       T2] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-19 22:56:47.605 [     main] t1 state: TIMED_WAITING
2024-07-19 22:56:47.605 [     main] t2 state: TIMED_WAITING
2024-07-19 22:56:48.147 [       T2] [출금 완료] 출금액: 800, 잔액: 200
2024-07-19 22:56:48.147 [       T1] [출금 완료] 출금액: 800, 잔액: -600
2024-07-19 22:56:48.148 [       T2] 거래 종료:
2024-07-19 22:56:48.149 [       T1] 거래 종료:
2024-07-19 22:56:48.156 [     main] 최종 잔액: -600

 

동시성 문제

이 시나리오는 악의적인 사용자가 2대의 PC에서 동시에 같은 계좌의 돈을 출금한다고 가정한다.

  • t1, t2 스레드는 거의 동시에 실행되지만, 아주 약간의 차이로 t1 스레드가 먼저 실행되고, t2 스레드가 그 다음에 실행된다고 가정하겠다.
  • 처음 계좌의 잔액은 1000원이다. t1 스레드가 800원을 출금하면 잔액은 200원이 남는다.
  • 이제 계좌의 잔액은 200원이다. t2 스레드가 800원을 출금하면 잔액보다 더 많은 돈을 출금하게 되므로 출금에 실패해야 한다.

그런데 실행 결과를 보면 기대와는 다르게 t1, t2 각각 800원씩 총 1600원 출금에 성공한다. 계좌의 잔액은 -600원이 되어있고, 계좌는 예상치 못하게 마이너스 금액이 되어버렸다. 악의적인 사용자는 2대의 PC를 통해 자신의 계좌에 있는 1000원보다 더 많은 금액인 1600원 출금에 성공한다. 분명히 계좌를 출금할 때 잔고를 체크하는 로직이 있는데도 불구하고 왜 이런 문제가 발생했을까?

참고로, balance 값에 volatile을 도입하면 문제가 해결되지 않을까? 하겠지만 그렇지 않다. volatile은 한 스레드가 값을 변경했을 때 다른 스레드에서 변경된 값을 즉시 볼 수 있게 하는 메모리 가시성의 문제를 해결할 뿐이다. 예를 들어, t1 스레드가 balance의 값을 변경했을 때 t2 스레드에서 balance의 변경된 값을 즉시 확인해도 여전히 같은 문제가 발생한다. 이미 t2가 실행할 검증 로직은 지나갔으므로. 물론, 위 예제는 메모리 가시성 문제도 있지만 그것을 해결한다고 해서 동시성 문제가 해결되는것이 아니다. 

 

그럼 이 동시성 문제는 어떤 흐름으로 발생했을까? 이 문제를 어떻게 해결할까를 알기 전에 어떻게 이런 일이 일어났는지를 먼저 확인해보자.

 

위 동시성 문제의 흐름

심지어 이 예제에서 두가지 케이스의 문제가 있다.

 

t1 → t2 순서로 실행된 케이스

t1 → t2 순서로 실행됐다고 가정한다. 즉, t1이 아주 약간 빠르게 실행되는 경우를 먼저 알아보자.

  • t1이 약간 먼저 실행되면서, 출금을 시도한다.
  • t1이 출금 코드에 있는 검증 로직을 실행한다. 이때 잔액이 출금 액수보다 많은지 확인한다.
    • 잔액(1000)이 출금액(800)보다 많으므로 검증 로직을 통과한다. 

  • t1: 출금 검증 로직을 통과해서 출금을 위해 잠시 대기중이다. 출금에 걸리는 시간이나 실제 출금하기까지 필요한 작업들을 처리하는 단계라고 생각하자.
  • t2: 검증 로직을 실행한다. 잔액이 출금 금액보다 많은지 확인한다.
    • 잔액(1000)이 출금액(800)보다 많으므로 통과한다.

바로 이 부분이 문제다! t1이 아직 잔액(balance)을 줄이지 못했기 때문에 t2는 검증 로직에서 현재 잔액을 1000원으로 확인한다. 

t1이 검증 로직을 통과하고 바로 잔액을 줄였다면 이런 문제가 발생하지 않겠지만, t1이 검증 로직을 통과하고 잔액을 줄이기도 전에 먼저 t2가 검증 로직을 확인한 것이다. 

 

"어? 그럼 sleep(1000)을 빼버리면 되지 않나요?"

→ t1이 검증 로직을 통과하고 balance = balance - amount를 계산하기 직전에 t2가 실행되면서 검증 로직을 통과 할수도 있다. sleep(1000)은 동시성 문제를 해결하는데 아무런 도움도 되지 않는다.

 

  • 결과적으로 t1, t2 모두 검증 로직을 통과하고, 출금을 위해 잠시 대기중이다. 출금에 걸리는 시간으로 생각하자.

  • t1800원을 출금하면서, 잔액을 1000원에서 출금 액수인 800원만큼 차감한다. 이제 계좌의 잔액은 200원이다.

  • t2800원을 출금하면서, 잔액 200원에서 출금 액수인 800원만큼 차감한다. 이제 잔액은 -600원이 된다.

결과적으로,

  • t1: 800원 출금 완료
  • t2: 800원 출금 완료
  • 처음 원금은 1000원이었는데, 최종 잔액은 -600원이 된다.
  • 은행 입장에서 마이너스 잔액이 있으면 안된다!

실행 결과

2024-07-19 22:56:47.129 [       T2] 거래 시작: BankAccountV1
2024-07-19 22:56:47.129 [       T1] 거래 시작: BankAccountV1
2024-07-19 22:56:47.144 [       T1] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-19 22:56:47.144 [       T2] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-19 22:56:47.145 [       T1] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-19 22:56:47.145 [       T2] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-19 22:56:47.605 [     main] t1 state: TIMED_WAITING
2024-07-19 22:56:47.605 [     main] t2 state: TIMED_WAITING
2024-07-19 22:56:48.147 [       T2] [출금 완료] 출금액: 800, 잔액: 200
2024-07-19 22:56:48.147 [       T1] [출금 완료] 출금액: 800, 잔액: -600
2024-07-19 22:56:48.148 [       T2] 거래 종료:
2024-07-19 22:56:48.149 [       T1] 거래 종료:
2024-07-19 22:56:48.156 [     main] 최종 잔액: -600

 

 

t1 ↔  t2 동시에 실행된 케이스

t1, t2가 완전히 동시에 실행되는 상황도 있다.

  • t1, t2는 동시에 검증 로직을 실행한다. 잔액이 출금 금액보다 많은지 확인한다.
    • 잔액(1000)이 출금액(800)보다 많으므로 둘 다 통과한다.

  • 결과적으로, t1, t2 모두 검증 로직을 통과하고, 출금을 위해 잠시 대기중이다. 출금에 걸리는 시간으로 생각하자.

  • t1800원을 출금하면서, 잔액을 1000원에서 출금 액수인 800원 만큼 차감한다. 이제 잔액은 200원이 된다.
  • t2800원을 출금하면서, 잔액을 1000원에서 출금 액수인 800원 만큼 차감한다. 이제 잔액은 200원이 된다.
  • t1, t2 모두 동시에 실행되기 때문에 둘 다 잔액(balance)를 확인하는 시점에 잔액은 1000원이다.
  • t1, t2 모두 동시에 계산된 결과를 잔액에 반영하는데, 둘 다 계산 결과인 200원을 balance에 반영하므로 최종 잔액이 200원이 된다.
balance = balance - amount;

이 한줄의 코드도 사실 3단계로 나뉘어진다.

  • 계산을 위해 오른쪽에 있는 balance 값과 amount 값을 조회한다.
  • 두 값을 계산한다.
  • 계산 결과를 왼쪽의 balance 변수에 저장한다

여기서, 1번 단계의 balance 값을 조회할 때 t1, t2 두 스레드가 동시에 x001.balance의 필드값을 읽는다. 이때 값은 1000이다. 따라서 두 스레드는 모두 잔액을 1000원으로 인식한다. 2번 단계에서 두 스레드 모두 1000 - 800을 계산해서 200이라는 결과를 얻는다. 3번 단계에서 두 스레드 모두 balance = 200을 대입한다.

 

결과적으로,

  • t1: 800원 출금 완료
  • t2: 800원 출금 완료
  • 원래 원금이 1000원이었는데, 최종 잔액은 200원이 된다.
  • 은행 입장에서 보면 1600원이 빠져나갔는데, 잔액은 800원만 줄었다. 800원이 감쪽같이 어디론가 사라진 것이다.

실행결과

2024-07-21 15:33:21.635 [       T1] 거래 시작: BankAccountV1
2024-07-21 15:33:21.635 [       T2] 거래 시작: BankAccountV1
2024-07-21 15:33:21.649 [       T1] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-21 15:33:21.649 [       T2] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-21 15:33:21.650 [       T2] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-21 15:33:21.650 [       T1] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-21 15:33:22.105 [     main] t1 state: TIMED_WAITING
2024-07-21 15:33:22.105 [     main] t2 state: TIMED_WAITING
2024-07-21 15:33:22.652 [       T1] [출금 완료] 출금액: 800, 잔액: 200
2024-07-21 15:33:22.652 [       T2] [출금 완료] 출금액: 800, 잔액: 200
2024-07-21 15:33:22.654 [       T1] 거래 종료:
2024-07-21 15:33:22.654 [       T2] 거래 종료:
2024-07-21 15:33:22.661 [     main] 최종 잔액: 200

실행 결과에서 시간이 완전히 동일하다는 사실을 통해 두 스레드가 같이 실행된 것을 대략 확인할 수 있다.

결국 이런 흐름으로 동시성 문제가 발생했다. 이 문제가 그럼 왜 발생했고 어떻게 해결할 수 있을지 알아보자!

 

임계 영역

이런 문제가 발생한 근본 원인은 여러 스레드가 함께 사용하는 공유 자원을 여러 단계로 나누어 사용하기 때문이다.

  • 1. 검증 단계: 잔액(balance)이 출금액(amount)보다 많은지 확인하는 단계
  • 2. 출금 단계: 잔액(balance)을 출금액(amount)만큼 줄이는 단계
출금() {
    1. 검증 단계
    2. 출금 단계
}

 

이 로직에는 하나의 큰 가정이 있다.

스레드 하나의 관점에서 출금()을 보면, 1. 검증 단계에서 확인한 잔액(balance) 1000원은 2. 출금 단계에서 계산을 끝마칠 때 까지 같은 1000원으로 유지되어야 한다. 그래야 검증 단계에서 확인한 금액으로, 출금 단계에서 정확한 잔액을 계산을 할 수 있다. 결국 여기서는 내가 사용하는 값이 중간에 변경되지 않을 것이라는 가정이 있다.

 

그런데, 만약 중간에 다른 스레드가 잔액의 값을 변경한다면? 큰 혼란이 발생한다. 1000원이라 생각한 잔액이 다른 값으로 변경되면 잔액이 전혀 다른 값으로 계산될 수 있다.

 

공유 자원

잔액(balance)은 여러 스레드가 함께 사용하는 공유 자원이다. 따라서 출금 로직을 수행하는 중간에 다른 스레드에서 이 값을 얼마든지 변경할 수 있다. 참고로 여기서는 출금() 메서드를 호출할 때만 잔액(balance)의 값이 변경된다. 따라서 다른 스레드가 출금 메서드를 호출하면서, 사용중인 balance 값을 중간에 변경해 버릴 수 있다. 

 

한 번에 하나의 스레드만 실행

만약, 출금() 이라는 메서드를 한 번에 하나의 스레드만 실행할 수 있게 제한한다면 어떻게 될까?

예를 들어, t1, t2 스레드가 함께 출금()을 호출하면 t1 스레드가 먼저 처음부터 끝까지 출금() 메서드를 완료하고, 그 다음에 t2 스레드가 처음부터 끝까지 출금() 메서드를 완료하는 것이다. 이렇게 하면 공유 자원인 balance를 한 번에 하나의 스레드만 변경할 수 있다. 따라서 계산 중간에 다른 스레드가 balance의 값을 변경하는 부분을 걱정하지 않아도 된다. (이 예제에선 출금() 메서드를 호출할때만 잔액(balance)의 값이 변경되므로)

  • 더 자세히는 출금을 진행할 때 잔액(balance)을 검증하는 단계부터 잔액의 계산을 완료할 때 까지 잔액의 값은 중간에 변하면 안된다.
  • 검증계산 두 단계는 한 번에 하나의 스레드만 실행해야 한다. 그래야 잔액(balance)이 중간에 변하지 않고, 안전하게 계산을 수행할 수 있다.

 

임계 영역 (critical section)

영어로 크리티컬 섹션이라고 한다.

  • 여러 스레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작이 발생할 수 있는 위험하고 또 중요한 코드 부분을 뜻한다.
  • 여러 스레드가 동시에 접근해서는 안되는 공유 자원을 접근하거나 수정하는 부분을 의미한다. 
    • 예) 공유 변수나 공유 객체를 수정

앞서, 살펴본 출금() 로직이 바로 임계 영역이다.

더 자세히는 출금을 진행할 때 잔액(balance)을 검증하는 단계부터 잔액의 계산을 완료할 때 까지가 임계 영역이다. 여기서 balance는 여러 스레드가 동시에 접근해서는 안되는 공유 자원이다.

 

이런 임계 영역은 한 번에 하나의 스레드만 접근할 수 있도록 안전하게 보호해야 한다. 그럼 어떻게 한 번에 하나의 스레드만 접근할 수 있도록 임계 영역을 안전하게 보호할 수 있을까? 여러가지 방법이 있는데 자바는 synchronized 키워드를 통해 아주 간단하게 임계 영역을 보호할 수 있다.

 

Synchorinzed

자바의 synchronized 키워드를 사용하면 한 번에 하나의 스레드만 실행할 수 있는 코드 구간을 만들 수 있다.

BankAccountV1을 복사해서 BankAccountV2 클래스를 만들고 synchronized를 도입해보자.

 

synchronized 메서드

 

BankAccountV2

package thread.sync;

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

public class BankAccountV2 implements BankAccount {

    private int balance;

    public BankAccountV2(int initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    public synchronized boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
        if (balance < amount) {
            log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
            return false;
        }

        log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
        sleep(1000);
        balance -= amount;
        log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);

        log("거래 종료:");
        return true;
    }

    @Override
    public synchronized int getBalance() {
        return balance;
    }
}
  • BankAccountV1과 같은데, withdraw(), getBalance() 코드에 synchronized 키워드가 추가되었다.
  • 이제 withdraw(), getBalance() 메서드는 한 번에 하나의 스레드만 실행할 수 있다.

BankMain

package thread.sync;

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

public class BankMain {

    public static void main(String[] args) throws InterruptedException {
        BankAccountV2 account = new BankAccountV2(1000);

        Thread t1 = new Thread(new WithdrawTask(account, 800), "T1");
        Thread t2 = new Thread(new WithdrawTask(account, 800), "T2");

        t1.start();
        t2.start();

        sleep(500);
        log("t1 state: " + t1.getState());
        log("t2 state: " + t2.getState());

        t1.join();
        t2.join();

        log("최종 잔액: " + account.getBalance());
    }
}
  • BankMain에서는 BankAccountV2를 사용하도록 코드를 변경했다.

실행결과

2024-07-21 16:24:03.274 [       T2] 거래 시작: BankAccountV2
2024-07-21 16:24:03.286 [       T2] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-21 16:24:03.286 [       T2] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-21 16:24:03.751 [     main] t1 state: BLOCKED
2024-07-21 16:24:03.751 [     main] t2 state: TIMED_WAITING
2024-07-21 16:24:04.289 [       T2] [출금 완료] 출금액: 800, 잔액: 200
2024-07-21 16:24:04.290 [       T2] 거래 종료:
2024-07-21 16:24:04.291 [       T1] 거래 시작: BankAccountV2
2024-07-21 16:24:04.292 [       T1] [검증 시작] 출금액: 800, 잔액: 200
2024-07-21 16:24:04.293 [       T1] [검증 실패] 출금액: 800, 잔액: 200
2024-07-21 16:24:04.300 [     main] 최종 잔액: 200

 

실행 결과를 보면 t2withdraw() 메서드를 시작부터 완료까지 모두 끝내고 나서 그 다음에 t1withdraw() 메서드를 수행한 것을 확인할 수 있다. 물론 환경에 따라 t1이 먼저 할수도 있다. 

 

synchronized 분석

지금부터 자바의 synchronized가 어떻게 작동하는지 그림으로 분석해보자. 

참고로 실행 결과를 보면 t1BLOCKED 상태인데 이 상태도 확인해보자. 

 

여기서부턴 t1이 먼저 실행됐다고 가정한다.

 

  • 스레드 t1이 먼저 synchronized 키워드가 있는 withdraw() 메서드를 호출한다.
  • synchronized 메서드를 호출하려면 먼저 해당 인스턴스의 락이 필요하다.
    • 모든 인스턴스는 인스턴스마다 락이 있다. 이건 자바의 표준 규칙이다.
    • 같은 클래스의 서로 다른 인스턴스도 다 인스턴스별로 락이 있다.
  • 락이 있으므로 스레드 t1BankAccount(x001) 인스턴스에 있는 락을 획득한다.

  • 스레드 t1은 해당 인스턴스의 락을 획득했기 때문에 withdraw() 메서드에 진입할 수 있다.
  • 스레드 t2withdraw() 메서드 호출을 시도한다. synchronized 메서드를 호출하려면 먼저 해당 인스턴스의 락이 필요하다.
  • 스레드 t2BankAccount(x001) 인스턴스에 있는 락 획득을 시도한다. 하지만 락이 없다. 이렇게 락이 없으면 t2 스레드는 락을 획득할 때 까지 BLOCKED 상태로 대기한다.
    • t2 스레드의 상태는 RUNNABLE → BLOCKED 상태로 변하고, 락을 획득할 때 까지 무한정 대기한다.
    • 참고로, BLOCKED 상태가 되면 락을 다시 획득하기 전까지는 계속 대기하고, CPU 실행 스케쥴링에 들어가지 않는다

  • t1: 출금을 위한 검증 로직을 수행한다. 조건을 만족하므로 검증 로직을 통과한다.
    • 잔액(1000)이 출금액(800)보다 많으므로 통과한다.

  • t1: 잔액 1000원에서 800원을 출금하고 계산 결과인 200원을 잔액(balance)에 반영한다.

  • t1: 메서드 호출이 끝나면 락을 반납한다.

  • t2: 인스턴스에 락이 반납되면 락 획득을 대기하는 스레드는 자동으로 락을 획득한다.
    • 이때 락을 획득한 스레드는 BLOCKED → RUNNABLE 상태가 되고, 다시 코드를 실행한다.

  • 스레드 t2는 해당 인스턴스의 락을 획득했기 때문에 withdraw() 메서드에 진입할 수 있다.
  • t2: 출금을 위한 검증 로직을 수행한다. 조건을 만족하지 않으므로 false를 반환한다.
    • 이때 잔액(balance)은 200원이다. 800원을 출금해야 하므로 조건을 만족하지 않는다.

  • t2: 락을 반납하면서 return한다.

결과

  • t1: 800원 출금 완료
  • t2: 잔액 부족으로 출금 실패
  • 원금 1000원, 최종 잔액 200

t1800원 출금에 성공하지만, t2는 잔액 부족으로 출금에 실패한다. 그리고 최종 잔액은 1000원에서 200원이 되므로 정확하게 맞다. 이렇게 자바의 synchronized를 사용하면 한 번에 하나의 스레드만 실행하는 안전한 임계 영역 구간을 편리하게 만들 수 있다.

 

참고로, 락을 획득하는 순서는 보장되지 않는다. 위 실행결과에서도 봤듯 t2가 먼저 실행됐고 t1BLOCKED 상태로 변경됐다면 이 그림에서는 그 반대였다. 즉, BankAccount(x001) 인스턴스의 withdraw()를 수많은 스레드가 동시에 호출한다면, 1개의 스레드만 락을 획득하고 나머지 모두 BLOCKED 상태가 된다. 그리고 이후에 BankAccount(x001) 인스턴스에 락을 반납하면, 해당 인스턴스의 락을 기다리는 수많은 스레드 중에 하나의 스레드만 락을 획득하고, 락을 획득한 스레드만 BLOCKEDRUNNABLE 상태가 된다. 이때, 어떤 순서로 락을 획득하는지는 자바 표준에 정의되어 있지 않다. 따라서 순서를 보장하지 않고 환경에 따라 달라질 수 있다.

 

참고로, volatile을 사용하지 않아도, synchronized 안에서 접근하는 변수의 메모리 가시성 문제는 해결된다. happens-before를 생각해보자.

 

 

synchronized 코드 블럭

synchronized의 가장 큰 장점이자 단점은 한 번에 하나의 스레드만 실행할 수 있다는 점이다. 여러 스레드가 동시에 실행하지 못하기 때문에, 전체로 보면 성능이 떨어질 수 있다. 따라서 synchronized를 통해 여러 스레드를 동시에 실행할 수 없는 코드 구간은 꼭! 필요한 곳으로 한정해서 설정해야 한다. 

 

다시 말해, 메서드 전체로 synchronized를 걸어버리면 그 메서드 안에서 굳이 임계 영역이 아닌 부분까지도 한 스레드만 접근이 가능하니까 성능 저하가 생길 수 있다는 얘기다. 이전에 작성한 코드를 보자.

@Override
public synchronized boolean withdraw(int amount) {
    log("거래 시작: " + getClass().getSimpleName());

    log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
    if (balance < amount) {
        log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
        return false;
    }

    log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
    sleep(1000);
    balance -= amount;
    log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);

    log("거래 종료:");
    return true;
}

처음에 로그를 출력하는 "거래 시작" 부분과 마지막에 로그를 출력하는 "거래 종료" 부분은 공유 자원을 전혀 사용하지 않는다. 이런 부분은 동시에 실행해도 아무 문제가 발생하지 않는다.

 

따라서 이 메서드에서 임계 영역은 메서드 전체가 아니라 딱 아래 (--)로 표시한 부분이다.

@Override
public synchronized boolean withdraw(int amount) {
    log("거래 시작: " + getClass().getSimpleName());

    --------------------------------------------------------------------
    log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
    if (balance < amount) {
        log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
        return false;
    }

    log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
    sleep(1000);
    balance -= amount;
    log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
    --------------------------------------------------------------------

    log("거래 종료:");
    return true;
}

 

근데, 메서드 전체에 synchronized를 걸어버리면 굳이 임계 영역이 아닌 부분까지도 한 스레드만 접근이 가능하니까 상대적으로 당연히 성능 저하가 생길 수 밖에 없다. 이 문제를 자바는 synchronized를 메서드 단위가 아니라 특정 코드 블록에 최적화해서 적용할 수 있게 제공한다. 다음 BankAccountV3를 보자.

 

BankAccountV3

package thread.sync;

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

public class BankAccountV3 implements BankAccount {

    private int balance;

    public BankAccountV3(int initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        synchronized (this) {
            log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
            if (balance < amount) {
                log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
                return false;
            }

            log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
            sleep(1000);
            balance -= amount;
            log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
        }

        log("거래 종료:");
        return true;
    }

    @Override
    public synchronized int getBalance() {
        return balance;
    }
}
  • withdraw() 메서드 앞에 사용했던 synchronized를 제거했다.
  • synchronized (this) {...} : 안전한 임계 영역을 코드 블록으로 지정한다.
    • 이렇게 하면 꼭 필요한 코드만 안전한 임계 영역으로 만들 수 있다. 
    • 여기서 괄호 () 안에 들어가는 값은 락을 획득할 인스턴스의 참조이다. 이 예제에서는 해당 인스턴스의 락이 필요하니까 this를 넣었다.
    • 이전 메서드 전체에 synchronized를 사용할 때도 마찬가지로 같은 인스턴스 락을 사용했다. 메서드 단위로는 `(this)`가 생략됐다고 생각하면 된다.
  • getBalance() 의 경우, return balance 한 줄이라서 메서드에 거나 코드 블록으로 거나 똑같다.

 

BankMain

package thread.sync;

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

public class BankMain {

    public static void main(String[] args) throws InterruptedException {
        BankAccountV3 account = new BankAccountV3(1000);

        Thread t1 = new Thread(new WithdrawTask(account, 800), "T1");
        Thread t2 = new Thread(new WithdrawTask(account, 800), "T2");

        t1.start();
        t2.start();

        sleep(500);
        log("t1 state: " + t1.getState());
        log("t2 state: " + t2.getState());

        t1.join();
        t2.join();

        log("최종 잔액: " + account.getBalance());
    }
}
  • BankMain에서 BankAccountV3를 사용하도록 코드를 변경했다.

실행결과

2024-07-21 16:58:41.150 [       T2] 거래 시작: BankAccountV3
2024-07-21 16:58:41.150 [       T1] 거래 시작: BankAccountV3
2024-07-21 16:58:41.163 [       T2] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-21 16:58:41.164 [       T2] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-21 16:58:41.625 [     main] t1 state: BLOCKED
2024-07-21 16:58:41.625 [     main] t2 state: TIMED_WAITING
2024-07-21 16:58:42.166 [       T2] [출금 완료] 출금액: 800, 잔액: 200
2024-07-21 16:58:42.167 [       T2] 거래 종료:
2024-07-21 16:58:42.167 [       T1] [검증 시작] 출금액: 800, 잔액: 200
2024-07-21 16:58:42.169 [       T1] [검증 실패] 출금액: 800, 잔액: 200
2024-07-21 16:58:42.177 [     main] 최종 잔액: 200

 

synchronized 블록 기능을 사용한 덕분에 딱 필요한 부분에 임계 영역을 지정할 수 있었다. 덕분에 아주 약간이지만 여러 스레드가 동시에 수행되는 부분을 더 늘려서, 전체적으로 성능을 더 향상할 수 있었다. 지금의 예는 단순 로그 몇 줄이지만, 실제 업무에서는 더 많은 코드가 존재할 거고 그럴땐 큰 성능 차이가 발생할 것이다.

 

이 예제에서는 처음 거래 시작 로그를 찍는 부분이 t1, t2 동시에 실행된 것을 확인할 수 있다.

2024-07-21 16:58:41.150 [       T2] 거래 시작: BankAccountV3
2024-07-21 16:58:41.150 [       T1] 거래 시작: BankAccountV3

 

 

synchronized 동기화 정리

자바에서 동기화는 여러 스레드가 동시에 접근할 수 있는 자원(예: 객체, 메서드)에 대해 일관성 있고 안전한 접근을 보장하기 위한 메커니즘이다. 동기화는 주로 멀티스레드 환경에서 발생할 수 있는 문제, 예를 들어 데이터 손상이나 예기치 않은 결과를 방지하기 위해 사용된다. 

 

메서드 동기화: 메서드를 synchronized로 선언해서, 메서드에 접근하는 스레드가 하나뿐이도록 보장한다. 이 과정에서 인스턴스의 락을 사용하게 된다. 모든 인스턴스는 다 자기의 락을 가지고 있고, synchronized 키워드가 붙은 곳에 접근하려면 이 락을 스레드는 획득해야 한다!

 

블록 동기화: 코드 블록을 synchronized로 감싸서 동기화를 구현한다. 위에서 설명한 것과 마찬가지로 인스턴스 락을 사용한다.

 

이런 동기화를 사용하면 다음 문제들을 해결할 수 있다.

  • 경합 조건(Race condition): 두 개 이상의 스레드가 경쟁적으로 동일한 자원을 수정할 때 발생하는 문제
  • 데이터 일관성: 여러 스레드가 동시에 읽고 쓰는 데이터의 일관성을 유지

동기화는 멀티 스레드 환경에서 필수적인 기능이지만, 과도하게 사용할 경우 성능 저하를 초래할 수 있으므로 꼭 필요한 곳에 적절히 사용해야 한다! 

 

자바는, 처음부터 멀티스레드를 고려하고 나온 언어이다. 그래서 자바 1.0부터 synchronized 동기화 방법을 프로그래밍 언어의 문법에 포함해서 제공한다.

 

synchronized 장점

  • 프로그래밍 언어에 문법으로 제공
  • 아주 편리함
  • 자동 잠금 해제: synchronized 메서드나 블록이 완료되면, 자동으로 락을 대기중인 다른 스레드에게 전달하고 기다리고 있던 스레드의 BLOCKED 상태가 해제된다. 개발자가 직접 특정 스레드를 깨우도록 관리해야 한다면, 매우 어렵고 번거로울 것이다.

synchronized는 매우 편리하지만, 제공하는 기능이 너무 단순하다는 단점이 있다. 시간이 점점 지나면서 멀티 스레드가 더 중요해지고 점점 더 복잡한 동시성 개발 방법들이 필요해졌다.

 

synchronized 단점

  • 무한 대기: BLOCKED 상태의 스레드는 락이 풀릴 때까지 무한 대기한다.
    • 특정 시간까지만 대기하는 타임아웃 X
    • 중간에 인터럽트 X
  • 공정성: 락이 돌아왔을 때 BLOCKED 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜기간 락을 획득하지 못할 수 있다. 

synchronized의 가장 치명적인 단점은 락을 얻기 위해 BLOCKED 상태가 되면 락을 얻을 때까지 무한 대기한다는 점이다. 예를 들어 웹 애플리케이션의 경우 고객이 어떤 요청을 했는데, 화면에 계속 요청 중만 뜨고 응답을 못 받는 것이다. 차라리 너무 오랜 시간이 지나면 시스템에 사용자가 너무 많아서 다음에 다시 시도해달라고 하는 식의 응답을 주는 것이 더 나은 선택일 것이다.

 

결국 더 유연하고, 더 세밀한 제어가 가능한 방법들이 필요하게 됐다. 이런 문제를 해결하기 위해 자바 1.5부터 java.util.concurrent 라는 동시성 문제 해결을 위한 패키지가 추가된다.

 

이제 synchronized 까지 배워봤다. 멀티 스레드의 기본적인 것들은 얼추 배웠다고 볼 수 있다. 이제 이 기본기를 가지고 더 좋은 더 여러 기능에 대해 하나씩 알아보자!

참고로, 단순하고 편리하게 사용하기에는 synchronized가 최고이므로, 목적에 부합만 한다면 사용하는데 아무런 문제는 없다!

 

 

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

volatile, 메모리 가시성 - 1 

volatile이 뭔지, 메모리 가시성은 뭔지 간단한 예제를 만들어보자.

VolatileFlagMain

package thread.volatilestudy;

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

public class VolatileFlagMain {
    public static void main(String[] args) {
        MyTask task = new MyTask();
        Thread t = new Thread(task, "work");

        log("runFlag = " + task.runFlag);
        t.start();

        sleep(1000);
        log("runFlag false로 변경");
        task.runFlag = false;
        log("runFlag = " + task.runFlag);
        log("main 종료");
    }

    static class MyTask implements Runnable {

        boolean runFlag = true;

        @Override
        public void run() {
            log("task 시작");
            while (runFlag) {
                // ?
            }
            log("task 종료");
        }
    }
}
  • 프로그램은 아주 간단하다. runFlag를 사용해서 스레드의 작업을 종료한다.
  • work 스레드는 MyTask를 실행한다. 여기에는 runFlag를 체크하는 무한 루프가 있다.
  • runFlag 값이 false가 되면 무한 루프를 탈출하여 작업을 종료한다.
  • 이후에 main 스레드가 runFlag의 값을 false로 변경한다.
  • runFlag의 값이 false가 되었으므로 work 스레드는 무한 루프를 탈출하며, 작업을 종료한다.
  • 참고로, 여기서 runFlagvolatile 키워드가 안 붙었다. 주의하자!

  • main 스레드, work 스레드 모두 MyTask 인스턴스(x001)에 있는 runFlag를 사용한다.
  • 이 값을 false로 변경하면 work 스레드의 작업을 종료할 수 있다.

기대하는 실행 결과

2024-07-19 18:26:30.057 [     main] runFlag = true
2024-07-19 18:26:30.061 [     work] task 시작
2024-07-19 18:26:31.063 [     main] runFlag false로 변경
2024-07-19 18:26:31.064 [     main] runFlag = false
2024-07-19 18:26:31.064 [     work] task 종료
2024-07-19 18:26:31.064 [     main] main 종료

 

실제 실행 결과

2024-07-19 18:26:30.057 [     main] runFlag = true
2024-07-19 18:26:30.061 [     work] task 시작
2024-07-19 18:26:31.063 [     main] runFlag false로 변경
2024-07-19 18:26:31.064 [     main] runFlag = false
2024-07-19 18:26:31.064 [     main] main 종료

실제 실행 결과를 보면 task 종료가 출력되지 않고, 자바 프로그램도 멈추지 않고 계속 실행된다. 정확히는 work 스레드가 while문에서 빠져나오지 못하고 있는 것이다. 분명히 runFlagfalse로 변경됐고, 그럼 while문을 빠져나와야 맞는데 그렇게 동작하지 않는다. 무슨 일일까?

 

메모리 가시성 문제

멀티 스레드는 메모리 가시성 문제라는 것이 있다. 이게 어떤 문제이고 왜 이런 문제가 발생하는지 그리고 어떻게 해결하는지 차근차근 알아보자. 

 

먼저 일반적으로 생각하는 메모리 접근 방식을 보자.

  • main 스레드와 work 스레드는 각각의 CPU 코어에 할당되어서 실행된다.
  • 물론 CPU 코어가 1개라면 빠르게 번갈아 가면서 실행할거다. 지금은 2개라고 생각해보자.

  • 실선 위쪽은 스레드의 실행 흐름을 나타내고 실선 아래쪽은 하드웨어를 나타낸다.
  • 자바 프로그램을 실행하고 main 스레드와 work 스레드는 모두 메인 메모리의 runFlag 값을 읽는다.
  • 프로그램의 시작 지점에는 runFlag의 값을 변경하지 않기 때문에 모든 스레드에서 true의 값을 읽는다.
    • runFlag의 초기값이 true니까 그렇다.
  • work 스레드의 경우 while(runFlag)가 만족하기 때문에 while문을 계속 반복해서 수행한다.

  • 0. main 스레드는 runFlag의 값을 false로 설정한다.
  • 1. 이때 메인 메모리의 runFlag의 값이 false로 변경된다.
  • 2. work 스레드는 while(runFlag)를 실행할 때 runFlag의 데이터를 메인 메모리에서 확인한다.
  • 3. runFlag의 값이 false이므로 while문을 탈출하고, "task 종료"를 출력한다.

아마도 이런 시나리오를 생각했을 것이다. 그런데 실제로는 이렇게 동작하지 않는다.

 

실제 메모리 접근 방식

CPU는 처리 성능을 개선하기 위해 중간에 캐시 메모리라는 것을 사용한다.

  • 메인 메모리는 CPU 입장에서 보면 거리도 멀고, 속도도 상대적으로 느리다. 대신에 상대적으로 가격이 저렴해서 큰 용량을 쉽게 구성할 수 있다. 
  • CPU 연산은 매우 빠르기 때문에 CPU 연산의 빠른 성능을 따라 가려면 CPU 가까이에 매우 빠른 메모리가 필요한데, 이것이 바로 캐시 메모리이다. 캐시 메모리는 CPU와 가까이 붙어있고, 속도도 매우 빠른 메모리이다. 하지만 상대적으로 가격이 비싸기 때문에 큰 용량을 구성하기는 어렵다.
  • 현대의 CPU 대부분은 코어 단위로 캐시 메모리를 각각 보유하고 있다.
    • 참고로 여러 코어가 공유하는 캐시 메모리도 있다.

  • 각 스레드가 runFlag의 값을 사용하면, CPU는 이 값을 효율적으로 처리하기 위해 먼저 runFlag를 캐시 메모리에 불러온다.
  • 그리고 이후에는 캐시 메모리에 있는 runFlag를 사용하게 된다.

  • 실선 위쪽은 스레드의 실행 흐름을 나타내고, 점선 아래쪽은 하드웨어를 나타낸다.
  • 자바 프로그램을 실행하고 main 스레드와 work 스레드 모두 runFlag 값을 읽는다.
  • CPU는 이 값을 효율적으로 처리하기 위해 먼저 캐시 메모리에 불러온다.
  • main 스레드와 work 스레드가 사용하는 runFlag가 각각의 캐시 메모리에 보관된다.
  • 프로그램의 시작 지점에는 runFlag의 값을 변경하지 않기 때문에 모든 스레드에서 true의 값을 읽는다.
    • runFlag의 초기값이 true이다.
  • work 스레드의 경우 while(runFlag)가 만족하기 때문에 while문을 계속 반복해서 수행한다.

  • 0. main 스레드는 runFlag의 값을 false로 변경한다.
  • 1. 이때 캐시 메모리의 runFlagfalse로 설정된다.

여기서 핵심은 캐시 메모리의 runFlag 값만 변한다는 것이다! 메인 메모리에 이 값이 즉시 반영되지 않는다.

  • main 스레드가 runFlag의 값을 변경해도 CPU 코어1이 사용하는 캐시 메모리의 runFlag 값만 false로 변경된다.
  • work 스레드가 사용하는 CPU 코어2의 캐시 메모리의 runFlag 값은 여전히 true이다.
  • work 스레드의 경우 while(runFlag)가 만족하기 때문에 while문을 계속 반복해서 수행한다.

  • 캐시 메모리에 있는 runFlag의 값이 언제 메인 메모리에 반영될까?
  • 이 부분에 대한 정답은 "알 수 없다"이다. CPU 설계 방식과 종류에 따라 다르다. 극단적으로 보면 평생 반영되지 않을수도 있다!
  • 메인 메모리에 반영을 한다고 해도 문제는 여기서 끝나지 않는다.
  • 메인 메모리에 반영이 된 runFlag의 값을 work 스레드가 사용하는 캐시 메모리에 다시 불러와야 한다.

  • 메인 메모리에 반영된 runFlag 값이 언제 CPU 코어2의 캐시 메모리에 반영될까?
  • 이 부분에 대한 정답도 "알 수 없다"이다. CPU 설계 방식과 종류에 따라 다르다. 극단적으로 보면 평생 반영되지 않을수도 있다!
  • 언젠가 CPU 코어2의 캐시 메모리에 runFlag 값을 불러오게 되면 work 스레드가 확인하는 runFlag의 값이 false가 되므로 while문을 탈출하고 "task 종료"를 출력한다.

캐시 메모리를 메인 메모리에 반영하거나, 메인 메모리의 변경 내역을 캐시 메모리에 다시 불러오는 것은 언제 발생할까?

이 부분은 CPU 설계 방식과 실행 환경에 따라 다를 수 있다. 즉시 반영될 수도 있고, 몇 밀리초 후에 될 수도 있고, 몇 초 후에 될 수도 있고, 평생 반영되지 않을 수도 있다. 주로 컨텍스트 스위칭이 될 때, 캐시 메모리도 함께 갱신되는데, 이 부분도 환경에 따라 달라질 수 있다.

예를 들어, Thread.sleep()이나 콘솔에 내용을 출력할 때 스레드가 잠시 쉬는데, 이럴때 컨텍스트 스위칭이 되면서 주로 갱신된다. 하지만 이것이 갱신을 보장하는 것이 아니다. 

 

메모리 가시성 (memory visibility)

이처럼, 멀티 스레드 환경에서 한 스레드가 변경한 값이 다른 스레드에서 언제 보이는지에 대한 문제를 메모리 가시성이라 한다. 이름 그대로 메모리에 변경한 값이 보이는가, 보이지 않는가의 문제이다. 

 

그렇다면 한 스레드에서 변경한 값이 다른 스레드에서 즉시 보이게 하려면 어떻게 해야 할까?

 

volatile 키워드 사용

캐시 메모리를 사용하면 CPU 처리 성능을 개선할 수 있다. 하지만 때로는 이런 성능 향상보다는, 여러 스레드에서 같은 시점에 정확히 같은 데이터를 보는 것이 더 중요할 수 있다. 해결방안은 아주 단순하다. 성능을 약간 포기하는 대신에, 값을 읽을 때, 값을 쓸 때 모두 메인 메모리에 직접 접근하면 된다. 자바에서는 volatile 이라는 키워드로 이런 기능을 제공한다.

 

VolatileFlagMain

package thread.volatilestudy;

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

public class VolatileFlagMain {
    public static void main(String[] args) {
        MyTask task = new MyTask();
        Thread t = new Thread(task, "work");
        log("runFlag = " + task.runFlag);
        t.start();

        sleep(1000);
        log("runFlag false로 변경");
        task.runFlag = false;
        log("runFlag = " + task.runFlag);
        log("main 종료");
    }

    static class MyTask implements Runnable {

        volatile boolean runFlag = true;

        @Override
        public void run() {
            log("task 시작");
            while (runFlag) {
                // ?
            }
            log("task 종료");
        }
    }
}
  • 기존 코드에서 boolean runFlag 앞에 volatile 키워드만 추가했다.
  • 이렇게 하면 runFlag에 대해서는 캐시 메모리를 사용하지 않고, 값을 읽거나 쓸 때 항상 메인 메모리에 직접 접근한다. 

실행결과

2024-07-19 19:11:10.542 [     main] runFlag = true
2024-07-19 19:11:10.545 [     work] task 시작
2024-07-19 19:11:11.551 [     main] runFlag false로 변경
2024-07-19 19:11:11.551 [     work] task 종료
2024-07-19 19:11:11.551 [     main] runFlag = false
2024-07-19 19:11:11.551 [     main] main 종료

 

여러 스레드에서 같은 값을 읽고 써야 한다면, volatile 키워드를 사용하면 된다. 단 캐시 메모리를 사용할 때 보다 성능이 느려지는 단점이 있기 때문에 꼭! 필요한 곳에만 사용하는 것이 좋다.

 

자바 메모리 모델 (Java Memory Model)

메모리 가시성(memory visibility)

멀티 스레드 환경에서 한 스레드가 변경한 값이 다른 스레드에 언제 보이는지에 대한 것을 메모리 가시성이라 한다. 이름 그대로 메모리에 변경한 값이 보이는가, 보이지 않는가의 문제이다.

 

위에서 봤지만 캐시 메모리를 사용한다면 스레드 간 공유하는 값이 서로 다를 수 있고 그 경우 메모리 가시성 문제가 있다고 말한다. (예: main에서 runFlagfalse로 변경했지만 work 스레드는 계속해서 while문을 돌고 있는 경우)

 

그래서 이 메모리 가시성 문제를 해결하기 위해, volatile 키워드를 사용해서 캐시 메모리를 사용하지 않고 메인 메모리에 직접 접근하게 하도록 했다. 이런 메모리에 대한 접근과 수정이 어떻게 이루어지는가에 대한 것을 정의한게 있는데 이게 자바 메모리 모델이다. 

 

Java Memory Model

Java Memory Model(JMM)은 자바 프로그램이 어떻게 메모리에 접근하고 수정할 수 있는지를 규정하며, 특히 멀티 스레드 환경에서 스레드 간 상호작용을 정의한다. JMM에 대한 여러가지 내용이 있지만, 핵심은 여러 스레드들의 작업 순서를 보장하는 happens-before 관계에 대한 정의다.

 

happens-before

happens-before 관계는 자바 메모리 모델에서 스레드 간의 작업 순서를 정의하는 개념이다. 만약 A 작업이 B 작업보다 happens-before 관계에 있다면 A 작업에서의 모든 메모리 변경 사항은 B 작업에서 볼 수 있다. 즉, A 작업에서 변경된 내용은 B 작업이 시작되기 전에 모두 메모리에 반영된다.

 

  • happens-before 관계는 이름 그대로, 한 동작이 다른 동작보다 먼저 발생함을 보장한다.
  • happens-before 관계는 스레드 간의 메모리 가시성을 보장하는 규칙이다.
  • happens-before 관계가 성립하면, 한 스레드의 작업을 다른 스레드에서 볼 수 있게 된다.
  • 즉, 한 스레드에서 수행한 작업을 다른 스레드가 참조할 때 최신 상태가 보장되는 것이다.

이 규칙을 따르면, 프로그래머가 멀티 스레드 프로그램을 작성할 때 예상치 못한 동작을 피할 수 있다.

happens-before 관계가 발생하는 경우

이건 외우는 게 아니다! 이런것이 있구나를 알고 넘어가는 것이다. 

 

프로그램 순서 규칙

단일 스레드 내에서 프로그램의 순서대로 작성된 모든 명령문은 happens-before 순서로 실행된다. 예를 들어,

int a = 1;
int b = 2;

이런 코드가 있을 때 a = 1b = 2 보다 먼저 실행된다는 게 보장된다.

 

volatile 변수 규칙

한 스레드에서 volatile 변수에 대한 쓰기 작업은 해당 변수를 읽는 모든 스레드에 보이도록 한다. 즉, volatile 변수에 대한 쓰기 작업은 그 변수를 읽는 작업보다 happens-before 관계를 형성한다.

그래서, 위에서 volatile로 선언한 runFlagfalse로 변경하고, 그 어떤 다른 동작없이 work 스레드가 while문을 빠져나가고 종료된 것이다.

 

스레드 시작 규칙

한 스레드에서 Thread.start()를 호출하면, start()를 호출 하기 전에 수행된 모든 작업이 새로운 스레드가 시작된 후의 작업보다 happens-before 관계를 가진다.

 

스레드 종료 규칙

한 스레드에서 Thread.join()을 호출하면, join 대상 스레드의 모든 작업은 join()이 반환된 후의 작업보다 happens-before 관계를 가진다. 그러니까 Thread.join() 이후에 호출된 코드는 저 join() 대상 스레드에서 한 모든 작업에 대해 알고 있는 상태라는 얘기다.

 

인터럽트 규칙

한 스레드에서 Thread.interrupt()를 호출하는 작업이 인터럽트된 스레드가 인터럽트를 감지하는 시점의 작업보다 happens-before 관계가 성립한다.

 

객체 생성 규칙

객체의 생성자는 객체가 완전히 생성된 이후에만 다른 스레드에 의해 참조될 수 있도록 보장한다. 즉, 객체의 생성자에서 초기화 된 필드는 생성자가 완료된 후 다른 스레드에서 참조될 때 happens-before 관계가 성립한다.

 

모니터 락 규칙

한 스레드에서 synchronized 블록을 종료한 후, 그 모니터 락을 얻는 모든 스레드는 해당 블록 내의 모든 작업을 볼 수 있다. 예를 들어, synchronized(lock) { ... } 블록 내에서의 모든 작업은 블록을 나가는 시점에 happens-before 관계가 형성된다. 뿐만 아니라, ReentrantLock과 같이 락을 사용하는 경우에도 happens-before 관계가 성립한다. 

 

전이 규칙

만약, A가 B보다 happens-before 관계에 있고, B가 C보다 happens-before 관계에 있다면 A는 C보다 happens-before 관계에 있다.

 

 

정리

메모리 가시성이란, 멀티 스레드 환경에서 한 스레드의 작업이 다른 스레드에 언제 보이는가에 대한 이야기이다. CPU 코어는 성능의 효율성을 위해 메인 메모리가 아닌 각각 캐시 메모리를 사용하기 때문에 각 스레드가 같은 참조 필드를 알고 있을지라도 서로 다른 값을 가지고 있을 수 있다. 이럴때 메모리 가시성 문제가 발생하고 이런 문제를 해결하기 위해 volatile 키워드를 사용해서, 이 키워드가 붙은 필드는 캐시 메모리를 사용하지 않고 메인 메모리에 직접 접근하게 했다. 

 

즉, volatile 또는 스레드 동기화 기법(synchronized, ReentrantLock)을 사용하면 메모리 가시성 문제가 발생하지 않는다.

 

이제 스레드 동기화 기법을 배워보자!!

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

인터럽트

특정 스레드의 작업을 중간에 중단하려면 어떻게 해야할까?

다음 코드를 보자.

 

ThreadStopMainV1

package thread.control.interrupt;

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

public class ThreadStopMainV1 {
    public static void main(String[] args) {
        MyTask task = new MyTask();
        Thread thread = new Thread(task, "work");

        thread.start();

        sleep(4000);
        task.runFlag = false;
        log("runFlag = false로 변경");
    }

    static class MyTask implements Runnable {

        volatile boolean runFlag = true;

        @Override
        public void run() {
            while (runFlag) {
                log("작업 중");
                sleep(3000);
            }
            log("자원 정리");
            log("작업 종료");
        }
    }
}
  • 특정 스레드의 작업을 중단하는 가장 쉬운 방법은 변수를 사용하는 것이다.
  • 여기서는 runFlag를 사용해서 work 스레드에 작업 중단을 지시할 수 있다.
  • 작업 하나에 3초가 걸린다고 가정하고, sleep(3000)을 사용하자.
  • main 스레드는 4초 뒤에 작업 중단을 지시한다.
  • volatile 키워드는 뒤에서 자세히 설명한다. 지금은 단순히 여러 스레드에서 공유하는 값에 사용하는 키워드라고 알아두자.

실행결과

2024-07-19 10:08:20.946 [     work] 작업 중
2024-07-19 10:08:23.950 [     work] 작업 중
2024-07-19 10:08:24.920 [     main] runFlag = false로 변경
2024-07-19 10:08:26.952 [     work] 자원 정리
2024-07-19 10:08:26.954 [     work] 작업 종료

 

문제점

실행해보면 바로 느끼겠지만 main 스레드가 runFlag=false를 통해 작업 중단을 지시해도, work 스레드가 즉각 반응하지 않는다. 왜냐하면 MyTaskrun()에는 sleep(3000)가 있기 때문이다. 3초간 잠들어 있는 상태에서는 runFlag 값이 false여도 잠이 깬 다음 while(runFlag) 코드를 실행해야 작업을 중단할 수 있다. 어떻게 하면 sleep()처럼 스레드가 대기하는 상태에서 스레드를 깨우고, 작업도 빨리 종료할 수 있을까?

 

인터럽트 코드 추가

예를 들어, 특정 스레드가 Thread.sleep()을 통해 쉬고 있는데 처리해야 하는 작업이 들어와서 해당 스레드를 급하게 깨워야 할 수 있다. 또는 sleep()으로 쉬고 있는 스레드에게 더는 일이 없으니 작업 종료를 지시할 수도 있다. 

 

인터럽트를 사용하면 WAITING, TIMED_WAITING 같은 대기 상태의 스레드를 직접 깨워서 작동하는 RUNNABLE 상태로 만들 수 있다. 아래 예제를 보자.

 

ThreadStopMainV2

package thread.control.interrupt;

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

public class ThreadStopMainV2 {
    public static void main(String[] args) {
        MyTask task = new MyTask();
        Thread thread = new Thread(task, "work");

        thread.start();

        sleep(4000);
        log("작업중단 지시: thread.interrupt()");
        thread.interrupt();
        log("work 스레드 인터럽트 상태1 = " + thread.isInterrupted());
    }

    static class MyTask implements Runnable {

        @Override
        public void run() {
            try {
                while (true) {
                    log("작업 중");
                    Thread.sleep(3000);
                }
            } catch (InterruptedException e) {
                log("work 스레드 인터럽트 상태2 = " + Thread.currentThread().isInterrupted());
                log("interrupt message = " + e.getMessage());
                log("state = " + Thread.currentThread().getState());
            }

            log("자원 정리");
            log("작업 종료");
        }
    }
}
  • 예제의 run()에서 Thread.sleep(3000)를 사용했다. 이 sleep()InterruptedException을 처리해야 한다.
  • 특정 스레드의 인스턴스에 interrupt() 메서드를 호출하면, 해당 스레드에 인터럽트가 발생한다.
  • 인터럽트가 발생하면 해당 스레드에 InterruptedException이 발생한다.
    • 이때 인터럽트를 받은 스레드는 대기 상태에서 깨어나 RUNNABLE 상태가 되고, 코드를 정상 수행한다.
    • 이때 InterruptedExceptioncatch로 잡아서 정상 흐름으로 변경하면 된다.
  • 참고로, interrupt()를 호출했다고 해서 즉각 InterruptedException이 발생하는 것은 아니다. 오직 sleep()처럼 InterruptedException을 던지는 메서드를 호출하거나 호출 중일 때 예외가 발생한다.
    • 예를 들어, 위 코드에서 while(true), log("작업 중") 에서는 InterruptedException이 발생하지 않는다.
    • Thread.sleep()처럼 InterruptedException을 던지는 메서드를 호출하거나 또는 호출하며 대기중일 때 예외가 발생한다.

실행결과

2024-07-19 10:57:48.092 [     work] 작업 중
2024-07-19 10:57:51.096 [     work] 작업 중
2024-07-19 10:57:52.070 [     main] 작업중단 지시: thread.interrupt()
2024-07-19 10:57:52.093 [     main] work 스레드 인터럽트 상태1 = true
2024-07-19 10:57:52.093 [     work] work 스레드 인터럽트 상태2 = false
2024-07-19 10:57:52.094 [     work] interrupt message = null
2024-07-19 10:57:52.094 [     work] state = RUNNABLE
2024-07-19 10:57:52.095 [     work] 자원 정리
2024-07-19 10:57:52.095 [     work] 작업 종료
  • thread.interrupt()를 통해 작업 중단을 지시하고, 거의 즉각적으로 인터럽트가 발생한 것을 확인할 수 있다.
  • 이때 work 스레드는 TIMED_WAITING 에서 RUNNABLE 상태로 변경되면서 InterruptedException 예외가 발생한다.
  • 참고로, 스레드가 RUNNABLE 상태여야 catch의 예외 코드도 실행될 수 있다.
  • 실행 결과를 보면 work 스레드가 catch 블록 안에서 RUNNABLE 상태로 바뀐 것을 알 수 있다.

 

흐름을 자세히 뜯어보자.

  • main 스레드가 4초 뒤에 work 스레드에 interrupt()를 호출한다.
  • work 스레드는 인터럽트 상태(true)가 된다.
  • 스레드가 인터럽트 상태일 땐, sleep()처럼 InterruptedException이 발생하는 메서드를 호출하거나 또는 이미 호출해서 대기 중이라면 InterruptedException이 발생한다. 그게 아니라 log("작업 중") 이런 단계에서는 InterruptedException은 발생하지 않는다.
    • 그러니까 만약, interrupt()가 호출되서 인터럽트 상태가 true가 된 work 스레드가 현재 진행중인 코드가 log("작업 중")이면 InterruptedException이 발생하지 않고 계속 다음 코드를 진행하고 그렇게 진행하다가 InterruptedException을 발생시키는 코드인 Thread.sleep()을 만나면 InterruptedException이 터진다.
  • 이때 두가지 일이 발생한다.
    • work 스레드는 TIMED_WAITING 에서 RUNNABLE 상태로 변경되고, InterruptedException 예외를 처리하면서 반복문을 탈출한다. 
    • work 스레드는 인터럽트 상태가 된 상태고 인터럽트 예외가 발생한다. 인터럽트 예외가 발생하면 다시 이 work 스레드는 인터럽트 상태가 false가 된다. InterruptedException이 터졌으니까. 그리고 터짐과 동시에 work 스레드는 다시 작동하는 RUNNABLE 상태가 되는 것이다.

주요 로그

2024-07-19 10:57:52.093 [     main] work 스레드 인터럽트 상태1 = true //여기서 인터럽트 발생
2024-07-19 10:57:52.093 [     work] work 스레드 인터럽트 상태2 = false //이 지점은 인터럽트 예외가 터지고 다시 인터럽트 상태가 false가 된, 즉 RUNNABLE 상태가 된 지점
  • 인터럽트가 적용되고, 인터럽트 예외가 발생하면, 해당 스레드는 실행 가능 상태가 되고 인터럽트 발생 상태도 정상으로 돌아온다.
  • 인터럽트를 사용하면 대기중인 스레드를 바로 깨워서 실행 가능한 상태로 바꿀 수 있다. 덕분에 단순히 runFlag를 사용하는 이전 방식보다 반응성이 더 좋아진 것을 알 수 있다.

 

근데 한가지 위 코드에서 아쉬운 부분이 있다. 

while (true) { // 인터럽트 체크 안함
    log("작업 중");
    Thread.sleep(3000);
}

여기서 while(true) 부분은 인터럽트 체크를 하지 않는다는 점이다. 인터럽트가 저 시점에 이미 발생했다고 해도 체크하지 않기 때문에 다음 코드로 넘어가고 또 다음 코드로 넘어가서 Thread.sleep(3000); 이라는 인터럽트 예외가 발생할 수 있는 지점에 도착해서야 비로소 인터럽트가 발생한다. 

 

만약 다음과 같이 인터럽트 상태를 확인하면 더 빨리 반응할 수 있을것이다.

while (인터럽트 상태 확인) {
    log("작업 중");
    Thread.sleep(3000);
}

 

추가적으로 인터럽트의 상태를 직접 확인하면, 인터럽트를 발생시키는 sleep()과 같은 코드가 없어도 인터럽트 상태를 직접 확인하기 때문에 while 문을 빠져나갈 수 있다.

while (인터럽트 상태 확인) {
    log("작업 중");
}

 

그래서 다음과 같이 코드를 바꿔보자.

ThreadStopMainV3

package thread.control.interrupt;

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

public class ThreadStopMainV3 {
    public static void main(String[] args) {
        MyTask task = new MyTask();
        Thread thread = new Thread(task, "work");

        thread.start();

        sleep(100);
        log("작업중단 지시: thread.interrupt()");
        thread.interrupt();
        log("work 스레드 인터럽트 상태1 = " + thread.isInterrupted());
    }

    static class MyTask implements Runnable {

        @Override
        public void run() {
            while (!Thread.currentThread().isInterrupted()) {
                log("작업 중");
            }
            log("work 스레드 인터럽트 상태2 = " + Thread.currentThread().isInterrupted());

            log("자원 정리");
            log("작업 종료");
        }
    }
}
  • Thread.currentThread().isInterrupted() 로 현재 스레드가 인터럽트 상태인지 확인할 수 있다.
  • 물론 이 Thread.currentThread().isInterrupted() 코드는 상태를 확인만 하지 인터럽트 상태를 변경하지는 않는다.

실행결과

...
2024-07-19 12:34:19.008 [     work] 작업 중
2024-07-19 12:34:19.008 [     work] 작업 중
2024-07-19 12:34:19.008 [     work] 작업 중
2024-07-19 12:34:19.008 [     main] 작업중단 지시: thread.interrupt()
2024-07-19 12:34:19.008 [     work] 작업 중
2024-07-19 12:34:19.016 [     work] work 스레드 인터럽트 상태2 = true
2024-07-19 12:34:19.016 [     main] work 스레드 인터럽트 상태1 = true
2024-07-19 12:34:19.016 [     work] 자원 정리
2024-07-19 12:34:19.016 [     work] 작업 종료
  • main 스레드는 interrupt() 메서드를 사용해서 work 스레드에 인터럽트를 건다.
  • work 스레드는 인터럽트 상태가 됐다. isInterrupted()true가 된다.
  • 이때 다음과 같이 while 조건이 인터럽트 상태가 아닌 경우에만 돌기 때문에 인터럽트 상태가 되면 이 while 문을 빠져나온다.

문제가 없어보이지만 이 코드에는 심각한 문제가 있다.

바로 work 스레드의 인터럽트 상태가 보는것과 같이 계속 true로 유지된다는 점이다.

앞서, 인터럽트 예외가 터진 경우 스레드의 인터럽트 상태는 false가 된다.

반면에 isInterrupted() 메서드는 인터럽트의 상태를 변경하지는 않고 상태를 확인만 한다.

 

그럼 원초적인 질문으로 돌아와서 왜 InterruptedException이 발생하면 다시 인터럽트 상태가 false로 변경될까?

다음과 같은 코드가 있다고 생각해보자.

@Override
public void run() {
    while (!Thread.currentThread().isInterrupted()) {
        log("작업 중");
    }
    log("work 스레드 인터럽트 상태2 = " + Thread.currentThread().isInterrupted());

    try {
        log("자원 정리");
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        log("인터럽트 예외 발생 - 자원 정리 실패");
    }
    log("작업 종료");
}
  • 인터럽트 상태라면 while 문을 빠져나오는 run() 메서드이다.
  • main 스레드가 work 스레드에 인터럽트를 발생시키고 이제 work 스레드는 인터럽트 상태가 된다.
  • 인터럽트 상태이니까 while 문을 빠져나온다.
  • 그러나 여전히 인터럽트 상태는 true이다.
  • 자원 정리를 시작한다. 자원 정리 시에는 상당시간 소요가 걸리는 작업이라고 가정하며 중간 중간에 sleep()을 통해 잠시 쉬어가야 하는 부분도 있다고 해보자.
  • 그러나 자원 정리 중 만나는 Thread.sleep(1000); 에서 인터럽트 예외가 발생한다. 왜? 인터럽트 상태가 계속 true 였으니까.

이걸 원하는 개발자는 없을것이다. 자원 정리시에는 어떤 상황에도 정리가 깔끔히 되기를 원하지만 인터럽트 예외가 터지지 않고서는 인터럽트 상태가 false로 변경되지 않으니까 자원 정리시 만나는 sleep()에 인터럽트 예외가 발생해서 자원 정리를 마저 할 수 없게 됐다.

 

이런 상황을 겪지 않게 하고자 InterruptedException이 발생하면 자동적으로 인터럽트 상태가 false로 변경되는 것이다. 즉, 인터럽트의 목적을 달성하면 인터럽트 상태를 다시 정상으로 돌려두어야 한다. 그리고 자원 정리 시 만나는 인터럽트 예외 시에 인터럽트 상태는 당연히 false로 변경된다.

 

그럼 어떻게 할까? 인터럽트를 확인한 다음에 인터럽트 상태를 확인해서 while 문을 빠져나오면 다시 인터럽트 상태를 정상으로 돌려두어야 한다.

 

Thread.interrupted()

스레드의 인터럽트 상태를 단순히 확인만 하는 용도라면 isInterrupted()를 사용하면 된다.

하지만 직접 체크해서 사용할땐 interrupted()를 사용해야 한다.

 

이 메서드는 굉장히 재밌다.

  • 우선 스레드가 인터럽트 상태인지 확인한다. 맞다면 true를 아니라면 false를 반환한다.
  • 그리고 true를 반환할 땐, 다른 말로 인터럽트 상태라면 인터럽트 상태를 원래대로(false) 돌려놓는다.

ThreadStopMainV4

package thread.control.interrupt;

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

public class ThreadStopMainV4 {
    public static void main(String[] args) {
        MyTask task = new MyTask();
        Thread thread = new Thread(task, "work");

        thread.start();

        sleep(100);
        log("작업중단 지시: thread.interrupt()");
        thread.interrupt();
        log("work 스레드 인터럽트 상태1 = " + thread.isInterrupted());
    }

    static class MyTask implements Runnable {

        @Override
        public void run() {
            while (!Thread.interrupted()) {
                log("작업 중");
            }
            log("work 스레드 인터럽트 상태2 = " + Thread.currentThread().isInterrupted());

            try {
                log("자원 정리");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                log("인터럽트 예외 발생 - 자원 정리 실패");
            }
            log("작업 종료");
        }
    }
}

실행결과

...
2024-07-19 12:56:21.495 [     work] 작업 중
2024-07-19 12:56:21.495 [     work] 작업 중
2024-07-19 12:56:21.495 [     work] 작업 중
2024-07-19 12:56:21.495 [     work] 작업 중
2024-07-19 12:56:21.494 [     main] 작업중단 지시: thread.interrupt()
2024-07-19 12:56:21.495 [     work] 작업 중
2024-07-19 12:56:21.504 [     work] work 스레드 인터럽트 상태2 = false
2024-07-19 12:56:21.504 [     work] 자원 정리
2024-07-19 12:56:21.504 [     main] work 스레드 인터럽트 상태1 = true
2024-07-19 12:56:22.505 [     work] 작업 종료

 

실행 결과처럼, Thread.interrupted()로 인터럽트 상태를 체크하고, 맞다면 다시 원래대로 돌려 놓기 때문에 2번째 상태 체크에서 false를 반환했다. 마치 인터럽트 예외를 만났을 때와 같이 동작한다.

 

그래서 마침내 원하는 목적을 최종적으로 모두 달성할 수 있게 됐다. 물론, 무조건 인터럽트 상태를 체크했다면 다시 돌려놔야 하는게 정답은 아니다. 모든 세상사가 다 그렇듯 정해져있는 정답이란게 없기 때문에. 만약, 자원 정리고 뭐고 당장 이 스레드를 꺼야한다면 그냥 인터럽트 만나자마자 바로 끝내버려도 된다. 그래서 정답은 없지만 일반적인 흐름에서는 이렇게 인터럽트의 목적을 달성하면, 인터럽트를 원래 상태로 돌려놓아야 한다.

 

참고로, 예전에 Thread.stop()이라는 메서드가 있었다. Java 1.2 이후에 Deprecated된 메서드이고 이제는 사용하면 안된다. 원하는대로 동작도 안할것이고 사용하면 안된다 그냥. 왜 그러냐면 뭐 밑도 끝도 없이 그냥 바로 스레드를 중지 시키면 예측 할 수 없는 상황이 발생할 가능성이 너무 높기 때문에 이제는 사용하지 않고 Thread.interrupt()를 사용한다고 알아두자.

 

 

인터럽트의 실제 사용 예제

인터럽트를 그럼 실제로 어떤식으로 사용할 수 있는지 실용적인 예제를 만들어보자.

프린터가 있다고 생각해보자. 조금 특별한 프린터인데 사용자의 입력을 출력하는 프린터.

 

MyPrinterV1

package thread.control.printer;

import java.util.Queue;
import java.util.Scanner;
import java.util.concurrent.ConcurrentLinkedQueue;

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

public class MyPrinterV1 {
    public static void main(String[] args) {
        Printer printer = new Printer();
        Thread printerThread = new Thread(printer, "printer");

        printerThread.start();

        Scanner userInput = new Scanner(System.in);
        while (true) {
            log("프린터할 문서를 입력하세요. 종료(q): ");
            String input = userInput.nextLine();
            if (input.equals("q")) {
                printer.work = false;
                break;
            }
            printer.addJob(input);
        }
    }

    static class Printer implements Runnable {
        volatile boolean work = true;

        Queue<String> q = new ConcurrentLinkedQueue<>();

        @Override
        public void run() {
            while (work) {
                if (q.isEmpty()) {
                    continue;
                }

                String job = q.poll();
                log("출력 시작: " + job + ", 대기 문서: " + q);
                sleep(3000);
                log("출력 완료");
            }
            log("프린터 종료");
        }

        public void addJob(String job) {
            q.offer(job);
        }
    }
}
  • 프린트 기능을 실질적으로 담당하는 스레드를 하나 만들었다. Printer
  • 이 스레드는 두 개의 필드를 가진다. work, q
  • work는 프린트 기능을 종료하는데 판단되는 필드이고 q는 사용자의 입력을 순서대로 받는 Queue이다.
  • workvolatile 키워드를 붙였다. 여러 스레드에서 접근하는 변수인 경우에 사용한다고 생각하면 된다. 이후에 더 자세히 배우게 된다.
  • qConcurrentLinkedQueue 인스턴스이다. 이 Concurrent 역시 여러 스레드에서 접근하는 경우에 사용하는 자료구조는 일반적인 자료구조를 사용하면 안전하지 않기 때문에 일단은 동시성을 지원하는 동시성 컬렉션을 사용한다고 생각하자.
  • run() 에서는 큐에 들어간 데이터가 없으면 continue;를 만나서 계속해서 while문을 다시 돌고 있다면 하나씩 앞에서부터 꺼내서 출력하고 큐에 남아있는 값들을 출력한다.
  • 만약, workfalse가 된다면 while 문을 빠져나오고 스레드는 종료된다.
  • addJob(String job) 메서드는 main 스레드에서 사용자의 입력을 받을때마다 그 값을 이 메서드를 호출해서 큐에 넣는다.

실행하면 다음과 유사한 결과를 얻는다.

2024-07-19 13:17:19.845 [     main] 프린터할 문서를 입력하세요. 종료(q): 
a
2024-07-19 13:17:21.631 [     main] 프린터할 문서를 입력하세요. 종료(q): 
2024-07-19 13:17:21.639 [  printer] 출력 시작: a, 대기 문서: []
b
2024-07-19 13:17:21.901 [     main] 프린터할 문서를 입력하세요. 종료(q): 
c
2024-07-19 13:17:22.228 [     main] 프린터할 문서를 입력하세요. 종료(q): 
d
2024-07-19 13:17:22.594 [     main] 프린터할 문서를 입력하세요. 종료(q): 
e
2024-07-19 13:17:22.923 [     main] 프린터할 문서를 입력하세요. 종료(q): 
f
2024-07-19 13:17:23.340 [     main] 프린터할 문서를 입력하세요. 종료(q): 
2024-07-19 13:17:24.640 [  printer] 출력 완료
2024-07-19 13:17:24.642 [  printer] 출력 시작: b, 대기 문서: [c, d, e, f]
2024-07-19 13:17:27.644 [  printer] 출력 완료
2024-07-19 13:17:27.644 [  printer] 출력 시작: c, 대기 문서: [d, e, f]
2024-07-19 13:17:30.646 [  printer] 출력 완료
2024-07-19 13:17:30.647 [  printer] 출력 시작: d, 대기 문서: [e, f]
2024-07-19 13:17:33.649 [  printer] 출력 완료
2024-07-19 13:17:33.650 [  printer] 출력 시작: e, 대기 문서: [f]
2024-07-19 13:17:36.651 [  printer] 출력 완료
2024-07-19 13:17:36.652 [  printer] 출력 시작: f, 대기 문서: []
2024-07-19 13:17:39.653 [  printer] 출력 완료

 

  • 여기서 사용자가 `q`를 입력하면 printer.work의 값을 false로 변경한다.
  • main 스레드는 while문을 빠져나가고 main 스레드가 종료된다.
  • printer 스레드는 while문에서 work의 값이 false인 것을 확인한다.
  • printer 스레드는 while문을 빠져나가고 프린터 종료를 출력하고 printer 스레드는 종료된다.

앞서 살펴보았듯, 이 방식의 문제는 종료(q)를 입력했을 때 바로 반응하지 않을것이라는 점이다. 왜냐하면 printer 스레드가 반복문을 빠져나오려면 while문을 체크해야 하는데 printer 스레드가 sleep(3000)을 통해 대기 상태에 빠져서 작동하지 않기 때문이다. 따라서 최악의 경우 q를 입력하고 3초 이후에 프린터가 종료된다. 이제 인터럽트를 사용해서 반응성이 느린 문제를 해결해보자.

 

인터럽트 도입

MyPrinterV1

package thread.control.printer;

import java.util.Queue;
import java.util.Scanner;
import java.util.concurrent.ConcurrentLinkedQueue;

import static util.MyLogger.log;

public class MyPrinterV1 {
    public static void main(String[] args) {
        Printer printer = new Printer();
        Thread printerThread = new Thread(printer, "printer");

        printerThread.start();

        Scanner userInput = new Scanner(System.in);
        while (true) {
            log("프린터할 문서를 입력하세요. 종료(q): ");
            String input = userInput.nextLine();
            if (input.equals("q")) {
                printerThread.interrupt();
                break;
            }
            printer.addJob(input);
        }
    }

    static class Printer implements Runnable {

        Queue<String> q = new ConcurrentLinkedQueue<>();

        @Override
        public void run() {
            try {
                while (!Thread.interrupted()) {
                    if (q.isEmpty()) {
                        continue;
                    }

                    String job = q.poll();
                    log("출력 시작: " + job + ", 대기 문서: " + q);
                    Thread.sleep(3000);
                    log("출력 완료");
                }
            } catch (InterruptedException e) {
                log("프린터 종료");
            }
        }

        public void addJob(String job) {
            q.offer(job);
        }
    }
}
  • 인터럽트를 도입한 코드이다. work 라는 스레드의 필드를 없애고 단순히 Thread.interrupted()while 문에서 체크한다.
  • 인터럽트를 main 스레드에서 발생시키면 while문에서도, Thread.sleep(3000); 에서도 인터럽트가 발생시 인터럽트 예외가 발생하고 곧바로 빠져나오게 된다.

 

yield

어떤 스레드를 얼마나 실행할지는 운영체제가 스케쥴링을 통해 결정한다. 그런데 특정 스레드가 크게 바쁘지 않은 상황이어서 다른 스레드에게 CPU 실행 기회를 양보하고 싶을 수 있다. 이렇게 양보하면 스케쥴링 큐에 대기중인 다른 스레드가 CPU 실행 기회를 더 빨리 얻을 수 있다. 

 

YieldMain

package thread.control.yield;

import static util.ThreadUtils.sleep;

public class YieldMain {

    static final int THREAD_COUNT = 1000;

    public static void main(String[] args) {
        for (int i = 0; i < THREAD_COUNT; i++) {
            Thread thread = new Thread(new MyRunnable());
            thread.start();
        }
    }

    static class MyRunnable implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 10; i++) {
                System.out.println(Thread.currentThread().getName() + " - " + i);
                // sleep(1);
                Thread.yield();
            }
        }
    }
}
  • 1000개의 스레드를 실행한다. 
  • 각 스레드가 실행하는 로직은 아주 단순하게 0 - 9까지 출력하는 것이다.

여기서 3가지 방식으로 사용해보자.

  • 아무것도 없이 그냥 실행 → 운영체제의 스레드 스케쥴링을 따른다.
  • sleep(1) → 특정 스레드를 잠시 쉬게 한다.
  • yield() → 다른 스레드에 실행을 양보한다.

아무것도 없이 실행

Thread-998 - 2
Thread-998 - 3
Thread-998 - 4
Thread-998 - 5
Thread-998 - 6
Thread-998 - 7
Thread-998 - 8
Thread-998 - 9
Thread-999 - 0
Thread-999 - 1
Thread-999 - 2
Thread-999 - 3
Thread-999 - 4
Thread-999 - 5
  • 결과가 조금씩 다르겠지만, 거의 한 스레드가 쭉 수행된 다음에 다른 스레드가 수행되는것을 확인할 수 있다.
  • 다른 예시보다 상대적으로 하나의 스레드가 쭉 연달아 실행되다가 다른 스레드로 넘어간다.

 

sleep()을 추가한 실행

Thread-626 - 9
Thread-997 - 9
Thread-993 - 9
Thread-949 - 7
Thread-645 - 9
Thread-787 - 9
Thread-851 - 9
Thread-949 - 8
Thread-949 - 9
  • sleep(1)을 사용해서 스레드의 상태를 1밀리초 동안 아주 잠깐 RUNNABLETIMED_WAITING 상태로 변경한다. 이렇게 되면 스레드는 CPU 자원을 사용하지 않고 실행 스케쥴러에서 잠시 제외된다. 1밀리초의 대기 시간 이후 다시 TIMED_WAITINGRUNNABLE 상태가 되면서 실행 스케쥴링에 포함된다.
  • 결과적으로 TIMED_WAITING 상태가 되면서 다른 스레드에 실행을 확실하게 양보하게 된다. 그리고 스케쥴링 큐에 대기중인 다른 스레드가 CPU 실행 기회를 빨리 얻을 수 있다.
  • 하지만 이 방식은 RUNNABLETIMED_WAITINGRUNNABLE 로 변경되는 복잡한 과정을 거치고 또 특정 시간 만큼 스레드가 실행되지 않는다는 단점이 있다. 예를 들어, 양보할 스레드가 없다면? 차라리 내 스레드를 계속 실행하는게 더 나은 선택일 것이다. 그러나 이 방법은 다른 스레드 모두 대기 상태로 쉬고 있어도 내 스레드까지 잠깐 실행되지 않는 경우가 생길 수 있다.

 

yield()를 추가한 실행

Thread-321 - 9
Thread-880 - 8
Thread-900 - 8
Thread-900 - 9
Thread-570 - 9
Thread-959 - 9
Thread-818 - 9
Thread-880 - 9
  • 자바의 스레드가 RUNNABLE 상태일 때, 운영체제의 스케쥴링은 다음과 같은 상태들을 가질 수 있다.
    • 실행 상태: 스레드가 CPU에서 실제로 실행중이다.
    • 실행 대기 상태: 스레드가 실행될 준비가 되었지만, CPU가 바빠서 스케쥴링 큐에서 대기중이다.
  • 운영체제는 실행 상태의 스레드들을 잠깐만 실행하고 실행 대기 상태로 만든다. 그리고 실행 대기 상태의 스레드들을 잠깐만 실행 상태로 변경해서 실행한다. 이 과정을 계속해서 반복한다. 참고로 자바에서는 두 상태를 구분할 수 없다. 둘 다 RUNNABLE이다.
  • Thread.yield() 메서드는 현재 실행 중인 스레드가 자발적으로 CPU를 양보하여 다른 스레드가 실행될 수 있도록 한다.
  • Thread.yield() 메서드를 호출한 스레드는 RUNNABLE 상태를 유지하면서 CPU를 양보한다. 이 스레드는 다시 스케쥴링 큐에 들어가면서 다른 스레드에게 CPU 사용 기회를 넘긴다.
  • 자바에서 Thread.yield() 메서드를 호출하면 현재 실행 중인 스레드가 CPU를 양보하고 스케쥴링 큐에 들어갈 뿐 RUNNABLE 상태를 유지하기 때문에 쉽게 이야기해서 양보할 사람이 없다면 본인 스레드가 계속 실행될 수 있다.

 

yield의 실제 사용 예제

난 처음에 이걸 왜 쓰나? 싶은 생각이 제일 먼저 들었다. 와닿지 않았으니까. 와닿는 예제가 바로 위에서 한 프린터 예제에 있다!

 

바로 이 부분이다.

while (!Thread.interrupted()) {
    if (q.isEmpty()) {
        continue;
    }
    ...
}

프린터 예제를 실행하면 생성된 스레드가 이 while문을 계속해서 돌면서 두 가지를 체크한다.

  • 스레드가 인터럽트가 걸렸는지 확인
  • 안 걸렸다면 자신이 가지고 있는 큐에 데이터가 있는지 확인
  • 없다면 continue를 만나 다시 while문으로 돌아와서 위 과정을 반복

이 과정이 눈에 안 보일뿐이지 쉴 틈 없이 CPU에서 이 로직이 수행되고 있다. 1초에 while문을 수억번 반복할 수도 있다! 운영체제가 컨텍스트 스위칭을 발생시켜 다른 스레드의 작업을 하기 전까지.

 

여기서 만약, 큐에 아무것도 없다면 continue를 만나서 다시 while문으로 돌아가 또 체크를 하는 과정을 몇번(어떻게 보면 몇억번)더 반복하게 CPU를 태우는것보다 다른 스레드에게 바로 양보하면 좋지 않을까? 그러니까 좀 더 크게 생각해서 이 프린터라는 스레드도 돌고 사용자 입력을 받는 스레드가 한 200개 있다고 가정하면 빨리 사용자의 입력을 받는 스레드에게 양보해서 큐에 채워넣는게 훨씬 더 이득일 것 같다. 

 

이렇게 코드를 수정해보자.

while (!Thread.interrupted()) {
    if (q.isEmpty()) {
        Thread.yield();
        continue;
    }
    ...
}

 

위에서 말했지만 yield()는 양보를 할 수 있게 다시 스케쥴링 큐에 들어가는 거지 RUNNABLE 상태임에는 변함이 없다. 또한, 양보할 스레드가 없다면 그냥 자기가 계속 CPU를 사용할수도 있다. 그래서 몇번 더 반복해서 사용자의 입력이 들어오거나 인터럽트가 발생하는걸 체크하는걸 먼저 하는 욕심을 부리지 않고 다른 스레드에게 양보하면 CPU를 효율적으로 사용할 수 있을 것 같다.

 

정리

interruptyield에 대해 알아보았다. 둘 다 왜 필요한지에 대해 깊이 있게 알아보았고 실전 예제로도 적용시켜 보면서 좀 더 와닿게 느껴졌다. 인터럽트는 인터럽트를 건다고 바로 인터럽트가 터지는게 아니라 인터럽트 예외를 터트리는 메서드를 만나거나 현재 인터럽트 상태인지 확인하고 맞다면 인터럽트 상태를 변경하는 Thread.interrupted() 같은 메서드를 만나야 인터럽트가 터진다. 그리고 이 경우 인터럽트 상태에서 원래대로 돌아온다.

 

yield()는 양보하는 방법이다. sleep()은 양보를 확실하게 주어진 시간만큼 하지만 양보하지 않아도 될 때에도 양보를 하게 되는 아이러니한 상황도 발생한다. 그리고 우선 RUNNABLETIMED_WAITING으로 스레드의 상태가 변경되는것도 비용이 발생하는 문제가 있다. 그래서 yield()를 사용해서 계속 RUNNABLE 상태를 유지하지만 스케쥴링 큐로만 돌아가는 그래서 다른 스레드에게 CPU를 사용하도록 양보하게 했다. 만약, 양보할 스레드가 없다면 그냥 계속 내가 사용하게 되는 좋은 이점도 가지고 있었다.

 

다음 포스팅에선 지금까지 volatile 이라는 키워드를 사용하면서 이게 뭔지 몰랐다. 이 녀석에 대해 알아보자.

 

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

스레드 기본 정보

Thread 클래스는 스레드를 생성하고 관리하는 기능을 제공한다.

Thread 클래스가 제공하는 정보들을 확인해보자.

하나는 기본으로 제공되는 main 스레드의 정보를, 하나는 직접 만든 myThread 스레드의 정보를 출력해보자.

 

ThreadInfoMain

package thread.control;

import thread.start.HelloRunnable;

import static util.MyLogger.log;

public class ThreadInfoMain {
    public static void main(String[] args) {
        Thread mainThread = Thread.currentThread();
        log("mainThread======================================");
        log("mainThread = " + mainThread);
        log("mainThread.threadId(): " + mainThread.threadId());
        log("mainThread.getName(): " + mainThread.getName());
        log("mainThread.getPriority(): " + mainThread.getPriority());
        log("mainThread.isDaemon(): " + mainThread.isDaemon());
        log("mainThread.getThreadGroup(): " + mainThread.getThreadGroup());
        log("mainThread.getState(): " + mainThread.getState());

        Thread myThread = new Thread(new HelloRunnable(), "myThread");
        log("myThread======================================");
        log("myThread = " + myThread);
        log("myThread.threadId(): " + myThread.threadId());
        log("myThread.getName(): " + myThread.getName());
        log("myThread.getPriority(): " + myThread.getPriority());
        log("myThread.isDaemon(): " + myThread.isDaemon());
        log("myThread.getThreadGroup(): " + myThread.getThreadGroup());
        log("myThread.getState(): " + myThread.getState());
    }
}

실행결과

2024-07-18 13:42:57.490 [     main] mainThread======================================
2024-07-18 13:42:57.494 [     main] mainThread = Thread[#2,main,5,main]
2024-07-18 13:42:57.502 [     main] mainThread.threadId(): 2
2024-07-18 13:42:57.502 [     main] mainThread.getName(): main
2024-07-18 13:42:57.505 [     main] mainThread.getPriority(): 5
2024-07-18 13:42:57.505 [     main] mainThread.isDaemon(): false
2024-07-18 13:42:57.505 [     main] mainThread.getThreadGroup(): java.lang.ThreadGroup[name=main,maxpri=10]
2024-07-18 13:42:57.506 [     main] mainThread.getState(): RUNNABLE
2024-07-18 13:42:57.506 [     main] myThread======================================
2024-07-18 13:42:57.506 [     main] myThread = Thread[#31,myThread,5,main]
2024-07-18 13:42:57.507 [     main] myThread.threadId(): 31
2024-07-18 13:42:57.507 [     main] myThread.getName(): myThread
2024-07-18 13:42:57.507 [     main] myThread.getPriority(): 5
2024-07-18 13:42:57.507 [     main] myThread.isDaemon(): false
2024-07-18 13:42:57.508 [     main] myThread.getThreadGroup(): java.lang.ThreadGroup[name=main,maxpri=10]
2024-07-18 13:42:57.508 [     main] myThread.getState(): NEW

 

1. 스레드 생성

스레드를 생성할 때는 실행할 Runnable 인터페이스의 구현체와, 스레드의 이름을 전달할 수 있다.

Thread myThread = new Thread(new HelloRunnable(), "myThread");

 

2. 스레드 객체 정보

myThread 객체를 문자열로 반환하여 출력한다. Thread 클래스의 toString() 메서드는 스레드 ID, 스레드 이름, 스레드 우선순위, 스레드 그룹을 포함하는 문자열을 반환한다.

log("myThread: " + myThread);
>> Thread[#31,myThread,5,main]

 

3. 스레드 ID

스레드의 고유 식별자를 반환하는 메서드이다. 이 ID는 JVM 내에서 각 스레드에 대해 유일하다. ID는 스레드가 생성될 때 할당되며, 직접 지정할 수 없다. 

log("myThread.threadId() = " + myThread.threadId());

 

4. 스레드 이름

스레드의 이름을 반환하는 메서드이다. 생성자에서 `myThread`라는 이름을 지정했기 때문에, 이 값이 반환된다. 참고로 스레드 ID는 중복되지 않지만, 스레드 이름은 중복될 수 있다.

log("myThread.getName() = " + myThread.getName());

 

5. 스레드 우선순위

스레드의 우선순위를 반환하는 메서드이다. 우선순위는 1 (가장 낮음)에서 10 (가장 높음)까지의 값으로 설정할 수 있으며, 기본값은 5이다. setPriority() 메서드를 사용해서 우선순위를 변경할 수 있다. 우선순위는 스레드 스케쥴러가 어떤 스레드를 우선 실행할지 결정하는데 사용된다. 하지만 실제 실행 순서는 JVM 구현과 운영체제에 따라 달라질 수 있다.

log("myThread.getPriority() = " + myThread.getPriority());

 

6. 스레드 그룹

log("myThread.getThreadGroup() = " + myThread.getThreadGroup());

스레드가 속한 스레드 그룹을 반환하는 메서드이다. 스레드 그룹은 스레드를 그룹화하여 관리할 수 있는 기능을 제공한다. 기본적으로 모든 스레드는 부모 스레드와 동일한 스레드 그룹에 속하게 된다. 스레드 그룹은 여러 스레드를 하나의 그룹으로 묶어서 특정 작업(예: 일괄 종료, 우선순위 설정 등)을 수행할 수 있다. 

여기서 부모 스레드란, 새로운 스레드를 생성하는 스레드를 말한다. 스레드는 기본적으로 다른 스레드에 의해 생성된다. 이러한 생성 관계에서 새로 생성된 스레드는 생성한 스레드를 부모로 간주한다. 그래서 지금 위 실행결과를 보면 myThreadmain 스레드에 의해 생성되었으니까 main 스레드가 부모 스레드가 되고 그룹도 main이 되는 것이다. 

 

그러나, 이 스레드 그룹은 거의 사용되지 않는다.

 

7. 스레드 상태

log("myThread.getState() = " + myThread.getState());

스레드의 현재 상태를 반환하는 메서드이다. 반환되는 값은 Thread.State 열거형에 정의된 상수 중 하나이다. 주요 상태는 다음과 같다.

  • NEW: 스레드가 아직 시작되지 않은 상태이다.
  • RUNNABLE: 스레드가 실행중이거나, 실행될 준비가 된 상태이다.
  • BLOCKED: 스레드가 동기화 락을 기다리는 상태이다.
  • WAITING: 스레드가 다른 스레드의 특정 작업이 완료되기를 기다리는 상태이다.
  • TIMED_WAITING: 일정 시간 동안 기다리는 상태이다 (예: Thread.sleep())
  • TERMINATED: 스레드가 실행을 마친 상태이다.

 

스레드의 생명 주기

스레드는 생성하고 시작하고, 종료되는 생명주기를 가진다. 스레드의 생명 주기에 대해 자세히 알아보자.

스레드의 상태

  • New: 스레드가 생성되었으나 아직 시작되지 않은 상태
  • Runnable: 스레드가 실행중이거나 실행될 준비가 된 상태
  • Blocked: 스레드가 동기화 락을 기다리는 상태
  • Waiting: 스레드가 무기한으로 다른 스레드의 작업을 기다리는 상태
  • Timed Waiting: 스레드가 일정 시간 동안 다른 스레드의 작업을 기다리는 상태
  • Terminated: 스레드의 실행이 완료된 상태

자바 스레드의 생명 주기는 여러 상태로 나뉘어지며, 각 상태는 스레드가 실행되고 종료되기까지의 과정을 나타낸다. 자바 스레드의 생명 주기를 자세히 알아보자! 

 

1. NEW (새로운 상태)

  • 스레드가 생성되고 아직 시작되지 않은 상태이다.
  • 이 상태에서는 Thread 객체가 생성되지만, start() 메서드가 호출되지 않은 상태이다.

2. Runnable (실행 가능 상태)

  • 스레드가 실행될 준비가 된 상태이다. 이 상태에서 스레드는 실제로 CPU에서 실행될 수 있다.
  • start() 메서드가 호출되면 스레드는 이 상태로 들어간다.
  • 이 상태는 스레드가 실행될 준비가 되어 있음을 나타내며, 실제로 CPU에서 실행될 수 있는 상태이다. 그러나 Runnable 상태에 있는 모든 스레드가 동시에 실행되는 것은 아니다. 운영체제의 스케쥴러가 각 스레드에 CPU 시간을 할당하여 실행하기 때문에, Runnable 상태에 있는 스레드는 스케쥴러의 실행 대기열에 포함되어 있다가 차례로 CPU에서 실행된다.
  • 참고로 운영체제 스케쥴러의 실행 대기열에 있든, CPU에서 실제 실행되고 있든 모두 RUNNABLE 상태이다. 자바에서 둘을 구분해서 확인할 수는 없다.
  • 보통 `실행 상태`라고 부른다.

3. Blocked (차단 상태)

  • 스레드가 다른 스레드에 의해 동기화 락을 얻기 위해 기다리는 상태이다.
  • 예를 들어, synchronized 블록에 진입하기 위해 락을 얻어야 하는 경우 이 상태에 들어간다.
  • 예) synchronized (lock) { ... } 코드 블록에 진입하려고 할 때, 다른 스레드가 이미 lock의 락을 가지고 있는 경우

4. Waiting (대기 상태)

  • 스레드가 다른 스레드의 특정 작업이 완료되기를 무기한 기다리는 상태이다.
  • wait(), join() 메서드가 호출될 때 이 상태가 된다.
  • 스레드는 다른 스레드가 notify() 또는 notifyAll() 메서드를 호출하거나, join()이 완료될 때까지 기다린다.
  • 예) object.wait();

5. Timed Waiting (시간 제한 대기 상태)

  • 스레드가 특정 시간 동안 다른 스레드의 작업이 완료되기를 기다리는 상태이다.
  • sleep(long millis), wait(long timeout), join(long millis) 메서드가 호출될 때 이 상태가 된다.
  • 주어진 시간이 경과하거나 다른 스레드가 해당 스레드를 깨우면 이 상태에서 벗어난다.
  • 예) Thread.sleep(1000);

6. Terminated (종료 상태)

  • 스레드의 실행이 완료된 상태이다.
  • 스레드가 정상적으로 종료되거나, 예외가 발생하여 종료된 경우 이 상태로 돌아간다.
  • 스레드는 한 번 종료되면 다시 시작할 수 없다.

자바 스레드의 상태 전이 과정

1. New -> Runnable: start() 메서드를 호출하면 스레드가 Runnable 상태로 전이된다.

2. Runnable -> Blocked/Waiting/Timed Waiting: 스레드가 락을 얻지 못하거나, wait() 또는 sleep() 메서드를 호출할 때 해당 상태로 전이된다. 

3. Blocked/Waiting/Timed Waiting -> Runnable: 스레드가 락을 얻거나, 기다림이 완료되면 다시 Runnable 상태로 돌아간다.

4. Runnable -> Terminated: 스레드의 run() 메서드가 완료되면 스레드는 Terminated 상태가 된다.

 

스레드의 생명 주기 - 코드로 직접 보기

실제로 이 일련의 과정을 코드로 직접 봐보자. 

ThreadStateMain

package thread.control;

import static util.MyLogger.log;

public class ThreadStateMain {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new MyRunnable(), "myThread");
        log("myThread.state1 = " + thread.getState());
        log("myThread.start()");
        thread.start();
        Thread.sleep(1000);
        log("myThread.state3 = " + thread.getState());
        Thread.sleep(4000);
        log("myThread.state5 = " + thread.getState());
    }

    static class MyRunnable implements Runnable {

        @Override
        public void run() {
            try {
                log("start");
                log("myThread.state2 = " + Thread.currentThread().getState());

                log("sleep() start");
                Thread.sleep(3000);
                log("sleep() end");
                log("myThread.state4 = " + Thread.currentThread().getState());
                log("end");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
  • 첫번째로 MyRunnable 이라는 Runnable을 구현한 클래스를 만든다. 이 클래스에서는 run()을 재정의하여 스레드가 동작할 행위들을 구현한다. 
  • main()에서는 이 MyRunnable()task로 하는 스레드를 생성한다. 그 스레드의 이름은 `myThread`가 된다.
  • myThread.start()를 하기 전, 현재 myThread의 상태를 찍어본다. (NEW)
  • 그리고 myThread.start()를 호출한다.
  • MyRunnablerun()은 실행된 후 자기 자신의 상태를 찍어본다 (RUNNABLE)
  • 그리고 3초동안의 sleep(3000)을 실행한다. 
  • 이 3초 동안 자는 시간에 myThread의 상태를 확인하기 위해 main()에서 start()를 호출 후 1초 정도의 텀을 두고 myThread 상태를 찍어본다. (TIMED_WAITING)
  • 3초가 지나고 myThreadrun() 안에서 자기 자신의 상태를 찍는다 (RUNNABLE)
  • run() 메서드가 종료된다.
  • main() 에서는 run()이 잘 종료될때까지 잠시 4초 정도 기다리고 다시 이 myThread의 상태를 찍어본다. (TERMINATED)

 

근데, 자꾸 불편한게 있다. Runnable 인터페이스의 run() 메서드를 구현할 때 InterruptedException 같은 체크 예외를 밖으로 던질 수가 없다. 왜 그럴까? 그 이유를 알아보자.

 

재정의 메서드에서의 체크 예외

Runnable 인터페이스의 run() 메서드를 구현할 때 InterruptedException 체크 예외를 밖으로 던질 수 없었다. 왜 그럴까?

Runnable 인터페이스는 다음과 같이 정의되어 있다.

@FunctionalInterface
public interface Runnable {
    void run();
}

 

자바에서 메서드를 재정의할 때, 재정의 메서드가 지켜야 할 예외와 관련된 규칙이 있다.

  • 체크예외
    • 부모 메서드가 체크 예외를 던지지 않는 경우, 재정의된 자식 메서드도 체크 예외를 던질 수 없다.
    • 자식 메서드는 부모 메서드가 던질 수 있는 체크 예외 또는 그 하위 타입만 던질 수 있다.
  • 언체크(런타임) 예외
    • 예외 처리를 강제하지 않으므로 상관없이 던질 수 있다.

그렇다. Runnable 인터페이스의 run() 메서드는 아무런 체크 예외를 던지지 않는다. 따라서 Runnable 인터페이스의 run() 메서드를 재정의 하는 곳에서는 체크 예외를 밖으로 던질 수 없다.

 

그럼 Runnable을 구현하는 클래스에서 재정의 한 run() 메서드는 아무런 체크 예외도 던질 수 없다는 것을 알았다. 근데 왜 이런 제약을 두지?

부모 클래스의 메서드를 호출하는 클라이언트 코드는 부모 메서드가 던지는 특정 예외만을 처리하도록 작성된다. 자식 클래스가 더 넓은 범위의 예외를 던지면 해당 코드는 모든 예외를 제대로 처리하지 못할 수 있다. 이는 예외 처리의 일관성을 해치고, 예상하지 못한 런타임 오류를 초래할 수 있다. 

 

다음 예시를 보면서 더 이해해보자 (실제 동작할 수 없는 코드이다)

class Parent {
	void method() throws InterruptedException {
        // ...
	} 
}

class Child extends Parent {
	@Override
	void method() throws Exception {
        // ...
	} 
}

public class Test {
    public static void main(String[] args) {
    	Parent p = new Child();
        try {
        	p.method();
        } catch (InterruptedException e) {
        	// InterruptedException 처리
        }
    } 
}
  • new Child();Child 인스턴스를 생성했고 변수 p의 타입은 Parent로 받았다. 
  • p.method()를 호출하면 컴파일러 입장에서는 타입이 Parent 이기 때문에 당연히 부모가 선언한 체크 예외인 InterruptedExceptioncatch에서 잡는다.
  • 그러나, 런타임에서는 p.method()를 호출하는 순간 재정의된 메서드가 있다면 재정의된 Childmethod()가 호출된다. 이게 규칙이다.
  • 만약, 그렇게 해서 정상적으로 끝났으면 상관이 없지만 에러가 발생하면 Exception이 던져지게 된다. 하지만 코드에서는 Exception을 잡을수가 없는 사태가 발생한다. 

재정의 메서드에서 체크 예외 규칙

  • 자식 클래스에 재정의된 메서드는 부모 메서드가 던질 수 있는 체크 예외와 그 예외의 하위 타입만을 던질 수 있다.
  • 원래 메서드가 체크 예외를 던지지 않는 경우, 재정의된 메서드도 체크 예외를 던질 수 없다.

안전한 예외 처리

체크 예외를 run() 메서드에서 던질 수 없도록 강제함으로써, 개발자는 반드시 체크 예외를 try - catch 블록 내에서 처리하게 된다. 이는 예외 발생 시 예외가 적절히 처리되지 않아서 프로그램이 비정상적으로 종료되는 상황을 방지할 수 있다. 특히, 멀티 스레드 환경에서는 예외 처리를 강제함으로써 스레드의 안정성과 일관성을 유지할 수 있다. 하지만 이제는 체크 예외를 강제하는 이런 부분들이 구시대적 흐름이고 최근에는 체크 예외보단 언체크 예외를 선호한다. 그리고 Runnable은 Java 1.0에서 만들어진 내용이다. 그리고 이제는 체크 예외도 던질 수 있는 Callable 이라는 인터페이스가 탄생한다. 이는 이후에 다루겠다.

 

그래서 Thread.sleep(int millis) 호출할때마다 try - catch 블록으로 잡는게 귀찮으니 다음과 같이 유틸리티를 만들자. 그리고 이것을 사용하자.

ThreadUtils

package util;

import static util.MyLogger.log;

public abstract class ThreadUtils {

    public static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            log("인터럽트 발생, " + e.getMessage());
            throw new RuntimeException(e);
        }
    }
}

 

join - 시작

앞서 Thread.sleep()을 통해 TIMED_WAITING 상태를 알아보았다. 이번에는 join() 메서드를 통해 WAITING(대기 상태)가 무엇이고 왜 필요한지 알아보자.

 

WAITING - 스레드가 다른 스레드의 특정 작업이 완료되기를 무기한 기다리는 상태

 

먼저 스레드로 특정 작업을 수행하는 간단한 예제를 하나 만들어보자.

JoinMainV0

package thread.control.join;

import util.ThreadUtils;

import static util.MyLogger.log;

public class JoinMainV0 {
    public static void main(String[] args) {
        log("start");
        Thread thread1 = new Thread(new Job(), "thread-1");
        Thread thread2 = new Thread(new Job(), "thread-2");

        thread1.start();
        thread2.start();

        log("end");
    }

    static class Job implements Runnable {

        @Override
        public void run() {
            log("작업 시작");
            ThreadUtils.sleep(2000);
            log("작업 완료");
        }
    }
}

실행결과 (스레드의 실행 순서는 보장되지 않기 때문에 실행 결과는 약간 다를 수 있다)

2024-07-18 16:43:04.713 [     main] start
2024-07-18 16:43:04.717 [     main] end
2024-07-18 16:43:04.717 [ thread-1] 작업 시작
2024-07-18 16:43:04.717 [ thread-2] 작업 시작
2024-07-18 16:43:06.719 [ thread-2] 작업 완료
2024-07-18 16:43:06.719 [ thread-1] 작업 완료
  • thread-1, thread-2 모두 main 스레드가 생성하고 start()를 호출해서 실행한다.
  • thread-1, thread-2는 각각 특정 작업을 수행한다. 작업 수행에 약 2초 정도가 걸린다고 가정하기 위해 sleep()을 사용해서 2초간 대기한다. 
  • main 스레드는 thread-1, thread-2를 실행하고 바로 자신의 다음 코드를 실행한다. 여기서 핵심은 main 스레드가 thread-1, thread-2가 끝날때까지 기다리지 않는다는 점이다. main 스레드는 단지 start()를 호출해서 다른 스레드를 실행만 하고 바로 자신의 다음 코드를 실행한다.

그런데 만약 thread-1, thread-2가 종료된 다음에 main 스레드를 가장 마지막에 종료하려면 어떻게 해야할까? 예를 들어 main 스레드가 thread-1, thread-2에 각각 어떤 작업을 지시하고, 그 결과를 받아서 처리하고 싶다면 어떻게 해야할까? 

 

join - 필요한 상황

1 - 100까지 더하는 코드를 생각해보자. 

int sum = 0;
for (int i = 1; i <= 100; i++) {
	sum += i; 
}

이 코드는 스레드를 하나만 사용하기 때문에 CPU 코어도 하나만 사용할 수 있다. CPU 코어를 더 효율적으로 사용하려면 여러 스레드로 나누어 계산하면 된다. 

  • 1 - 50까지 더하기
  • 51 - 100까지 더하기

두 계산 결과를 합치면 된다. main 스레드가 1 - 100까지 더하는 작업을 thread-1, thread-2에 각각 작업을 나누어 지시하면 CPU 코어를 더 효율적으로 활용할 수 있다. CPU 코어가 2개라면 이론적으로 연산 속도가 2배 빨라진다.

  • thread-1: 1 - 50까지 더하기
  • thread-2: 51 - 100까지 더하기

JoinMainV1

package thread.control.join;

import util.ThreadUtils;

import static util.MyLogger.log;

public class JoinMainV1 {
    public static void main(String[] args) {
        log("start");

        SumTask task1 = new SumTask(1, 50);
        SumTask task2 = new SumTask(51, 100);

        Thread thread1 = new Thread(task1, "thread-1");
        Thread thread2 = new Thread(task2, "thread-2");

        thread1.start();
        thread2.start();

        log("task1.result = " + task1.result);
        log("task2.result = " + task2.result);

        int sumAll = task1.result + task2.result;
        log("task1 + task2 = " + sumAll);

        log("end");

    }

    static class SumTask implements Runnable {

        int startValue;
        int endValue;
        int result;

        public SumTask(int startValue, int endValue) {
            this.startValue = startValue;
            this.endValue = endValue;
        }

        @Override
        public void run() {
            log("작업 시작");
            ThreadUtils.sleep(2000);
            int sum = 0;
            for (int i = startValue; i <= endValue; i++) {
                sum += i;
            }
            result = sum;
            log("작업 완료 result = " + result);
        }
    }
}

 

SumTask는 계산의 시작값(startValue)과 계산의 마지막 값(endValue)을 가진다. 그리고 계산이 끝나면 그 결과를 result 필드에 담아둔다. main 스레드는 thread-1, thread-2를 만들고 다음과 같이 작업을 할당한다.

  • thread-1: task1 (1 - 50까지 더하기)
  • thread-2: task2 (51 - 100까지 더하기)

thread-1task1 인스턴스의 run()을 실행하고, thread-2task2 인스턴스의 run()을 실행한다. 각각의 스레드는 계산 결과를 result 멤버 변수에 보관한다.

 

run()에서 수행하는 계산이 2초 정도는 걸리는 복잡한 계산이라고 가정하자. 그래서 sleep(2000)으로 설정했다. 여기서는 약 2초 후에 계산이 완료되고 result에 결과가 담긴다.

 

main 스레드는 thread-1, thread-2에 작업을 지시한 다음에 작업의 결과인 task1.result, task2.result를 얻어서 사용한다.

 

실행결과

2024-07-18 16:59:17.656 [     main] start
2024-07-18 16:59:17.660 [ thread-1] 작업 시작
2024-07-18 16:59:17.660 [ thread-2] 작업 시작
2024-07-18 16:59:17.668 [     main] task1.result = 0
2024-07-18 16:59:17.669 [     main] task2.result = 0
2024-07-18 16:59:17.670 [     main] task1 + task2 = 0
2024-07-18 16:59:17.670 [     main] end
2024-07-18 16:59:19.663 [ thread-2] 작업 완료 result = 3775
2024-07-18 16:59:19.663 [ thread-1] 작업 완료 result = 1275

그런데 실행 결과를 보면 기대와 다르게 task1.result, task2.result 모두 0으로 나온다. 그리고 task1 + task2의 결과도 0으로 나온다. 계산이 전혀 진행되지 않았다. 왜 그럴까?

main 스레드는 thread-1, thread-2에 작업을 지시하고, thread-1, thread-2가 계산을 완료하기도 전에 먼저 계산 결과를 조회했다. 참고로 thread-1, thread-2가 계산을 완료하기까지는 2초 정도의 시간이 걸린다. 따라서 결과가 task1 + task2 = 0으로 출력된다.

 

 

이 부분을 메모리 구조로 좀 더 자세히 살펴보자.

  • 프로그램이 처음 시작되면 main 스레드는 thread-1, thread-2를 생성하고 start()로 실행한다.
  • thread-1, thread-2는 각각 자신에게 전달된 SumTask 인스턴스의 run() 메서드를 스택에 올리고 실행한다.
  • thread-1x001 인스턴스의 run() 메서드를 실행한다.
  • thread-2x002 인스턴스의 run() 메서드를 실행한다.

  • main 스레드는 두 스레드를 시작한 다음에 바로 task1.result, task2.result를 통해 인스턴스에 있는 결과값을 조회한다. 참고로 main 스레드가 실행한 start() 메서드는 스레드의 실행이 끝날 때 까지 기다리지 않는다! 다른 스레드를 실행만 해두고 자신의 다음 코드를 실행할 뿐이다.
  • thread-1, thread-2가 계산을 완료해서, result에 연산 결과를 담을 때 까지는 약 2초 정도의 시간이 걸린다. main 스레드는 계산이 끝나기 전에 result의 결과를 조회한 것이다. 따라서 0이 출력된다.

  • 2초가 지난 이후에 thread-1, thread-2는 계산을 완료한다.
  • 이때, main 스레드는 이미 자신의 코드를 모두 실행하고 종료된 상태이다.
  • task1 인스턴스의 result에는 1275가 담겨있고, task2 인스턴스의 result에는 3775가 담겨있다.

여기서 문제의 핵심은 main 스레드가 thread-1, thread-2의 계산이 끝날 때 까지 기다려야 한다는 점이다. 그럼 어떻게 해야 main 스레드가 기다릴 수 있을까? 

 

참고 - this의 비밀

어떤 메서드를 호출하는 것은, 정확히는 특정 스레드가 어떤 메서드를 호출하는 것이다.

스레드는 메서드의 호출을 관리하기 위해 메서드 단위로 스택 프레임을 만들고 해당 스택 프레임을 스택위에 쌓아 올린다.

이때 인스턴스의 메서드를 호출하면, 어떤 인스턴스의 메서드를 호출했는지 기억하기 위해 해당 인스턴스의 참조값을 스택 프레임 내부에 저장해둔다. 이것이 바로 우리가 자주 사용하던 this이다.

 

특정 메서드 안에서 this를 호출하면 바로 스택 프레임 안에 있는 this 값을 불러서 사용하게 된다.

그림을 보면 스택 프레임 안에 있는 this를 확인할 수 있다. 이렇게 this가 있기 때문에 thread-1, thread-2는 자신의 인스턴스를 구분해서 사용할 수 있다. 예를 들어 필드에 접근할 때 this를 생략하면 자동으로 this를 참고해서 필드에 접근한다. 

 

정리하면, 스레드는 자기만의 스택을 가지고 있고, 메서드를 호출한다는 것은 스레드가 어떤 메서드를 호출한다는 것이며 그 메서드를 자신의 스택에 스택 프레임으로 하나씩 쌓아 올린다. 이때, 해당 메서드가 어떤 인스턴스의 메서드인지를 기억하기 위해 해당 인스턴스의 참조값을 스택 프레임에 저장하게 되는데 이것이 this이다.

 

 

각설하고, 이제 과연 main이 어떻게 thread-1, thread-2을 기다리고 계산 결과를 가져올 수 있을까? 

가장 간단하고 단순한 방법은 sleep을 사용하는 것이다.

 

JoinMainV1

package thread.control.join;

import util.ThreadUtils;

import static util.MyLogger.log;

public class JoinMainV1 {
    public static void main(String[] args) {
        log("start");

        SumTask task1 = new SumTask(1, 50);
        SumTask task2 = new SumTask(51, 100);

        Thread thread1 = new Thread(task1, "thread-1");
        Thread thread2 = new Thread(task2, "thread-2");

        thread1.start();
        thread2.start();

        log("main 스레드 sleep()");
        ThreadUtils.sleep(3000);
        log("main 스레드 깨어남");

        log("task1.result = " + task1.result);
        log("task2.result = " + task2.result);

        int sumAll = task1.result + task2.result;
        log("task1 + task2 = " + sumAll);

        log("end");

    }

    static class SumTask implements Runnable {

        int startValue;
        int endValue;
        int result;

        public SumTask(int startValue, int endValue) {
            this.startValue = startValue;
            this.endValue = endValue;
        }

        @Override
        public void run() {
            log("작업 시작");
            ThreadUtils.sleep(2000);
            int sum = 0;
            for (int i = startValue; i <= endValue; i++) {
                sum += i;
            }
            result = sum;
            log("작업 완료 result = " + result);
        }
    }
}

각 스레드가 작업을 한 2초정도 하니까 3초 정도 main 스레드가 잠을 자고 있다가 일어나면 계산이 다 됐을꺼라고 가정하는 것이다. 즉 무슨 말이냐면, 실제로 스레드의 작업에 대한 정확한 시간은 모두가 모른다는 것이다.

실행결과

2024-07-18 17:21:49.452 [     main] start
2024-07-18 17:21:49.454 [ thread-1] 작업 시작
2024-07-18 17:21:49.454 [     main] main 스레드 sleep()
2024-07-18 17:21:49.454 [ thread-2] 작업 시작
2024-07-18 17:21:51.478 [ thread-2] 작업 완료 result = 3775
2024-07-18 17:21:51.478 [ thread-1] 작업 완료 result = 1275
2024-07-18 17:21:52.460 [     main] main 스레드 깨어남
2024-07-18 17:21:52.462 [     main] task1.result = 1275
2024-07-18 17:21:52.463 [     main] task2.result = 3775
2024-07-18 17:21:52.464 [     main] task1 + task2 = 5050
2024-07-18 17:21:52.464 [     main] end

 

원하는 결과는 나왔지만, 얼마나 자고 있어야 하는지 전혀 모른다. 지금이야 명시적으로 run()안에서 2초의 대기상태라는 명확한 시간이 존재하지만, 실제 서비스라고 생각해보면 절대 알 수 없다.

 

그래서 이럴때 join() 메서드를 사용하면 깔끔하게 문제를 해결할 수 있다.

 

join() 사용

다음 코드를 보자. 

JoinMainV2

package thread.control.join;

import util.ThreadUtils;

import static util.MyLogger.log;

public class JoinMainV2 {
    public static void main(String[] args) throws InterruptedException {
        log("start");

        SumTask task1 = new SumTask(1, 50);
        SumTask task2 = new SumTask(51, 100);

        Thread thread1 = new Thread(task1, "thread-1");
        Thread thread2 = new Thread(task2, "thread-2");

        thread1.start();
        thread2.start();

        log("join() - main 스레드가 thread1, thread2 종료까지 대기");
        thread1.join();
        thread2.join();
        log("main 스레드 대기 완료");

        log("task1.result = " + task1.result);
        log("task2.result = " + task2.result);

        int sumAll = task1.result + task2.result;
        log("task1 + task2 = " + sumAll);

        log("end");

    }

    static class SumTask implements Runnable {

        int startValue;
        int endValue;
        int result;

        public SumTask(int startValue, int endValue) {
            this.startValue = startValue;
            this.endValue = endValue;
        }

        @Override
        public void run() {
            log("작업 시작");
            ThreadUtils.sleep(2000);
            int sum = 0;
            for (int i = startValue; i <= endValue; i++) {
                sum += i;
            }
            result = sum;
            log("작업 완료 result = " + result);
        }
    }
}

 

  • thread1.start(), thread2.start()를 호출한 후 thread1.join(), thread2.join()을 호출한다.
  • thread1.join()을 호출하는 순간 호출한 스레드(main)은 thread1이 작업이 끝날때까지 무기한 기다린다. (WAITING)
  • thread1이 작업이 다 끝나고 다시 RUNNABLE 상태가 된 main은 다음 코드로 간다. 다음 코드가 thread2.join()이므로 또 무기한 기다리는 상태가 된다 (WAITING) 물론, thread1thread2가 소요되는 시간이 거의 비슷하기 때문에 아마 thread1이 작업이 끝남과 동시에 thread2도 작업이 끝날것같다. 그렇게 되면 thread1.join()이 풀림과 동시에 거의 바로 thread2.join()도 풀려서 바로 다음 코드로 진행된다.
  • thread2가 작업이 다 끝나고 다시 mainRUNNABLE 상태가 되며 다음 코드를 진행한다.

실행결과

2024-07-19 09:47:06.821 [     main] start
2024-07-19 09:47:06.826 [ thread-2] 작업 시작
2024-07-19 09:47:06.826 [     main] join() - main 스레드가 thread1, thread2 종료까지 대기
2024-07-19 09:47:06.826 [ thread-1] 작업 시작
2024-07-19 09:47:08.835 [ thread-1] 작업 완료 result = 1275
2024-07-19 09:47:08.835 [ thread-2] 작업 완료 result = 3775
2024-07-19 09:47:08.835 [     main] main 스레드 대기 완료
2024-07-19 09:47:08.836 [     main] task1.result = 1275
2024-07-19 09:47:08.836 [     main] task2.result = 3775
2024-07-19 09:47:08.836 [     main] task1 + task2 = 5050
2024-07-19 09:47:08.836 [     main] end

이렇게 특정 스레드를 `무기한` 기다리는 WAITING 상태로 만드는 방법이 join()이라고 할 수 있다. 하지만 join()의 단점은 정말 무기한 기다리기 때문에, 죽을때까지 기다리기만 할수도 있다. 예외 상황이 발생하면. 그런 단점을 해결하기 위해 특정 시간만큼만 대기할 수 있다. 

 

join(int millis) - 특정 시간 만큼만 대기

JoinMainV3

package thread.control.join;

import util.ThreadUtils;

import static util.MyLogger.log;

public class JoinMainV3 {
    public static void main(String[] args) throws InterruptedException {
        log("start");

        SumTask task1 = new SumTask(1, 50);
        SumTask task2 = new SumTask(51, 100);

        Thread thread1 = new Thread(task1, "thread-1");
        Thread thread2 = new Thread(task2, "thread-2");

        thread1.start();
        thread2.start();

        log("join(500) - main 스레드가 thread1, thread2 종료까지 대기");
        thread1.join(500);
        thread2.join(500);
        log("main 스레드 대기 완료");

        log("task1.result = " + task1.result);
        log("task2.result = " + task2.result);

        int sumAll = task1.result + task2.result;
        log("task1 + task2 = " + sumAll);

        log("end");

    }

    static class SumTask implements Runnable {

        int startValue;
        int endValue;
        int result;

        public SumTask(int startValue, int endValue) {
            this.startValue = startValue;
            this.endValue = endValue;
        }

        @Override
        public void run() {
            log("작업 시작");
            ThreadUtils.sleep(2000);
            int sum = 0;
            for (int i = startValue; i <= endValue; i++) {
                sum += i;
            }
            result = sum;
            log("작업 완료 result = " + result);
        }
    }
}

이렇게 join(500)으로 시간을 밀리초로 넘기면 이 넘긴 시간만큼만 기다리게 된다. 실행해보자. 아마 각 스레드는 2초정도의 시간이 필요하기 때문에 결과를 가져올 수 없을것이다. 

실행결과

2024-07-19 09:48:40.893 [     main] start
2024-07-19 09:48:40.897 [     main] join(500) - main 스레드가 thread1, thread2 종료까지 대기
2024-07-19 09:48:40.897 [ thread-2] 작업 시작
2024-07-19 09:48:40.897 [ thread-1] 작업 시작
2024-07-19 09:48:41.907 [     main] main 스레드 대기 완료
2024-07-19 09:48:41.914 [     main] task1.result = 0
2024-07-19 09:48:41.914 [     main] task2.result = 0
2024-07-19 09:48:41.914 [     main] task1 + task2 = 0
2024-07-19 09:48:41.915 [     main] end
2024-07-19 09:48:42.901 [ thread-1] 작업 완료 result = 1275
2024-07-19 09:48:42.901 [ thread-2] 작업 완료 result = 3775

 

당연히 이때 main 스레드의 상태는 TIMED_WAITING 상태가 된다. 무기한 기다리는 상태가 아니라 특정 시간만큼만 기다리는 상태이기 때문이다.

 

정리

기본적으로 스레드는 어떤 상태들이 존재하고 생명 주기가 어떻게 되는지 알아보았다. 그리고 join()을 사용해서 다른 스레드의 작업이 끝나는 것을 기다려야 하는 경우 무기한 기다리는 방법과 특정 시간만큼만 기다리는 방법을 알아보았다. 이 Xxx.join()을 마주한 스레드는 Xxx가 작업이 다 끝날때까지 그 코드라인에서 기다리고 다음 라인으로 넘어가지 않는다. 다음 포스팅에서는 좀 더 깊이 있는 스레드 제어와 생명 주기에 대해 알아보자.

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

스레드를 시작하기 앞서, 스레드를 제대로 이해하려면 자바 메모리 구조를 확실히 이해하고 있어야 한다.

자바 메모리 구조

  • 메서드 영역: 메서드 영역은 프로그램을 실행하는데 필요한 공통 데이터를 관리한다. 이 영역은 프로그램의 모든 영역에서 공유한다.
    • 클래스 정보: 클래스의 실행 코드(바이트 코드), 필드, 메서드와 생성자 코드등 모든 실행 코드가 존재한다.
    • static 영역: static 변수들을 보관한다.
    • 런타임 상수 : 프로그램을 실행하는데 필요한 공통 리터럴 상수를 보관한다.
  • 스택 영역: 자바 실행 시, 하나의 실행 스택이 생성된다. 각 스택 프레임은 지역 변수, 중간 연산 결과, 메서드 호출 정보 등을 포함한다.
  • 힙 영역: 객체(인스턴스)와 배열이 생성되는 영역이다. 가비지 컬렉션(GC)이 이루어지는 주요 영역이며, 더 이상 참조되지 않는 객체는 GC에 의해 제거된다.

스택 영역은 더 정확히는 각 스레드별로 하나의 실행 스택이 생성된다. 따라서 스레드 수 만큼 스택이 생성된다. 지금은 스레드 1개만 사용하므로 스택도 하나이다. 이후 스레드를 추가할 것인데 그러면 스택도 스레드 수만큼 증가한다.

 

스레드 생성

스레드를 직접 만들어보자. 그래서 해당 스레드에서 별도의 로직을 수행해보자.

스레드를 만들때는 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법이 있다.

먼저 Thread 클래스를 상속 받아서 스레드를 생성해보자.

 

스레드 생성 - Thread 상속

자바는 많은 것을 객체로 다룬다. 자바가 예외도 객체로 다루듯 스레드도 객체로 다룬다.

스레드가 필요하면 스레드 객체를 생성해서 사용하면 된다.

 

HelloThread

package thread.start;

public class HelloThread extends Thread {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + ": run()");
    }
}
  • Thread 클래스를 상속하고, 스레드가 실행할 코드를 run() 메서드에 재정의한다.
  • Thread.currentThread()를 호출하면 해당 코드를 실행하는 스레드 객체를 조회할 수 있다.
  • Thread.currentThread().getName(): 실행중인 스레드의 이름을 조회한다.

HelloThreadMain

package thread.start;

public class HelloThreadMain {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName() + ": main() start");

        HelloThread helloThread = new HelloThread();
        System.out.println(Thread.currentThread().getName() + ": start() 호출 전");
        helloThread.start();
        System.out.println(Thread.currentThread().getName() + ": start() 호출 후");

        System.out.println(Thread.currentThread().getName() + ": main() end");
    }
}
  • 앞서 만든 HelloThread 객체를 생성하고 start() 메서드를 호출한다.
  • start() 메서드는 스레드를 실행하는 아주 특별한 메서드이다.
  • start()를 호출하면 HelloThread 스레드가 run() 메서드를 실행한다.
주의! run() 메서드가 아니라 반드시 start() 메서드를 호출해야 한다. 그래야 별도의 스레드에서 run() 코드가 실행된다. run()을 직접 호출하면 현재 진행중인 쓰레드가 그냥 run() 메서드를 실행하는 것이다.

 

실행결과

main: main() start
main: start() 호출 전
main: start() 호출 후
main: main() end
Thread-0: run()

실행 결과는 스레드의 실행 순서에 따라 약간 다를 수 있다. 가령 이런 경우도 있고.

main: main() start
main: start() 호출 전
main: start() 호출 후
Thread-0: run()
main: main() end

 

실행 결과 설명 - 1단계

실행 결과를 보면 main() 메서드는 main 이라는 이름의 스레드가 실행하는 것을 확인할 수 있다. 프로세스가 작동하려면 스레드가 최소한 하나는 있어야 한다. 그래야 코드를 실행할 수 있다. 자바는 실행 시점에 main 이라는 이름의 스레드를 만들고 프로그램의 시작점인 main() 메서드를 실행한다.

 

실행 결과 설명 - 2단계

  • HelloThread 스레드 객체를 생성한 다음에 start() 메서드를 호출하면 자바는 HelloThread 스레드를 위한 별도의 스택 공간을 할당한다.
  • 스레드 객체를 생성하고 반드시 start()를 호출해야 스택 공간을 할당받고 스레드가 작동한다.
  • 스레드에 이름을 주지 않으면 자바는 스레드에 Thread-0, Thread-1 과 같은 임의의 이름을 부여한다.
  • 새로운 Thread-0 스레드가 사용할 전용 스택 공간이 마련되었다.
  • Thread-0 스레드는 run() 메서드의 스택 프레임을 스택에 올리면서 run() 메서드를 시작한다.

 

 

메서드를 실행하면 스택 위에 스택 프레임이 쌓인다.

  • main 스레드는 main() 메서드 스택 프레임을 스택에 올리면서 시작한다.
  • 직접 만드는 스레드는 run() 메서드 스택 프레임을 스택에 올리면서 시작한다.

실행 결과를 보면 Thread-0 스레드가 run() 메서드를 실행한 것을 확인할 수 있다.

  • main 스레드가 HelloThread 인스턴스를 생성한다. 이때 스레드에 이름을 부여하지 않으면 자바가 Thread-0, Thread-1 과 같은 임의의 이름을 부여한다.
  • main 스레드가 start() 메서드를 호출하면, Thread-0 스레드가 시작되면서 Thread-0 스레드가 run() 메서드를 호출한다.
  • 여기서 핵심은 main 스레드가 run() 메서드를 실행하는 게 아니라, Thread-0 스레드가 run() 메서드를 실행한다는 점이다.
  • main 스레드는 단지 start() 메서드를 통해 Thread-0 스레드에게 실행을 지시할 뿐이다. 다시 강조하지만 main 스레드가 run()을 호출하는 게 아니다! main 스레드는 다른 스레드에게 일을 시작하라고 지시만 하고 바로 start() 메서드를 빠져나온다.
  • 이제 main 스레드와 Thread-0 스레드는 동시에 실행된다.
  • main 스레드 입장에서 보면 그림 1, 2, 3번 코드를 멈추지 않고 계속 수행한다. 그리고 run() 메서드는 main이 아닌 별도의 스레드에서 실행된다.

스레드 간 실행 순서는 보장하지 않는다.

스레드는 동시에 수행되기 때문에 스레드 간에 실행 순서는 얼마든지 달라질 수 있다. 따라서 위에서 보여준 실행결과와 같이 다양한 실행 결과가 나올 수 있다. CPU 코어가 2개여서 물리적으로 정말 동시에 실행될 수도 있고, 하나의 CPU 코어에 시간을 나누어 실행될 수도 있다.

그리고 한 스레드가 얼마나 오랜기간 실행되는지도 보장하지 않는다. 한 스레드가 먼저 다 수행된 다음에 다른 스레드가 수행될 수도 있고, 둘이 완전히 번갈아 가면서 수행되는 경우도 있다. 

 

스레드는 순서와 실행 기간을 모두 보장하지 않는다. 이것이 바로 멀티 스레드다!

 

start() vs run()

스레드의 start() 대신에 재정의한 run()을 직접 호출하면 어떻게 될까?

package thread.start;

public class BadThreadMain {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName() + ": main() start");

        HelloThread helloThread = new HelloThread();
        System.out.println(Thread.currentThread().getName() + ": start() 호출 전");
        helloThread.run();
        System.out.println(Thread.currentThread().getName() + ": start() 호출 후");

        System.out.println(Thread.currentThread().getName() + ": main() end");
    }
}

실행결과

main: main() start
main: start() 호출 전
main: run()
main: start() 호출 후
main: main() end

  • 실행 결과를 잘 보면 별도의 스레드가 run()을 실행하는 것이 아니라, main 스레드가 run() 메서드를 호출한 것을 알 수 있다.
  • 자바를 처음 실행하면 main 스레드가 main() 메서드를 호출하면서 시작한다.
  • main 스레드는 HelloThread 인스턴스에 있는 run() 이라는 메서드를 호출한다.
  • main 스레드가 run() 메서드를 실행했기 때문에 main 스레드가 사용하는 스택위에 run() 스택 프레임이 올라간다.

결과적으로 main 스레드에서 모든 것을 처리한 것이 된다.

 

스레드의 start() 메서드는 스레드에 스택 공간을 할당하면서 스레드를 시작하는 아주 특별한 메서드이다. 그리고 해당 스레드에서 run() 메서드를 실행한다. 따라서 main 스레드가 아닌 별도의 스레드에서 재정의한 run() 메서드를 실행하려면, 반드시 start() 메서드를 호출해야 한다.

 

데몬 스레드

스레드는 사용자 스레드데몬 스레드 2가지 종류로 구분할 수 있다.

 

사용자 스레드 (non-daemon 스레드)

  • 프로그램의 주요 작업을 수행한다.
  • 작업이 완료될 때까지 실행된다.
  • 모든 user 스레드가 종료되면 JVM도 종료된다.

데몬 스레드

  • 백그라운드에서 보조적인 작업을 수행한다.
  • 모든 user 스레드가 종료되면 데몬 스레드는 자동으로 종료된다.

JVM은 데몬 스레드의 실행 완료를 기다리지 않고 종료된다. 데몬 스레드가 아닌 모든 스레드가 종료되면, 자바 프로그램도 종료된다.

참고로, 데몬 스레드라는 용어는 사용자에게 직접적으로 보이지 않으면서 시스템의 백그라운드에서 작업을 수행하는 것을 데몬 스레드, 데몬 프로세스라고 한다. 

 

DaemonThreadMain

package thread.start;

public class DaemonThreadMain {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName() + ": main start");

        DaemonThread dt = new DaemonThread();
        dt.setDaemon(true); // 데몬 스레드 여부
        dt.start();

        System.out.println(Thread.currentThread().getName() + ": main end");
    }

    static class DaemonThread extends Thread {
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + ": run() start");

            try {
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            System.out.println(Thread.currentThread().getName() + ": run() end");
        }
    }
}
  • 데몬 스레드를 설정하려면 setDaemon(true); 를 호출하면 된다.
  • 데몬 스레드 여부는 start() 실행 전에 결정해야 한다. 이후에는 변경되지 않는다.
  • 기본값은 false

실행결과

main: main start
main: main end
Thread-0: run() start

 

분명 Thread-0은 10초가 지나야 끝나는데 그냥 끝나버렸다. 그 이유는 Thread-0은 데몬 스레드이고, 메인 스레드(사용자 스레드)가 다 끝났기 때문에 자바 프로그램도 종료된 것이다. 만약 데몬 스레드가 아니라면 예상한대로 아래처럼 실행된다.

 

실행결과 (setDaemon(false))

main: main start
main: main end
Thread-0: run() start
Thread-0: run() end

 

스레드 생성 - Runnable

스레드를 만들 때는 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법이 있다고 했다.

이제 Runnable 인터페이스를 구현하는 방식으로 스레드를 생성해보자.

 

Runnable 인터페이스

package java.lang;

@FunctionalInterface
public interface Runnable {
    void run();
}

 

HelloRunnable

package thread.start;

public class HelloRunnable implements Runnable {

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + ": run() start");
        System.out.println(Thread.currentThread().getName() + ": run() end");
    }
}

 

HelloRunnableMain

package thread.start;

public class HelloRunnableMain {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName() + ": main() start");

        HelloRunnable helloRunnable = new HelloRunnable();
        new Thread(helloRunnable).start();

        System.out.println(Thread.currentThread().getName() + ": main() end");
    }
}

실행결과

main: main() start
main: main() end
Thread-0: run() start
Thread-0: run() end

 

실행 결과는 기존과 같다. 차이가 있다면 스레드와 해당 스레드가 실행할 작업이 서로 분리되어 있다는 점이다.

스레드 객체를 생성할 때, 실행할 작업을 생성자로 전달하면 된다. 위 코드처럼.

 

Thread 상속 vs Runnable 구현

스레드를 사용할 땐 Thread를 상속받는 것보다 Runnable 인터페이스를 구현하는 방식을 사용하자.

두 방식이 서로 장단점이 있지만, 스레드를 생성할 때는 Thread 클래스를 상속받는 방식보다 Runnable 인터페이스를 구현하는 방식이 더 나은 선택이다. 

 

Thread 상속

장점

  • 간단한 구현: Thread 클래스를 상속받아 run() 메서드만 재정의하면 된다.

단점

  • 상속의 제한: 자바는 단일 상속만을 허용하므로 이미 다른 클래스를 상속받고 있는 경우 Thread 클래스를 상속받을 수 없다.
  • 유연성 부족: 인터페이스를 사용하는 방법에 비해 유연성이 떨어진다.

Runnable 구현

장점

  • 상속의 자유로움: Runnable 인터페이스 방식은 다른 클래스를 상속받아도 문제없이 구현할 수 있다.
  • 코드의 분리: 스레드와 실행할 작업을 분리하여 코드의 가독성을 높일 수 있다.
  • 여러 스레드가 동일한 Runnable 객체를 공유할 수 있어 자원 관리를 효율적으로 할 수 있다.

단점

  • 코드가 약간 복잡해질 수 있다. Runnable 객체를 생성하고 이를 Thread에 전달하는 과정이 추가된다.

정리하자면, Runnable 인터페이스를 구현하는 방식을 사용하자. 스레드와 실행할 작업을 명확히 분리하고, 인터페이스를 사용하므로 Thread 클래스를 직접 상속받는 방식보다 더 유연하고 유지보수 하기 쉬운 코드를 만들 수 있다.

 

로거 만들기

현재 어떤 스레드가 코드를 실행하는지 출력하기 위해 다음과 같이 긴 코드를 작성하는게 이젠 귀찮다.

System.out.println(Thread.currentThread().getName() + ": main() start");

 

이런식으로 실행하면 현재 시간, 스레드 이름, 출력 내용등이 한번에 나올 수 있으면 좋을것같다.

log("Hello World");
log(1234);

실행결과

2024-07-18 09:54:08.567 [     main] Hello World
2024-07-18 09:54:08.570 [     main] 1234

 

MyLogger

package util;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public abstract class MyLogger {

    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");

    public static void log(Object obj) {
        String localDateTime = LocalDateTime.now().format(formatter);
        System.out.printf("%s [%9s] %s\n", localDateTime, Thread.currentThread().getName(), obj);
    }
}
  • 다른곳에서 new로 생성하지 못하도록 abstract 클래스를 만들었다.
  • 현재 시간을 원하는 포맷으로 출력하기 위해 DateTimeFormatter를 사용한다.
  • printf에서 %s는 문자열을 뜻한다. 인자를 순서대로 사용한다.
  • 마지막 출력할 객체는 문자열이 아니라 Object 타입인데, %s를 사용하면 toString()을 사용해서 문자열로 변환 후 출력한다. 이렇게 Object 타입을 사용하면 문자열 뿐만 아니라 객체도 출력할 수 있어서 이렇게 했다.
  • %9s는 다음과 같이 문자를 출력할 때 9칸을 확보한다는 뜻이다. 9칸이 차지 않으면 왼쪽에 그 만큼 비워둔다. 이 기능은 단순히 출력시 정렬을 깔끔히 하기위해 사용한다.
  • [     main]: 앞에 5칸 공백
  • [ Thread-0]: 앞에 1칸 공백

 

MyLoggerMain

package util;

import static util.MyLogger.*;

public class MyLoggerMain {
    public static void main(String[] args) {
        log("Hello World");
        log(1234);
    }
}

실행결과

2024-07-18 09:54:08.567 [     main] Hello World
2024-07-18 09:54:08.570 [     main] 1234

 

이제 이 로거를 사용해서 마음껏 스레드 공부를 해보자!

 

여러 스레드 만들기

많은 스레드를 만들어보고 실행해보자.

ManyThreadMainV1

package thread.start;

import static util.MyLogger.log;

public class ManyThreadMainV1 {
    public static void main(String[] args) {

        log("main() start");

        HelloRunnable helloRunnable = new HelloRunnable();

        Thread thread1 = new Thread(helloRunnable);
        thread1.start();

        Thread thread2 = new Thread(helloRunnable);
        thread2.start();

        Thread thread3 = new Thread(helloRunnable);
        thread3.start();

        log("main() end");
    }
}

실행결과

2024-07-18 10:07:46.166 [     main] main() start
2024-07-18 10:07:46.171 [     main] main() end
2024-07-18 10:07:46.171 [ Thread-1] run() start
2024-07-18 10:07:46.171 [ Thread-2] run() start
2024-07-18 10:07:46.171 [ Thread-0] run() start
2024-07-18 10:07:46.171 [ Thread-1] run() end
2024-07-18 10:07:46.171 [ Thread-2] run() end
2024-07-18 10:07:46.172 [ Thread-0] run() end

 

이 예시는 단순 3개의 스레드를 만들고 실행한 결과이다.

  • 스레드 3개를 생성할 때 모두 같은 HelloRunnable 인스턴스를 스레드의 실행 작업으로 전달했다.
  • Thread-0, Thread-1, Thread-2 모두 HelloRunnable 인스턴스에 있는 run() 메서드를 실행한다.

 

스레드 100개도 생성해보자.

ManyThreadMainV2

package thread.start;

import static util.MyLogger.log;

public class ManyThreadMainV2 {
    public static void main(String[] args) {

        log("main() start");

        HelloRunnable helloRunnable = new HelloRunnable();
        for (int i = 0; i < 100; i++) {
            Thread thread = new Thread(helloRunnable);
            thread.start();
        }

        log("main() end");
    }
}

실행결과

2024-07-18 10:10:01.192 [     main] main() start
2024-07-18 10:10:01.195 [ Thread-2] run() start
2024-07-18 10:10:01.195 [ Thread-5] run() start
2024-07-18 10:10:01.196 [ Thread-5] run() end
2024-07-18 10:10:01.196 [Thread-20] run() start
2024-07-18 10:10:01.195 [ Thread-4] run() start
2024-07-18 10:10:01.197 [Thread-29] run() start
2024-07-18 10:10:01.195 [ Thread-8] run() start
...
2024-07-18 10:10:01.218 [Thread-98] run() end

 

실행 결과는 그때마다 다르고 스레드의 실행 순서는 보장되지 않는다!

 

Runnable을 만드는 다양한 방법

중첩 클래스 사용하면 Runnable을 더 편리하게 만들 수 있다.

InnerRunnableMainV1

package thread.start;

import static util.MyLogger.log;

public class InnerRunnableMainV1 {
    public static void main(String[] args) {
        log("main() start");
        MyRunnable myRunnable = new MyRunnable();
        Thread t1 = new Thread(myRunnable);
        t1.start();
        log("main() end");
    }

    static class MyRunnable implements Runnable {

        @Override
        public void run() {
            log("run() start");
            log("run() end");
        }
    }
}

실행결과 (실행 결과는 모두 같다)

2024-07-18 10:47:29.014 [     main] main() start
2024-07-18 10:47:29.017 [     main] main() end
2024-07-18 10:47:29.017 [ Thread-0] run() start
2024-07-18 10:47:29.018 [ Thread-0] run() end

 

익명 내부 클래스를 사용하면 더 편리하게 만들수 있다.

InnerRunnableMainV2

package thread.start;

import static util.MyLogger.log;

public class InnerRunnableMainV2 {
    public static void main(String[] args) {
        log("main() start");

        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                log("run() start");
                log("run() end");
            }
        });
        t1.start();
        log("main() end");
    }
}

 

람다를 사용하면 더더 편리하게 만들수도 있다.

InnerRunnableMainV3

package thread.start;

import static util.MyLogger.log;

public class InnerRunnableMainV3 {
    public static void main(String[] args) {
        log("main() start");

        Thread t1 = new Thread(() -> {
            log("run() start");
            log("run() end");
        });
        t1.start();
        log("main() end");
    }
}

 

 

정리

이렇게 여러 방법으로 Runnable을 만드는 방법을 알아보았다. 스레드를 어떻게 만들고 뭐로 만드는게 더 효율적인지를 배워본것이다.

다음 포스팅부턴 더 깊은 내용을 알아볼 예정이다.

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

멀티 스레드에 대해 공부할 시간이 왔다. 멀티 스레드에 대해 제대로 이해하려면 먼저 멀티 태스킹프로세스 같은 운영체제의 기본 개념들에 대해서 알아야 한다. 여기서는 멀티 스레드를 이해하기 위한 목적으로 최대한 단순하게 핵심 내용만 알아보겠다.

 

단일 프로그램 실행

만약, 프로그램을 2개 이상 동시에 실행한다고 가정해보자. 예를 들어서 음악 프로그램을 통해 음악을 들으면서, 동시에 워드 프로그램을 통해 문서를 작성하는 것이다. 여기서는 연산을 처리할 수 있는 CPU 코어가 1개만 있다고 가정하겠다.

 

 

프로그램 A의 코드 1 실행 시작

 

 

 

프로그램 A의 코드 2 실행 중

 

 

 

프로그램 A의 실행 완료

 

 

프로그램 B의 실행 시작

 

 

프로그램 A 완료 후 프로그램 B 완료

 

  • 프로그램 실행이란 프로그램을 구성하는 코드를 순서대로 CPU에서 연산(실행)하는 일이다.
  • 여기서 CPU 코어는 하나로 가정하므로, 한 번에 하나의 프로그램 코드만 실행할 수 있다.
  • 이때, 하나의 프로그램 안에 있는 코드를 모두 실행한 후에야 다른 프로그램의 코드를 실행할 수 있다면? 예를 들어 음악 프로그램이 끝난 후에야 워드 프로그램을 실행할 수 있다면 컴퓨터 사용자는 매우 답답할 것이다.
  • 실제로 초창기의 컴퓨터는 이처럼 한 번에 하나의 프로그램만 실행했다.
  • 이를 해결하기 위해 하나의 CPU 코어로 여러 프로그램을 동시에 실행하는 멀티 태스킹 기술이 등장했다.

멀티 태스킹

순서대로 촬영한 연속된 사진을 빠르게 교차해서 보여줄 경우 사람은 이를 움직이는 영상으로 인지한다. 애니메이션이 바로 이 원리이다.

현대의 CPU는 초당 수십억번 이상의 연산을 수행한다. 쉽게 이야기해서 초당 수십억 장의 사진이 빠르게 교차되는 것이다.

 

만약, CPU가 매우 빠르게 두 프로그램의 코드를 번갈아 수행한다면, 사람이 느낄 때 두 프로그램이 동시에 실행되는 것처럼 느낄 것이다. (대략 0.01초 단위로 돌아가며 실행한다)

 

 

프로그램 A의 코드 1 수행

 

 

프로그램 B의 코드 1 수행

 

(이 연산이 0.01초만에 빠르게 프로그램A와 B를 한번씩 실행한다고 생각해보자. 사람은 느낄 수 없다)

 

 

프로그램 수행 완료

이 방식은 CPU 코어가 프로그램 A의 코드를 0.01초 정도 수행하다가 잠시 멈추고, 프로그램 B의 코드를 0.01초 정도 수행한다. 그리고 다시 프로그램 A의 이전에 실행중인 코드로 돌아가서 0.01초 정도 코드를 수행하는 방식으로 반복 동작한다.

 

이렇게 각 프로그램의 실행 시간을 분할해서 마치 동시에 실행되는 것처럼 하는 기법을 시분할(Time Sharing) 기법이라고 한다.

이런 방식을 사용하면 CPU 코어가 하나만 있어도 여러 프로그램이 동시에 실행되는 것처럼 느낄 수 있다.

 

이렇게 하나의 컴퓨터 시스템이 동시에 여러 작업을 수행하는 능력을 멀티태스킹이라 한다.

참고로, CPU에 어떤 프로그램이 얼마만큼 실행될지는 운영체제가 결정하는데 이것을 스케쥴링이라고 한다. 이때 단순히 시간으로만 작업을 분할하지는 않고, CPU를 최대한 활용할 수 있는 다양한 우선순위와 최적화 기법을 사용한다. 우리는 운영체제가 스케쥴링을 수행하고, CPU를 최대한 사용하면서 작업이 골고루 수행될 수 있게 최적화한다는 정도로 이해하면 충분하다. 

 

멀티 프로세싱

CPU 코어가 둘 이상이면 어떻게 될까? 여기서 프로그램은 A, B, C 세가지이고 CPU 코어는 2개이다.

참고로, CPU 안에는 실제 연산을 처리할 수 있는 코어라는 것이 있다. 과거에는 하나의 CPU 안에 보통 하나의 코어만 들어있었다. 그래서 CPU와 코어를 따로 분리해서 이야기하지 않았다. 최근에는 하나의 CPU 안에 보통 2개 이상의 코어가 들어있다.

 

 

프로그램 A, 프로그램 B 실행

 

 

프로그램 A, 프로그램 C 실행

 

  • CPU 코어가 2개이므로 물리적으로 동시에 2개의 프로그램을 처리할 수 있다. 
  • 위 그림에서는 먼저 A와 B를 수행하고 그 다음 B를 잠시 멈추고 C를 수행한다. 

그러니까 쉽게 말해, 한 개가 3개를 처리하는 것보다 두 개가 3개를 처리하면 더 빨리 일이 끝난다.

멀티 프로세싱은 컴퓨터 시스템에서 둘 이상의 프로세서(CPU 코어)를 사용하여 여러 작업을 동시에 처리하는 기술을 의미한다. 멀티 프로세싱 시스템은 하나의 CPU 코어만을 사용하는 시스템보다 동시에 더 많은 작업을 처리할 수 있다.

 

멀티 프로세싱과 멀티 태스킹

멀티 프로세싱은 하드웨어 장비의 관점(둘 이상의 CPU 코어)이고, 멀티 태스킹은 운영체제 소프트웨어의 관점(하나의 CPU 코어라고 할지라도 여러개의 소프트웨어의 연산을 수행)이다.

 

멀티 프로세싱

  • 여러 CPU(여러 CPU 코어)를 사용하여 동시에 여러 작업을 수행하는 것을 의미한다.
  • 하드웨어 기반으로 성능을 향상시킨다.
  • 예) 다중 코어 프로세서를 사용하는 현대 컴퓨터 시스템

멀티 태스킹

  • 단일 CPU(단일 CPU 코어)가 여러 작업을 동시에 수행하는 것처럼 보이게 하는 것을 의미한다.
  • 소프트웨어 기반으로 CPU 시간을 분할하여 각 작업에 할당한다.

 

프로세스와 스레드

프로세스

  • 프로그램은 실제 실행하기 전까지는 단순한 파일에 불과하다.
  • 프로그램을 실행하면 프로세스가 만들어지고 프로그램이 실행된다.
  • 이렇게 운영체제 안에서 실행중인 프로그램을 프로세스라고 한다.
  • 프로세스는 실행 중인 프로그램의 인스턴스이다.

프로세스는 실행 중인 프로그램의 인스턴스이다. 각 프로세스는 독립적인 메모리 공간을 갖고 있으며, 운영체제에서 별도의 작업 단위로 분리해서 관리된다. (위 그림에서 프로세스A, 프로세스B는 분리된 상태)

 

각 프로세스는 별도의 메모리 공간을 갖고 있기 때문에 서로 간섭하지 않는다. 그리고 프로세스가 서로의 메모리에 직접 접근할 수 없다. 프로세스는 이렇듯 서로 격리되어 관리되기 때문에, 하나의 프로세스가 충돌해도 다른 프로세스에는 영향을 미치지 않는다. 쉽게 이야기해서 특정 프로세스(프로그램)에 심각한 문제가 발생하면 해당 프로세스만 종료되고, 다른 프로세스에 영향을 주지 않는다.

 

프로세스의 메모리 구성

  • 코드 섹션: 실행할 프로그램의 코드가 저장되는 부분
  • 데이터 섹션: 전역 변수 및 정적 변수가 저장되는 부분(그림에서 기타에 포함)
  • 힙 (Heap): 동적으로 할당되는 메모리 영역
  • 스택 (Stack): 메서드(함수) 호출 시 생성되는 지역 변수와 반환 주소가 저장되는 영역 (스레드에 포함)

스레드

프로세스는 하나 이상의 스레드를 반드시 포함한다.

스레드는 프로세스 내에서 실행되는 작업의 단위이다. 한 프로세스 내에서 여러 스레드가 존재할 수 있으며, 이들은 프로세스가 제공하는 동일한 메모리 공간을 공유한다. 스레드는 프로세스보다 단순하므로 생성 및 관리가 단순하고 가볍다.

 

스레드의 메모리 구성

  • 공유 메모리: 같은 프로세스의 코드 섹션, 데이터 섹션, 힙(메모리)은 프로세스 안의 모든 스레드가 공유한다.
  • 개별 스택: 각 스레드는 자신의 스택을 가지고 있다.

프로그램이 실행된다는 것은 어떤 의미일까?

프로그램을 실행하면 운영체제는 먼저 디스크에 있는 파일 덩어리인 프로그램을 메모리로 불러오면서 프로세스를 만든다. 그럼 만들어진 프로세스를 어떻게 실행할까? 프로그램이 실행된다는 것은 사실 프로세스 안에 있는 코드가 한 줄씩 실행되는 것이다. 코드는 보통 main()부터 시작해서 하나씩 순서대로 내려가면서 실행된다.

 

생각해보면 어떤 무언가가 코드를 하나씩 순서대로 실행하기 때문에 프로그램이 작동하고 계산도 하고 출력도 할 수 있다. 이 코드를 하나씩 실행하면서 내려가는 것의 정체가 무엇일까? 바로 스레드. 프로세스의 코드를 실행하는 흐름을 스레드라고 한다.

 

스레드는 프로세스 내에서 실행되는 작업의 단위이다. 한 프로세스 내에 하나의 스레드가 존재할 수 있고, 한 프로세스 내에 여러 스레드가 존재할 수도 있다. 그리고 스레드는 프로세스가 제공하는 동일한 메모리 공간을 공유한다.

 

  • 단일 스레드: 한 프로세스 내에 하나의 스레드만 존재
  • 멀티 스레드: 한 프로세스 내에 여러 스레드가 존재

하나의 프로세스 안에는 최소 하나의 스레드가 존재한다. 그래야 프로그램이 실행될 수 있다. 정리하면 프로세스는 실행 환경과 자원을 제공하는 컨테이너 역할을 하고, 스레드는 CPU를 사용해서 코드를 하나하나 실행한다.

 

멀티 스레드가 필요한 이유

"하나의 프로그램도 그 안에서 동시에 여러 작업이 필요하다."

  • 워드 프로그램으로 문서를 편집하면서, 문서가 자동으로 저장되고 맞춤법 검사도 동시에 수행된다.
  • 유튜브는 영상을 보는 동안, 댓글도 달 수 있다.

운영체제 관점에서 보면 다음과 같이 구분할 수 있다.

  • 워드 프로그램 - 프로세스 A
    • 스레드 1: 문서 편집
    • 스레드 2: 자동 저장
    • 스레드 3: 맞춤법 검사
  • 유튜브 - 프로세스 B
    • 스레드 1: 영상 재생
    • 스레드 2: 댓글

스레드와 스케쥴링

앞서 멀티 태스킹에서 설명한 운영체제의 스케쥴링 과정을 더 자세히 알아보자.

CPU 코어는 한개이고, 프로세스는 2개이다. 프로세스 A는 스레드 1개, 프로세스 B는 스레드 2개가 있다.

프로세스는 실행 환경과 자원을 제공하는 컨테이너 역할을 하고, 실제 CPU를 사용해서 코드를 하나하나 실행하는 것은 스레드이다. 

 

 

프로세스 A에 있는 스레드 A1을 실행한다.

 

 

프로세스 A에 있는 스레드 A1의 실행을 잠시 멈추고, 프로세스 B에 있는 스레드 B1을 실행한다.

 

 

프로세스 B에 있는 스레드 B1의 실행을 잠시 멈추고 같은 프로세스의 스레드 B2를 실행한다.

다시 첫번째 과정으로 돌아가고 이 과정을 반복한다.

 

단일 코어 스케쥴링

 

스레드 A1, 스레드 B1, 스레드 B2가 스케쥴링 큐에 대기한다.

 

 

 

 

운영체제는 스레드 A1을 큐에서 꺼내고 CPU를 통해 실행한다. 이때, 스레드 A1이 프로그램의 코드를 수행하고, CPU를 통한 연산도 일어난다.

 

 

운영체제는 스레드 A1을 잠시 멈추고, 스케쥴링 큐에 다시 넣는다.

 

 

운영체제는 스레드 B1을 큐에서 꺼내고 CPU를 통해 실행한다.

이 과정을 반복해서 수행한다. 

 

멀티 코어 스케쥴링

CPU 코어가 2개 이상이면, 한번에 더 많은 스레드를 물리적으로 진짜 동시에 실행할 수 있다.

 

 

스레드 A1, 스레드 B1, 스레드 B2가 스케쥴링 큐에 대기한다.

 

 

 

스레드 A1, 스레드 B1을 병렬로 실행한다. 스레드 B2는 스케쥴링 큐에 대기한다.

 

 

스레드 A1의 수행을 잠시 멈추고, 스레드 A1을 스케쥴링 큐에 다시 넣는다.

 

 

스케쥴링 큐에 대기중인 스레드 B1을 CPU 코어 1에서 실행한다. 물론 조금 있다가 CPU 코어 2에서 실행중인 스레드 B2도 수행을 멈추고, 스레드 스케쥴링에 큐에 있는 다른 스레드가 실행될 것이다.

 

이 과정을 반복해서 수행한다.

 

프로세스, 스레드와 스케쥴링 정리

멀티 태스킹과 스케쥴링

  • 멀티 태스킹이란 동시에 여러 작업을 수행하는 것을 말한다. 이를 위해 운영체제는 스케쥴링이라는 기법을 사용한다. 스케쥴링은 CPU 시간을 여러 작업에 나누어 배분하는 방법이다.
  • 위 그림에서 스케쥴링 큐에 여러 스레드의 작업이 있고 그게 번갈아가면서 CPU 코어1, 코어2에서 실행된다. 그리고 하나의 CPU 코어가 한 작업을 전부 끝내고 다음 작업을 하는게 아니라 번갈아가면서 조금씩 조금씩 해나간다. 

 

프로세스와 스레드

  • 프로세스는 실행중인 프로그램의 인스턴스이다. 각 프로세스는 독립적인 메모리 공간을 가지며, 운영체제에서 독립된 실행 단위로 취급된다.
  • 스레드는 프로세스 내에서 실행되는 작은 단위이다. 여러 스레드는 하나의 프로세스 내에서 자원을 공유하며, 프로세스의 코드, 데이터, 시스템 자원등을 공유한다. 실제로 CPU에 의해 실행되는 단위는 스레드이다.

 

프로세스의 역할

  • 프로세스는 실행 환경을 제공한다. 여기에는 메모리 공간, 파일 핸들, 시스템 자원(네트워크 연결)등이 포함된다. 이는 프로세스가 컨테이너 역할을 한다는 의미이다.
  • 프로세스 자체는 운영체제의 스케쥴러에 의해 직접 실행되지 않으며, 프로세스 내의 스레드가 실행된다. 참고로 1개의 프로세스 안에 하나의 스레드만 실행되는 경우도 있고, 1개의 프로세스 안에 여러 스레드가 실행되는 경우도 있다. 

컨텍스트 스위칭

멀티 태스킹이 반드시 효율적인 것만은 아니다.

비유를 하자면 사람이 프로그램 A를 개발하고 있는데 갑자기 기획자가 프로그램 B를 수정해달라고 한다. 프로그램 A의 개발을 멈추고, 프로그램 B를 수정한다고 하면 어찌저찌 수정을 다 하고 다시 프로그램 A를 개발하기 위해 돌아가는데 어디까지 개발을 했는지, 선언한 변수들에 들어가는 값들이 뭐였는지 등등 다시 기억해내기 쉽지 않다. 차라리 프로그램 A를 다 개발하고 끝난 후에 프로그램 B를 수정하는 게 전체 시간으로 보면 더 효율적일 수 있다.

 

컴퓨터의 멀티 태스킹

운영체제의 멀티 태스킹을 생각해보자. CPU 코어는 하나만 있다고 가정하자.

스레드 A, 스레드 B가 있다. 운영체제는 먼저 스레드 A를 실행한다. 멀티 태스킹을 해야하기 때문에 스레드 A를 계속 실행할 수 없다. 스레드 A를 잠시 멈추고, 스레드 B를 실행한다. 이후에 스레드 A로 그냥 돌아갈 수 없다. CPU에서 스레드를 실행하는데, 스레드의 A의 코드가 어디까지 수행되었는지 위치를 찾아야 한다. 그리고 계산하던 변수들의 값을 CPU에 다시 불러들여야 한다. 따라서 스레드 A를 멈추는 시점에 CPU에서 사용하던 이런 값들을 메모리에 저장해두어야 한다. 그리고 이후에 스레드 A를 다시 실행할 때 이 값들을 CPU에 다시 불러와야 한다.

 

이런 과정을 컨텍스트 스위칭이라고 한다.

컨텍스트 스위칭 과정에서 이전에 실행 중인 값을 메모리에 잠깐 저장하고, 이후에 다시 실행하는 시점에 저장한 값을 CPU에 다시 불러와야 한다. 결과적으로 컨텍스트 스위칭 과정에는 약간의 비용이 발생한다. 

 

멀티 스레드는 대부분 효율적이지만, 컨텍스트 스위칭 과정이 필요하므로 항상 효율적인 것은 아니다.

예를 들어 1 - 10000까지 더해야 한다고 가정해보자. 이 문제는 둘로 나눌 수 있다. 

  • 스레드 1: 1 - 5000 까지 더함
  • 스레드 2: 5001 - 10000 까지 더함
  • 마지막에 스레드 1의 결과와 스레드 2의 결과를 더함

CPU 코어가 2개

CPU 코어가 2개 있다면 스레드 1, 스레드 2로 나누어서 멀티 스레드로 병렬 처리하는게 효율적이다. 모든 CPU를 사용하므로 연산을 2배 빠르게 처리할 수 있다. (코어 1개당 스레드 1개로 남김없이 CPU를 사용)

 

CPU 코어가 1개

CPU 코어가 1개가 있는데, 스레드를 2개로 만들어서 연산하면 중간중간 컨텍스트 스위칭 비용이 발생한다. 운영체제 스케쥴링 방식에 따라 다르겠지만, 스레드 1을 1 - 1000 까지 연산한 상태에서 잠시 멈추고 스레드 2를 5001 - 6001 까지 연산하는 식으로 반복할 수 있다. 이때 CPU는 스레드 1을 잠시 멈추고 다시 실행할 때 어디까지 연산했는지 알아야 하고, 그 값을 다시 CPU에 불러와야 한다. 결과적으로 이렇게 반복할 때 마다 컨텍스트 스위칭 비용이 든다. 연산 시간 + 컨텍스트 스위칭 시간이 곧 전체 비용이다. 

 

이런 경우 단일 스레드로 1 - 10000 까지 더하는 것이 컨텍스트 스위칭 비용 없이 연산 시간만 사용하기 때문에 더 효율적이다.

물론, 예를 이렇게 들었지만 실제로 컨텍스트 스위칭에 걸리는 시간은 아주 짧다. 그러나 스레드가 매우 많다면 이 비용이 커질 수 있다.

 

 

실무 이야기

CPU 4개, 스레드 2개

스레드의 숫자가 너무 적으면 모든 CPU를 100% 다 활용할 수 없지만, 스레드가 몇 개 없으므로 컨텍스트 스위칭 비용이 줄어든다.

 

CPU 4개, 스레드 100개

스레드의 숫자가 너무 많으면 CPU를 100% 다 활용할 수 있지만 컨텍스트 스위칭 비용이 늘어난다.

 

CPU 4개, 스레드 4개

스레드의 숫자를 CPU의 숫자와 맞춘다면 CPU를 100% 활용할 수 있고, 컨텍스트 스위칭 비용도 자주 발생하지 않기 때문에 최적의 상태가 된다. 이상적으로는 CPU 코어 수 + 1개 정도로 스레드를 맞추면 특정 스레드가 잠시 대기할 때 남은 스레드를 활용할 수 있다.

 

CPU 바운드 작업 VS I/O 바운드 작업

각각의 스레드가 하는 작업은 크게 2가지로 구분할 수 있다.

  • CPU 바운드 작업
    • CPU의 연산 능력을 많이 요구하는 작업을 의미한다.
    • 이러한 작업은 주로 계산, 데이터 처리, 알고리즘 실행 등 CPU의 처리 속도가 작업 완료 시간을 결정하는 경우다.
    • 예시: 복잡한 수학 연산, 데이터 분석, 비디오 인코딩 등
  • I/O 바운드 작업
    • 디스크, 네트워크, 파일 시스템등과 같은 입출력(I/O) 작업을 많이 요구하는 작업을 의미한다.
    • 이러한 작업은 I/O 작업이 완료될 때까지 대기 시간이 많이 발생하며, CPU는 상대적으로 유휴(대기)상태에 있는 경우가 많다. 쉽게 이야기해서 스레드가 CPU를 사용하지 않고 I/O 작업이 완료될 때까지 대기한다.
    • 예시: 데이터베이스 쿼리 처리, 파일 읽기/쓰기, 네트워크 통신, 사용자 입력 처리 등

웹 애플리케이션 서버

분야마다 다르겠지만 실무에서는 CPU 바운드 작업보다는 I/O 바운드 작업이 많다.

예를 들어 백엔드 개발자의 경우 주로 웹 애플리케이션 서버를 개발하는데 스레드가 1 -10000 까지 더하는 CPU의 연산이 필요한 작업보다는 대부분 사용자의 입력을 기다리거나, 데이터베이스를 호출하고 그 결과를 기다리는 등 기다리는 일이 더 많다. 쉽게 이야기해서 스레드가 CPU를 많이 사용하지 않는 I/O 바운드 작업이 많다는 뜻이다.

 

일반적인 자바 웹 애플리케이션 서버의 경우, 사용자의 요청 하나를 처리하는데 1개의 스레드가 필요하다. 사용자 4명이 동시에 요청하면 4개의 스레드가 작동하는 것이다. 그래야 4명의 사용자의 요청을 동시에 처리할 수 있다.

사용자의 요청을 하나 처리하는데 스레드는 CPU 1% 정도를 사용하고, 대부분 데이터베이스 서버에 어떤 결과를 조회하면서 기다린다고 가정하자. 이때는 스레드는 CPU를 거의 사용하지 않고 대기한다. 바로 I/O 바운드 작업이 많다는 것이다.

 

이 경우 CPU 코어가 4개 있다고 해서 스레드 숫자도 CPU 코어에 맞추어 4개로 설정하면 안된다! 그러면 동시에 4명의 사용자 요청만 처리할 수 있다. 이때 CPU는 단순하게 계산해서 4% 정도만 사용할 것이다. 결국 사용자는 동시에 4명밖에 못받지만 CPU는 4%만을 사용하며 CPU가 놀고 있는 사태, 낭비되는 사태가 발생할 수 있다. 

 

사용자의 요청 하나를 처리하는데 CPU를 1%만 사용한다면, 단순하게 생각해도 100개의 스레드를 만들 수 있다. 이렇게 하면 동시에 100명의 사용자 요청을 받을 수 있다. 물론 실무에서는 성능 테스트를 통해서 최적의 스레드 숫자를 찾는것이 이상적이다.

 

결국 스레드 숫자만 늘리면 되는데 이런 부분을 잘 이해하지 못해서 서버 장비에 문제가 있다고 생각하고 2배 더 좋은 장비로 구매하는 사태가 발생하기도 한다! 이렇게 되면 CPU는 4%의 절반인 2%만 사용하고(장비가 2배 더 좋아졌으니) 사용자는 여전히 동시에 4명밖에 받지 못하는 사태가 벌어진다. 

 

정리하면 스레드의 숫자는 CPU-바운드 작업이 많은가, 아니면 I/O 바운드 작업이 많은가에 따라 다르게 설정해야 한다.

  • CPU 바운드 작업이 많다면: CPU 코어 수 + 1개
    • CPU를 거의 100% 사용하는 작업이므로, 스레드를 CPU 숫자에 최적화
  • I/O 바운드 작업이 많다면: CPU 코어수보다 많은 스레드를 생성, CPU를 최대한 사용할 수 있는 숫자까지 스레드 생성
    • CPU를 많이 사용하지 않으므로 성능 테스트를 통해 CPU를 최대한 활용하는 숫자까지 스레드 생성
    • 단 너무 많은 스레드를 생성하면 컨텍스트 스위칭 비용도 함께 증가 - 적절한 성능 테스트 필요

 

그래서 결론은, 적절한 스레드 숫자를 성능 테스트와 함께 내가 구축하고 있는 서버가 어떤 작업을 더 많이 하는가?를 고민해서 스레드 수를 만들어내야 한다.

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 중급 2편 | 김영한 - 인프런

김영한 | 자바 제네릭과 컬렉션 프레임워크를 실무 중심으로 깊이있게 학습합니다. 자료 구조에 대한 기본기도 함께 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전

www.inflearn.com

 

정렬

자료 구조에 저장된 데이터를 정렬하는 방법을 알아보자.

바로 예제를 살펴보자.

 

SortMain1

package org.example.collection.compare;

import java.util.Arrays;
import java.util.Comparator;

public class SortMain1 {
    public static void main(String[] args) {
        Integer[] array = {3, 2, 1};
        System.out.println(Arrays.toString(array));

        Arrays.sort(array);
        System.out.println(Arrays.toString(array));
    }
}

실행결과

[3, 2, 1]
[1, 2, 3]

 

Arrays.sort()를 사용하면 배열에 들어있는 데이터를 순서대로 정렬할 수 있다.

근데 반대로 1 - 2 - 33 - 2 - 1로 내림차순으로 정렬하고 싶을 때도 있을거다. 그럴땐 어떻게 하면 될까?

 

이때, 비교자(Comparator)를 사용하면 된다. 비교 기준을 직접 제공할 수가 있다.

public interface Comparator<T> {
	int compare(T o1, T o2);
}

두 인수를 비교해서 결과 값을 반환하면 된다.

이 아래 3문장이 제일중요하다!!

  • 음수를 반환한다면 첫번째 객체가 두번째 객체보다 앞에 와야함을 의미
  • 두 값이 같으면 0
  • 양수를 반환한다면 첫번째 객체가 두번째 객체보다 뒤에 와야함을 의미

Comparator는 음수를 반환하면 첫번째 인자를 더 작다고 판단하고 앞으로 보낸다. 그래서 오름차순으로 정렬하려고 한다면 다음과 같은 Comparator를 만들 수가 있다.

private static class AscComparator implements Comparator<Integer> {
    @Override
    public int compare(Integer o1, Integer o2) {
        return o1 < o2 ? -1 : o1 == o2 ? 0 : 1; //오름차순
    }
}

o1o2보다 작다면 -1을 리턴한다. 같으면 0을, 작지도 같지도 않다면(더 크다면) 1을 리턴한다.

즉, 첫번째 인자가 두번째 인자보다 작으면 -1을 리턴한다는 것은 첫번째 인자를 더 작다고 판단하고 앞으로 보내는 오름차순 정렬이라고 볼 수 있다.

 

그럼, 반대로 생각해보자. 더 작은걸 뒤로 보내는 내림차순의 경우 첫번째 인자가 두번째 인자보다 작을 때 1을 리턴하면 된다.

private static class DescComparator implements Comparator<Integer> {
    @Override
    public int compare(Integer o1, Integer o2) {
        return o1 < o2 ? 1 : o1 == o2 ? 0 : -1; //내림차순
    }
}

 

그래서 Comparator를 사용해서 Arrays.sort()를 사용하면 내가 원하는 정렬기준에 맞게 정렬할 수 있다.

package org.example.collection.compare;

import java.util.Arrays;
import java.util.Comparator;

public class SortMain1 {
    public static void main(String[] args) {
        Integer[] array = {3, 2, 1};
        System.out.println(Arrays.toString(array));

        Arrays.sort(array);
        System.out.println(Arrays.toString(array));

        Arrays.sort(array, new AscComparator());
        System.out.println("오름차순 정렬= " + Arrays.toString(array));

        Arrays.sort(array, new DescComparator());
        System.out.println("내림차순 정렬= " + Arrays.toString(array));
    }

    private static class AscComparator implements Comparator<Integer> {
        @Override
        public int compare(Integer o1, Integer o2) {
            return o1 < o2 ? -1 : o1 == o2 ? 0 : 1;
        }
    }

    private static class DescComparator implements Comparator<Integer> {
        @Override
        public int compare(Integer o1, Integer o2) {
            return o1 < o2 ? 1 : o1 == o2 ? 0 : -1;
        }
    }
}

실행결과

[3, 2, 1]
[1, 2, 3]
오름차순 정렬= [1, 2, 3]
내림차순 정렬= [3, 2, 1]

 

근데 Comparator는 편의 메서드인 reversed()가 있다. 다음과 같이 사용하면 된다.

말 그대로 정렬을 반대로 한것이라고 보면 된다.

package org.example.collection.compare;

import java.util.Arrays;
import java.util.Comparator;

public class SortMain1 {
    public static void main(String[] args) {
        Integer[] array = {3, 2, 1};

        Arrays.sort(array, new AscComparator().reversed());
        System.out.println("오름차순 정렬의 리버스(내림차순)= " + Arrays.toString(array));
    }

    private static class AscComparator implements Comparator<Integer> {
        @Override
        public int compare(Integer o1, Integer o2) {
            return o1 < o2 ? -1 : o1 == o2 ? 0 : 1;
        }
    }
}

실행결과

오름차순 정렬의 리버스(내림차순)= [3, 2, 1]

 

 

근데 여기서 의문이 한가지 생긴다. 자바가 제공하는 Integer, String같은 객체를 제외하고 내가 직접 만든 객체(User, Team 등)도 정렬하고 싶을수 있다. 이럴땐 어떻게 할까? 내가 만든 객체이기 때문에 정렬을 할 때 내가 만든 객체의 두 인스턴스 중에 어떤 인스턴스가 더 큰지 알려줄 방법이 있어야 한다. 이때는 내가 만든 객체가 Comparable 인터페이스를 구현하면 된다. 이 인터페이스는 이름 그대로 비교 가능한, 비교할 수 있는 이라는 뜻으로 객체에 비교 기능을 추가해준다.

 

Comparable을 구현한 클래스

다음과 같은 클래스를 직접 만들었다고 생각해보자.

MyUser

package org.example.collection.compare;

public class MyUser {
    private String id;
    private int age;

    public MyUser(String id, int age) {
        this.id = id;
        this.age = age;
    }

    public String getId() {
        return id;
    }

    public int getAge() {
        return age;
    }

    @Override
    public String toString() {
        return "MyUser{" +
                "id='" + id + '\'' +
                ", age=" + age +
                '}';
    }
}

 

이 클래스를 두고 나이 기준으로 정렬을 하고 싶으면 이 클래스가 Comparable을 구현하면 된다.

 

Comparable을 구현한 MyUser

package org.example.collection.compare;

public class MyUser implements Comparable<MyUser> {
    private String id;
    private int age;

    public MyUser(String id, int age) {
        this.id = id;
        this.age = age;
    }

    public String getId() {
        return id;
    }

    public int getAge() {
        return age;
    }

    @Override
    public String toString() {
        return "MyUser{" +
                "id='" + id + '\'' +
                ", age=" + age +
                '}';
    }

    @Override
    public int compareTo(MyUser o) {
        // 오름차순
        return this.age < o.age ? -1 : this.age > o.age ? 1 : 0;
    }
}

 

그럼 별도의 Comparator없이도 정렬이 가능하다. 내가 정렬 기준을 제시해줬기 때문에.

SortMain2 (Comparable을 구현한 클래스를 정렬할때)

package org.example.collection.compare;

import java.util.Arrays;
import java.util.Comparator;

public class SortMain2 {
    public static void main(String[] args) {
        MyUser myUser1 = new MyUser("a", 30);
        MyUser myUser2 = new MyUser("b", 20);
        MyUser myUser3 = new MyUser("c", 10);

        MyUser[] users = {myUser1, myUser2, myUser3};

        System.out.println("기본 데이터");
        System.out.println(Arrays.toString(users));

        System.out.println("나이 기준 오름차순 정렬 데이터");
        Arrays.sort(users); // MyUser 클래스가 Comparable을 구현한 경우
        System.out.println(Arrays.toString(users));
    }
}

실행결과

기본 데이터
[MyUser{id='a', age=30}, MyUser{id='b', age=20}, MyUser{id='c', age=10}]
나이 기준 오름차순 정렬 데이터
[MyUser{id='c', age=10}, MyUser{id='b', age=20}, MyUser{id='a', age=30}]

 

실행결과를 보니 나이 기준으로 오름차순 정렬이 됐다. 근데 꼭 이렇게 Comparable을 구현해야 할까? 아니다. 그럴 필요 없다. 그냥 두번째 인자에 나만의 Comparator를 던져주면된다.

 

SortMain2 (Comparable을 구현하지 않고 Comparator를 두번째 인자로 준 경우)

package org.example.collection.compare;

import java.util.Arrays;
import java.util.Comparator;

public class SortMain2 {
    public static void main(String[] args) {
        MyUser myUser1 = new MyUser("a", 30);
        MyUser myUser2 = new MyUser("b", 20);
        MyUser myUser3 = new MyUser("c", 10);

        MyUser[] users = {myUser1, myUser2, myUser3};

        Arrays.sort(users, Comparator.comparing(MyUser::getId)); // Comparable 구현이랑 상관없이, 직접 Comparator를 만든 경우
        System.out.println(Arrays.toString(users));
    }
}

두번째 인자로 Comparator를 람다 표현식의 익명 내부 클래스로 만들어서 던져줬다. (구현할 인터페이스가 딱 한개의 메서드만 가진 경우 익명 내부 클래스를 람다 표현식으로 작성할 수 있다)

 

참고로, 저 람다 표현식의 익명 내부 클래스는 이 클래스를 만든거랑 똑같다고 보면 된다.
package org.example.collection.compare;

import java.util.Comparator;

public class IdComparator implements Comparator<MyUser> {
    @Override
    public int compare(MyUser o1, MyUser o2) {
        return o1.getId().compareTo(o2.getId()); //오름차순
    }
}

그래서 저 위의 코드를 바꾸면 이렇게 되는거다.

Arrays.sort(users, new IdComparator());

 

 

보면 MyUser가 가진 `id`를 기준으로 오름차순 정렬을 한다. 자바에서 문자를 오름차순 정렬하기 편하게 compareTo()를 구현해놨고 그거를 그대로 사용한다고 보면 된다. 그리고 만약 내가 `id`를 기준으로 내림차순 정렬하고 싶으면? 이렇게 쓰면 된다.

익명 내부 클래스를 람다 표현식으로 사용한 방식
Arrays.sort(users, Comparator.comparing(MyUser::getId).reversed());
직접 클래스를 만든 방식
Arrays.sort(users, new IdComparator().reversed());

 

 

중요

그래서, 중요한건 내가 만든 클래스를 정렬할 때 그 클래스가 Comparable을 구현한 클래스라면 그 클래스의 compareTo() 메서드를 가지고 정렬을 한다. 그게 아니라면 두번째 인자로 Comparator를 던져주어 내 입맛에 맞게 정렬한다. 둘 다 아니라면? 런타임 에러가 발생한다.

 

 

List와 정렬

정렬은 배열 뿐만 아니라 순서가 있는 List 같은 자료 구조에도 사용할 수 있다.

 

SortMain3

package org.example.collection.compare;

import java.util.Comparator;
import java.util.LinkedList;
import java.util.List;

public class SortMain3 {
    public static void main(String[] args) {
        MyUser myUser1 = new MyUser("a", 30);
        MyUser myUser2 = new MyUser("b", 20);
        MyUser myUser3 = new MyUser("c", 10);

        List<MyUser> users = new LinkedList<>();
        users.add(myUser1);
        users.add(myUser2);
        users.add(myUser3);

        System.out.println("기본 데이터");
        System.out.println(users);

        System.out.println("Comparable 기본 정렬");
        users.sort(null); // Comparator를 만들어서 넣어주는게 아니고 Comparable 구현한 그대로로 정렬한다는 뜻
        System.out.println(users);

        System.out.println("Comparator로 정렬");
        users.sort(Comparator.comparing(MyUser::getId)); // 첫번째 방법: 람다 익명 내부 클래스
        users.sort(new IdComparator()); // 두번째 방법: Comparator를 구현한 클래스
        System.out.println(users);
    }
}

실행결과

기본 데이터
[MyUser{id='a', age=30}, MyUser{id='b', age=20}, MyUser{id='c', age=10}]
Comparable 기본 정렬
[MyUser{id='c', age=10}, MyUser{id='b', age=20}, MyUser{id='a', age=30}]
Comparator로 정렬
[MyUser{id='a', age=30}, MyUser{id='b', age=20}, MyUser{id='c', age=10}]

 

배열과 다르지 않다. Comparable을 구현한 클래스이고 그 정렬 기준 그대로를 사용한다면 List가 자체적으로 가지고 있는 sort() 메서드에 아무것도 넣지 않는다는 의미로 `null`을 넣으면 된다. 

 

그게 아니라 내가 원하는 방식의 다른 정렬을 원한다면 Comparator를 직접 만들어 넣으면 된다.

 

근데 저 방법 말고 다른 방법이 하나 더 있긴한데 이건 권장되지 않고 저 방식대로 사용하면 된다. 알기는 알아야 하니까 작성하자면,

Collections.sort(users);
Collections.sort(users, new IdComparator());
Collections.sort(users, Comparator.comparing(MyUser::getId));

 

Collections를 사용해도 된다. 근데 보면 알겠지만 이런 `Warning` 문구를 보여준다.

 

 

Tree와 정렬

TreeSet과 같은 이진 탐색 트리 구조는 데이터를 보관할 때 데이터를 정렬하면서 보관한다.

이렇게 생긴 이진 탐색 트리가 있다고 하면, 내가 여기에 6을 넣으려고 하면 아무곳에 넣을 수 있는게 아니라 정렬해서 6의 정확한 위치가 들어가야 한다. 이진 탐색 트리를 만족해야 하니까. (현재 노드를 기준으로 왼쪽은 무조건 더 작게, 오른쪽은 무조건 더 크게)

 

그 말은 TreeSet, TreeMap같은 자료 구조는 Comparable이나 Comparator가 필수이다.

 

SortMain4

package org.example.collection.compare;

import java.util.TreeSet;

public class SortMain4 {
    public static void main(String[] args) {
        MyUser myUser1 = new MyUser("a", 30);
        MyUser myUser2 = new MyUser("b", 20);
        MyUser myUser3 = new MyUser("c", 10);

        TreeSet<MyUser> treeSet = new TreeSet<>();
        treeSet.add(myUser1);
        treeSet.add(myUser2);
        treeSet.add(myUser3);

        System.out.println("Comparable 기본 정렬");
        System.out.println(treeSet);
    }
}

MyUserComparable을 구현한 클래스이다. 그래서 TreeSet의 요소 타입으로 받아들일 수 있다. 그냥 트리에 데이터를 넣는순간 정렬이 된다. 진짜 그런지 실행결과를 보자.

실행결과

Comparable 기본 정렬
[MyUser{id='c', age=10}, MyUser{id='b', age=20}, MyUser{id='a', age=30}]

알아서 나이의 오름차순(MyUser에서 구현한 정렬방식)으로 찍히는걸 볼 수 있다. 

 

그럼 Comparable을 구현한 클래스가 아니라면 Comparator를 반드시 줘야 하는데 어떻게 주면 될까?

TreeSet<MyUser> treeSet2 = new TreeSet<>(new IdComparator());

이렇게 생성자에 Comparator를 넘겨주면 된다.

 

그래서 결론은 자바에서 트리 구조는 반드시 Comparable이나 Comparator가 필수라는 사실. 진짜 그런지 궁금한데 한번 해보자.

우선 MyUserComparable을 구현한 클래스가 아니게 바꿨다.

 

MyUser (Comparable 구현 안 함)

package org.example.collection.compare;

public class MyUser {
    private String id;
    private int age;

    public MyUser(String id, int age) {
        this.id = id;
        this.age = age;
    }

    public String getId() {
        return id;
    }

    public int getAge() {
        return age;
    }

    @Override
    public String toString() {
        return "MyUser{" +
                "id='" + id + '\'' +
                ", age=" + age +
                '}';
    }
}

 

그랬더니 벌써부터 경고문구가 보인다.

 

실행해보면? 런타임 에러가 발생한다.

 

 

결론

Integer, String 뿐 아니라 내가 직접 만든 클래스 또한 Array, List, Tree 자료구조에서 모두 내가 원하는 방식으로 정렬을 할 수 있다. 크게 두 가지 방법이 있다.

  • Comparable을 구현한 클래스이면 된다.
  • 구현하지 않았어도 Comparator를 제공해주면 된다.

 

728x90
반응형
LIST

+ Recent posts