C++20 counting_semaphore·binary_semaphore: 동시 사용 수 제한과 스레드 간 신호

이 글의 핵심

뮤텍스는 한 번에 하나의 스레드만 들여보내고 잠근 스레드가 풀어야 하지만, 세마포어는 N개까지 허용하고 다른 스레드가 release할 수도 있다는 점이 핵심 차이입니다. 이 유연함 때문에 예외가 나서 release가 빠지면 슬롯이 영구히 줄어드는 버그가 생기기 쉬워, 예외 경로에서도 release를 보장하는 구조가 필요합니다. 언제 뮤텍스 대신 세마포어를 쓸지와 latch, barrier와의 차이도 정리했습니다.

세마포어란?

세마포어는 “지금 몇 개가 남아 있는가”를 세는 정수 카운터에 대기 기능을 붙인 동기화 도구입니다. acquire()는 카운트가 0보다 크면 1 줄이고 바로 돌아오며, 0이면 누군가 release()로 카운트를 올려 줄 때까지 스레드를 재웁니다. C++20에서 <semaphore> 헤더로 표준에 들어왔고, 그 전에는 POSIX sem_t나 Win32 CreateSemaphore, 혹은 mutex + condition_variable 조합으로 직접 만들어 써야 했습니다.

#include <semaphore>

// 최대 3개 스레드
std::counting_semaphore<3> sem(3);

void worker() {
    sem.acquire();  // 카운트 감소
    // 작업
    sem.release();  // 카운트 증가
}

counting_semaphore

// 최대 카운트 지정
std::counting_semaphore<10> sem(5);  // 초기값 5

sem.acquire();  // 5 -> 4
sem.release();  // 4 -> 5

템플릿 인자(10)와 생성자 인자(5)는 서로 다른 의미입니다. 템플릿 인자는 “카운트가 최대 얼마까지 올라갈 수 있어야 하는가”라는 컴파일 타임 상한이고, 생성자 인자는 시작 카운트입니다. 구현체는 이 상한을 보고 내부 카운터 크기나 futex 기반 구현 여부를 고릅니다. 카운트가 max()를 넘도록 release()하는 것은 정의되지 않은 동작이라, 예외 경로에서 release()를 두 번 부르는 실수가 조용한 버그가 될 수 있습니다.

binary_semaphore

// 0 또는 1
std::binary_semaphore sem(1);

sem.acquire();  // 1 -> 0
// ...
sem.release();  // 0 -> 1

실전 예시

예시 1: 자원 풀

10개의 스레드를 만들지만 동시에 “시작”과 “완료” 사이에 있는 스레드는 최대 3개입니다. 나머지 7개는 acquire()에서 잠들어 있다가 앞선 스레드가 release()할 때마다 하나씩 깨어납니다. 어떤 스레드가 먼저 깨어날지는 표준이 보장하지 않으므로(FIFO가 아님), 출력 순서는 실행할 때마다 달라집니다. 순서 공정성이 필요하다면 세마포어가 아니라 명시적인 작업 큐가 필요합니다.

#include <chrono>
#include <iostream>
#include <semaphore>
#include <thread>
#include <vector>

std::counting_semaphore<3> poolSem(3);  // 최대 3개

void worker(int id) {
    poolSem.acquire();
    std::cout << "스레드 " << id << " 시작" << std::endl;
    
    std::this_thread::sleep_for(std::chrono::seconds(1));
    
    std::cout << "스레드 " << id << " 완료" << std::endl;
    poolSem.release();
}

int main() {
    std::vector<std::thread> threads;
    
    for (int i = 0; i < 10; i++) {
        threads.emplace_back(worker, i);
    }
    
    for (auto& t : threads) {
        t.join();
    }
}

예시 2: Producer-Consumer

std::counting_semaphore<10> empty(10);  // 빈 슬롯
std::counting_semaphore<10> full(0);    // 찬 슬롯
std::mutex mtx;
std::queue<int> buffer;

