C++ 멀티스레드 크래시 | "데이터 레이스" 원인과 mutex 해결법
이 글의 핵심
C++ 멀티스레드 크래시에 대한 실전 가이드입니다.
들어가며: “멀티스레드로 바꿨더니 간헐적으로 크래시…"
"싱글스레드에서는 되는데 멀티스레드에서 이상해요”
멀티스레드 프로그래밍에서 가장 흔한 버그는 데이터 레이스(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이 아닐 수 있음!
이 글에서 다루는 것:
- 데이터 레이스와 race condition
- mutex로 동기화
- atomic 변수
- ThreadSanitizer로 버그 탐지
- 자주 나오는 멀티스레드 버그 10가지
목차
1. 데이터 레이스란?
정의
데이터 레이스는 다음 조건을 모두 만족할 때 발생합니다:
- 여러 스레드가 같은 메모리에 접근
- 최소 하나가 쓰기 연산
- 동기화 없음 (mutex, atomic 등)
// ❌ 데이터 레이스
int shared = 0;
// 스레드 1
shared = 42; // 쓰기
// 스레드 2
int x = shared; // 읽기
// 동기화 없음 → 데이터 레이스 → 미정의 동작
데이터 레이스의 결과
- 잘못된 값 읽기
- 크래시 (Segmentation Fault)
- 간헐적 버그 (재현 어려움)
- 컴파일러 최적화로 인한 예측 불가능한 동작
2. 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(); // 다시 잠금
}
데드락 방지
// ❌ 데드락 가능
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);
}
3. 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
4. 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 변수에 데이터 레이스 발생.
5. 자주 나오는 버그 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; // 별도 캐시 라인
};
버그 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일 때까지 대기
}
버그 6: 락 없이 읽기
// ❌ 읽기도 보호 필요
std::mutex mtx;
int shared = 0;
// 쓰기 스레드
{
std::lock_guard lock(mtx);
shared = 42;
}
// 읽기 스레드
int x = shared; // ❌ 락 없이 읽기 → 데이터 레이스
// ✅ 해결: 읽기도 보호
{
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::lock_guard lock(mtx);
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); // 락 없이 안전
}
}
버그 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;
}
버그 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(); // 락 없이 실행
}
}
};
사례 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;
}
};
정리
멀티스레드 버그 방지 체크리스트
- 공유 변수를 mutex 또는 atomic으로 보호하는가?
- 읽기도 동기화하는가? (쓰기 스레드가 있으면)
- 데드락 가능성이 있는가? (여러 mutex)
- 조건 변수를 올바르게 사용하는가?
- ThreadSanitizer로 테스트했는가?
동기화 도구 선택
| 상황 | 권장 | 이유 |
|---|---|---|
| 단순 카운터 | atomic | 빠름, 간단 |
| 플래그 | atomic | 락 불필요 |
| 여러 변수 | mutex | 함께 보호 |
| 복잡한 연산 | mutex | atomic으로 불가능 |
| 읽기 많음 | shared_mutex | 읽기 동시 허용 |
| 초기화 | call_once | 한 번만 실행 |
핵심 규칙
- 공유 변수는 반드시 동기화
- 단순 연산은 atomic, 복잡한 연산은 mutex
- ThreadSanitizer를 CI/CD에 통합
- 데드락 방지 (scoped_lock, 락 순서)
- 읽기도 보호 (쓰기 스레드가 있으면)
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 데이터 레이스 | mutex·atomic으로 동기화
- C++ Lock-Free 프로그래밍 | atomic 고급
- C++ 멀티스레딩 | thread·mutex·condition_variable
- C++ ThreadSanitizer | 데이터 레이스 탐지
마치며
멀티스레드 버그는 재현이 어렵고 디버깅이 힘듭니다. ThreadSanitizer를 사용하면 대부분의 데이터 레이스를 자동으로 탐지할 수 있습니다.
핵심 원칙:
- 공유 변수는 반드시 동기화
- ThreadSanitizer를 CI/CD에 통합
- 단순 연산은 atomic, 복잡한 연산은 mutex
- 데드락 방지 (scoped_lock)
멀티스레드 프로그래밍은 어렵지만 강력합니다. 동기화 규칙을 지키고, ThreadSanitizer로 검증하면 안전한 멀티스레드 코드를 작성할 수 있습니다.
다음 단계: 멀티스레드를 이해했다면, C++ Lock-Free 프로그래밍에서 더 고급 기법을 배워보세요.
관련 글
- C++ 시리즈 전체 보기
- C++ Adapter Pattern 완벽 가이드 | 인터페이스 변환과 호환성
- C++ ADL |
- C++ Aggregate Initialization |