자바 동시성 제어: CountDownLatch 심층 이해

CountDownLatch란 무엇인가?

CountDownLatch는 자바의 동시성 유틸리티 중 하나로, 하나 이상의 스레드가 특정 작업을 완료할 때까지 다른 스레드(주로 메인 스레드)가 기다리도록 만드는 데 사용됩니다. 이는 내부적으로 카운터 값을 관리하여 동작합니다. 이 카운터는 CountDownLatch 객체 생성 시 초기화되며, 특정 이벤트가 발생할 때마다 카운터 값이 1씩 감소합니다. 카운터가 0이 되면, 대기 중이던 스레드들이 작업을 재개할 수 있게 됩니다.

CountDownLatch의 동작 원리

CountDownLatchjava.util.concurrent 패키지에 포함되어 있으며, 그 이름처럼 '세고(count)' '문을 잠그는(latch)' 방식의 동기화를 제공합니다. 핵심 메서드는 다음과 같습니다:

  • CountDownLatch(int count): 지정된 초기 카운터 값으로 CountDownLatch를 생성합니다. 이 값은 스레드가 기다려야 할 이벤트의 총 개수를 의미합니다.
  • await(): 현재 스레드를 대기 상태로 전환합니다. 카운터가 0이 될 때까지 스레드는 블록됩니다.
  • countDown(): 카운터 값을 1 감소시킵니다. 카운터가 0이 되면 await() 호출로 대기 중인 모든 스레드를 해제합니다.

CountDownLatch는 한 번 사용하고 나면 재사용할 수 없다는 중요한 특징이 있습니다. 즉, 카운터가 0이 되면 그 CountDownLatch 인스턴스는 더 이상 유효하지 않습니다.

일반적인 활용 사례

CountDownLatch는 다양한 동시성 시나리오에서 유용하게 사용될 수 있습니다. 대표적인 두 가지 유형을 살펴보겠습니다.

1. 동시성 시작 지점 조율 (Start Synchronization)

여러 스레드가 동시에 작업을 시작해야 할 때 CountDownLatch를 사용하여 모든 스레드가 준비될 때까지 기다렸다가 한 번에 작업을 시작하도록 조율할 수 있습니다. 예를 들어, 성능 테스트 시 여러 클라이언트 스레드를 동시에 시작하여 실제 동시성 환경을 시뮬레이션하는 경우에 활용할 수 있습니다.

import java.util.concurrent.CountDownLatch;
import java.util.stream.IntStream;

public class ConcurrencyStarter {
    public static void main(String[] args) throws InterruptedException {
        int numberOfWorkers = 5;
        // 카운터가 1인 CountDownLatch를 생성하여, 한 번의 countDown()으로 모든 스레드를 해제합니다.
        CountDownLatch startSignal = new CountDownLatch(1);
        
        System.out.println("작업 스레드 준비 중...");

        IntStream.range(0, numberOfWorkers).forEach(workerId -> {
            new Thread(() -> {
                try {
                    System.out.println("작업자 " + workerId + ": 시작 신호 대기 중...");
                    startSignal.await(); // 시작 신호가 올 때까지 대기
                    System.out.println("작업자 " + workerId + ": 신호 수신, 작업 시작!");
                    // 실제 작업 수행 로직...
                    Thread.sleep(100 + (long)(Math.random() * 500)); // 짧은 작업 시뮬레이션
                    System.out.println("작업자 " + workerId + ": 작업 완료.");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    System.err.println("작업자 " + workerId + " 스레드 인터럽트 발생.");
                }
            }, "Worker-" + workerId).start(); // 스레드 이름 설정
        });

        // 모든 작업 스레드가 await()에 도달할 시간을 줍니다.
        Thread.sleep(2000); 
        System.out.println("\n메인 스레드: 모든 작업자에게 시작 신호 전송!");
        startSignal.countDown(); // 모든 대기 스레드 해제

        // 모든 작업이 시작될 시간을 줍니다.
        Thread.sleep(1000); 
        System.out.println("모든 작업자에게 시작 신호가 전달되었습니다. 메인 스레드 종료.");
    }
}

2. 하위 작업 완료 대기 및 결과 통합 (Completion Waiting and Aggregation)

메인 스레드가 여러 하위 스레드에 작업을 분배하고, 모든 하위 스레드가 작업을 완료할 때까지 기다린 후 최종 결과를 통합하는 시나리오에 적합합니다. 예를 들어, 대규모 데이터를 병렬 처리하거나, 웹 페이지 로딩 시 여러 컴포넌트의 데이터를 비동기로 가져와 통합하여 사용자에게 보여주는 경우에 활용할 수 있습니다.

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.IntStream;