void producer() {
    for (int i = 0; i < 20; i++) {
        empty.acquire();
        
        {
            std::lock_guard lock(mtx);
            buffer.push(i);
        }
        
        full.release();
    }
}

void consumer() {
    for (int i = 0; i < 20; i++) {
        full.acquire();
        
        int value;
        {
            std::lock_guard lock(mtx);
            value = buffer.front();
            buffer.pop();
        }
        
        empty.release();
        std::cout << value << std::endl;
    }
}

세마포어 두 개와 뮤텍스 하나가 각자 다른 일을 맡습니다. empty는 “생산자가 더 넣을 자리가 있는가”, full은 “소비자가 꺼낼 아이템이 있는가”를 세고, mtx는 std::queue 자체의 동시 수정을 막습니다. 세마포어만으로 큐를 보호할 수 있다고 착각하기 쉬운데, 생산자와 소비자가 하나씩뿐이어도 push와 pop이 동시에 같은 std::queue를 수정할 수 있고, 생산자가 여럿이면 두 push가 겹칠 수도 있으므로 뮤텍스가 반드시 필요합니다. 반대로 condition_variable 버전과 비교하면, 세마포어 쪽은 “조건 확인 → 대기 → 재확인” 루프와 spurious wakeup 처리를 직접 쓸 필요가 없어서 코드가 짧고 실수할 곳이 적습니다.

std::cout을 뮤텍스 밖에서 호출한 것도 의도입니다. 락을 잡은 채로 I/O를 하면 임계 구역이 길어져 생산자가 불필요하게 기다립니다. 다만 여러 소비자가 동시에 출력하면 줄이 섞일 수 있습니다.

예시 3: 동시 접근 제한

class Database {
    std::counting_semaphore<5> connSem{5};  // 최대 5개 연결
    
public:
    void query() {
        connSem.acquire();
        
        try {
            executeQuery();
        } catch (...) {
            connSem.release();
            throw;
        }
        
        connSem.release();
    }
};

예시 4: 신호 전달

std::binary_semaphore signal(0);

void producer() {
    prepareData();
    signal.release();  // 신호
}

void consumer() {
    signal.acquire();  // 대기
    processData();
}

초기값 0인 binary_semaphore는 일회성 “준비됐다” 신호로 쓰기 좋습니다. condition_variable로 같은 일을 하면 bool ready 플래그, 뮤텍스, wait(lock, pred)까지 세 가지를 맞춰야 하고, 플래그 없이 notify_one()만 부르면 소비자가 wait()에 들어가기 전에 보낸 알림이 사라지는 lost wakeup 문제가 생깁니다. 세마포어는 카운트 자체가 상태라서 release()가 먼저 호출돼도 신호가 남아 있다가 나중의 acquire()가 바로 통과합니다. release()는 이후 acquire()에 대해 synchronizes-with 관계를 만들기 때문에, prepareData()에서 쓴 데이터는 processData()에서 별도 원자 변수 없이 안전하게 보입니다.

try_acquire

std::counting_semaphore<3> sem(3);

if (sem.try_acquire()) {
    // 획득 성공
    sem.release();
} else {
    // 획득 실패
}

// 타임아웃
if (sem.try_acquire_for(std::chrono::seconds(1))) {
    // 획득 성공
    sem.release();
}

try_acquire()는 기다리지 않고 즉시 성공/실패를 돌려주므로, “슬롯이 없으면 요청을 거절하고 503을 돌려준다” 같은 부하 차단(load shedding)에 맞습니다. try_acquire_for는 제한 시간 동안만 기다립니다. 주의할 점은 표준이 try_acquire()에 대해 카운트가 양수여도 실패할 수 있는(spurious failure) 여지를 두고 있다는 것입니다. 그래서 “반드시 성공해야 하는” 로직을 try_acquire() 한 번의 결과에 걸면 안 되고, 실패를 정상적인 분기로 처리해야 합니다.

자주 발생하는 문제

문제 1: release 누락

// release 누락
sem.acquire();
process();
// sem.release();  // 누락

