C++ 멀티스레드 성능 튜닝 | 락 프리·작업 훔치기·스레드 풀 [#51-3]
들어가며: 스레드를 늘렸는데 오히려 느려지는 이유
물리 연산이나 로그 처리를 병렬화한 뒤 코어가 더 많은 서버로 옮겨 스레드 수를 늘렸는데 처리량이 그대로이거나 오히려 떨어지는 일은 흔합니다. 코어 수를 늘린다고 자동으로 빨라지지 않는 이유는 대개 세 가지입니다.
첫째는 락 경합(lock contention)입니다. 모든 워커가 std::mutex 하나로 보호된 큐에서 작업을 가져오면, 스레드가 늘수록 그 락을 기다리는 시간이 늘어나고 실제로 일하는 시간의 비율은 줄어듭니다. 둘째는 false sharing입니다. 스레드마다 별도 카운터를 두었더라도 int counter[8]처럼 배열에 나란히 두면 여러 카운터가 같은 64바이트 캐시 라인에 들어가고, 한 스레드가 쓸 때마다 다른 코어가 가진 그 라인의 사본이 무효화됩니다. 셋째는 부하 불균형입니다. 작업 크기가 들쭉날쭉한데 작업을 고정적으로 나눠 주면, 큰 작업이 몰린 워커만 바쁘고 나머지는 놀게 됩니다.
flowchart TB
subgraph 문제[문제 상황]
P1[락 경합] --> S1[처리량 저하]
P2[False Sharing] --> S2[캐시 무효화 폭증]
P3[부하 불균형] --> S3[코어 활용도 낮음]
end
subgraph 해결[해결 기법]
H1[Lock-Free / 배치] --> R1[락 경합 감소]
H2[캐시 라인 정렬] --> R2[독립 캐시 라인]
H3[Work Stealing] --> R3[작업 재분배]
end
이 글은 각 원인을 진단하는 방법과, 캐시 라인 정렬, lock-free 자료구조, 작업 배치, work stealing 스레드 풀로 개선하는 코드를 차례로 다룹니다.
락 경합 진단
단일 락 작업 큐
// 모든 워커가 하나의 락을 두고 경쟁하는 큐
#include <condition_variable>
#include <functional>
#include <mutex>
#include <queue>
class NaiveTaskQueue {
std::queue<std::function<void()>> queue_;
std::mutex mutex_;
std::condition_variable cv_;
public:
void push(std::function<void()> task) {
{
std::lock_guard<std::mutex> lock(mutex_);
queue_.push(std::move(task));
}
cv_.notify_one();
}
std::function<void()> pop() {
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this]{ return !queue_.empty(); });
auto task = std::move(queue_.front());
queue_.pop();
return task;
}
};
이 큐 자체는 올바르게 동작합니다. 문제는 작업 하나가 아주 짧을 때 드러납니다. 워커 16개가 모두 같은 mutex_에서 pop()을 기다리면, 작업을 하나 꺼낼 때마다 락을 잡고 놓는 과정이 직렬화되고, 락이 넘어갈 때마다 캐시 라인이 코어 사이를 이동하며, 경합이 심하면 스레드가 커널에서 잠들었다 깨어나는 비용까지 더해집니다. 작업 처리 시간이 이 비용과 비슷한 수준이면 스레드를 늘릴수록 락 앞의 대기열만 길어집니다.
무엇을 측정해야 하나
경합 없는 락·언락 한 쌍은 원자적 읽기-수정-쓰기 명령 두 번 정도라서, 단일 스레드에서 반복 측정해 보면 비용이 작게 나옵니다. 즉 단일 스레드 마이크로벤치마크는 경합 문제를 보여 주지 못합니다. 실제로 봐야 할 것은 여러 스레드가 동시에 돌 때의 지표입니다. Linux에서는 perf stat으로 컨텍스트 스위치 수와 캐시 미스를, perf lock이나 perf record -g로 futex 호출이 어디에 몰리는지를 확인합니다. Intel VTune의 Threading 분석이나 mutrace 같은 도구는 락별 대기 시간을 직접 보여 줍니다. 스레드 수를 1, 2, 4, 8로 늘리며 처리량 곡선을 그려 보고, 어느 지점에서 꺾이는지 확인하는 것이 첫 단계입니다.
False Sharing 방지
False sharing이란?
CPU 캐시는 캐시 라인(x86-64와 대부분의 ARM에서 64바이트) 단위로 데이터를 다루고, 코어 사이의 일관성도 라인 단위로 유지합니다. 서로 다른 변수 A와 B가 같은 캐시 라인에 있으면, 스레드 1이 A를 수정할 때 그 라인의 소유권을 가져가면서 다른 코어의 사본이 무효화됩니다. 스레드 2가 B에 접근하면 라인을 다시 가져와야 하므로, 다른 코어의 캐시나 공유 캐시에서 라인을 옮겨 오는 동안 기다립니다. A와 B는 논리적으로 독립인데 물리적으로 같은 라인을 공유해서 생기는 문제라 false sharing(가짜 공유)이라고 부릅니다. 두 스레드가 모두 쓰기를 반복하면 라인이 코어 사이를 계속 오가므로 영향이 가장 큽니다.
flowchart LR
subgraph 캐시라인["캐시 라인 (64B)"]
A[변수 A]
B[변수 B]
end
T1[스레드1: A 수정] --> A
T2[스레드2: B 수정] --> B
A -.->|라인 무효화| B
문제가 되는 코드: 연속 배열의 카운터
#include <atomic>
#include <thread>
#include <vector>
void bad_per_thread_counter() {
std::atomic<int> counters[8]{}; // 32바이트: 한 캐시 라인에 다 들어감
std::vector<std::thread> threads;
for (int t = 0; t < 8; ++t) {
threads.emplace_back([&counters, t]() {
for (int i = 0; i < 1000000; ++i) {
counters[t]++; // 다른 스레드가 가진 같은 라인을 무효화
}
});
}
for (auto& th : threads) th.join();
}
C++17까지 std::atomic<int>의 기본 생성자는 값을 초기화하지 않으므로 {}를 붙여 0으로 만듭니다(C++20부터는 기본 생성자가 값 초기화합니다).
개선한 코드: 캐시 라인 정렬
#include <atomic>
#include <cstddef>
#include <thread>
#include <vector>
constexpr std::size_t CACHE_LINE_SIZE = 64; // x86-64 기준
struct alignas(CACHE_LINE_SIZE) PaddedCounter {
std::atomic<int> value{0};
}; // alignas 때문에 sizeof(PaddedCounter) == 64, 별도 padding 불필요
void good_per_thread_counter() {
PaddedCounter counters[8]; // 각각 독립 캐시 라인
std::vector<std::thread> threads;
for (int t = 0; t < 8; ++t) {
threads.emplace_back([&counters, t]() {
for (int i = 0; i < 1000000; ++i) {
counters[t].value++;
}
});
}
for (auto& th : threads) th.join();
}
alignas(64)를 붙이면 구조체의 시작 주소가 64의 배수가 되고 크기도 64의 배수로 올림되므로, 배열의 각 원소가 서로 다른 캐시 라인에 놓입니다. 시작 주소 정렬 없이 char padding[60]만 붙이면 원소 크기는 64바이트가 되지만 경계에 걸쳐 두 라인에 나뉠 수 있으므로, 패딩보다 alignas가 핵심입니다.
C++17에는 이 값을 플랫폼에 맞게 알려 주는 std::hardware_destructive_interference_size(<new>)가 있습니다.
#include <new>
struct alignas(std::hardware_destructive_interference_size) PaddedCounter17 {
std::atomic<int> value{0};
};
다만 이 상수는 컴파일러 버전과 대상 CPU 옵션에 따라 값이 달라질 수 있어서, GCC는 헤더나 ABI 경계에서 사용하면 -Winterference-size 경고를 냅니다. 라이브러리 인터페이스에 노출되는 타입이라면 64처럼 고정된 값을 쓰는 편이 안전합니다. 일부 ARM 칩(예: Apple Silicon)은 캐시 라인이 128바이트이므로 대상 하드웨어를 확인합니다.
Lock-Free 자료구조
Lock-free 스택과 ABA 문제
lock-free는 “어떤 스레드가 멈추더라도 시스템 전체로는 항상 누군가 진행한다”는 진행 보장을 뜻하며, 보통 std::atomic과 CAS(Compare-And-Swap)로 구현합니다. 스택은 head 포인터 하나만 원자적으로 바꾸면 되므로 가장 단순한 예입니다.
// Lock-free 스택 (개념 설명용: 메모리 회수 문제가 있음)
#include <atomic>
template<typename T>
class LockFreeStack {
struct Node {
T data;
Node* next;
explicit Node(const T& d) : data(d), next(nullptr) {}
};
std::atomic<Node*> head_{nullptr};
public:
void push(const T& value) {
Node* new_node = new Node(value);
new_node->next = head_.load(std::memory_order_relaxed);
while (!head_.compare_exchange_weak(new_node->next, new_node,
std::memory_order_release, std::memory_order_relaxed)) {
// 실패하면 new_node->next가 현재 head로 갱신되고 재시도
}
}
bool pop(T& value) {
Node* old_head = head_.load(std::memory_order_acquire);
while (old_head && !head_.compare_exchange_weak(old_head, old_head->next,
std::memory_order_acquire, std::memory_order_acquire)) {
// 실패하면 old_head가 현재 head로 갱신됨
}
if (!old_head) return false;
value = old_head->data;
delete old_head; // 위험: 다른 스레드가 아직 old_head->next를 읽고 있을 수 있음
return true;
}
};
이 코드에는 두 가지 문제가 있습니다. 하나는 해제 후 사용입니다. 스레드 A가 old_head를 읽고 old_head->next를 평가하려는 순간 스레드 B가 같은 노드를 pop해서 delete하면, A는 해제된 메모리를 읽습니다. 다른 하나는 ABA 문제입니다. B가 노드를 꺼내 해제한 뒤 새 노드가 같은 주소에 할당되어 다시 push되면, A의 CAS는 head가 “여전히 같은 값”이라고 보고 성공하면서 이미 무효한 next를 head로 설치합니다. 실제 코드에서는 hazard pointer나 epoch 기반 회수(C++26에서 std::hazard_pointer와 RCU가 표준에 들어갑니다), 또는 포인터와 버전 카운터를 묶은 128비트 CAS로 이 문제를 해결합니다. 직접 구현하기보다 Boost.Lockfree나 folly 같은 검증된 라이브러리를 먼저 검토하는 것이 현실적입니다.
Lock-free 원형 큐 (단일 생산자-단일 소비자)
생산자와 소비자가 각각 하나뿐인 SPSC(Single Producer Single Consumer) 큐는, 각 인덱스를 한쪽만 쓰기 때문에 CAS 없이 load/store만으로 구현할 수 있습니다.
#include <array>
#include <atomic>
#include <cstddef>
template<typename T, std::size_t N>
class SPSCQueue {
std::array<T, N> buffer_;
alignas(64) std::atomic<std::size_t> head_{0}; // 소비자만 씀
alignas(64) std::atomic<std::size_t> tail_{0}; // 생산자만 씀
public:
bool push(const T& value) {
std::size_t current_tail = tail_.load(std::memory_order_relaxed);
std::size_t next_tail = (current_tail + 1) % N;
if (next_tail == head_.load(std::memory_order_acquire)) {
return false; // 가득 참 (실제 용량은 N - 1)
}
buffer_[current_tail] = value;
tail_.store(next_tail, std::memory_order_release);
return true;
}
bool pop(T& value) {
std::size_t current_head = head_.load(std::memory_order_relaxed);
if (current_head == tail_.load(std::memory_order_acquire)) {
return false; // 비어 있음
}
value = buffer_[current_head];
head_.store((current_head + 1) % N, std::memory_order_release);
return true;
}
};
생산자가 tail_을 release로 저장하고 소비자가 acquire로 읽기 때문에, 소비자가 새 tail_ 값을 보면 그 전에 쓴 buffer_ 내용도 반드시 보입니다. 반대 방향의 head_도 같은 원리로, 생산자가 아직 소비자가 읽는 중인 칸을 덮어쓰지 않게 합니다. head_와 tail_을 서로 다른 캐시 라인에 둔 것은 앞에서 본 false sharing을 피하기 위해서입니다. 가득 참과 비어 있음을 구분하려고 한 칸을 비워 두므로 실제 용량은 N - 1이고, N을 2의 거듭제곱으로 잡으면 % 대신 비트 마스크를 쓸 수 있습니다.
스레드 풀 최적화
풀 크기 결정
CPU 바운드 작업은 스레드 수를 하드웨어 스레드 수에 맞추는 것이 기본입니다. 그보다 많으면 컨텍스트 스위치와 캐시 오염만 늘어납니다. I/O를 기다리는 시간이 긴 작업은 대기하는 동안 다른 스레드가 CPU를 쓸 수 있으므로 더 많은 스레드를 둡니다. 흔히 인용되는 근사식은 코어 수 × (1 + 대기 시간 / 계산 시간)이며, 아래 코드의 2배는 출발점일 뿐 측정으로 조정해야 합니다.
#include <thread>
unsigned get_pool_size(bool io_bound = false) {
unsigned hw = std::thread::hardware_concurrency();
if (hw == 0) hw = 4; // 알 수 없으면 0을 반환하므로 기본값 지정
return io_bound ? hw * 2 : hw;
}
작업 배치(Batching)로 락 오버헤드 감소
작업을 하나씩 넣고 빼면 작업 수만큼 락을 잡습니다. 여러 작업을 한 번에 넣고 빼면 락 획득 횟수가 배치 크기만큼 줄어듭니다.
#include <cstddef>
#include <mutex>
#include <queue>
#include <vector>
template<typename T>
class BatchQueue {
std::queue<T> queue_;
std::mutex mutex_;
public:
void push_batch(std::vector<T>&& batch) {
std::lock_guard<std::mutex> lock(mutex_);
for (auto& item : batch) {
queue_.push(std::move(item));
}
}
bool try_pop_batch(std::vector<T>& out, std::size_t max_size = 32) {
std::lock_guard<std::mutex> lock(mutex_);
while (!queue_.empty() && out.size() < max_size) {
out.push_back(std::move(queue_.front()));
queue_.pop();
}
return !out.empty();
}
};
배치가 너무 크면 한 워커가 많은 작업을 가져가 부하 불균형이 다시 생기므로, 배치 크기는 작업 크기의 편차를 보며 정합니다.
작업 훔치기 (Work Stealing)
워커별 큐와 훔쳐 오기
각 워커가 자기 전용 큐를 갖고, 자기 큐가 비면 다른 워커의 큐에서 작업을 가져옵니다. 공유 큐가 하나일 때와 달리 평소에는 각자 자기 큐만 건드리므로 락 경합이 거의 없고, 부하가 몰린 워커의 작업은 놀고 있는 워커가 가져가므로 불균형도 해소됩니다. 소유자와 훔치는 쪽이 큐의 반대쪽 끝을 사용하는 것이 일반적인데, Chase-Lev 덱 같은 lock-free 구현에서는 이렇게 해야 두 쪽이 대부분의 경우 서로 다른 원소를 건드려 동기화 비용이 줄어듭니다.
flowchart LR
subgraph 워커1[워커 1]
Q1[큐: t1 t2 t3 t4]
W1[처리]
end
subgraph 워커2[워커 2]
Q2[큐: 비어있음]
W2[훔치기]
end
W2 -.->|t4 훔침| Q1
Work-stealing 스레드 풀 구현
아래 구현은 구조를 보여 주기 위해 큐마다 mutex를 둔 단순한 버전입니다. 소유자는 앞에서 꺼내고, 훔치는 쪽은 뒤에서 가져갑니다.
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstddef>
#include <deque>
#include <functional>
#include <mutex>
#include <thread>
#include <vector>
class WorkStealingPool {
using Task = std::function<void()>;
const std::size_t num_workers_;
std::vector<std::deque<Task>> queues_;
std::vector<std::mutex> mutexes_; // mutex는 이동 불가라 생성 시 크기 지정
std::vector<std::condition_variable> cvs_;
std::atomic<bool> done_{false};
std::vector<std::thread> workers_;
bool try_steal(std::size_t thief_id, Task& task) {
for (std::size_t i = 1; i < num_workers_; ++i) {
std::size_t victim = (thief_id + i) % num_workers_;
std::lock_guard<std::mutex> lock(mutexes_[victim]);
if (!queues_[victim].empty()) {
task = std::move(queues_[victim].back()); // 뒤에서 훔침
queues_[victim].pop_back();
return true;
}
}
return false;
}
void worker_loop(std::size_t id) {
while (!done_) {
Task task;
{
std::unique_lock<std::mutex> lock(mutexes_[id]);
// 다른 큐에 작업이 생겨도 알림이 오지 않으므로 짧게 깨어나 훔치기를 시도
cvs_[id].wait_for(lock, std::chrono::milliseconds(1),
[this, id] { return done_ || !queues_[id].empty(); });
if (done_) break;
if (!queues_[id].empty()) {
task = std::move(queues_[id].front());
queues_[id].pop_front();
}
}
if (task || try_steal(id, task)) {
task();
}
}
}
public:
explicit WorkStealingPool(std::size_t n)
: num_workers_(n), queues_(n), mutexes_(n), cvs_(n) {
for (std::size_t i = 0; i < n; ++i) {
workers_.emplace_back(&WorkStealingPool::worker_loop, this, i);
}
}
void submit(std::size_t preferred_worker, Task task) {
std::size_t id = preferred_worker % num_workers_;
{
std::lock_guard<std::mutex> lock(mutexes_[id]);
queues_[id].push_back(std::move(task));
}
cvs_[id].notify_one();
}
~WorkStealingPool() {
done_ = true;
for (auto& cv : cvs_) cv.notify_all();
for (auto& w : workers_) w.join();
// 이 구현은 종료 시 큐에 남은 작업을 버림
}
};
std::mutex와 std::condition_variable은 복사도 이동도 할 수 없으므로 std::vector<std::mutex>에 resize()를 쓰면 컴파일되지 않습니다. 생성자 초기화 목록에서 mutexes_(n)처럼 크기를 지정해 기본 생성해야 합니다. 이 버전은 1ms 간격으로 깨어나 훔치기를 시도하므로 놀고 있을 때도 약간의 CPU를 쓰고, 종료 시 남은 작업을 버립니다. 운영 환경용으로는 남은 작업을 모두 처리한 뒤 종료하는 정책과, 모든 워커가 놀고 있을 때 잠드는 장치를 추가해야 합니다. 성숙한 구현이 필요하다면 Intel oneTBB의 task arena를 검토할 만합니다.
튜닝 예제
병렬 카운터와 false sharing 비교
#include <atomic>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <thread>
#include <vector>
struct alignas(64) AlignedCounter {
std::atomic<std::uint64_t> value{0};
};
struct PlainCounter {
std::atomic<std::uint64_t> value{0};
};
template<typename Counter>
long long run(int num_threads, int iterations) {
std::vector<Counter> counters(num_threads); // C++17부터 over-aligned 할당 지원
std::vector<std::thread> threads;
auto start = std::chrono::steady_clock::now();
for (int t = 0; t < num_threads; ++t) {
threads.emplace_back([&counters, t, iterations]() {
for (int i = 0; i < iterations; ++i) {
counters[t].value.fetch_add(1, std::memory_order_relaxed);
}
});
}
for (auto& th : threads) th.join();
auto end = std::chrono::steady_clock::now();
return std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
}
int main() {
const int num_threads = 8;
const int iterations = 10'000'000;
std::cout << "plain (false sharing): " << run<PlainCounter>(num_threads, iterations) << " ms\n";
std::cout << "aligned: " << run<AlignedCounter>(num_threads, iterations) << " ms\n";
}
g++ -O2 -std=c++17 -pthread로 빌드해 실행하면 두 결과의 차이로 false sharing의 비용을 직접 확인할 수 있습니다. 차이의 크기는 코어 수, CPU 세대, 소켓 구성에 따라 달라지므로, 자신의 대상 하드웨어에서 측정해 보는 것이 좋습니다. 경과 시간 측정에는 시스템 시계 조정의 영향을 받지 않는 steady_clock을 씁니다.
Lock-free SPSC 로그 버퍼
// 로그 메시지를 SPSC 큐로 비동기 전달
#include <array>
#include <atomic>
#include <cstddef>
#include <cstdio>
#include <iostream>
#include <thread>
template<std::size_t N>
class LogBuffer {
struct Entry {
char msg[256];
int level;
};
std::array<Entry, N> buffer_;
alignas(64) std::atomic<std::size_t> head_{0};
alignas(64) std::atomic<std::size_t> tail_{0};
public:
bool push(const char* msg, int level) {
std::size_t t = tail_.load(std::memory_order_relaxed);
std::size_t next = (t + 1) % N;
if (next == head_.load(std::memory_order_acquire)) return false;
std::snprintf(buffer_[t].msg, sizeof(buffer_[t].msg), "%s", msg);
buffer_[t].level = level;
tail_.store(next, std::memory_order_release);
return true;
}
bool pop(char* out_msg, std::size_t out_size, int& out_level) {
std::size_t h = head_.load(std::memory_order_relaxed);
if (h == tail_.load(std::memory_order_acquire)) return false;
std::snprintf(out_msg, out_size, "%s", buffer_[h].msg);
out_level = buffer_[h].level;
head_.store((h + 1) % N, std::memory_order_release);
return true;
}
};
int main() {
static LogBuffer<1024> log_buf; // 약 260KB라 스택 대신 정적 저장소에
std::thread producer([&]() {
for (int i = 0; i < 100; ++i) {
char buf[64];
std::snprintf(buf, sizeof(buf), "log %d", i);
while (!log_buf.push(buf, 0)) std::this_thread::yield();
}
});
std::thread consumer([&]() {
char msg[256];
int level;
int count = 0;
while (count < 100) {
if (log_buf.pop(msg, sizeof(msg), level)) {
std::cout << msg << "\n";
++count;
} else {
std::this_thread::yield();
}
}
});
producer.join();
consumer.join();
}
로그를 남기는 스레드는 버퍼에 복사만 하고 바로 돌아가며, 느린 파일·콘솔 출력은 소비자 스레드가 맡습니다. 생산자가 여러 개라면 SPSC 큐를 스레드마다 하나씩 두고 소비자가 돌아가며 비우는 방식으로 확장할 수 있습니다.
자주 만나는 문제
스레드를 늘렸는데 성능이 나빠짐
대부분 락 경합이나 false sharing이 원인입니다. 프로파일러(perf, VTune)로 락 대기와 캐시 미스를 먼저 확인하고, 스레드별 데이터는 alignas(64)로 캐시 라인을 분리합니다. 그다음 공유 큐를 배치 처리나 워커별 큐로 바꾸는 것을 검토합니다. lock-free 구조로 바꾸는 것은 그 뒤의 선택지입니다.
Lock-free 큐에서 데이터가 깨짐
메모리 순서를 잘못 쓴 경우입니다. 인덱스를 memory_order_relaxed로 저장하면 버퍼에 쓴 내용보다 인덱스 갱신이 먼저 보일 수 있어, 소비자가 아직 쓰이지 않은 칸을 읽습니다.
// 잘못된 예
tail_.store(next, std::memory_order_relaxed); // 버퍼 쓰기보다 먼저 보일 수 있음
// 올바른 예
tail_.store(next, std::memory_order_release); // 이전 쓰기가 모두 함께 보임
Work stealing에서 데드락
여러 큐의 락을 동시에 잡으면서 순서가 일정하지 않으면 데드락이 생길 수 있습니다. 두 개 이상을 함께 잡아야 한다면 std::scoped_lock을 쓰거나 큐 ID 순서로 잡습니다. 위 예제는 한 번에 하나의 victim 락만 잡으므로 데드락이 생기지 않습니다.
std::atomic<std::string>이 컴파일되지 않음
std::atomic<T>는 trivially copyable 타입만 받습니다. 문자열 같은 객체를 원자적으로 교체하려면 포인터를 atomic으로 두거나, C++20의 std::atomic<std::shared_ptr<T>>를 씁니다. C++11의 자유 함수 std::atomic_load/std::atomic_store(shared_ptr 오버로드)는 C++20에서 deprecated되었습니다. std::atomic<std::shared_ptr<T>>는 대부분의 구현에서 내부적으로 락을 쓰므로 lock-free는 아닙니다.
// std::atomic<std::string> s; // 컴파일 에러
std::atomic<std::shared_ptr<std::string>> ptr; // C++20
스레드 풀 종료 시 멈춤
done_ = true만 설정하고 notify_all을 하지 않으면, 시간 제한 없이 wait 중인 워커가 깨어나지 않아 소멸자의 join()이 영원히 기다립니다. 종료 플래그를 설정한 뒤 모든 조건 변수에 notify_all을 호출합니다. 큐에 남은 작업을 처리하고 끝낼지, 버릴지도 명확히 정해 두어야 합니다.
CAS 루프가 오래 돈다
compare_exchange_weak의 가짜 실패(spurious failure)는 드물고 다음 반복에서 해결되므로 그 자체로 루프가 끝나지 않는 일은 없습니다. CAS 루프가 오래 도는 진짜 원인은 여러 스레드가 같은 변수를 두고 경쟁하는 것입니다. 이때는 실패할 때마다 대기 시간을 늘리는 백오프(x86의 _mm_pause(), 일정 횟수 뒤 std::this_thread::yield())를 넣거나, 근본적으로 그 변수에 대한 경쟁을 줄이는 구조(스레드별 집계 후 합산 등)로 바꿉니다.
조건 변수의 lost wakeup
조건 변수는 알림을 저장하지 않으므로, 소비자가 wait에 들어가기 전에 notify_one이 호출되면 그 알림은 사라집니다. 해결책은 두 가지를 함께 지키는 것입니다. 상태(큐 내용, 종료 플래그)는 반드시 같은 mutex를 잡고 바꾸고, wait에는 predicate를 넘겨 깨어날 때와 잠들기 전에 조건을 확인하게 합니다. predicate 버전은 가짜 깨어남(spurious wakeup)도 함께 처리합니다.
cv_.wait(lock, [this] { return !queue_.empty() || done_; });
크리티컬 섹션 축소와 기타 기법
크리티컬 섹션 최소화
락을 잡은 상태에서 하는 일을 최소한으로 줄입니다.
// 나쁜 예: 락을 잡은 채로 무거운 계산
{
std::lock_guard<std::mutex> lock(m);
auto data = expensive_computation();
queue_.push(data);
}
// 좋은 예: 계산은 락 밖에서
auto data = expensive_computation();
{
std::lock_guard<std::mutex> lock(m);
queue_.push(std::move(data));
}
읽기가 압도적으로 많으면 shared_mutex
읽기가 대부분이고 쓰기가 드물면 C++17의 std::shared_mutex로 읽기 락을 공유합니다.
#include <shared_mutex>
#include <vector>
std::shared_mutex rw_mutex;
std::vector<int> data;
void reader() {
std::shared_lock lock(rw_mutex); // 여러 스레드 동시 읽기 가능
use(data);
}
void writer() {
std::unique_lock lock(rw_mutex); // 쓰기는 독점
data.push_back(42);
}
shared_mutex는 일반 mutex보다 획득 비용이 크고, 읽기 락을 잡을 때도 내부 카운터를 원자적으로 바꾸므로 그 캐시 라인을 두고 경합이 생깁니다. 읽기 구간이 매우 짧다면 일반 mutex보다 느릴 수 있으니 측정해서 결정합니다.
스핀락은 아주 짧은 구간에만
대기 시간이 매우 짧고 스레드 수가 코어 수를 넘지 않을 때만 스핀락을 고려합니다. 락을 잡은 스레드가 선점되면 다른 스레드는 그동안 CPU를 태우며 돌기만 하므로, 그 외의 경우에는 std::mutex가 낫습니다. 대부분의 std::mutex 구현은 잠들기 전에 잠깐 스핀하므로, 직접 만든 스핀락이 이득을 보는 경우는 생각보다 적습니다.
#include <atomic>
class SpinLock {
std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag_.test_and_set(std::memory_order_acquire)) {
// C++20이라면 flag_.test()로 읽기만 하며 기다려 캐시 라인 경합을 줄일 수 있음
}
}
void unlock() {
flag_.clear(std::memory_order_release);
}
};
메모리 할당 줄이기
lock-free 자료구조라도 노드마다 new/delete를 하면 메모리 할당자가 새로운 공유 지점이 됩니다. 노드를 미리 할당해 재사용하면 이 비용을 줄일 수 있지만, 재사용용 프리 리스트를 여러 스레드가 CAS로 공유하면 앞에서 본 ABA 문제가 그대로 다시 나타납니다. 그래서 흔히 쓰는 방식은 프리 리스트를 thread_local로 두어 스레드마다 따로 관리하거나, SPSC 큐처럼 고정 크기 배열을 미리 할당해 노드 할당 자체를 없애는 것입니다. tcmalloc, jemalloc, mimalloc처럼 스레드별 캐시를 가진 할당자로 바꾸는 것만으로 할당 경합이 크게 줄어드는 경우도 많습니다.
NUMA 인지 (서버급 시스템)
여러 소켓을 가진 NUMA(Non-Uniform Memory Access) 서버에서는 스레드가 다른 소켓에 붙은 메모리에 접근할 때 지연이 커집니다. Linux는 기본적으로 메모리를 처음 쓴 스레드가 실행 중인 노드에 페이지를 배치(first-touch)하므로, 스레드를 특정 CPU에 고정하고 그 스레드가 자기 데이터를 직접 초기화하게 하면 지역성을 지킬 수 있습니다. 프로세스 단위로는 numactl, 스레드 단위로는 pthread_setaffinity_np를 씁니다.
#ifdef __linux__
#include <pthread.h>
#include <sched.h>
void pin_to_cpu(int cpu_id) {
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(cpu_id, &cpuset);
pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset);
}
#endif
기법별 적용 조건
| 기법 | 적용 상황 | 효과가 커지는 조건 |
|---|---|---|
| False sharing 제거 | 스레드별 카운터·버퍼 | 스레드 수가 많고, 같은 캐시 라인에 쓰는 빈도가 높을수록 |
| Lock-free 큐 | 고빈도 enqueue/dequeue | 락 보유 시간이 짧고 경합이 심할 때 (경합이 낮으면 mutex와 차이 작음) |
| Work stealing | 작업 크기 불균형 | 작업 크기 편차가 크고 개수가 많을 때 |
| 배치 처리 | 다수의 소규모 작업 | 작업 하나의 처리 시간이 락 획득 비용과 비슷할 만큼 작을 때 |
| shared_mutex | 읽기가 쓰기보다 훨씬 많음 | 읽기 구간이 충분히 길 때 (짧으면 shared_mutex 자체의 오버헤드가 더 큼) |
효과의 크기는 워크로드와 하드웨어에 따라 크게 달라지므로, 적용 전후를 perf stat(컨텍스트 스위치, 캐시 미스)과 처리량 측정으로 비교하세요.
운영 환경에서 필요한 장치
종료 처리
작업 중인 항목을 마저 처리하고 스레드를 종료합니다. 종료 플래그 설정과 notify_all, 모든 스레드의 join이 한 묶음입니다.
void shutdown() {
{
std::lock_guard lock(mutex_);
stop_requested_ = true; // 상태 변경은 mutex 안에서
}
cv_.notify_all();
for (auto& w : workers_) {
if (w.joinable()) w.join();
}
}
백프레셔(Backpressure)
큐 크기에 상한을 두고, 가득 차면 생산자를 대기시켜 메모리가 끝없이 늘어나는 것을 막습니다.
void push_with_backpressure(Task task) {
std::unique_lock lock(mutex_);
cv_not_full_.wait(lock, [this] { return queue_.size() < max_size_; });
queue_.push(std::move(task));
lock.unlock();
cv_not_empty_.notify_one();
}
// 소비자는 작업을 꺼낸 뒤 cv_not_full_.notify_one()을 호출해야 함
우선순위 큐
긴급한 작업을 먼저 처리하려면 우선순위 큐를 씁니다. std::pair<int, std::function<void()>>에 std::greater<>를 그대로 쓰면 우선순위가 같을 때 std::function끼리 비교하려다 컴파일 에러가 나므로, 우선순위만 비교하는 비교자를 직접 둡니다.
#include <functional>
#include <queue>
#include <vector>
struct PrioTask {
int priority; // 작을수록 먼저
std::function<void()> fn;
};
struct ByPriority {
bool operator()(const PrioTask& a, const PrioTask& b) const {
return a.priority > b.priority;
}
};
std::priority_queue<PrioTask, std::vector<PrioTask>, ByPriority> queue_;
우선순위가 낮은 작업이 계속 밀려 실행되지 못하는 기아(starvation)가 생길 수 있으므로, 대기 시간에 따라 우선순위를 올리는 장치를 함께 고려합니다.
상태 모니터링
struct PoolStats {
std::size_t queue_size;
std::size_t active_tasks;
std::size_t completed_tasks;
};
// const 멤버 함수에서 잠그려면 mutex를 mutable로 선언해야 함
// mutable std::mutex mutex_;
PoolStats get_stats() const {
std::lock_guard lock(mutex_);
return {queue_.size(), active_, completed_};
}
큐 길이와 처리량을 주기적으로 내보내면, 큐가 계속 늘어나는지(소비 속도 부족)와 워커가 놀고 있는지(생산 부족 또는 불균형)를 운영 중에 구분할 수 있습니다.
오래 걸리는 작업 다루기
C++에는 실행 중인 스레드를 밖에서 강제로 멈추는 안전한 방법이 없습니다. 시간 제한이 필요하다면 작업이 스스로 중단 요청을 확인하는 협력적 취소를 써야 합니다. C++20의 std::jthread와 std::stop_token이 이를 위한 표준 도구입니다.
#include <chrono>
#include <stop_token>
#include <thread>
void long_task(std::stop_token st) {
while (!st.stop_requested()) {
do_one_step(); // 작업을 잘게 나눠 중간중간 확인
}
}
void run_with_deadline() {
std::jthread worker(long_task);
std::this_thread::sleep_for(std::chrono::seconds(5));
worker.request_stop(); // 중단 요청; jthread 소멸자가 join
}
타임아웃이 지나면 스레드를 detach()하고 잊어버리는 방식은 그 스레드가 계속 자원을 쓰고, 프로그램 종료 시 이미 소멸한 객체에 접근할 위험이 있으므로 피하는 것이 좋습니다.
CPU 바운드 풀과 I/O 바운드 풀 분리
CPU를 많이 쓰는 작업과 I/O를 기다리는 작업을 별도 풀로 나누면, I/O 대기가 CPU 워커를 붙잡지 않습니다.
// CPU 풀: 하드웨어 스레드 수
WorkStealingPool cpu_pool{std::thread::hardware_concurrency()};
// I/O 풀: 대기 시간을 고려해 더 많이
WorkStealingPool io_pool{std::thread::hardware_concurrency() * 2};
void handle_request(Request req) {
io_pool.submit(0, [req]() {
auto data = fetch_from_db(req); // I/O 대기
cpu_pool.submit(0, [data]() {
process(data); // CPU 연산
});
});
}
스레드 로컬 저장소
스레드마다 독립적인 버퍼를 두면 락 없이 재사용할 수 있고, 매 호출마다 메모리를 새로 할당하는 비용도 사라집니다.
thread_local std::vector<int> thread_local_buffer;
void process_item(int x) {
thread_local_buffer.clear(); // 용량은 유지되어 재할당 없음
thread_local_buffer.push_back(x);
do_work(thread_local_buffer); // 이 스레드만 접근하므로 락 불필요
}