C++ 고급 멀티스레딩 | 스레드 풀·Work Stealing
들어가며: 요청마다 스레드를 만들면 생기는 일
요청마다 std::thread를 만들어 처리하는 서버는 처음에는 단순하고 잘 동작하는 것처럼 보입니다.
// 요청마다 스레드 생성
void handleRequest(const Request& req) {
std::thread t([req]() {
processRequest(req);
});
t.detach();
}
스레드 하나를 만들려면 커널 스레드 객체를 생성하고 스택용 가상 메모리를 예약해야 합니다(Linux glibc 기본값은 ulimit -s를 따라 보통 8MB를 예약하고, 실제로 쓰는 페이지만 물리 메모리에 올라갑니다). 작업이 짧으면 이 생성·종료 비용이 작업 자체보다 커질 수 있고, 요청이 몰리면 동시에 존재하는 스레드 수가 제한 없이 늘어나 스케줄링 부담과 컨텍스트 스위칭으로 캐시 효율이 떨어집니다. detach한 스레드는 종료 시점을 기다릴 방법도 없습니다.
해결책은 워커 스레드를 미리 만들어 두고 작업만 큐에 넣는 스레드 풀입니다. 이 글은 스레드 풀에서 출발해 워커 간 작업 불균형을 줄이는 Work Stealing, 락 경합을 없애는 Lock-Free 큐, 플래그와 데이터를 올바르게 동기화하는 메모리 순서, 공유 카운터 경합을 줄이는 Thread-Local Storage를 차례로 다룹니다. 각 절에서는 이 패턴들을 쓸 때 실제로 자주 생기는 버그도 함께 봅니다. 종료 시 큐에 남은 작업이 사라지는 문제, 재사용되는 워커의 thread_local에 이전 요청의 trace ID가 남는 문제, 제출 속도가 처리 속도보다 빨라 큐가 끝없이 커지는 문제 등이 그 예입니다.
작업 큐와 워커로 스레드 풀 구현하기
스레드 풀은 미리 생성한 워커 스레드가 공유 작업 큐에서 작업을 꺼내 처리하는 구조입니다. 스레드 생성 비용은 풀을 만들 때 한 번만 들고, 이후에는 작업을 큐에 넣고 꺼내는 비용만 듭니다.
스레드 풀 구조
flowchart TB
subgraph pool[스레드 풀]
Q[작업 큐]
W1[워커 1]
W2[워커 2]
W3[워커 3]
W4[워커 4]
end
P1[Producer 1] -->|submit| Q
P2[Producer 2] -->|submit| Q
Q -->|condition_variable| W1
Q --> W2
Q --> W3
Q --> W4
스레드 풀 예제
// g++ -std=c++17 -pthread -O2 -o thread_pool thread_pool.cpp && ./thread_pool
#include <chrono>
#include <condition_variable>
#include <functional>
#include <future>
#include <iostream>
#include <memory>
#include <mutex>
#include <queue>
#include <stdexcept>
#include <thread>
#include <type_traits>
#include <vector>
class ThreadPool {
public:
explicit ThreadPool(size_t num_threads) {
threads_.reserve(num_threads);
for (size_t i = 0; i < num_threads; ++i) {
threads_.emplace_back([this] { worker(); });
}
}
~ThreadPool() { shutdown(); }
template <typename F, typename... Args>
auto submit(F&& f, Args&&... args)
-> std::future<std::invoke_result_t<F, Args...>> {
using return_type = std::invoke_result_t<F, Args...>;
auto task = std::make_shared<std::packaged_task<return_type()>>(
std::bind(std::forward<F>(f), std::forward<Args>(args)...));
std::future<return_type> result = task->get_future();
{
std::lock_guard<std::mutex> lock(mtx_);
if (stop_) throw std::runtime_error("submit on stopped ThreadPool");
tasks_.emplace([task]() { (*task)(); });
}
cv_.notify_one();
return result;
}
void shutdown() {
{
std::lock_guard<std::mutex> lock(mtx_);
stop_ = true;
}
cv_.notify_all();
for (auto& t : threads_) {
if (t.joinable()) t.join();
}
}
private:
void worker() {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mtx_);
cv_.wait(lock, [this] { return stop_ || !tasks_.empty(); });
if (stop_ && tasks_.empty()) return; // 남은 작업을 모두 처리한 뒤에만 종료
task = std::move(tasks_.front());
tasks_.pop();
}
task(); // 락 밖에서 실행
}
}
std::vector<std::thread> threads_;
std::queue<std::function<void()>> tasks_;
std::mutex mtx_;
std::condition_variable cv_;
bool stop_ = false;
};
int main() {
ThreadPool pool(4);
auto f1 = pool.submit([](int a, int b) { return a + b; }, 10, 20);
auto f2 = pool.submit([] { return 42; });
std::cout << "f1=" << f1.get() << " f2=" << f2.get() << "\n";
for (int i = 0; i < 8; ++i) {
pool.submit([i] {
std::cout << "Task " << i << " on " << std::this_thread::get_id() << "\n";
});
}
pool.shutdown(); // 큐에 남은 8개 작업을 처리한 뒤 워커 종료
return 0;
}
워커는 cv_.wait(lock, predicate)로 작업이 있거나 종료 신호가 올 때까지 잠듭니다. 종료 조건을 stop_ && tasks_.empty()로 둔 것이 핵심인데, stop_만 보고 바로 반환하면 큐에 남은 작업이 실행되지 않고, 그 작업의 packaged_task가 파괴되면서 호출자의 future::get()은 broken_promise 예외를 받습니다. submit은 std::packaged_task로 반환값과 예외를 std::future에 연결하고, 락을 푼 다음 notify_one()을 호출해 깨어난 워커가 바로 락을 얻을 수 있게 합니다. 여러 스레드가 동시에 출력하므로 Task 줄은 섞여 나올 수 있습니다.
packaged_task는 작업 안에서 던진 예외를 잡아 future에 저장하므로 워커 스레드는 계속 살아 있습니다. 반대로 std::function을 직접 실행하는 풀에서 작업이 예외를 던지고 아무도 잡지 않으면, 스레드 함수 밖으로 나간 예외 때문에 std::terminate가 호출되어 프로세스 전체가 종료됩니다. future를 쓰지 않는 fire-and-forget 작업만 받는 풀이라면 워커 루프에서 try { task(); } catch (...) { /* 로깅 */ }로 감싸야 합니다.
스레드 수 선택
CPU 연산 위주의 작업은 std::thread::hardware_concurrency()(논리 코어 수, 알 수 없으면 0을 반환) 정도가 출발점입니다. 그보다 늘려도 코어가 늘지 않으므로 빨라지지 않고 컨텍스트 스위칭만 늘어납니다. I/O 대기가 많은 작업은 대기 중인 스레드 대신 다른 스레드가 코어를 쓸 수 있으므로 코어 수보다 많이 두며, 흔히 쓰는 근사식은 코어 수 × (1 + 대기 시간 / 연산 시간)입니다. 대기 비율은 작업마다 다르므로 결국 부하를 걸어 측정하며 정합니다.
놀고 있는 워커가 작업을 가져오는 Work Stealing
공유 큐 하나를 모든 워커가 쓰면 그 큐의 락이 경합 지점이 됩니다. 워커마다 로컬 큐를 두면 경합은 줄지만, 이번에는 작업이 특정 워커의 큐에 몰려 나머지가 노는 불균형이 생깁니다. Work Stealing은 자기 큐가 빈 워커가 다른 워커의 큐에서 작업을 가져오게 해 이 불균형을 줄입니다. Intel TBB, Java ForkJoinPool, Go 런타임 스케줄러가 이 방식을 씁니다.
flowchart LR
subgraph busy[바쁜 워커]
Q1[로컬 큐]
end
subgraph idle[유휴 워커]
W2[워커 2]
end
W2 -->|steal| Q1
Work Stealing 스레드 풀 구현
// g++ -std=c++17 -pthread -O2 -o work_stealing work_stealing.cpp
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <deque>
#include <functional>
#include <future>
#include <iostream>
#include <memory>
#include <mutex>
#include <thread>
#include <vector>
class WorkStealingPool {
public:
// mutex와 condition_variable은 이동할 수 없으므로 resize 대신 생성 시 개수를 정함
explicit WorkStealingPool(size_t n)
: queues_(n), mtxs_(n), cvs_(n) {
threads_.reserve(n);
for (size_t i = 0; i < n; ++i) {
threads_.emplace_back([this, i] { worker(i); });
}
}
~WorkStealingPool() { shutdown(); }
template <typename F>
auto submit(F&& f) -> std::future<std::invoke_result_t<F>> {
using return_type = std::invoke_result_t<F>;
auto task = std::make_shared<std::packaged_task<return_type()>>(std::forward<F>(f));
std::future<return_type> result = task->get_future();
size_t idx = next_.fetch_add(1, std::memory_order_relaxed) % queues_.size();
{
std::lock_guard<std::mutex> lock(mtxs_[idx]);
queues_[idx].push_back([task]() { (*task)(); });
}
cvs_[idx].notify_one();
return result;
}
void shutdown() {
stop_.store(true);
// 각 큐의 락을 한 번씩 잡았다 놓아, predicate 확인과 대기 진입 사이에 있는 워커가 없게 함
for (auto& m : mtxs_) std::lock_guard<std::mutex> lock(m);
for (auto& cv : cvs_) cv.notify_all();
for (auto& t : threads_) if (t.joinable()) t.join();
}
private:
using Task = std::function<void()>;
bool trySteal(size_t my_id, Task& task) {
const size_t n = queues_.size();
for (size_t i = 1; i < n; ++i) {
size_t victim = (my_id + i) % n;
std::lock_guard<std::mutex> lock(mtxs_[victim]); // 한 번에 하나의 락만 잡음
if (!queues_[victim].empty()) {
task = std::move(queues_[victim].back());
queues_[victim].pop_back();
return true;
}
}
return false;
}
void worker(size_t my_id) {
while (true) {
Task task;
{
std::unique_lock<std::mutex> lock(mtxs_[my_id]);
// 다른 큐에 작업이 들어와도 이 cv는 깨지 않으므로 짧게 자고 일어나 steal을 시도
cvs_[my_id].wait_for(lock, std::chrono::milliseconds(1),
[this, my_id] { return stop_ || !queues_[my_id].empty(); });
if (!queues_[my_id].empty()) {
task = std::move(queues_[my_id].front());
queues_[my_id].pop_front();
} else if (stop_) {
return;
}
}
if (task || trySteal(my_id, task)) task();
}
}
std::vector<std::deque<Task>> queues_;
std::vector<std::mutex> mtxs_;
std::vector<std::condition_variable> cvs_;
std::vector<std::thread> threads_;
std::atomic<bool> stop_{false};
std::atomic<size_t> next_{0};
};
int main() {
WorkStealingPool pool(4);
std::vector<std::future<int>> futures;
for (int i = 0; i < 16; ++i) {
futures.push_back(pool.submit([i]() { return i * i; }));
}
for (auto& f : futures) std::cout << f.get() << " ";
std::cout << "\n";
pool.shutdown();
return 0;
}
각 워커는 자기 deque를 가지며, submit은 라운드 로빈으로 작업을 분배합니다. 워커는 자기 큐의 앞에서 꺼내고, 비어 있으면 trySteal로 다른 워커 큐의 뒤에서 가져옵니다. trySteal은 victim 큐의 락을 하나씩만 잡고 자기 큐 락은 이미 풀어 둔 상태에서 호출하므로, 두 워커가 서로의 락을 기다리는 교착은 생기지 않습니다.
wait_for의 1ms 타임아웃은 교착 방지용이 아니라, 다른 워커 큐에 작업이 들어와도 내 condition_variable은 알림을 받지 못하기 때문에 주기적으로 깨어나 steal을 시도하려는 것입니다. 단순한 대신 유휴 워커가 계속 깨어나 CPU를 조금씩 소모합니다. 실제 구현은 유휴 워커 수를 세어 두고 작업이 들어올 때 하나를 깨우는 식으로 이 폴링을 없앱니다.
이 예제는 양 끝 모두 같은 mutex로 보호하므로 front와 back을 나눠 쓰는 이점이 크지 않습니다. TBB 같은 실제 구현은 Chase-Lev 같은 lock-free deque를 써서, 소유 워커는 한쪽 끝에서 락 없이 넣고 빼며(LIFO, 캐시에 남아 있는 최근 작업 우선) 훔치는 쪽만 반대쪽 끝에서 CAS로 가져갑니다. 반대쪽 끝에는 오래된 작업이 있고, 재귀적으로 쪼개는 작업에서는 오래된 작업이 대체로 더 큰 단위라 한 번 훔쳐서 오래 일할 수 있다는 점도 이 배치의 이유입니다.
SPSC 큐와 MPMC lock-free 큐
Lock-Free 큐는 mutex 없이 원자 연산만으로 enqueue/dequeue를 수행합니다. 락을 잡은 스레드가 선점되어 다른 스레드가 모두 기다리는 상황이 없고, 고빈도 생산자-소비자 경로에서 커널 대기로 넘어가지 않습니다.
SPSC (Single Producer Single Consumer) 큐
한 스레드만 push하고 한 스레드만 pop하는 경우가 가장 단순합니다.
// g++ -std=c++17 -pthread -O2 -o spsc_demo spsc_demo.cpp
#include <array>
#include <atomic>
#include <cstddef>
#include <iostream>
#include <string>
#include <thread>
template <typename T, size_t N>
struct SPSCQueue {
std::array<T, N> buffer;
alignas(64) std::atomic<size_t> write_idx{0}; // 생산자만 씀
alignas(64) std::atomic<size_t> read_idx{0}; // 소비자만 씀
bool push(const T& v) {
size_t w = write_idx.load(std::memory_order_relaxed);
size_t next = (w + 1) % N;
if (next == read_idx.load(std::memory_order_acquire)) return false; // 가득 참
buffer[w] = v;
write_idx.store(next, std::memory_order_release); // buffer[w] 쓰기를 소비자에게 공개
return true;
}
bool pop(T& v) {
size_t r = read_idx.load(std::memory_order_relaxed);
if (r == write_idx.load(std::memory_order_acquire)) return false; // 비어 있음
v = buffer[r];
read_idx.store((r + 1) % N, std::memory_order_release); // 이 칸을 생산자가 다시 써도 됨
return true;
}
};
int main() {
SPSCQueue<std::string, 4096> q;
std::atomic<bool> done{false};
std::thread producer([&] {
for (int i = 0; i < 10000; ++i) {
while (!q.push("log-" + std::to_string(i))) std::this_thread::yield();
}
done.store(true, std::memory_order_release);
});
std::thread consumer([&] {
std::string s;
int count = 0;
while (true) {
if (q.pop(s)) { ++count; continue; }
if (done.load(std::memory_order_acquire)) {
while (q.pop(s)) ++count; // done 이전에 들어간 나머지를 비움
break;
}
std::this_thread::yield();
}
std::cout << "consumed " << count << " items\n"; // 10000
});
producer.join();
consumer.join();
return 0;
}
생산자만 write_idx를, 소비자만 read_idx를 쓰므로 CAS 루프 없이 load와 store만으로 충분합니다. 생산자의 release store와 소비자의 acquire load가 짝을 이뤄, 소비자가 새 write_idx를 보면 그 칸에 쓴 데이터도 보이게 됩니다. 원형 버퍼는 빈 상태와 가득 찬 상태를 구분하려고 한 칸을 비워 두므로 실제로는 N-1개만 담을 수 있습니다. 두 인덱스를 alignas(64)로 다른 캐시 라인에 둔 것은 생산자와 소비자가 서로의 인덱스를 쓰면서 같은 라인을 주고받는 False Sharing을 피하기 위해서입니다.
소비 루프를 짤 때 흔한 실수는 while (!done || q.pop(s)) { if (q.pop(s)) ++count; }처럼 조건식 안에서 pop을 호출하는 것입니다. 조건식에서 꺼낸 원소는 세지 않고 버려지므로 개수가 맞지 않습니다. 위 코드처럼 done을 확인한 뒤 큐를 한 번 더 비우는 순서가 안전합니다. 이 큐는 생산자나 소비자가 둘 이상이 되는 순간 data race가 생기므로, 구조상 각각 하나만 접근한다는 것이 보장될 때만 씁니다.
MPMC 큐: Michael-Scott 알고리즘과 메모리 회수
다중 생산자·다중 소비자 큐의 고전적인 lock-free 구현은 Michael-Scott 큐입니다. 연결 리스트에 항상 더미 노드를 하나 두고, 다음처럼 동작합니다.
- push는 새 노드를 만들고, 현재 tail의
next가 nullptr일 때만 CAS로 새 노드를 연결한 뒤, tail을 CAS로 새 노드로 옮깁니다. tail이 뒤처져 있으면(tail->next가 nullptr가 아니면) 다른 스레드가 대신 tail을 앞으로 옮겨 줍니다. - pop은
head->next를 읽어 값을 꺼내고, CAS로 head를 다음 노드로 옮깁니다. 옮겨진 이전 head 노드가 새 더미가 됩니다.
알고리즘 자체보다 어려운 부분은 꺼낸 노드를 언제 해제하느냐입니다. pop에 성공한 스레드가 이전 head를 바로 delete하면, 같은 head를 읽고 head->next를 읽으려던 다른 스레드가 해제된 메모리에 접근합니다. glibc에서 corrupted size vs. prev_size 같은 힙 손상 메시지나 드문 크래시로 나타나는 것이 이 경우입니다. 해제된 주소가 재할당되어 같은 포인터 값으로 돌아오면 CAS가 잘못 성공하는 ABA 문제도 같은 뿌리에서 나옵니다. 그래서 실제 구현에는 다음 중 하나가 필요합니다.
- Hazard pointer: 스레드가 지금 읽고 있는 노드를 전역 슬롯에 등록하고, 해제하려는 쪽은 어떤 슬롯에도 등록되지 않은 노드만 해제합니다. C++26에서
std::hazard_pointer로 표준화되었습니다. - Epoch 기반 회수(EBR): 모든 스레드가 특정 시점(epoch) 이후로 진행했을 때 그 이전에 빠진 노드를 한꺼번에 해제합니다.
- 태그 포인터: 포인터와 버전 카운터를 묶어 CAS합니다(16바이트 CAS 필요). ABA는 막지만 해제된 메모리 접근 자체는 막지 못하므로, 노드를 해제하지 않고 풀에서 재사용할 때 주로 씁니다.
// Hazard pointer 등록의 핵심: 등록 후 원래 위치를 다시 읽어 여전히 같은지 확인
std::atomic<void*> hazard_ptrs[MAX_THREADS];
void* protect(int tid, std::atomic<void*>& src) {
void* p = src.load(std::memory_order_acquire);
while (true) {
hazard_ptrs[tid].store(p, std::memory_order_seq_cst);
void* again = src.load(std::memory_order_acquire);
if (again == p) return p; // 등록한 뒤에도 그대로라면 이제 해제되지 않음
p = again;
}
}
void clear(int tid) { hazard_ptrs[tid].store(nullptr, std::memory_order_release); }
등록과 재확인 사이의 순서가 중요해서, 등록 store는 해제하는 쪽의 스캔과 전역 순서를 맞추도록 보통 seq_cst(또는 별도 fence)를 씁니다. 이런 세부 때문에 MPMC 큐는 직접 작성하기보다 boost::lockfree::queue, moodycamel ConcurrentQueue, oneTBB concurrent_queue, folly::MPMCQueue처럼 검증된 구현을 쓰는 편이 안전합니다.
Lock-Free와 mutex 큐 비교
| 방식 | 장점 | 단점 |
|---|---|---|
| Mutex 큐 | 구현 단순, 대기 시 CPU를 쓰지 않음 | 경합이 심하면 커널 대기로 넘어가 지연이 커짐 |
| Lock-Free SPSC | 락 없음, 원자 load/store만 사용 | 생산자와 소비자가 각각 하나일 때만 사용 가능 |
| Lock-Free MPMC | 다중 생산자·소비자, 락 없음 | 메모리 회수와 ABA 처리가 복잡 |
lock-free가 항상 빠른 것은 아닙니다. 경합이 적으면 mutex의 빠른 경로(경합 없는 lock/unlock)는 원자 연산 몇 개 수준이고, 큐가 비었을 때 lock-free 소비자가 바쁜 대기를 하면 오히려 CPU를 낭비합니다. 지연 요구가 엄격하지 않다면 mutex 큐로 시작하고, 프로파일링에서 락 경합이 확인될 때 바꾸는 순서가 무난합니다.
release/acquire와 seq_cst: 메모리 순서 고르기
컴파일러와 CPU는 단일 스레드에서 결과가 같다면 메모리 접근 순서를 바꿀 수 있습니다. 다른 스레드에서 보면 이 재배치가 드러나므로, memory_order로 어떤 순서를 보장할지 지정합니다.
메모리 순서 종류
| 순서 | 의미 | 사용처 |
|---|---|---|
| seq_cst | 모든 seq_cst 연산이 하나의 전체 순서에 참여 | 기본값, 판단이 어려울 때 |
| acquire | 이 로드 이후의 읽기·쓰기가 이 로드 앞으로 올라가지 않음 | 플래그 확인, 락 획득 |
| release | 이 스토어 이전의 읽기·쓰기가 이 스토어 뒤로 내려가지 않음 | 데이터 게시, 락 해제 |
| acq_rel | acquire와 release를 함께 | CAS 등 read-modify-write |
| relaxed | 원자성만 보장, 순서 보장 없음 | 통계 카운터 등 |
생산자-소비자 동기화
int shared_data[1024];
std::atomic<bool> data_ready{false};
void producer() {
for (int i = 0; i < 1024; ++i) shared_data[i] = i * 2;
data_ready.store(true, std::memory_order_release);
}
void consumer() {
while (!data_ready.load(std::memory_order_acquire)) {}
int sum = 0;
for (int i = 0; i < 1024; ++i) sum += shared_data[i];
// sum == 1047552 보장
}
consumer의 acquire 로드가 producer의 release 스토어가 쓴 값(true)을 읽으면 두 연산 사이에 synchronizes-with 관계가 생깁니다. 그 결과 release 이전의 모든 쓰기(shared_data 채우기)가 acquire 이후의 읽기에 보이는 것이 보장됩니다.
relaxed를 쓰면 생기는 문제
void producer_bad() {
for (int i = 0; i < 1024; ++i) shared_data[i] = i * 2;
data_ready.store(true, std::memory_order_relaxed);
}
void consumer_bad() {
while (!data_ready.load(std::memory_order_relaxed)) {}
int x = shared_data[1]; // 2가 보장되지 않음
}
relaxed 플래그는 synchronizes-with 관계를 만들지 않으므로, shared_data 접근은 두 스레드가 동기화 없이 같은 변수를 읽고 쓰는 data race가 되고 표준상 미정의 동작입니다. 실제로는 ARM처럼 약한 메모리 모델의 CPU에서 초기값을 읽는 형태로 드러나고, x86에서도 컴파일러가 쓰기 순서를 바꾸면 같은 일이 생길 수 있습니다.
sequenceDiagram participant P as Producer participant M as Memory participant C as Consumer P->>M: shared_data 쓰기 P->>M: data_ready = true (release) Note over P,M: release 이전 쓰기는 뒤로 내려가지 않음 C->>M: data_ready 로드 (acquire) Note over C,M: acquire 이후 읽기는 앞으로 올라가지 않음 C->>M: shared_data 읽기
seq_cst와 release/acquire의 비용
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_seq_cst); // 기본값
counter.fetch_add(1, std::memory_order_relaxed); // 원자성만 필요한 카운터
data_ready.store(true, std::memory_order_release);
data_ready.store(true, std::memory_order_seq_cst);
비용 차이는 아키텍처에 따라 다릅니다. x86-64에서는 fetch_add 같은 read-modify-write가 순서와 무관하게 lock 접두사 명령 하나로 컴파일되므로 seq_cst와 relaxed의 비용이 거의 같습니다. x86에서 차이가 나는 곳은 seq_cst 저장으로, mfence나 xchg가 추가됩니다(release 저장은 일반 mov). ARM 같은 약한 메모리 모델 CPU에서는 순서 보장에 따라 다른 명령이나 배리어가 쓰여 relaxed와 차이가 커질 수 있습니다. 어느 쪽이든 동기화가 필요한 곳에 relaxed를 쓰면 버그가 되므로, 먼저 정확성을 기준으로 고르고 성능은 측정 후에 판단합니다. 확신이 없으면 기본값인 seq_cst로 두는 것이 안전합니다.
Double-Checked Locking
싱글톤 지연 초기화에서 if (!ptr) { lock(); if (!ptr) ptr = new T(); unlock(); }처럼 ptr을 일반 포인터로 두면, 다른 스레드가 생성이 끝나지 않은 객체의 주소를 먼저 볼 수 있습니다. ptr을 std::atomic<T*>로 두고 첫 확인은 load(std::memory_order_acquire), 게시는 store(p, std::memory_order_release)로 해야 합니다. 대부분의 경우 이 패턴을 직접 짤 필요는 없습니다. 함수 안의 static 지역 변수 초기화는 C++11부터 스레드 안전하게 한 번만 일어나도록 보장되고, 그 밖의 일회성 초기화는 std::call_once가 처리합니다.
flowchart TD A[atomic 연산 필요?] -->|Yes| B[동기화 필요?] B -->|플래그+데이터| C[release/acquire] B -->|순수 카운터| D[relaxed] B -->|여러 변수 간 전체 순서 필요| E[seq_cst] A -->|No| F[일반 변수 + mutex]
thread_local로 공유 데이터 경합 줄이기
thread_local 변수는 스레드마다 별도의 인스턴스를 가집니다. 스레드별 캐시, 통계, 요청 컨텍스트 등에 씁니다.
기본 사용법
#include <iostream>
#include <thread>
thread_local int tls_counter = 0;
void increment() {
++tls_counter;
std::cout << "thread " << std::this_thread::get_id()
<< " counter=" << tls_counter << "\n";
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
increment(); // 메인 스레드
return 0;
}
세 번 모두 counter=1이 출력됩니다. 각 스레드가 tls_counter의 자기 복사본을 가지므로 서로의 증가가 보이지 않고, 동기화도 필요 없습니다.
TLS로 통계 수집
#include <atomic>
#include <cstdint>
std::atomic<uint64_t> global_count{0};
thread_local uint64_t local_count = 0;
constexpr uint64_t BATCH = 1000;
void on_event() {
if (++local_count >= BATCH) {
global_count.fetch_add(local_count, std::memory_order_relaxed);
local_count = 0;
}
}
void flush_local() { // 스레드 종료 직전에 호출
global_count.fetch_add(local_count, std::memory_order_relaxed);
local_count = 0;
}
이벤트마다 전역 atomic에 fetch_add하면 여러 코어가 같은 캐시 라인의 소유권을 매번 주고받습니다. 스레드별로 모았다가 1000개마다 반영하면 공유 변수에 쓰는 횟수가 1/1000로 줄어, 스레드가 많을수록 효과가 커집니다. 대신 flush 전까지 전역 값은 최신이 아니고, 스레드가 끝나기 전에 flush_local()을 호출하지 않으면 마지막 묶음이 사라집니다.
TLS 주의사항
동적 초기화가 필요한 thread_local 변수는 구현에 따라 그 스레드에서 처음 사용할 때 초기화되며, 접근할 때마다 초기화 여부를 확인하는 작은 비용이 붙습니다. 소멸자는 스레드가 종료될 때 실행됩니다. 동적 라이브러리(특히 dlopen으로 늦게 불러온 경우)의 TLS는 접근 방식이 달라 더 느리고, 플랫폼에 따라 제약이 있습니다.
스레드별 로그 버퍼
#include <iostream>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
thread_local std::ostringstream tls_log_buffer;
std::mutex g_log_mtx;
void thread_log(const std::string& msg) {
tls_log_buffer << msg << '\n';
if (tls_log_buffer.tellp() > 1024) {
std::lock_guard<std::mutex> lock(g_log_mtx);
std::cout << "[" << std::this_thread::get_id() << "]\n"
<< tls_log_buffer.str() << std::flush;
tls_log_buffer.str("");
}
}
스레드별 버퍼에 모았다가 1KB를 넘으면 한 번에 출력하므로 전역 락을 잡는 횟수가 줄어듭니다. str().size()로 크기를 확인하면 매번 문자열 전체가 복사되므로 tellp()를 씁니다. 스레드가 끝날 때 남은 버퍼를 출력하는 처리가 따로 필요합니다.
스레드 풀에서 요청 컨텍스트 관리
스레드 풀의 워커는 여러 요청을 차례로 처리하므로, thread_local에 넣은 trace ID는 다음 작업에도 그대로 남습니다. 이전 요청의 trace ID로 다음 요청의 로그가 찍히는 혼선을 막으려면 작업 진입 시 설정하고, 예외가 나든 아니든 종료 시 지워야 합니다. RAII 가드를 쓰면 두 경로를 한 번에 처리할 수 있습니다.
thread_local std::optional<RequestContext> tls_request_ctx;
struct ContextScope {
explicit ContextScope(const RequestContext& ctx) { tls_request_ctx = ctx; }
~ContextScope() { tls_request_ctx.reset(); }
};
void worker_task(const std::function<void()>& task, const RequestContext& ctx) {
ContextScope scope(ctx); // 정상 종료와 예외 모두에서 초기화
task();
}
자주 생기는 버그
shutdown 시 작업 유실
워커 루프가 종료 플래그만 보고 바로 반환하면 큐에 남은 작업이 실행되지 않습니다.
// 잘못된 예: stop_만 보고 반환
cv_.wait(lock, [this] { return stop_ || !tasks_.empty(); });
if (stop_) return; // 큐에 남은 작업이 버려지고, 그 future는 broken_promise
종료 조건을 stop_ && tasks_.empty()로 두어 남은 작업을 모두 처리한 뒤 반환합니다. 반대로 빠른 종료가 필요하면 남은 작업을 명시적으로 취소하고 그 사실을 호출자에게 알리는 정책을 정해 둡니다.
풀 안에서 같은 풀의 결과를 기다림
작업 A가 같은 풀에 하위 작업 B를 제출하고 B.get()으로 기다리는데, 모든 워커가 A 같은 작업으로 막혀 있으면 B를 실행할 워커가 없어 교착이 됩니다. 큐 크기 제한이 없어도 생기는 문제입니다. 하위 작업을 기다리는 작업은 별도 풀로 분리하거나, 기다리는 동안 그 스레드가 큐의 다른 작업을 대신 실행하게(work stealing 풀이 흔히 하는 방식) 해야 합니다.
condition_variable의 lost wakeup
predicate 없이 cv_.wait(lock)만 쓰면, 생산자가 작업을 넣고 notify_one()을 호출한 시점에 소비자가 아직 wait에 들어가지 않았을 때 알림이 사라지고 소비자는 이미 있는 작업을 두고 잠듭니다. wait(lock, [this]{ return !tasks_.empty(); })처럼 predicate를 쓰면 대기 전에 조건을 먼저 확인하고, spurious wakeup도 함께 처리됩니다. 이때 조건이 되는 상태(큐)는 반드시 같은 mutex를 잡은 채로 바꿔야 합니다.
future.get() 반복 호출
std::future::get()은 한 번만 호출할 수 있고, 두 번째 호출은 미정의 동작입니다(대부분의 구현은 valid()가 false가 된 상태를 검사해 std::future_error를 던집니다). 결과를 변수에 저장해 쓰거나, 여러 곳에서 기다려야 하면 std::shared_future를 씁니다.
”terminate called without an active exception”
joinable() 상태인 std::thread 객체가 파괴되면 std::terminate가 호출되며 이 메시지가 출력됩니다. 소멸자에서 shutdown()을 호출해 모든 스레드를 join하도록 하고, 예외로 생성자 중간에 빠져나가는 경로에서도 이미 만든 스레드가 join되는지 확인합니다. C++20의 std::jthread는 소멸자에서 자동으로 join합니다.
Bounded 풀, 우선순위 큐, 종료 처리 같은 운영 패턴
Bounded 큐와 백프레셔
제출 속도가 처리 속도보다 빠르면 무제한 큐는 메모리가 다할 때까지 커집니다. 큐 크기를 제한하고, 가득 차면 제출자를 기다리게 하거나(백프레셔) 거부합니다.
void submit(Task task) {
std::unique_lock<std::mutex> lock(mtx_);
not_full_.wait(lock, [this] { return tasks_.size() < max_queue_size_; });
tasks_.push(std::move(task));
not_empty_.notify_one();
}
// 워커는 작업을 꺼낸 뒤 not_full_.notify_one()을 호출해야 대기 중인 제출자가 깨어남
무한정 기다리면 안 되는 호출자라면 not_full_.wait_for(lock, timeout, predicate)로 바꿔, 시간 안에 자리가 나지 않으면 실패를 반환합니다.
우선순위 작업 큐
std::priority_queue에 우선순위 값을 가진 작업 구조체를 넣고, 같은 condition_variable 패턴을 씁니다. 우선순위가 낮은 작업이 계속 밀리는 starvation을 막으려면 대기 시간에 따라 우선순위를 올리는 aging을 고려합니다.
struct PrioritizedTask {
int priority;
std::function<void()> fn;
bool operator<(const PrioritizedTask& o) const { return priority < o.priority; }
};
std::priority_queue<PrioritizedTask> tasks_;
종료 시간 제한
std::thread::join에는 타임아웃이 없으므로, 기한이 지났다고 join을 건너뛰면 joinable한 스레드 객체가 파괴되는 순간 std::terminate가 호출됩니다. 시간 제한이 필요하면 작업 쪽에서 협조해야 합니다. 기한이 지나면 취소 플래그를 세우고, 작업이 이 플래그를 주기적으로 확인해 일찍 끝나게 한 뒤 모든 스레드를 join합니다.
class CancellationToken {
std::atomic<bool> cancelled_{false};
public:
void cancel() { cancelled_.store(true, std::memory_order_relaxed); }
bool is_cancelled() const { return cancelled_.load(std::memory_order_relaxed); }
};
// 긴 작업 내부: if (token.is_cancelled()) return;
// shutdown: 기한까지 기다린 뒤 token.cancel() → 모든 스레드 join
플래그 자체만 확인하고 다른 데이터를 함께 넘기지 않으므로 relaxed로 충분합니다. C++20에서는 std::jthread와 std::stop_token이 같은 역할을 표준으로 제공합니다.
모니터링과 스레드 고정
큐 길이, 처리 완료 수, 작업 대기 시간을 atomic 카운터로 노출하면 백프레셔가 필요한지, 워커 수가 부족한지 판단할 수 있습니다. 워커가 주기적으로 heartbeat 시각을 갱신하게 하면 오래 멈춘 워커를 감지할 수 있지만, 멈춘 std::thread를 외부에서 안전하게 중단할 방법은 없으므로 감지 후에는 경보를 내고 원인을 조사하는 용도로 씁니다.
#ifdef __linux__
#include <pthread.h>
// 특정 워커를 특정 코어에 고정 (캐시 지역성, 지연 편차 감소 목적)
void set_affinity(std::thread& t, int cpu) {
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(cpu, &cpuset);
pthread_setaffinity_np(t.native_handle(), sizeof(cpuset), &cpuset);
}
#endif
게임 엔진처럼 물리, 렌더링, AI 작업의 지연 요구가 서로 다르면 도메인별로 풀을 나눠 한쪽의 긴 작업이 다른 쪽을 막지 않게 하는 구성도 흔합니다.
디버깅 도구
동시성 버그는 재현이 어려우므로 도구의 도움이 큽니다. -fsanitize=thread(ThreadSanitizer)는 data race와 일부 락 순서 문제를 검출하고, -fsanitize=address(AddressSanitizer)는 lock-free 코드의 use-after-free를 잡습니다. 두 sanitizer는 함께 켤 수 없으므로 빌드를 나눠 돌립니다. 교착이 의심되면 GDB에서 thread apply all bt로 모든 스레드의 스택을 보고 누가 어떤 락을 기다리는지 확인합니다.
비용이 어디서 생기는가
스레드 풀 vs 매번 스레드 생성
| 방식 | 작업마다 드는 추가 비용 | 메모리 사용 |
|---|---|---|
매번 std::thread 생성 | 커널 스레드 생성·스택 매핑·종료·join | 스레드당 스택 예약(Linux 기본 보통 8MB 가상 메모리) × 동시 스레드 수 |
| 스레드 풀 (N 워커) | 큐에 넣고 꺼내는 비용(락 또는 원자 연산) | 고정 (N개 워커만) |
스레드 생성은 시스템 콜과 스택 매핑이 필요해 짧은 작업 자체보다 비쌀 수 있습니다. 그래서 짧은 작업이 많을수록 스레드 풀의 이점이 크고, 작업이 길면(수백 ms 이상) 생성 비용이 전체에서 차지하는 비중이 작아져 차이가 줄어듭니다.
Mutex 큐 vs Lock-Free SPSC 큐
| 방식 | 연산마다 하는 일 | 비고 |
|---|---|---|
| Mutex + condition_variable | 락 획득·해제, 대기 중인 소비자가 있으면 notify | 경합 시 커널 대기로 넘어가 지연이 커짐 |
| Lock-Free SPSC | 인덱스를 acquire/release로 읽고 쓰기 | 락 없음, 인덱스를 다른 캐시 라인에 두면 False Sharing도 피함 |
SPSC 링 버퍼는 큐가 비었을 때 소비자가 바쁜 대기를 하면 CPU를 낭비하므로, 지연이 아주 중요하지 않다면 mutex 큐가 더 단순하고 안전한 선택일 수 있습니다. 실제 처리량 차이는 CPU·OS·작업 크기에 따라 다르므로 직접 측정해 보세요.
TLS 배치 flush
| 방식 | 100만 이벤트당 공유 변수 갱신 횟수 | 비용이 생기는 곳 |
|---|---|---|
매번 fetch_add | 100만 | 여러 스레드가 같은 캐시 라인을 두고 매번 소유권을 주고받음 |
| TLS 배치 1000 | 1000 | 대부분 스레드 로컬 변수 증가(경합 없음) |
참고 자료
- cppreference - std::thread
- cppreference - std::atomic
- cppreference - thread_local
- C++ Concurrency in Action (Anthony Williams)
- Intel oneTBB