// RAII 래퍼 (최대값이 다른 세마포어도 받도록 템플릿으로)
template <std::ptrdiff_t N>
class SemaphoreGuard {
    std::counting_semaphore<N>& sem;
public:
    explicit SemaphoreGuard(std::counting_semaphore<N>& s) : sem(s) {
        sem.acquire();
    }
    ~SemaphoreGuard() {
        sem.release();
    }
    SemaphoreGuard(const SemaphoreGuard&) = delete;
    SemaphoreGuard& operator=(const SemaphoreGuard&) = delete;
};

처음 이런 래퍼를 만들 때 흔히 하는 실수가 std::counting_semaphore<>&(기본 템플릿 인자)로 받는 것입니다. counting_semaphore<3>과 counting_semaphore<>는 완전히 다른 타입이라 참조로 묶이지 않고, cannot bind reference of type 'std::counting_semaphore<...>&' to ... 'std::counting_semaphore<3>' 같은 컴파일 에러가 납니다. 위처럼 템플릿으로 만들면 C++17 이후의 클래스 템플릿 인자 추론(CTAD) 덕분에 SemaphoreGuard guard(poolSem);만 써도 됩니다. 복사를 막아 두는 이유는 가드가 복사되면 소멸자가 두 번 돌아 release()가 한 번 더 호출되기 때문입니다.

문제 2: 초기값

// 초기값 0
std::counting_semaphore<10> sem(0);
sem.acquire();  // 영원히 대기

// 적절한 초기값
std::counting_semaphore<10> sem(10);

초기값 0은 그 자체로 틀린 것이 아닙니다. 앞의 “신호 전달” 예제처럼 누군가 release()해 줄 것을 기다리는 용도라면 0이 맞습니다. 문제는 자원 풀처럼 “처음부터 N개가 비어 있다”는 의미인데 0으로 시작하는 경우로, 이때 첫 acquire()부터 영원히 대기하고 프로그램은 에러 메시지 없이 멈춥니다. 디버거로 붙여 보면 모든 스레드가 futex wait(Linux) 또는 WaitOnAddress(Windows) 안에 잠들어 있는 모습으로 나타납니다.

문제 3: 데드락

std::counting_semaphore<1> sem1(1);
std::counting_semaphore<1> sem2(1);

// Thread 1
sem1.acquire();
sem2.acquire();  // 데드락 가능

// Thread 2
sem2.acquire();
sem1.acquire();

뮤텍스 데드락과 원리는 같습니다. 두 스레드가 서로 상대가 가진 것을 기다립니다. 뮤텍스는 std::scoped_lock(m1, m2)로 여러 개를 데드락 없이 한꺼번에 잡을 수 있지만, 세마포어에는 그런 도구가 없으므로 모든 코드에서 획득 순서를 고정(항상 sem1 → sem2)하는 규칙으로 막아야 합니다. try_acquire_for로 타임아웃을 두고 실패하면 가진 것을 모두 반납한 뒤 재시도하는 방식도 있지만, 경합이 심하면 서로 양보만 반복하는 라이브락이 될 수 있습니다.

문제 4: 예외 안전성

sem.acquire();
try {
    process();
} catch (...) {
    sem.release();  // 필수
    throw;
}
sem.release();

이 try/catch 패턴은 동작은 하지만, release() 호출이 두 군데로 나뉘어 있어서 나중에 누군가 return 문을 중간에 추가하면 바로 누수가 생깁니다. 세마포어 카운트가 서서히 줄어 결국 모든 요청이 멈추는 증상은 이런 식으로 새로 추가된 조기 반환 경로에서 자주 생깁니다. 로그에는 아무것도 남지 않고 “몇 시간 돌리면 처리량이 0이 된다”는 형태로만 드러나서 찾기 어렵습니다. 그래서 위의 SemaphoreGuard 같은 RAII 래퍼를 기본으로 쓰고, 수동 release()는 acquire와 release가 서로 다른 스레드에서 일어나는 신호 전달 용도로만 남겨 두는 편이 안전합니다.

뮤텍스와 세마포어의 차이

// mutex: 소유권 있음
std::mutex mtx;
mtx.lock();
mtx.unlock();  // lock한 스레드가 풀어야 함