public class TaskCoordinator {
    public static void main(String[] args) throws InterruptedException {
        int numberOfTasks = 4;
        // numberOfTasks 만큼 카운터를 설정하여, 모든 하위 작업이 완료될 때까지 기다립니다.
        CountDownLatch completionLatch = new CountDownLatch(numberOfTasks);
        
        System.out.println("메인 스레드: " + numberOfTasks + "개의 하위 작업을 시작합니다.");

        IntStream.range(0, numberOfTasks).forEach(taskId -> {
            new Thread(() -> {
                try {
                    System.out.println("작업 #" + taskId + ": 시작.");
                    // 작업별로 다른 시간 소요 시뮬레이션
                    Thread.sleep(500 + ThreadLocalRandom.current().nextInt(1500)); 
                    System.out.println("작업 #" + taskId + ": 완료.");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    System.err.println("작업 #" + taskId + " 스레드 인터럽트 발생.");
                } finally {
                    completionLatch.countDown(); // 작업 완료 시 카운터 감소
                }
            }, "Task-Executor-" + taskId).start();
        });

        System.out.println("\n메인 스레드: 모든 하위 작업 완료 대기 중...");
        completionLatch.await(); // 카운터가 0이 될 때까지 메인 스레드 대기

        System.out.println("메인 스레드: 모든 하위 작업이 완료되었습니다. 결과 통합을 시작합니다.");
        // 모든 하위 작업이 완료된 후 수행할 추가 작업 (예: 결과 취합, 데이터 처리)
    }
}

CountDownLatch의 내부 구현

CountDownLatch는 자바 동시성 API의 근간이 되는 AbstractQueuedSynchronizer (AQS)를 기반으로 구현됩니다. AQS의 state 필드를 카운터로 사용하며, 이 state 필드는 CountDownLatch 생성 시 지정된 초기값으로 설정됩니다.

  • countDown() 메서드는 AQS의 releaseShared(int arg) 메서드를 호출하며, arg 값은 항상 1입니다.
  • releaseShared(int arg)는 내부적으로 tryReleaseShared(int releases)를 호출하여 state 값을 원자적으로 감소시킵니다.
  • tryReleaseShared 메서드는 state 값이 0이 되는 순간 true를 반환하고, 이 경우 AQS는 doReleaseShared()를 호출하여 await() 호출로 대기 중이던 모든 스레드를 깨웁니다.

AQS의 공유 모드(Shared Mode)를 활용하여 여러 스레드가 동시에 해제될 수 있도록 합니다.

// AQS의 releaseShared 메서드 (간략화된 형태)
public final boolean releaseShared(int arg) {
    // tryReleaseShared는 CountDownLatch의 내부 클래스(Sync)에서 오버라이드 됨
    if (tryReleaseShared(arg)) {
        // 성공적으로 state가 0이 되어 릴리즈 가능하면 대기 중인 스레드를 깨움
        doReleaseShared();
        return true;
    }
    return false;
}

// CountDownLatch 내부 Sync 클래스의 tryReleaseShared 구현 (간략화된 형태)
protected boolean tryReleaseShared(int releases) {
    // state 값을 원자적으로 감소시키고, 0이 되면 true 반환
    for (;;) {
        int currentCount = getState(); // 현재 카운터 값 조회
        if (currentCount == 0) // 이미 0이면 더 이상 감소할 수 없음
            return false;
        int nextCount = currentCount - 1; // 카운터 감소
        // CAS (Compare And Swap) 연산을 통해 state 값 업데이트 시도
        if (compareAndSetState(currentCount, nextCount)) 
            return nextCount == 0; // 감소 후 카운터가 0이 되었는지 확인
    }
}

이러한 메커니즘을 통해 CountDownLatch는 스레드 간의 정확하고 효율적인 동기화를 보장합니다.

CountDownLatch의 주요 특징 및 고려사항

  • 재사용 불가능: CountDownLatch의 카운터는 한 번 0이 되면 다시 초기화하거나 값을 변경할 수 없습니다. 따라서 여러 번 동기화가 필요한 경우에는 CyclicBarrier와 같은 다른 동시성 유틸리티를 고려해야 합니다.
  • 유연한 대기: await() 메서드 외에도 await(long timeout, TimeUnit unit)을 사용하여 타임아웃을 설정할 수 있습니다. 이는 특정 시간 내에 모든 작업이 완료되지 않으면 대기를 포기하고 다음 로직으로 진행할 때 유용합니다.
  • Thread.join()과의 비교: CountDownLatch는 특정 스레드들이 완료될 때까지 기다린다는 점에서 Thread.join()과 유사해 보일 수 있습니다. 하지만 CountDownLatch는 더 유연한 제어를 제공합니다. join()은 대상 스레드의 종료 여부만 기다리는 반면, CountDownLatch는 여러 스레드가 특정 "이벤트"를 완료했을 때를 기준으로 대기할 수 있으며, 여러 스레드가 동시에 한 신호를 기다리거나, 한 스레드가 여러 신호를 기다릴 수 있는 등 다양한 동기화 패턴을 구현할 수 있습니다. 또한, join()은 스레드의 활성 상태를 지속적으로 확인하는 방식으로 동작하는 반면, CountDownLatch는 AQS의 대기 큐를 통해 효율적으로 스레드를 관리합니다.

태그: java CountDownLatch concurrency ThreadSynchronization AQS

7월 24일 16:51에 게시됨