동시성 버그와 디버깅
동시성 버그는 왜 "가끔"만 터지는가? 데드락·데이터 레이스·false sharing의 정체와, ThreadSanitizer로 재현 어려운 버그를 잡아내는 법.
시리즈 내내 도구를 하나씩 쌓았다. 스레드를 띄우고(1편), 잠그고(2편), 신호를 주고받고(3편), 결과를 넘기고(4편), 잠금 없이 공유했다(5편). 마지막 편은 그 도구들을 잘못 썼을 때 벌어지는 일과, 재현조차 어려운 그 버그들을 잡아내는 방법이다. 동시성 버그는 대개 “가끔” 터지기 때문에, 예방과 도구를 아는 것이 곧 실력이다.
데드락 — 서로를 영원히 기다림
데드락(deadlock) 은 둘 이상의 스레드가 서로가 쥔 자원을 기다리며 아무도 진행하지 못하는 상태다. 가장 흔한 형태는 두 mutex를 서로 다른 순서로 잡을 때다.
std::mutex m1, m2;
// 스레드 A
void a() {
std::lock_guard<std::mutex> l1(m1); // m1 잡음
std::lock_guard<std::mutex> l2(m2); // m2 기다림
}
// 스레드 B
void b() {
std::lock_guard<std::mutex> l1(m2); // m2 잡음
std::lock_guard<std::mutex> l2(m1); // m1 기다림
}
A가 m1을 쥐고 m2를 기다리는데, 그 순간 B가 m2를 쥐고 m1을 기다리면, 둘은 서로가 놓기를 영원히 기다린다. 프로그램은 멈춘 채 아무 에러도 내지 않는다.
해법 1 — 항상 같은 순서로 잠근다. 모든 스레드가 “m1을 먼저, 그다음 m2”라는 전역 순서를 지키면 이 순환 대기가 원천적으로 생기지 않는다. 이것이 가장 기본이고 강력한 규칙이다.
해법 2 — 한 번에 함께 잠근다. 여러 mutex를 동시에 잡아야 하면 2편에서 본 std::scoped_lock(C++17)을 쓴다. 내부적으로 데드락을 피하는 알고리즘으로 한꺼번에 잠근다.
void a() {
std::scoped_lock lock(m1, m2); // 둘을 데드락 없이 한 번에
}
void b() {
std::scoped_lock lock(m2, m1); // 순서가 달라도 안전
}
해법 3 — 잠금 보유 시간을 최소화한다. 임계 구역 안에서 다른 락을 잡거나, 콜백을 호출하거나, 오래 걸리는 일을 하지 않는다. 특히 락을 쥔 채 외부 콜백을 부르면, 그 콜백이 또 다른 락을 잡아 예상치 못한 순환을 만들 수 있다.
데이터 레이스 — 조용한 UB
데이터 레이스(data race) 는 5편에서 정의했다. happens-before 관계가 없는 두 접근이 같은 위치를 건드리고 그중 하나가 쓰기이면 데이터 레이스이고, 이는 정의되지 않은 동작(UB) 이다.
데드락과 달리 데이터 레이스는 멈추지 않는다. 대부분의 경우 그냥 잘 돌아가는 것처럼 보인다. 그러다 특정 타이밍, 특정 최적화 레벨, 특정 하드웨어에서 값이 깨지거나, 크래시가 나거나, 더 나쁘게는 아주 가끔만 틀린 답을 낸다. “내 컴퓨터에선 되는데” “디버그 빌드에선 되는데 릴리스에선 안 되는” 버그의 상당수가 이것이다.
핵심은 눈으로 잡으려 하지 말라는 것이다. 데이터 레이스는 코드 리뷰로 놓치기 쉽고, 재현이 비결정적이라 디버거로 쫓기도 어렵다. 대신 전용 도구를 쓴다.
ThreadSanitizer — 레이스를 잡는 도구
ThreadSanitizer(TSan) 는 GCC와 Clang에 내장된 동적 분석 도구로, 런타임에 메모리 접근을 추적해 데이터 레이스를 잡아낸다. 컴파일 플래그 하나로 켜진다.
g++ -fsanitize=thread -g program.cpp -o program
./program
레이스가 발생하면 TSan은 어느 두 스레드가, 어느 변수를, 어느 코드 위치에서 충돌했는지를 스택 트레이스와 함께 보고한다. 눈으로는 며칠 걸릴 버그를 몇 초 만에 짚어준다.
주의할 점 두 가지. TSan은 실제로 실행된 경로에서 일어난 레이스만 잡는다. 그 코드 경로가 테스트 중에 실행되지 않으면 놓친다. 그래서 동시성 코드에 부하를 주는 테스트와 함께 돌려야 효과가 크다. 또 TSan은 실행을 상당히 느리게(5~15배) 하고 메모리를 더 쓰므로, 상시 프로덕션이 아니라 CI와 테스트에서 돌린다.
리눅스라면 Helgrind(Valgrind의 도구)도 비슷한 일을 한다. 재컴파일 없이 쓸 수 있지만 TSan보다 훨씬 느리다. 재컴파일이 가능하면 보통 TSan이 낫다.
false sharing — 보이지 않는 성능 함정
이건 버그는 아니다. 결과는 정확하다. 하지만 이유 없이 느려지는 함정이라 알아둘 가치가 크다.
CPU는 메모리를 바이트 단위가 아니라 캐시 라인(cache line) 단위(보통 64바이트)로 다룬다. 두 스레드가 서로 다른 변수를 만지더라도, 그 두 변수가 같은 캐시 라인에 있으면, 한 스레드가 자기 변수를 쓸 때마다 다른 스레드의 캐시가 무효화된다. 논리적으로는 공유가 없는데 하드웨어 수준에서 캐시 라인을 공유해 서로를 방해하는 것이다. 이것이 false sharing이다.
struct Counters {
std::atomic<int> a; // 스레드 1이 씀
std::atomic<int> b; // 스레드 2가 씀
}; // a와 b가 같은 캐시 라인에 있으면 서로 방해
두 카운터가 나란히 붙어 있어 같은 64바이트 라인에 들어가면, 두 스레드가 각자 다른 카운터를 열심히 올리는데도 캐시 무효화가 오가며 성능이 급락한다. 해법은 각 변수를 캐시 라인 경계로 떼어놓는 것이다. C++17은 이를 위한 상수를 제공한다.
#include <new>
struct Counters {
alignas(std::hardware_destructive_interference_size)
std::atomic<int> a;
alignas(std::hardware_destructive_interference_size)
std::atomic<int> b;
}; // a와 b가 서로 다른 캐시 라인에 놓인다
false sharing은 프로파일러로 “왜 스레드를 늘렸는데 빨라지지 않지?”를 파고들 때 드러나는 경우가 많다. 원자적 카운터나 스레드별 데이터가 배열로 촘촘히 붙어 있다면 의심해 볼 지점이다.
재현 어려운 버그를 다루는 법
동시성 버그의 가장 큰 특징은 비결정성이다. 같은 입력에도 실행 순서에 따라 됐다 안 됐다 한다. 몇 가지 실전 전략이 있다.
부하를 준다. 스레드 수를 늘리고, 반복 횟수를 키우고, 여러 코어에서 오래 돌린다. 드물게 나는 레이스도 수백만 번 반복하면 드러난다. 스트레스 테스트를 TSan과 함께 돌리는 조합이 특히 강력하다.
단순화한다. 버그가 나는 최소 재현 코드를 만든다. 스레드를 둘로 줄이고, 무관한 로직을 걷어내면 문제의 핵심이 드러난다.
로그에 의존하지 않는다. printf나 로깅을 넣는 순간 타이밍이 바뀌어 버그가 사라지는 일이 흔하다(이른바 heisenbug). 로그보다 TSan 같은 도구를, 그리고 결정적으로 재현되는 최소 케이스를 우선한다.
설계로 예방한다. 가장 확실한 디버깅은 애초에 버그를 만들지 않는 것이다. 공유 가변 상태를 줄이고, 락 순서를 문서화하고, 가능하면 이 시리즈의 고수준 도구(async/future, 메시지 큐)로 공유 자체를 피한다. 공유하지 않는 데이터는 레이스가 날 수 없다.
시리즈를 닫으며
여섯 편에 걸쳐 C++ 동시성의 실무 핵심을 훑었다. 흐름을 다시 요약하면 이렇다.
스레드를 띄우고(1편), 공유 자원을 mutex로 잠그고(2편), condition_variable로 기다렸다 깨우고(3편), future/async로 결과를 주고받고(4편), atomic으로 잠금 없이 공유하고(5편), 그리고 이번 편에서 그 모든 것이 어긋났을 때를 다뤘다.
관통하는 원칙 몇 개가 있었다. 공유 가변 상태가 모든 문제의 뿌리다 — 줄일수록 안전하다. RAII로 정리를 자동화하라 — lock_guard, jthread, future가 모두 이 원리다. 의심스러우면 강한 쪽을 골라라 — seq_cst atomic, mutex, seq_cst 오더링은 느릴지언정 틀리지 않는다. 그리고 눈이 아니라 도구를 믿어라 — TSan은 사람이 놓치는 것을 잡는다.
동시성은 어렵다. 하지만 어려운 이유의 대부분은 “가끔만 틀린다”는 비결정성에 있고, 그 비결정성은 올바른 도구와 규율로 길들일 수 있다. 이 시리즈가 그 출발점이 되었기를.