// semaphore: 소유권 없음
std::counting_semaphore<1> sem(1);
sem.acquire();  // 스레드 A
sem.release();  // 스레드 B (OK)

뮤텍스는 상호 배제와 소유권을 함께 표현합니다. std::mutex를 잠그지 않은 스레드가 unlock()을 호출하면 정의되지 않은 동작입니다. 세마포어는 카운터일 뿐이라 acquire()로 하나 빼고 release()로 하나 더하며, 어느 스레드가 release()해도 대기 중인 스레드가 깨어납니다. 그래서 “풀에 자리를 반납한다”거나 “다른 스레드에 준비됐다고 알린다” 같은 모델에 맞습니다.

카운트가 1인 세마포어와 뮤텍스는 겉보기에는 비슷하지만, 소유권이 없으므로 잘못된 스레드의 반납을 막아 주지 않고 condition_variable과 함께 쓸 수도 없습니다. 공유 데이터를 보호하는 기본 도구는 여전히 뮤텍스와 RAII 락이고, 세마포어는 동시 실행 개수나 슬롯 수를 제한하는 데 씁니다.

counting_semaphore vs binary_semaphore

std::counting_semaphore<LeastMaxValue>의 템플릿 인자는 지원해야 하는 최대 카운트의 하한입니다. 구현은 더 큰 값을 허용할 수 있고, 실제 상한은 max()로 확인합니다. 초기값은 생성자로 주며 0 이상 max() 이하여야 합니다. std::binary_semaphore는 표준에서 using binary_semaphore = counting_semaphore<1>;로 정의된 별칭이라 카운트가 0 또는 1뿐이고, “준비됨” 같은 이벤트 신호나 단일 슬롯에 씁니다.

std::binary_semaphore ready{0};  // 아직 준비 안 됨

// 생산자
data = produce();
ready.release();  // 0 -> 1, 대기자 깨움

// 소비자
ready.acquire();  // 카운트가 1이 될 때까지 기다린 뒤 0으로 줄임
consume(data);

C++20 세마포어 API

<semaphore> 헤더는 std::counting_semaphore와 std::binary_semaphore를 제공합니다. 멤버 함수는 acquire(), release(update = 1)(한 번에 여러 카운트를 올릴 수 있음), try_acquire(), try_acquire_for(), try_acquire_until(), 그리고 정적 함수 max()입니다. 기본 생성자는 없어서 초기 카운트를 반드시 넘겨야 하고, 복사와 이동도 할 수 없으므로 여러 스레드가 같은 객체를 참조로 공유해서 씁니다. POSIX sem_t나 Win32 세마포어를 쓰던 코드를 이식성 있게 옮길 때 유용하지만, 프로세스 간 공유(named semaphore)는 표준 세마포어가 다루지 않으므로 그 경우에는 여전히 OS API가 필요합니다.

latch, barrier와의 차이

셋 다 C++20에 함께 들어온 동기화 도구지만 용도가 다릅니다. std::latch는 카운트가 0이 될 때까지 기다리는 일회용 문으로, “워커 N개가 모두 초기화를 마칠 때까지 대기”에 씁니다. std::barrier는 같은 일을 여러 단계(phase)에 걸쳐 반복할 수 있고 단계가 끝날 때 콜백을 실행합니다. 세마포어는 카운트가 올라갔다 내려갔다 하며 “동시에 몇 개까지”를 계속 제한하는 도구라는 점에서 두 가지와 성격이 다릅니다.

성능

주요 구현(libstdc++, libc++, MSVC STL)은 경합이 없을 때 원자 연산만으로 처리하고, 대기가 필요할 때만 OS 대기 기능(futex, WaitOnAddress)을 씁니다. 그래서 mutex + condition_variable로 직접 만든 세마포어보다 대체로 가볍습니다. 다만 차이는 플랫폼과 경합 정도에 따라 크게 달라지므로 병목이 의심되면 직접 측정해야 합니다.

참고 자료


같이 보면 좋은 글