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하는 순간이 생기므로 뮤텍스가 반드시 필요합니다. 반대로 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 vs semaphore
// mutex: 소유권 있음
std::mutex mtx;
mtx.lock();
mtx.unlock(); // 같은 스레드
// semaphore: 소유권 없음
std::counting_semaphore<1> sem(1);
sem.acquire(); // 스레드 A
sem.release(); // 스레드 B (OK)
세마포어 vs 뮤텍스 (언제 무엇을 쓰나)
- 뮤텍스는 보통 상호 배제(mutual exclusion) 와 소유권 모델입니다. 같은 스레드가
lock한 뮤텍스를 다른 스레드가unlock하면 정의되지 않은 동작(UB) 에 가깝습니다(구현에 따라 검사). - 세마포어는 카운터입니다.
acquire로 하나 빼고,release로 하나 더합니다. 어느 스레드가 release해도 대기 중인 다른 스레드가 깨어날 수 있어, “풀에서 자리 반납” 같은 모델에 잘 맞습니다.
직관: 뮤텍스는 “화장실 한 칸(한 번에 한 명, 들어간 사람만 나올 수 있음)”에 가깝으며, 세마포어는 “주차장 빈 칸 N개”에 가깝습니다.
중요: 카운트가 1인 세마포어(바이너리)와 뮤텍스는 겉보기 비슷하지만, 소유권·재진입 규칙·조건 변수와의 관습적 조합이 다릅니다. 공유 데이터를 보호하는 기본 도구는 여전히 뮤텍스 + RAII 락이고, 세마포어는 동시 실행 개수·슬롯 수를 제한하는 쪽에 강합니다.
counting_semaphore vs binary_semaphore
- std::counting_semaphore
: 템플릿 인자 LeastMax는 최대치의 하한입니다. 구현은 그 이상을 허용할 수 있지만, 최소한LeastMax까지는 지원합니다. 초기값은 생성자로 주며, 현재 카운트는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);
생산자-소비자 패턴 (세마포어 관점)
앞의 예시처럼 빈 슬롯(empty) 과 찬 슬롯(full) 을 세마포어로 세고, 큐 자체는 mutex로 보호하는 전형적인 구조입니다.
empty초기값 = 버퍼 크기,full초기값 = 0.- 생산자:
empty.acquire()→ 큐에push→full.release() - 소비자:
full.acquire()→ 큐에서pop→empty.release()
주의: 버퍼가 한 칸이면 생산자·소비자가 동시에 접근할 때 여전히 데이터 레이스가 나지 않도록 큐 연산은 반드시 뮤텍스 안에서 하세요. 세마포어는 “몇 개의 아이템이 있는가”만 세 줄 뿐, 메모리 일관성은 뮤텍스가 담당합니다.
리소스 풀 관리 (연결 풀·워커 슬롯)
counting_semaphore<N>(N) 패턴은 최대 N개의 동시 사용자를 허용합니다. DB 커넥션 풀처럼 실제 객체는 풀 안에 고정해 두며, 세마포어는 “꺼내 쓸 수 있는 권한”만 나눠 줍니다.
std::counting_semaphore<8> db_slots{8};
void handle_request() {
db_slots.acquire();
try {
run_query();
} catch (...) {
db_slots.release();
throw;
}
db_slots.release();
}
예외 안전을 위해 RAII 래퍼(앞서 나온 SemaphoreGuard)를 두면 release 누락을 줄일 수 있습니다.
C++20 표준 세마포어가 제공하는 것
<semaphore>헤더, std::counting_semaphore, std::binary_semaphore.- acquire, release (인자로 한 번에 여러 카운트 증가 가능), try_acquire, try_acquire_for, try_acquire_until.
- 기본 생성은 없음 — 반드시 초기 카운트를 넘겨야 합니다.
- 복사·이동 불가 — 세마포어 객체는 스레드 간 공유용으로 두며, 여러 스레드가 같은 객체에
acquire/release합니다.
표준이 아닌 플랫폼 API( POSIX 세마포어 등)를 쓰던 코드를 이식성 있게 옮길 때 C++20 세마포어가 유용합니다.
FAQ
Q1: latch, barrier와는 어떻게 다른가요?
A: 셋 다 C++20에 함께 들어온 동기화 도구지만 용도가 다릅니다. std::latch는 카운트가 0이 될 때까지 기다리는 일회용 문으로, “워커 N개가 모두 초기화를 마칠 때까지 대기”에 씁니다. std::barrier는 같은 일을 여러 단계(phase)에 걸쳐 반복할 수 있고 단계가 끝날 때 콜백을 실행합니다. 세마포어는 카운트가 올라갔다 내려갔다 하며 “동시에 몇 개까지”를 계속 제한하는 도구라는 점에서 두 가지와 성격이 다릅니다.
Q2: 언제 사용하나요?
A: 동시 실행 개수를 제한할 때(커넥션 풀, 외부 API 동시 호출 수, 동시 파일 핸들 수), 생산자-소비자의 버퍼 슬롯을 셀 때, 스레드 간 일회성 신호를 보낼 때입니다. 공유 데이터 하나를 보호하는 용도라면 뮤텍스가 맞습니다.
Q3: mutex와 차이는?
A: 뮤텍스는 잠근 스레드가 소유권을 갖고 그 스레드만 풀 수 있습니다. 세마포어는 소유권 없이 카운트만 있어서 다른 스레드가 release()해도 됩니다. 이 유연함이 신호 전달을 가능하게 하지만, 누가 반납할 책임이 있는지 코드에 드러나지 않아 누수를 찾기 어렵게 만들기도 합니다.
Q4: 성능은 어떤가요?
A: 주요 구현(libstdc++, libc++, MSVC STL)은 경합이 없을 때 원자 연산만으로 처리하고, 대기가 필요할 때만 OS 대기 기능(futex, WaitOnAddress)을 씁니다. 그래서 mutex + condition_variable로 직접 만든 세마포어보다 대체로 가볍습니다. 다만 수치는 플랫폼·경합 정도에 따라 크게 달라지므로 병목이 의심되면 직접 측정해야 합니다.
Q5: C++20이 꼭 필요한가요?
A: 표준 std::counting_semaphore는 C++20부터입니다. 컴파일러 플래그(-std=c++20, /std:c++20)와 표준 라이브러리 버전이 모두 지원해야 하며, 그 이전 환경에서는 위 FAQ처럼 mutex + condition_variable, POSIX sem_t, Boost 등으로 대체합니다.
Q6: 세마포어 학습 리소스는?
A:
- “C++20 The Complete Guide”
- “Operating System Concepts”
- cppreference.com
같이 보면 좋은 글
- C++20 std::barrier와 std::latch: 스레드 동기화 지점 만들기
- C++ 메모리 순서 예제로 이해하기: relaxed, acquire/release, seq_cst, consume
- C++ mutex와 lock_guard·unique_lock·scoped_lock: 임계 영역 보호와 데드락 방지
- C++ atomic: 락 없는 카운터, 메모리 순서, 언제 mutex 대신 쓰나
- C++ Branch Prediction
- C++ Calendar & Timezone
- 모던 C++ (C++11~C++20) 핵심 문법 치트시트 | 현업에서 자주 쓰는 한눈에 보기