C++ 고급 멀티스레딩 | 스레드 풀·Work Stealing
이 글의 핵심
스레드 풀·Work Stealing·Lock-Free 큐·메모리 순서·Thread-Local Storage 등 고급 멀티스레딩 패턴. 실제 문제 시나리오부터 프로덕션 패턴까지, 1000줄 분량의 실전 가이드.
들어가며: “스레드를 매번 만들면 너무 느려요”
실제 겪는 문제 시나리오
초당 수만 건의 HTTP 요청을 처리하는 API 서버를 만들었습니다. 요청마다 std::thread를 생성해 처리했더니, 피크 타임에 스레드 생성 오버헤드로 CPU 사용률이 80%를 넘었고, 메모리도 급증했습니다.
당시 코드에서는 각 요청마다 새 스레드를 띄웠습니다. 스레드 생성에는 스택 할당(수 MB), 커널 객체 생성, 컨텍스트 스위칭 설정 등이 필요해, 짧은 작업을 많이 처리할 때 오버헤드가 치명적입니다.
// ❌ 문제: 요청마다 스레드 생성 — 오버헤드 폭발
void handleRequest(const Request& req) {
std::thread t([req]() {
processRequest(req);
});
t.detach(); // 매 요청마다 새 스레드
}
원인:
- 스레드 생성/파괴 비용이 요청 처리 시간보다 클 수 있음
- 수천 개 스레드가 동시에 존재하면 메모리·스케줄링 부담
- 컨텍스트 스위칭 증가로 캐시 효율 저하
해결: 스레드 풀로 미리 워커를 두고, 작업만 큐에 넣어 재사용합니다. 이번 글에서는 스레드 풀, Work Stealing, Lock-Free 큐, 메모리 순서, Thread-Local Storage 등 고급 패턴을 다룹니다.
추가 문제 시나리오
시나리오 1: 워커 간 작업 불균형
4개 워커가 있는 스레드 풀에서, 한 워커의 큐에만 작업이 몰리고 나머지는 유휴 상태가 됐습니다. 해결: Work Stealing으로 유휴 워커가 바쁜 워커의 큐에서 작업을 훔쳐옵니다.
시나리오 2: 락 경합으로 병목
고빈도 로그 수집에서 mutex로 큐를 보호하니, 로그 쓰기 지연이 수백 ms까지 늘어났습니다. 해결: Lock-Free 큐로 전환해 락 없이 enqueue/dequeue.
시나리오 3: 플래그와 데이터 동기화 오류
생산자가 데이터를 채운 뒤 ready = true를 설정했는데, 소비자가 ready는 true인데 데이터는 초기화 전 값을 읽는 버그가 발생했습니다. 해결: memory_order_release/acquire로 메모리 순서 보장.
시나리오 4: 스레드별 통계 수집 충돌
여러 스레드가 전역 카운터에 동시에 더하면서 락 경합이 발생했습니다. 해결: Thread-Local Storage로 스레드별 누적 후 주기적으로 전역에 합산.
시나리오 5: 스레드 풀 shutdown 시 작업 유실
종료 시 대기 중인 작업이 처리되지 않고 사라지는 문제. 해결: shutdown 플래그와 predicate를 함께 사용해 남은 작업을 처리한 뒤 종료.
시나리오 6: 게임 엔진 프레임 드롭
물리·렌더링·AI가 각각 스레드를 쓰는데, 프레임마다 스레드를 생성/종료하니 60fps 유지가 어려웠습니다. 해결: 전용 스레드 풀을 도메인별로 두고, 프레임 시작 시 작업만 제출.
시나리오 7: 실시간 로그 버퍼 오버플로우
고빈도 이벤트 로그를 mutex로 보호한 큐에 넣었더니, 로그 쓰기 지연이 누적되어 버퍼가 넘쳤습니다. 해결: SPSC Lock-Free 큐 + 별도 로그 스레드로 비동기 flush.
시나리오 8: 분산 추적에서 스레드 ID 혼선
여러 요청이 같은 스레드 풀 워커를 재사용할 때, thread_local에 이전 요청의 trace ID가 남아 잘못된 추적이 발생했습니다. 해결: 요청 진입 시 TLS 컨텍스트를 명시적으로 초기화.
시나리오 9: Double-Checked Locking 실패
싱글톤 지연 초기화에서 if (!ptr) { lock(); if (!ptr) ptr = new T(); unlock(); } 패턴을 썼는데, ptr이 atomic이 아니어서 다른 스레드가 초기화 중인 객체를 읽는 문제가 발생했습니다. 해결: std::call_once 또는 std::atomic + memory_order_acquire/release로 초기화 순서 보장.
시나리오 10: 백프레셔(Backpressure) 부재
작업 제출 속도가 처리 속도보다 빨라서 큐가 무한히 커지고, 메모리 OOM이 발생했습니다. 해결: Bounded 큐로 크기 제한, submit 시 큐가 가득하면 대기 또는 거부 정책 적용.
목차
- 스레드 풀 완전 구현
- Work Stealing 스케줄러
- Lock-Free 큐
- 메모리 순서 심화
- Thread-Local Storage
- 일반적인 실수
- 모범 사례
- 프로덕션 패턴
- 성능 비교
- 구현 체크리스트
1. 스레드 풀 완전 구현
스레드 풀은 미리 생성한 워커 스레드가 작업 큐에서 작업을 가져와 처리하는 패턴입니다. 스레드 생성/파괴 비용을 제거하고, 작업만 제출하면 됩니다.
스레드 풀 아키텍처
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
W1 -->|pop & 실행| Q
W2 -->|pop & 실행| Q
W3 -->|pop & 실행| Q
W4 -->|pop & 실행| Q
완전한 스레드 풀 예제
// g++ -std=c++17 -pthread -O2 -o thread_pool thread_pool.cpp && ./thread_pool
#include <condition_variable>
#include <functional>
#include <future>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>
class ThreadPool {
public:
explicit ThreadPool(size_t num_threads) : stop_(false) {
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_;
};
int main() {
ThreadPool pool(4);
auto f1 = pool.submit( { 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";
});
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
pool.shutdown();
return 0;
}
위 코드 설명:
- worker():
cv_.wait(lock, predicate)로 “작업이 있거나 종료 신호”일 때까지 대기. predicate에stop_ || !tasks_.empty()를 넣어 shutdown 후에도 대기 중인 작업을 처리. - submit():
std::packaged_task로 반환값을std::future로 전달. 락 풀고notify_one()호출. - shutdown():
stop_ = true후notify_all()로 모든 워커를 깨워, 빈 큐와 종료 플래그를 보고 정상 종료.
실행 결과: f1=30 f2=42 및 8개 작업이 4개 워커에 분배되어 출력됩니다.
스레드 풀 + HTTP 요청 처리 실전 예제
실제 API 서버에서 스레드 풀을 활용하는 패턴입니다. std::packaged_task로 반환값을 future로 전달해, 호출자가 get()으로 결과를 기다립니다.
// 핵심: submit 시 packaged_task로 감싸 future 반환
std::future<HttpResponse> submit(HttpRequest req) {
auto task = std::make_shared<std::packaged_task<HttpResponse()>>(
[req]() { return processRequest(req); });
auto fut = task->get_future();
{ std::lock_guard<std::mutex> lock(mtx_);
tasks_.emplace([task]() { (*task)(); }); }
cv_.notify_one();
return fut;
}
// 호출: auto r = pool.submit({id, "/api/users"}).get();
워커는 예외를 삼키지 않고 packaged_task 실행 시 future로 전파됩니다.
스레드 수 선택 가이드
| 작업 유형 | 권장 스레드 수 | 이유 |
|---|---|---|
| CPU 집약적 | std::thread::hardware_concurrency() | 코어 수에 맞춤 |
| I/O 대기 많음 | 코어 수의 2~4배 | I/O 대기 시 다른 스레드가 CPU 사용 |
| 혼합 | 실험적 튜닝 | 벤치마크로 최적값 찾기 |
2. Work Stealing 스케줄러
Work Stealing은 유휴 워커가 바쁜 워커의 로컬 큐에서 작업을 훔쳐와 처리하는 방식입니다. 작업 불균형을 자동으로 완화합니다.
Work Stealing 동작 원리
flowchart LR
subgraph busy["바쁜 워커"]
Q1[로컬 큐 10개]
end
subgraph idle["유휴 워커"]
W2[워커 2]
end
W2 -->|steal| Q1
Q1 -->|5개 남음| Q1
Work Stealing 스레드 풀 구현
// g++ -std=c++17 -pthread -O2 -o work_stealing work_stealing.cpp
#include <atomic>
#include <deque>
#include <functional>
#include <future>
#include <iostream>
#include <mutex>
#include <random>
#include <thread>
#include <vector>
class WorkStealingPool {
public:
explicit WorkStealingPool(size_t num_threads) : stop_(false) {
queues_.resize(num_threads);
threads_.reserve(num_threads);
for (size_t i = 0; i < num_threads; ++i) {
threads_.emplace_back([this, i] { worker(static_cast<int>(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_victim_.fetch_add(1) % 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);
for (auto& cv : cvs_) cv.notify_all();
for (auto& t : threads_) if (t.joinable()) t.join();
}
private:
using Task = std::function<void()>;
bool trySteal(int my_id, Task& task) {
for (size_t i = 0; i < queues_.size(); ++i) {
int victim = (my_id + 1 + static_cast<int>(i)) % static_cast<int>(queues_.size());
if (victim == my_id) continue;
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(int my_id) {
std::random_device rd;
std::mt19937 gen(rd());
while (!stop_.load(std::memory_order_acquire)) {
Task task;
{
std::unique_lock<std::mutex> lock(mtxs_[my_id]);
cvs_[my_id].wait_for(lock, std::chrono::milliseconds(1),
[this, my_id] { return stop_.load() || !queues_[my_id].empty(); });
if (stop_.load()) return;
if (!queues_[my_id].empty()) {
task = std::move(queues_[my_id].front());
queues_[my_id].pop_front();
}
}
if (task) {
task();
} else if (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_;
std::atomic<size_t> next_victim_{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은 round-robin으로 큐에 분배. - trySteal: 유휴 워커가 다른 워커의 큐 뒤쪽에서 작업을 훔쳐옴. 뒤에서 가져오면 로컬 워커와 충돌 감소.
- worker: 먼저 자신의 큐에서 pop, 비어 있으면
trySteal로 다른 큐에서 가져옴.
주의: 위 예제는 데드락 방지를 위해 wait_for로 짧은 타임아웃을 사용합니다. 프로덕션에서는 더 정교한 대기 전략이 필요할 수 있습니다.
3. Lock-Free 큐
Lock-Free 큐는 mutex 없이 enqueue/dequeue를 수행합니다. 고빈도 생산자-소비자 패턴에서 락 경합을 제거합니다.
SPSC (Single Producer Single Consumer) Lock-Free 큐
한 스레드만 push, 한 스레드만 pop하는 경우가 가장 단순합니다.
#include <array>
#include <atomic>
template <typename T, size_t N>
struct SPSCLockFreeQueue {
std::array<T, N> buffer;
std::atomic<size_t> write_idx{0};
std::atomic<size_t> read_idx{0};
bool push(const T& value) {
size_t w = write_idx.load(std::memory_order_relaxed);
if ((w + 1) % N == read_idx.load(std::memory_order_acquire))
return false;
buffer[w] = value;
write_idx.store((w + 1) % N, std::memory_order_release);
return true;
}
bool pop(T& value) {
size_t r = read_idx.load(std::memory_order_relaxed);
if (r == write_idx.load(std::memory_order_acquire))
return false;
value = buffer[r];
read_idx.store((r + 1) % N, std::memory_order_release);
return true;
}
};
위 코드 설명: write_idx/read_idx만 atomic으로 관리. acquire/release로 생산자-소비자 간 메모리 순서 보장. pop에서 read_idx를 (r + 1) % N으로 증가시켜 소비 위치를 진행시킵니다.
SPSC Lock-Free 큐 실전 사용 예제
단일 생산자(로그 수집 스레드)와 단일 소비자(로그 flush 스레드)가 SPSC 큐로 통신하는 완전한 예제입니다.
// g++ -std=c++17 -pthread -O2 -o spsc_demo spsc_demo.cpp
#include <array>
#include <atomic>
#include <chrono>
#include <iostream>
#include <string>
#include <thread>
template <typename T, size_t N>
struct SPSCQueue {
std::array<T, N> buffer;
std::atomic<size_t> write_idx{0};
std::atomic<size_t> read_idx{0};
bool push(const T& v) {
size_t w = write_idx.load(std::memory_order_relaxed);
if ((w + 1) % N == read_idx.load(std::memory_order_acquire)) return false;
buffer[w] = v;
write_idx.store((w + 1) % N, std::memory_order_release);
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 (!done.load(std::memory_order_acquire) || q.pop(s)) {
if (q.pop(s)) ++count;
else std::this_thread::yield();
}
std::cout << "consumed " << count << " items\n";
});
producer.join();
consumer.join();
return 0;
}
실행: 생산자가 1만 개 로그를 push하고, 소비자가 pop해 처리합니다. 락 없이 고빈도 전달이 가능합니다.
MPMC Lock-Free 큐 (Michael-Scott 큐)
다중 생산자·다중 소비자를 지원하는 Lock-Free 큐는 노드 기반으로 CAS를 사용합니다.
#include <atomic>
template <typename T>
struct LockFreeQueue {
struct Node {
T data;
std::atomic<Node*> next{nullptr};
};
std::atomic<Node*> head_{nullptr};
std::atomic<Node*> tail_{nullptr};
void push(const T& value) {
Node* new_node = new Node{value, nullptr};
Node* old_tail = tail_.exchange(new_node, std::memory_order_acq_rel);
old_tail->next.store(new_node, std::memory_order_release);
}
bool pop(T& value) {
Node* old_head = head_.load(std::memory_order_acquire);
while (old_head) {
Node* next = old_head->next.load(std::memory_order_acquire);
if (head_.compare_exchange_weak(old_head, next,
std::memory_order_acq_rel, std::memory_order_acquire)) {
value = old_head->data;
delete old_head;
return true;
}
}
return false;
}
};
위 코드 설명: Michael-Scott 큐는 dummy 노드와 head/tail 포인터로 동작. push는 tail을 exchange로 새 노드로 바꾸고, 이전 tail의 next를 연결. pop은 head를 CAS로 다음 노드로 교체. ABA 문제 완화를 위해 hazard pointer 등이 필요할 수 있음.
Lock-Free vs Mutex 기반 성능 비교
| 방식 | 장점 | 단점 |
|---|---|---|
| Mutex 큐 | 구현 단순, 안정적 | 락 경합 시 병목 |
| Lock-Free SPSC | 락 없음, 고속 | 단일 생산자/소비자만 |
| Lock-Free MPMC | 다중 지원, 락 없음 | ABA, 메모리 재사용 복잡 |
Lock-Free 큐 사용 시 주의점
- SPSC: 생산자와 소비자가 각각 하나일 때만 사용. 다중 스레드가 push/pop하면 data race.
- 버퍼 크기: 원형 버퍼는 N-1개만 실제로 사용 가능 (빈/가득 구분을 위해 한 칸 비움).
- 메모리: MPMC는 노드 할당/해제가 빈번. 객체 풀 또는 hazard pointer로 재사용 정책 필요.
Hazard Pointer로 MPMC 안전한 노드 재사용
MPMC Lock-Free 큐에서 노드를 즉시 delete하면, 다른 스레드가 아직 참조 중일 수 있어 “use-after-free”가 발생합니다. Hazard Pointer는 “이 포인터를 사용 중”이라고 등록해, 다른 스레드가 해당 노드를 재사용·해제하지 못하게 합니다.
// Hazard Pointer: 스레드가 사용 중인 노드 포인터를 hazard_ptrs[tid]에 등록
// delete 시 모든 hazard_ptrs에 해당 노드가 없을 때만 delete
void acquire_hazard(int tid, std::atomic<void*>& ptr) {
void* p = ptr.load(std::memory_order_acquire);
while (true) {
hazard_ptrs[tid].store(p, std::memory_order_release);
if (ptr.load(std::memory_order_acquire) == p) break;
p = ptr.load(std::memory_order_acquire);
}
}
void release_hazard(int tid) {
hazard_ptrs[tid].store(nullptr, std::memory_order_release);
}
실무 권장: folly::ConcurrentHashMap, boost::lockfree::queue, Intel TBB concurrent_queue 등 검증된 라이브러리를 사용하는 것이 안전합니다.
4. 메모리 순서 심화
여러 스레드가 메모리를 읽고 쓸 때, 실제 실행 순서는 코드 순서와 다르게 재배치될 수 있습니다. memory_order는 이 순서를 제어합니다.
메모리 순서 종류
| 순서 | 의미 | 사용처 |
|---|---|---|
| seq_cst | 단일 전체 순서, 가장 강한 보장 | 기본값, 디버깅 |
| acquire | 이 로드 이후의 연산은 이 로드 이전으로 재배치 안 됨 | 락 획득, 데이터 로드 후 |
| release | 이 스토어 이전의 연산은 이 스토어 이후로 재배치 안 됨 | 락 해제, 데이터 저장 후 |
| acq_rel | acquire + release | CAS 등 RMW |
| relaxed | 순서 보장 없음, 원자성만 | 카운터 등 |
Producer-Consumer 동기화 예제
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 보장
}
위 코드 설명: release로 저장하면 producer의 모든 쓰기가 이 스토어 이후로 재배치되지 않음. acquire로 로드하면 consumer가 이 로드 이후의 읽기가 이 로드 이전으로 재배치되지 않음. 따라서 consumer가 data_ready를 true로 보면 shared_data는 반드시 채워진 상태.
잘못된 예: relaxed 사용
// ❌ 잘못된 예 — consumer가 이전 값을 볼 수 있음
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[0]; // 0이 아닐 수 있음 (재배치)
}
메모리 장벽 시각화
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 vs release/acquire 성능 차이
seq_cst는 모든 스레드가 단일 전체 순서를 공유해, 메모리 장벽이 가장 강합니다. release/acquire는 동기화 관계가 있는 스레드 쌍에만 적용되어, 일반적으로 더 빠릅니다.
// seq_cst: 모든 atomic 연산이 전역 순서에 참여 (느림)
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_seq_cst); // 전체 메모리 장벽
// release/acquire: 동기화 쌍만 보장 (빠름)
data_ready.store(true, std::memory_order_release);
while (!data_ready.load(std::memory_order_acquire)) {}
// relaxed: 순서 없음, 원자성만 (가장 빠름) — 카운터 등에 적합
counter.fetch_add(1, std::memory_order_relaxed);
벤치마크 예시: 100만 회 fetch_add 시 seq_cst vs relaxed — relaxed가 2~3배 빠른 경우가 많습니다. 단, 동기화가 필요한 곳에 relaxed를 쓰면 버그가 되므로 주의합니다.
Double-Checked Locking (메모리 순서 적용)
싱글톤 지연 초기화에서는 instance_.load(std::memory_order_acquire)로 이미 초기화된 경우 안전하게 읽고, instance_.store(p, std::memory_order_release)로 초기화 완료를 전파해야 합니다. relaxed만 쓰면 초기화 전 객체를 읽는 UB가 발생할 수 있으므로, std::call_once 사용을 권장합니다.
메모리 순서 결정 플로우차트
flowchart TD A[atomic 연산 필요?] -->|Yes| B[동기화 필요?] B -->|플래그+데이터| C[release/acquire] B -->|순수 카운터| D[relaxed] B -->|복잡한 의존| E[seq_cst] A -->|No| F[일반 변수 + mutex]
5. Thread-Local Storage (TLS)
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;
}
위 코드 설명: 각 스레드가 tls_counter의 자기만의 복사본을 가짐. 스레드 간 공유되지 않아 락 없이 안전하게 사용 가능.
TLS로 통계 수집 (락 경합 감소)
#include <atomic>
#include <thread>
#include <vector>
std::atomic<uint64_t> global_count{0};
thread_local uint64_t local_count = 0;
constexpr int BATCH = 1000;
void on_event() {
++local_count;
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;
}
위 코드 설명: 스레드별로 local_count에 누적하고, BATCH마다 global_count에 반영. atomic 접근 횟수를 1/BATCH로 줄여 락 경합을 감소시킴.
TLS 주의사항
- 초기화:
thread_local변수는 스레드가 처음 접근할 때 초기화됨. - 수명: 스레드가 종료될 때 소멸. 스레드 종료 시 flush 로직이 필요하면 명시적으로 호출.
- 동적 라이브러리: 플랫폼에 따라 TLS가 DLL 로드 순서에 의존할 수 있음.
TLS로 스레드별 로거 구현
thread_local std::ostringstream tls_log_buffer;
std::mutex g_log_mtx;
void thread_log(const std::string& msg) {
tls_log_buffer << msg;
if (tls_log_buffer.str().size() > 1024) {
std::lock_guard<std::mutex> lock(g_log_mtx);
std::cout << "[" << std::this_thread::get_id() << "] "
<< tls_log_buffer.str() << std::flush;
tls_log_buffer.str("");
}
}
위 코드 설명: 스레드별 tls_log_buffer에 로그를 모았다가 1024바이트 이상 쌓이면 락을 잡고 한 번에 출력. 락 경합 감소.
TLS로 요청별 컨텍스트 관리 (분산 추적)
분산 추적에서 thread_local로 현재 요청의 trace ID를 보관합니다. 요청 진입 시 set, 완료 시 clear가 필수입니다.
thread_local std::optional<RequestContext> tls_request_ctx;
void worker_task(std::function<void()> task, const RequestContext& ctx) {
set_request_context(ctx.trace_id, ctx.span_id);
try { task(); } catch (...) { clear_request_context(); throw; }
clear_request_context(); // 정상 완료 시에도 반드시 초기화
}
스레드 풀 워커는 재사용되므로, 작업 완료 시 clear_request_context()를 호출해 다음 작업에 이전 trace ID가 남지 않도록 합니다.
6. 일반적인 실수
실수 1: shutdown 시 작업 유실
문제: stop_ = true 후 즉시 join하면, 큐에 남은 작업이 처리되지 않음.
// ❌ 잘못된 예
void shutdown_bad() {
stop_ = true;
cv_.notify_all();
for (auto& t : threads_) t.join(); // 큐에 남은 작업 무시
}
해결: predicate에 !tasks_.empty()를 포함해, 종료 전 남은 작업을 처리.
// ✅ 올바른 예
cv_.wait(lock, [this] { return stop_ || !tasks_.empty(); });
if (stop_ && tasks_.empty()) return;
실수 2: Work Stealing 데드락
문제: 모든 워커가 서로의 큐를 기다리며 교착 상태. 또는 steal 시 락 순서가 엇갈려 교착.
해결: trySteal 호출 전에 자신의 큐 lock을 해제. victim 큐는 한 번에 하나만 lock하고, 타임아웃 대기·실패 시 yield 후 재시도.
실수 3: Lock-Free 큐에서 ABA 문제
문제: CAS 시 “같은 포인터”가 중간에 다른 노드로 바뀌었다가 다시 돌아오면, CAS가 잘못 성공할 수 있음.
해결: 버전 카운터를 CAS에 포함하거나, hazard pointer로 노드 재사용 시점 제어.
실수 4: memory_order 잘못 사용
문제: relaxed로 플래그만 바꾸고 데이터는 일반 변수로 쓸 때, consumer가 이전 값을 읽음.
// ❌ 잘못된 예
data_ready.store(true, std::memory_order_relaxed);
해결: 플래그+데이터 동기화에는 release/acquire 쌍 사용.
실수 5: TLS 변수 스레드 종료 시 미반영
문제: 스레드가 종료될 때 thread_local 변수에 쌓인 값을 전역에 반영하지 않음.
해결: 워커 루프 종료 직전에 flush_local() 호출.
실수 6: 스레드 풀에 재귀적 submit
문제: 풀 내부 작업이 다시 submit을 호출하고, 큐가 가득 차면 데드락 가능.
해결: 큐 크기 제한(bounded queue), 또는 재귀 submit을 별도 경로로 처리.
실수 7: condition_variable의 Lost Wakeup
문제: notify_one()을 호출했는데, 아직 wait()에 진입하지 않은 스레드가 있어 신호를 놓칠 수 있음.
// ❌ 잘못된 예: notify가 wait보다 먼저 실행되면 영원히 대기
// 스레드 A: tasks_.push(...); cv_.notify_one();
// 스레드 B: cv_.wait(lock, predicate); // B가 아직 wait 전이면 A의 notify가 사라짐
해결: predicate를 항상 사용해, 조건이 이미 만족되면 wait가 즉시 반환하도록 합니다. wait(lock, [this]{ return !tasks_.empty(); })처럼요.
실수 8: Lock-Free에서 데이터 의존성 무시
문제: pop에서 값을 읽은 뒤 노드를 delete하는데, 다른 스레드가 같은 노드를 CAS로 가져가 사용 중일 수 있음.
해결: Hazard pointer 또는 shared_ptr로 노드 수명을 관리. 또는 소비자가 delete하지 않고, 생산자 풀에서 재사용하는 방식.
실수 9: future.get()을 반복 호출
문제: std::future::get()은 한 번만 호출 가능. 두 번째 호출 시 future_error 예외.
// ❌ 잘못된 예
auto f = pool.submit([]{ return 42; });
int a = f.get();
int b = f.get(); // 예외!
해결: get() 결과를 변수에 저장해 재사용. 또는 std::shared_future를 사용해 여러 번 get() 호출 가능.
실수 10: 스레드 풀에서 예외가 워커를 중단시킴
문제: std::function으로 task를 직접 호출하면 예외 시 워커 루프가 종료되어 스레드 수가 줄어듭니다.
해결: std::packaged_task 사용 시 예외가 future로 전파되고 워커는 유지됩니다. 또는 try { task(); } catch(...) { /* 로깅 */ }으로 루프를 보호합니다.
자주 발생하는 문제와 해결법
문제 1: “terminate called without an active exception” (스레드 풀 소멸 시)
원인: 소멸자에서 join을 호출하지 않거나, 스레드가 이미 종료된 상태.
해결법: ~ThreadPool()에서 shutdown() 호출. shutdown()은 stop_=true → notify_all() → joinable() 체크 후 join().
문제 2: Work Stealing 데드락 — 모든 워커가 대기
원인: 모든 워커가 wait에서 잠들어 있고, 새 작업이 들어오지 않음. 또는 steal 시 락 순서가 엇갈려 교착.
해결법: steal 시 락 순서를 통일(예: 인덱스 오름차순), wait_for로 타임아웃 후 steal 재시도.
문제 3: Lock-Free 큐에서 “corrupted size vs. prev_size”
원인: 한 스레드가 delete한 노드를 다른 스레드가 아직 참조 중. 사용 후 즉시 delete하면 발생.
해결법: hazard pointer, epoch 기반 재사용, 또는 std::shared_ptr로 노드 수명 관리.
문제 4: memory_order_relaxed로 플래그 썼을 때 데이터가 0이 나옴
원인: relaxed는 순서 보장이 없어, 플래그가 true여도 데이터 쓰기가 반영되지 않았을 수 있음.
해결법: data_ready.store(true, std::memory_order_release)와 load(std::memory_order_acquire) 쌍 사용.
문제 5: TLS 변수 값이 스레드 풀에서 이전 작업 값으로 남음
원인: 스레드 풀 워커는 재사용되므로, thread_local 변수가 작업 간에 초기화되지 않음.
해결법: 각 작업 시작 시 TLS 변수를 초기화하거나, 작업별로 명시적으로 리셋.
7. 모범 사례
스레드 풀
| 항목 | 권장 |
|---|---|
| 스레드 수 | hardware_concurrency() 또는 I/O 바운드 시 2~4배 |
| shutdown | predicate에 종료 조건 포함, 남은 작업 처리 후 join |
| 예외 | 작업 내 예외를 future로 전파, 워커 루프는 예외로 중단되지 않게 |
Work Stealing
| 항목 | 권장 |
|---|---|
| steal 방향 | 로컬 큐는 front에서 pop, steal은 back에서 (충돌 감소) |
| 락 범위 | 큐 접근 시에만 락, 작업 실행은 락 밖에서 |
| 빈 큐 대기 | 짧은 sleep 또는 condition_variable로 효율적 대기 |
Lock-Free
| 항목 | 권장 |
|---|---|
| SPSC vs MPMC | 단일 생산자/소비자면 SPSC가 더 단순하고 빠름 |
| ABA | 버전 카운터, hazard pointer 등으로 완화 |
| 메모리 | 노드 재사용 시 안전한 재사용 정책 (예: epoch 기반) |
메모리 순서
| 항목 | 권장 |
|---|---|
| 기본 | seq_cst만 써도 대부분 충분 |
| 최적화 | 플래그+데이터는 release/acquire, 순수 카운터는 relaxed |
| 복잡한 순서 | 전문가 수준이 아니면 seq_cst 유지 |
Thread-Local
| 항목 | 권장 |
|---|---|
| 배치 flush | 주기적으로 전역에 반영해 통계 수집 |
| 수명 | 스레드 종료 전 flush 호출로 누락 방지 |
| 스레드 풀 | 작업 완료 시 컨텍스트(trace ID 등) 초기화 필수 |
8. 프로덕션 패턴
패턴 1: 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();
}
패턴 2: 우선순위 작업 큐
std::priority_queue와 condition_variable로 우선순위 기반 스케줄링.
std::priority_queue<Task, std::vector<Task>, Compare> tasks_;
cv_.wait(lock, [this] { return stop_ || !tasks_.empty(); });
패턴 3: Graceful Shutdown
남은 작업 처리 + 타임아웃.
void shutdown(std::chrono::seconds timeout) {
stop_.store(true);
cv_.notify_all();
auto deadline = std::chrono::steady_clock::now() + timeout;
for (auto& t : threads_) {
if (std::chrono::steady_clock::now() > deadline) break;
if (t.joinable()) t.join();
}
}
패턴 4: 모니터링 메트릭
큐 길이, 처리량, 대기 시간 추적.
struct PoolMetrics {
std::atomic<size_t> queue_size{0};
std::atomic<uint64_t> tasks_completed{0};
std::atomic<uint64_t> total_wait_ns{0};
};
패턴 5: Thread Affinity
특정 워커를 특정 코어에 고정 (플랫폼별 API 사용).
#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
패턴 6: 작업 취소 (Cancellation Token)
std::atomic<bool>로 취소 플래그를 두고, 작업 내부에서 주기적으로 확인합니다.
class CancellationToken {
std::atomic<bool> cancelled_{false};
public:
void cancel() { cancelled_.store(true, std::memory_order_release); }
bool is_cancelled() const {
return cancelled_.load(std::memory_order_acquire);
}
};
// long_running_task 내부: if (token.is_cancelled()) return;
패턴 7: 실전 디버깅 팁
- ThreadSanitizer:
-fsanitize=thread로 data race 검출. - AddressSanitizer:
-fsanitize=address로 use-after-free 검출. - GDB:
thread apply all bt로 모든 스레드 백트레이스 확인.
패턴 8~10: 백프레셔·헬스체크·도메인 풀
- Bounded Submit:
not_full_.wait_for(lock, timeout, predicate)로 큐 가득 시 블로킹 또는 타임아웃. - 헬스체크: 워커가
last_heartbeat_[id].store(now())갱신, 모니터가 N초 미갱신 시 재시작. - 도메인 풀: 물리·렌더링·AI 등 별도 풀로 우선순위·격리 보장.
성능 비교: 스레드 풀 vs 매번 스레드 생성
벤치마크 시나리오
1만 개의 짧은 작업(1ms 미만)을 처리할 때의 총 소요 시간을 비교합니다.
| 방식 | 1만 작업 처리 시간 (예시) | 메모리 사용 |
|---|---|---|
매번 std::thread 생성 | 스레드당 수 MB × 동시 스레드 수 | |
| 스레드 풀 (4 워커) | 고정 (4개 워커만) | |
| 스레드 풀 (8 워커) | 고정 (8개 워커만) |
해석: 짧은 작업이 많을수록 스레드 풀의 이점이 큽니다. 작업이 길면(수백 ms 이상) 차이가 줄어들 수 있습니다.
Mutex 큐 vs Lock-Free SPSC 큐
단일 생산자·단일 소비자 환경에서 초당 enqueue/dequeue 횟수를 측정한 예시:
| 방식 | 초당 처리량 (대략) | 비고 |
|---|---|---|
| Mutex + condition_variable | 1~5백만 ops/s | 락 오버헤드 존재 |
| Lock-Free SPSC | 5~20백만 ops/s | 락 없음, 캐시 친화적 |
실제 수치는 CPU·OS·작업 크기에 따라 다릅니다.
TLS 배치 flush 효과
전역 atomic에 매 이벤트마다 fetch_add vs TLS로 1000개씩 모았다가 반영:
| 방식 | atomic 호출 횟수/100만 이벤트 | 상대 지연 |
|---|---|---|
매번 fetch_add | 100만 | 1.0 (기준) |
| TLS 배치 1000 | 1000 | 약 0.3~0.5 |
10. 구현 체크리스트
- 스레드 풀 shutdown 시 남은 작업 처리
- Work Stealing 시 데드락 방지 (타임아웃, steal 실패 시 yield)
- Lock-Free 큐 사용 시 ABA 문제 대응
- 플래그+데이터 동기화에
release/acquire사용 - TLS 변수 스레드 종료 전 flush
- 스레드 수를
hardware_concurrency()기준으로 설정 - 예외가 워커 루프를 중단하지 않도록 처리
- 프로덕션에서 큐 크기 제한(bounded) 고려
- 모니터링: 큐 길이, 처리량, 지연 추적
-
condition_variable사용 시 predicate 필수 (Lost Wakeup 방지) - Lock-Free 노드 삭제 시 hazard pointer 또는 shared_ptr 사용
-
future.get()한 번만 호출, 또는shared_future사용 - 스레드 풀 워커에서 TLS 컨텍스트(추적 ID 등) 작업 완료 시 초기화
- ThreadSanitizer/AddressSanitizer로 빌드해 동시성 버그 검증
정리
- 스레드 풀: 워커 재사용으로 생성/파괴 오버헤드 제거. Work Stealing: 유휴 워커가 바쁜 워커 큐에서 작업 훔쳐와 부하 분산.
- Lock-Free 큐: mutex 없이 enqueue/dequeue, 고빈도에서 락 경합 제거.
- 메모리 순서:
release/acquire로 플래그·데이터 동기화. TLS: 스레드별 변수로 락 없이 통계·캐시 관리. - 실수: shutdown 시 작업 유실, Work Stealing 데드락, ABA, memory_order 오류, TLS 미반영.
- 프로덕션: Bounded 큐, Graceful Shutdown, 모니터링, Thread Affinity 적용.
관련 검색어: C++ 스레드 풀, Work Stealing, Lock-Free 큐, 메모리 순서, thread_local
다음 글
mutex, condition_variable, atomic과 함께 실무급 동시성 설계가 가능합니다.
자주 묻는 질문 (FAQ)
- 스레드 풀 vs 매번 생성: 스레드 풀은 워커 재사용으로 생성/파괴 비용 제거. 짧은 작업이 많을 때 성능 향상.
- Work Stealing: 작업 크기가 고르지 않거나 특정 워커에 몰릴 때 유용.
- Lock-Free vs mutex: 경합이 적으면 mutex가 나을 수 있음. 벤치마크로 확인.
- memory_order: 기본
seq_cst로 충분. 병목 시에만relaxed/release/acquire고려. - thread_local + 스레드 풀: 안전. 단, 작업 간 상태가 남지 않도록 완료 시 초기화 필수.
참고 자료
- cppreference - std::thread
- cppreference - std::atomic
- cppreference - thread_local
- C++ Concurrency in Action (Anthony Williams)
- Intel TBB - Work Stealing
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
- C++ mutex로 race condition 해결하기 | 주문 카운터 버그부터 lock_guard까지
- C++ condition_variable | “작업이 올 때만 깨워 주세요” 작업 큐
- C++ atomic | Mutex 없이 스레드 안전 카운터 만드는 법 (memory_order 포함)
이 글에서 다루는 키워드 (관련 검색어)
C++, 멀티스레딩, 스레드풀, work-stealing, lock-free, 메모리순서, thread-local, TLS, 동시성, 고급패턴 등으로 검색하시면 이 글이 도움이 됩니다.
관련 글
- C++ condition_variable 실무 패턴 |
- C++ atomic | Mutex 없이 스레드 안전 카운터 만드는 법 (memory_order 포함)
- C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
- C++ mutex로 race condition 해결하기 | 주문 카운터 버그부터 lock_guard까지
- C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례