C++ 멀티스레드 간헐적 크래시: ThreadSanitizer로 데이터 레이스 찾고 mutex·atomic으로 고치기
이 글의 핵심
데이터 레이스는 재현이 들쭉날쭉해서 로그만으로는 원인을 잡기 어렵고, C++에서는 정의되지 않은 동작이라 크래시 대신 값이 조용히 틀어지기도 합니다. vector 동시 수정, 초기화 레이스, 이중 체크 락킹, 락 범위 실수 등 자주 나오는 버그 10가지를 짚고, 스레드 풀 작업 큐 같은 사례로 atomic과 mutex의 선택 기준을 정리합니다.
들어가며: “멀티스레드로 바꿨더니 간헐적으로 크래시…”
멀티스레드 프로그래밍에서 가장 흔한 버그는 데이터 레이스(Data Race—여러 스레드가 동기화 없이 같은 메모리에 동시 접근)입니다. 싱글스레드에서는 정상 작동하다가 멀티스레드로 바꾸면 간헐적으로 크래시가 발생합니다.
// ❌ 데이터 레이스
// 변수 선언 및 초기화
int counter = 0;
void worker() {
for (int i = 0; i < 1000000; ++i) {
++counter; // 동기화 없이 공유 변수 수정
}
}
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
std::cout << counter << '\n'; // 2000000이 아닐 수 있음!
++counter는 한 줄이지만 CPU 입장에서는 “메모리에서 읽기 → 1 더하기 → 메모리에 쓰기”의 세 단계입니다. 두 스레드가 같은 값을 읽고 각자 1을 더해 쓰면 증가 한 번이 사라집니다. 더 곤란한 점은 이 코드가 최적화 옵션에 따라 전혀 다른 증상을 보인다는 것입니다. -O2에서는 컴파일러가 “다른 스레드가 이 변수를 건드리지 않는다”고 가정해도 되므로 루프 전체를 counter += 1000000 한 번으로 합쳐 버리기도 하고, 그러면 결과가 우연히 2000000으로 맞아 버그가 숨습니다. 디버그 빌드에서는 틀리고 릴리스 빌드에서는 맞거나, 그 반대가 되는 것이 데이터 레이스의 전형적인 모습입니다.
이 글에서 다루는 것:
- 데이터 레이스와 race condition
- mutex로 동기화
- atomic 변수
- ThreadSanitizer로 버그 탐지
- 자주 나오는 멀티스레드 버그 10가지
데이터 레이스란?
정의
데이터 레이스는 다음 조건을 모두 만족할 때 발생합니다:
- 여러 스레드가 같은 메모리에 접근
- 최소 하나가 쓰기 연산
- 동기화 없음 (mutex, atomic 등)
// ❌ 데이터 레이스
int shared = 0;
// 스레드 1
shared = 42; // 쓰기
// 스레드 2
int x = shared; // 읽기
// 동기화 없음 → 데이터 레이스 → 미정의 동작
데이터 레이스의 결과
- 잘못된 값 읽기
- 크래시 (Segmentation Fault)
- 간헐적 버그 (재현 어려움)
- 컴파일러 최적화로 인한 예측 불가능한 동작
C++ 표준은 데이터 레이스가 있는 프로그램 전체를 정의되지 않은 동작(UB)으로 봅니다. “값이 조금 틀릴 뿐”이 아니라는 뜻입니다. 예를 들어 while (!done) {}에서 done이 일반 bool이면 컴파일러는 루프 안에서 done이 바뀌지 않는다고 보고 한 번만 읽어 레지스터에 둘 수 있고, 다른 스레드가 done = true를 써도 루프가 영원히 끝나지 않습니다. 크래시가 나는 경우는 대개 vector·string·map처럼 내부에 포인터와 크기를 함께 관리하는 객체가 동시에 수정되어 포인터와 크기가 서로 어긋날 때입니다. 이때 크래시 위치는 레이스가 일어난 곳이 아니라 한참 뒤에 그 망가진 객체를 쓰는 곳이라서, 코어 덤프의 콜스택만 보고는 원인을 찾기 어렵습니다.
mutex로 동기화
기본 사용법
#include <mutex>
int counter = 0;
std::mutex mtx;
void worker() {
for (int i = 0; i < 1000000; ++i) {
std::lock_guard<std::mutex> lock(mtx); // 자동 잠금
++counter;
} // lock 소멸 시 자동 해제
}
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
std::cout << counter << '\n'; // 2000000 (정확함)
lock_guard vs unique_lock
// lock_guard: 간단, RAII
{
std::lock_guard<std::mutex> lock(mtx);
// 임계 영역
} // 자동 해제
// unique_lock: 고급 (수동 잠금/해제)
{
std::unique_lock<std::mutex> lock(mtx);
// 임계 영역
lock.unlock(); // 수동 해제
// 잠금 없이 작업
lock.lock(); // 다시 잠금
}
lock_guard는 생성할 때 잠그고 소멸할 때 푸는 것 외에 아무 기능이 없어서 실수할 여지가 적습니다. unique_lock은 중간에 풀고 다시 잡을 수 있고, 잠그지 않은 상태로 만들거나(std::defer_lock) 다른 함수로 이동시킬 수도 있습니다. condition_variable::wait는 대기하는 동안 락을 풀어야 하므로 반드시 unique_lock을 요구합니다. C++17 이후에는 std::lock_guard lock(mtx);처럼 템플릿 인자를 생략할 수 있고(CTAD), 여러 뮤텍스를 한꺼번에 잡을 때는 std::scoped_lock이 lock_guard의 상위 호환입니다.
데드락 방지
// ❌ 데드락 가능
std::mutex mtx1, mtx2;
// 스레드 1
{
std::lock_guard lock1(mtx1);
std::lock_guard lock2(mtx2); // mtx1 → mtx2 순서
}
// 스레드 2
{
std::lock_guard lock2(mtx2);
std::lock_guard lock1(mtx1); // mtx2 → mtx1 순서 → 데드락!
}
// ✅ 해결: std::lock으로 동시 잠금
{
std::scoped_lock lock(mtx1, mtx2); // C++17, 데드락 방지
// 또는
std::lock(mtx1, mtx2);
std::lock_guard lock1(mtx1, std::adopt_lock);
std::lock_guard lock2(mtx2, std::adopt_lock);
}
std::scoped_lock(과 std::lock)은 여러 뮤텍스를 잡을 때 하나를 잡고 다음 것이 실패하면 잡았던 것을 풀고 다시 시도하는 방식이라, 호출하는 쪽의 순서가 달라도 데드락이 나지 않습니다. 다만 이것은 한 지점에서 두 뮤텍스를 동시에 잡을 때만 통합니다. 실무의 데드락은 “락을 잡은 채로 콜백이나 다른 모듈의 함수를 호출했는데, 그 함수가 내부에서 다른 락을 잡는” 형태가 더 흔하고, 이것은 scoped_lock으로 막을 수 없습니다. 그래서 락을 잡은 상태에서는 외부 코드(콜백, 가상 함수, 로깅 라이브러리 등)를 부르지 않는다는 규칙이 가장 효과적입니다. 데드락이 났을 때는 프로세스가 CPU를 쓰지 않고 멈춰 있으므로, gdb -p <pid>로 붙어 thread apply all bt를 실행하면 각 스레드가 어느 뮤텍스에서 기다리는지 바로 보입니다.
atomic 변수
기본 사용법
#include <atomic>
std::atomic<int> counter{0};
void worker() {
for (int i = 0; i < 1000000; ++i) {
++counter; // 원자적 증가 (동기화 불필요)
}
}
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
std::cout << counter << '\n'; // 2000000 (정확함)
atomic vs mutex
// atomic: 단순 연산
std::atomic<int> counter{0};
++counter; // 빠름
// mutex: 복잡한 연산
std::mutex mtx;
int counter = 0;
{
std::lock_guard lock(mtx);
++counter;
// 여러 변수 함께 수정 가능
}
선택 기준:
- 단순 카운터/플래그 → atomic
- 여러 변수 함께 보호 → mutex
- 복잡한 연산 → mutex
“atomic이 더 빠르다”는 말은 경합이 적을 때 대체로 맞지만, 많은 스레드가 같은 atomic 변수를 계속 증가시키면 그 캐시 라인이 코어 사이를 오가느라 결국 병목이 됩니다. 이 경우 스레드마다 지역 카운터를 두고 마지막에 한 번 합치는 편이 atomic보다도 훨씬 빠릅니다. 또 흔한 착각은 atomic 변수 두 개를 쓰면 둘의 관계도 안전하다고 생각하는 것입니다. balance와 history_count를 각각 atomic으로 만들어도, 다른 스레드는 한쪽만 갱신된 중간 상태를 볼 수 있습니다. 여러 값 사이의 불변식(invariant)을 지켜야 한다면 mutex가 필요합니다.
ThreadSanitizer로 탐지
컴파일 (GCC/Clang)
# ThreadSanitizer 활성화
g++ -g -fsanitize=thread -std=c++17 -o myapp main.cpp
# 실행
./myapp
출력 예시
// 테스트 코드
int shared = 0;
void writer() {
shared = 42;
}
void reader() {
int x = shared;
}
int main() {
std::thread t1(writer);
std::thread t2(reader);
t1.join();
t2.join();
}
ThreadSanitizer 출력:
==================
WARNING: ThreadSanitizer: data race (pid=12345)
Write of size 4 at 0x7b0400000000 by thread T1:
#0 writer() main.cpp:4
Previous read of size 4 at 0x7b0400000000 by thread T2:
#0 reader() main.cpp:8
SUMMARY: ThreadSanitizer: data race main.cpp:4 in writer()
==================
해석: shared 변수에 데이터 레이스 발생. 실제 출력에는 각 접근의 전체 콜스택과 해당 스레드가 어디서 생성됐는지(Thread T1 (tid=...) created by main thread at:), 그리고 변수가 전역인지 힙인지(Location is global 'shared')까지 나옵니다. 두 콜스택을 보고 “이 두 지점 사이에 어떤 락도 공유되지 않았다”는 것을 확인하는 것이 분석의 출발점입니다.
ThreadSanitizer는 실제로 실행된 경로에서만 레이스를 찾습니다. 테스트가 두 스레드를 동시에 해당 코드로 보내지 않으면 보고되지 않으므로, 멀티스레드 스트레스 테스트와 함께 돌려야 효과가 있습니다. 대신 레이스가 실제로 “터지지” 않아도, 두 접근 사이에 동기화가 없었다는 사실만으로 보고하기 때문에 재현이 드문 버그를 잡는 데 강합니다. 실행 속도가 수 배~십수 배 느려지고 메모리도 크게 늘기 때문에 운영 빌드가 아니라 테스트 전용 빌드로 CI에서 돌리는 것이 일반적입니다. -fsanitize=address(ASan)와는 함께 켤 수 없으니 빌드 구성을 따로 둬야 하고, 직접 만든 lock-free 구조나 인라인 어셈블리는 오탐을 낼 수 있어 __attribute__((no_sanitize("thread")))나 suppressions 파일로 예외 처리합니다.
자주 나오는 버그 10가지
버그 1: 공유 변수 동기화 없음
// ❌ 데이터 레이스
int counter = 0;
void worker() {
for (int i = 0; i < 1000000; ++i) {
++counter; // 동기화 없음
}
}
// ✅ 해결: atomic
std::atomic<int> counter{0};
void worker() {
for (int i = 0; i < 1000000; ++i) {
++counter; // 원자적 증가
}
}
버그 2: vector 동시 수정
// ❌ 데이터 레이스
std::vector<int> vec;
void worker() {
for (int i = 0; i < 1000; ++i) {
vec.push_back(i); // 동기화 없음 → 크래시
}
}
// ✅ 해결: mutex
std::vector<int> vec;
std::mutex mtx;
void worker() {
for (int i = 0; i < 1000; ++i) {
std::lock_guard lock(mtx);
vec.push_back(i);
}
}
버그 3: 거짓 공유 (False Sharing)
// ❌ 거짓 공유
struct Data {
std::atomic<int> counter1; // 캐시 라인 공유
std::atomic<int> counter2; // 같은 캐시 라인
};
Data data;
// 스레드 1
++data.counter1; // 캐시 라인 무효화
// 스레드 2
++data.counter2; // 캐시 라인 무효화 → 느림
// ✅ 해결: 캐시 라인 분리
struct Data {
alignas(64) std::atomic<int> counter1; // 64바이트 정렬
alignas(64) std::atomic<int> counter2; // 별도 캐시 라인
};
64바이트는 x86 계열의 일반적인 캐시 라인 크기입니다. Apple Silicon처럼 128바이트 단위로 동작하는 CPU도 있으므로, 이식성을 원하면 C++17의 std::hardware_destructive_interference_size를 쓸 수 있습니다. 다만 GCC는 이 값을 ABI에 쓰면 컴파일러 옵션에 따라 달라질 수 있다고 경고(-Winterference-size)하므로, 헤더에 공개되는 구조체에서는 상수를 직접 정해 두는 쪽을 택하기도 합니다.
버그 4: 초기화 레이스
// ❌ 초기화 레이스
MyClass* instance = nullptr;
MyClass* getInstance() {
if (instance == nullptr) { // 체크
instance = new MyClass(); // 초기화
}
return instance;
}
// 두 스레드가 동시에 호출하면 두 번 초기화! (하나는 누수, 생성자 부작용은 두 번)
// ✅ 해결: std::call_once
std::once_flag flag;
MyClass* instance = nullptr;
MyClass* getInstance() {
std::call_once(flag, []() {
instance = new MyClass();
});
return instance;
}
버그 5: 조건 변수 잘못 사용
// ❌ spurious wakeup 미처리
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
// 대기 스레드
{
std::unique_lock lock(mtx);
cv.wait(lock); // ❌ spurious wakeup 가능
// ready가 false일 수 있음!
}
// ✅ 해결: 조건 확인
{
std::unique_lock lock(mtx);
cv.wait(lock, []{ return ready; }); // 조건이 true일 때까지 대기
}
조건자를 받는 wait는 내부적으로 while (!pred()) wait(lock);과 같습니다. spurious wakeup(알림 없이 깨어나는 현상)만이 이유는 아닙니다. 알림을 받고 깨어나 락을 다시 잡기까지의 사이에 다른 스레드가 먼저 조건을 소비해 버릴 수 있기 때문에, 깨어난 뒤에는 항상 조건을 다시 확인해야 합니다. 또 ready = true는 반드시 같은 뮤텍스를 잡은 상태에서 바꿔야 합니다. 락 없이 플래그를 바꾸고 notify_one()을 부르면, 대기 스레드가 조건을 확인한 직후·잠들기 직전에 알림이 지나가 버려 영원히 깨어나지 못하는 lost wakeup이 생깁니다.
버그 6: 락 없이 읽기
// ❌ 읽기도 보호 필요
std::mutex mtx;
int shared = 0;
// 쓰기 스레드
{
std::lock_guard lock(mtx);
shared = 42;
}
// 읽기 스레드
int x = shared; // ❌ 락 없이 읽기 → 데이터 레이스 ("int 읽기는 원자적"이라는 하드웨어 직관은 C++ 표준에서 통하지 않음)
// ✅ 해결: 읽기도 보호
{
std::lock_guard lock(mtx);
int x = shared;
}
버그 7: 포인터 경합
// ❌ 포인터 동시 수정
std::unique_ptr<int> ptr;
// 스레드 1
ptr = std::make_unique<int>(42);
// 스레드 2
ptr = std::make_unique<int>(99); // 동시 수정 → 크래시
// ✅ 해결: mutex
std::unique_ptr<int> ptr;
std::mutex mtx;
// 스레드 1
{
std::lock_guard lock(mtx);
ptr = std::make_unique<int>(42);
}
버그 8: 반복자 무효화 (멀티스레드)
// ❌ 반복자 무효화
std::vector<int> vec = {1, 2, 3, 4, 5};
std::mutex mtx;
// 스레드 1: 순회
{
std::unique_lock lock(mtx); // 중간에 unlock하려면 unique_lock이어야 함 (lock_guard에는 unlock이 없음)
for (auto it = vec.begin(); it != vec.end(); ++it) {
lock.unlock(); // ❌ 락 해제
process(*it);
lock.lock();
}
}
// 스레드 2: 수정
{
std::lock_guard lock(mtx);
vec.push_back(6); // 반복자 무효화!
}
// ✅ 해결: 락 유지 또는 복사
{
std::vector<int> copy;
{
std::lock_guard lock(mtx);
copy = vec; // 복사
}
for (int x : copy) {
process(x); // 락 없이 안전
}
}
push_back이 용량을 넘으면 vector는 새 메모리를 할당하고 원소를 옮긴 뒤 옛 메모리를 해제합니다. 락을 푼 사이에 이 일이 일어나면 스레드 1의 반복자는 해제된 메모리를 가리키게 되고, 크래시는 process(*it)에서 날 수도, 한참 뒤 힙이 망가진 채로 날 수도 있습니다. 복사 방식은 복사 비용이 들지만 락을 잡는 시간이 짧아집니다. 원소가 크거나 많다면 std::shared_ptr<const std::vector<int>>를 두고 쓰는 쪽이 새 벡터를 만들어 포인터만 교체하는 copy-on-write 방식도 쓸 만합니다.
버그 9: 이중 체크 락킹 (Double-Checked Locking)
// ❌ 이중 체크 락킹 (C++11 이전)
MyClass* instance = nullptr;
std::mutex mtx;
MyClass* getInstance() {
if (instance == nullptr) { // 첫 번째 체크 (락 없이)
std::lock_guard lock(mtx);
if (instance == nullptr) { // 두 번째 체크 (락 안에서)
instance = new MyClass(); // ❌ 메모리 순서 문제
}
}
return instance;
}
// ✅ 해결: std::call_once 또는 static 지역 변수
MyClass& getInstance() {
static MyClass instance; // C++11: 스레드 안전
return instance;
}
이중 체크 락킹이 깨지는 이유는 instance = new MyClass()가 “메모리 할당 → 생성자 실행 → 포인터 대입” 순서로 실행된다는 보장이 없기 때문입니다. 컴파일러나 CPU가 포인터 대입을 생성자 완료보다 먼저 보이게 할 수 있고, 그러면 락 없이 첫 번째 체크를 하는 다른 스레드가 nullptr이 아닌 포인터를 보고 아직 생성 중인 객체를 사용합니다. C++11 이후에는 std::atomic<MyClass*>와 acquire/release 순서로 올바르게 만들 수 있지만, 함수 안의 static 지역 변수는 표준이 초기화의 스레드 안전성을 보장하므로(흔히 magic static이라 부름) 대부분 이쪽이 가장 간단하고 안전합니다.
버그 10: 락 범위 실수
// ❌ 락 범위가 좁음
std::mutex mtx;
std::vector<int> vec;
void worker() {
int value;
{
std::lock_guard lock(mtx);
value = vec.back(); // 읽기
} // 락 해제
// 다른 스레드가 vec.pop_back() 호출 가능
{
std::lock_guard lock(mtx);
vec.pop_back(); // ❌ value와 pop_back 사이에 경합
}
}
// ✅ 해결: 락 범위 확장
void worker() {
std::lock_guard lock(mtx);
if (!vec.empty()) {
int value = vec.back();
vec.pop_back();
}
}
실전 사례 분석
사례 1: 스레드 풀 작업 큐
요구사항: 여러 스레드가 작업 큐에서 작업을 가져감.
class ThreadPool {
std::queue<Task> tasks_;
std::mutex mtx_;
std::condition_variable cv_;
bool stop_ = false;
public:
void enqueue(Task task) {
{
std::lock_guard lock(mtx_);
tasks_.push(std::move(task));
}
cv_.notify_one(); // 대기 중인 스레드 깨우기
}
void worker() {
while (true) {
Task task;
{
std::unique_lock lock(mtx_);
cv_.wait(lock, [this]{ return stop_ || !tasks_.empty(); });
if (stop_ && tasks_.empty()) return;
task = std::move(tasks_.front());
tasks_.pop();
}
task(); // 락 없이 실행
}
}
};
이 구조에서 가장 중요한 줄은 task()를 락 블록 밖에서 호출하는 부분입니다. 작업을 락 안에서 실행하면 한 번에 하나의 작업만 돌아서 스레드 풀의 의미가 없어지고, 작업이 다시 enqueue를 부르면 같은 뮤텍스를 두 번 잡아 데드락이 납니다. 예제에는 빠져 있지만 실제로는 소멸자에서 stop_ = true를 락 안에서 설정하고 notify_all()로 모든 워커를 깨운 뒤 join()해야 합니다. 이 종료 처리를 빠뜨리면 프로그램 종료 시 joinable한 std::thread가 파괴되면서 std::terminate가 호출되어 terminate called without an active exception 메시지와 함께 비정상 종료됩니다.
사례 2: 읽기-쓰기 락
요구사항: 읽기는 동시 허용, 쓰기는 배타적.
#include <shared_mutex>
class Cache {
std::unordered_map<Key, Value> data_;
mutable std::shared_mutex mtx_; // 읽기-쓰기 락
public:
// 읽기 (공유 락)
Value get(const Key& key) const {
std::shared_lock lock(mtx_); // 여러 스레드 동시 읽기 가능
auto it = data_.find(key);
return it != data_.end() ? it->second : Value{};
}
// 쓰기 (배타적 락)
void set(const Key& key, const Value& value) {
std::unique_lock lock(mtx_); // 배타적 잠금
data_[key] = value;
}
};
shared_mutex는 읽기가 압도적으로 많고 읽기 구간이 어느 정도 길 때 효과가 있습니다. 읽기 구간이 해시 조회 한 번처럼 짧으면, 공유 락 자체가 내부 카운터를 atomic으로 갱신하는 비용 때문에 일반 mutex보다 느린 경우도 흔합니다. 또 구현에 따라 읽기가 끊이지 않으면 쓰기 스레드가 오래 기다리는 writer starvation이 생길 수 있습니다. 바꾸기 전에 실제 부하로 측정해 보는 것이 좋습니다.
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. 거짓 공유(false sharing)는 크래시를 일으키지 않는데 왜 문제가 되나요?
A. 거짓 공유는 서로 다른 스레드가 서로 다른 변수를 쓰더라도 두 변수가 같은 캐시 라인에 있으면, 한 코어의 쓰기가 다른 코어의 캐시 라인을 계속 무효화하는 현상입니다. 결과값은 맞기 때문에 테스트로는 드러나지 않고, 스레드를 늘릴수록 오히려 느려지는 성능 문제로만 나타납니다. 스레드별 카운터처럼 자주 쓰는 데이터는 alignas(64)나 std::hardware_destructive_interference_size로 캐시 라인을 분리해 두면 해결됩니다.