C++ 멀티스레드 Asio의 딜레마 | Data Race와 Mutex의 한계 [#2]
이 글의 핵심
멀티스레드 run()은 CPU 코어를 활용하는 쉬운 방법처럼 보이지만, 세션 상태를 공유하는 순간 핸들러 실행 순서가 보장되지 않는 문제가 드러납니다. ThreadSanitizer로 레이스를 찾는 방법과 흔한 실수를 정리하고, 락 대신 핸들러를 직렬화하는 Strand가 왜 다음 단계인지 연결합니다.
들어가며: io_context 하나를 여러 스레드가 돌리면?
고성능을 위해 하나의 io_context에 대해 여러 스레드가 run()을 호출하는 패턴을 많이 씁니다. 그러면 완료 핸들러가 스레드 풀에 분산되어 실행되므로 CPU 코어를 활용할 수 있습니다. 대신 여러 스레드가 같은 연결 상태, 같은 버퍼, 같은 세션 객체를 건드리게 되고, 그때 Data Race와 동기화 문제가 터집니다.
직관적으로 “그럼 Mutex로 감싸면 되지 않나?”라고 생각하기 쉽습니다. 하지만 비동기 핸들러 안에서 Mutex를 잡는 설계는 처리량을 깎아 먹고, 핸들러 안에서 다른 완료를 기다리기 시작하면 교착에 빠집니다. 이 글에서는 그 이유를 분석하고, 다음 글(Strand)으로 이어지는 “락 없는” 해법의 동기를 제시합니다. 요약하면, 같은 연결에 대한 핸들러를 “한 줄로만” 실행하게 만드는 Strand를 쓰면 Mutex 없이도 Data Race를 피할 수 있습니다. 이 글은 “왜 Mutex만으로는 부족한지”를 먼저 이해하는 데 집중합니다.
목표:
- 여러 스레드가 하나의 io_context::run()을 공유할 때 어떤 문제가 생기는지
- 비동기 핸들러 안에서 Mutex를 쓰면 왜 성능이 나빠지는지
- 교착에 빠지기 쉬운 패턴과, 그래서 Strand가 필요한 이유
멀티스레드 run()과 핸들러 분산
스레드 풀에서 run() 호출
boost::asio::io_context io;
// 4개 스레드가 같은 io_context를 돌린다
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([&io]() { io.run(); });
}
- async_read / async_write 등이 완료되면, 그 완료 핸들러는 실행 큐에 들어갑니다.
- run()을 호출하는 스레드들이 그 큐에서 핸들러를 가져가 실행합니다.
- 따라서 같은 연결에 대한 “읽기 완료”와 “쓰기 완료” 핸들러가 서로 다른 스레드에서, 그리고 동시에 실행될 수 있습니다.
한 연결(세션)의 버퍼나 상태를 “이 연결만의 전용 스레드”처럼 가정하고 작성했다면, 멀티스레드 run() 환경에서는 동시에 두 스레드가 같은 세션 객체를 건드리는 상황이 발생합니다.
다만 모든 세션이 곧바로 위험한 것은 아닙니다. Asio 문서는 이를 암묵적 strand(implicit strand)라고 부릅니다. 한 세션에서 “읽기가 끝나면 쓰기를 시작하고, 쓰기가 끝나면 다음 읽기를 시작하는” 식으로 비동기 작업이 한 줄로 이어지는 사슬만 있다면, 다음 핸들러는 이전 핸들러가 작업을 시작한 뒤에야 존재할 수 있으므로 같은 세션의 핸들러가 겹칠 수 없습니다. 문제가 되는 것은 읽기 루프와 쓰기 루프가 동시에 돌아가는 전이중(full-duplex) 세션, 타임아웃 타이머가 따로 걸린 세션, 다른 스레드에서 post로 메시지를 밀어 넣는 세션처럼 사슬이 둘 이상인 경우입니다. 채팅 서버나 게임 서버처럼 서버가 먼저 메시지를 보내야 하는 프로토콜은 거의 항상 이 경우에 해당합니다.
Data Race가 나는 전형적 상황
공유 세션 객체
struct Session {
boost::asio::ip::tcp::socket socket;
std::vector<char> read_buf;
std::string write_queue; // 여러 핸들러가 동시에 접근 가능
};
// 연결마다 하나의 Session
std::map<connection_id, std::shared_ptr<Session>> sessions;
void on_read(std::shared_ptr<Session> s, const boost::system::error_code& ec, size_t n) {
// 스레드 A에서 실행
s->read_buf.resize(n);
s->write_queue += process(s->read_buf); // 쓰기
async_write(s->socket, buffer(s->write_queue), on_write);
}
void on_write(std::shared_ptr<Session> s, ...) {
// 스레드 B에서 실행될 수 있음!
s->write_queue.erase(0, n); // 읽기 + 수정
if (!s->write_queue.empty())
async_write(..., on_write);
}
- 이 세션에서 다음
async_read가 이미 걸려 있는 상태(읽기 루프)라면, on_read와 on_write가 서로 다른 스레드에서 동시에 실행될 수 있고, write_queue에 대한 동시 읽기/쓰기가 일어나 Data Race입니다. C++ 표준상 undefined behavior입니다. - read_buf도 한 스레드는 resize, 다른 스레드는 읽기를 할 수 있으면 마찬가지로 race입니다.
구체적인 예: 스레드 A가 write_queue += process(...)로 문자열을 붙이는 동안, 스레드 B가 write_queue.erase(0, n)으로 앞부분을 지우면, 같은 std::string 객체를 두 스레드가 동시에 수정하게 됩니다. 이렇게 “같은 메모리 위치를 한쪽은 쓰기, 다른 쪽도 쓰기(또는 읽기)“가 겹치면 Data Race이고, 결과가 비결정적이거나 크래시로 이어질 수 있습니다.
이 코드에는 스레드와 무관한 버그도 두 가지 숨어 있어서, 단일 스레드로 바꿔도 완전히 안전해지지 않습니다.
- 진행 중인 쓰기의 버퍼를 수정:
async_write는 완료될 때까지buffer(s->write_queue)가 가리키는 메모리를 계속 사용합니다. 쓰기가 끝나기 전에 다음on_read가write_queue +=로 문자열을 늘리면 재할당이 일어나 진행 중인 쓰기가 해제된 메모리를 읽습니다. - 같은 소켓에
async_write를 겹쳐 호출:async_write는 내부적으로async_write_some을 여러 번 호출하는 합성 연산이라, 두 개가 동시에 진행되면 두 메시지의 조각이 섞여 나갈 수 있습니다. Asio 문서는 하나의 스트림에 대해 합성 쓰기가 끝나기 전에 다른 쓰기를 시작하지 말라고 명시합니다.
그래서 실무의 세션 코드는 거의 항상 “보낼 메시지를 std::deque에 쌓고, 쓰기가 진행 중이 아닐 때만 맨 앞 메시지로 async_write를 시작하며, 완료 핸들러에서 다음 메시지를 꺼내는” 쓰기 큐 구조를 씁니다. 이 큐 자체가 여러 핸들러에서 접근되므로, 결국 같은 세션의 핸들러들이 동시에 한 스레드에서만 실행되도록 강제할 필요가 생깁니다. 그게 다음 글의 Strand입니다.
핸들러 안에서 Mutex를 쓰면 왜 성능이 떨어지는가
전역 락으로 핸들러를 감싸면
std::mutex mtx;
void on_read(std::shared_ptr<Session> s, ...) {
std::lock_guard<std::mutex> lock(mtx);
s->write_queue += process(s->read_buf);
async_write(s->socket, buffer(s->write_queue), [s](...) { on_write(s, ...); });
}
void on_write(std::shared_ptr<Session> s, ...) {
std::lock_guard<std::mutex> lock(mtx);
s->write_queue.erase(0, n);
// ...
}
- 문제 1:
lock_guard는 핸들러가 끝날 때 풀리므로,async_write가 완료될 때까지 락이 잡혀 있지는 않습니다. 문제는 모든 연결의 모든 핸들러가 같은mtx를 지나가야 한다는 점입니다.process()처럼 무거운 작업이 락 안에 있으면 스레드를 네 개 두어도 그 구간은 한 번에 하나씩만 실행되고, 나머지 스레드는 락을 기다리며 잠듭니다. 연결 A의 처리가 연결 B, C, D의 무관한 핸들러까지 막는 셈이라, 핸들러 대부분이 락 안에서 일한다면 처리량은 단일 스레드와 비슷해지고 락 경합 비용만 추가됩니다. - 문제 2: 이 락이 다 보호하지도 못합니다.
async_write가 백그라운드에서write_queue의 버퍼를 읽는 동안 다음on_read가 락을 잡고write_queue를 수정하면, 락은 두 핸들러 사이의 경쟁은 막지만 진행 중인 I/O와의 경쟁은 막지 못합니다. 앞 절의 쓰기 큐 구조가 여전히 필요합니다. - 문제 3: 락을 “연결 단위”로 나누더라도, 락을 잡은 상태에서 다른 비동기 연산의 완료를 기다리면 안 됩니다. (아래 교착 참고)
요약
- 전역 Mutex 하나로 모든 세션을 감싸면: 핸들러 임계 구역이 한 줄로 서서 멀티스레드의 이점이 사라지고, 경합 비용만 늘어납니다.
- 세션별 Mutex를 쓰면 무관한 연결끼리는 막지 않지만, 세션 사이를 오가는 코드(브로드캐스트, 방 목록 갱신)가 생기는 순간 여러 락을 잡는 순서를 사람이 일관되게 지켜야 합니다. 락 자체의 경합과 캐시 라인 이동(bouncing)도 핫 패스에서는 부담입니다.
Asio 설계 철학은 “핸들러를 논리적으로 직렬화해서, 아예 동시에 실행되지 않게 하자”입니다. 그게 Strand이고, 락을 잡고 잠드는 대신 실행 큐 수준에서 순서를 보장합니다. 락을 기다리는 스레드는 잠들어 아무것도 못 하지만, strand에 걸린 핸들러를 기다리는 동안 워커 스레드는 다른 세션의 핸들러를 실행할 수 있다는 점이 결정적인 차이입니다.
교착에 빠지기 쉬운 패턴
핸들러 안에서 다른 핸들러가 끝나기를 기다리는 경우
std::mutex mtx;
std::condition_variable cv;
bool done = false;
void on_read(...) {
std::unique_lock<std::mutex> lock(mtx);
async_write(socket, buffer(data), [&](...) {
std::lock_guard<std::mutex> l2(mtx); // 같은 락 대기
done = true;
cv.notify_one();
});
cv.wait(lock, [&] { return done; }); // ⚠️ 핸들러 안에서 완료를 동기 대기
}
cv.wait는 대기하는 동안mtx를 풀어 줍니다. 그래서 이 코드의 문제는 락 그 자체보다 워커 스레드를 붙잡고 잠드는 것입니다.- async_write의 완료 핸들러는 누군가
run()을 돌리는 스레드가 있어야 실행됩니다.io.run()을 한 스레드만 돌린다면 그 스레드가cv.wait에서 자고 있으므로 완료 핸들러는 영원히 실행되지 않고,done도 영원히true가 되지 않습니다. 멀티스레드여도 동시에 들어온on_read네 개가 워커 네 개를 모두 붙잡으면 같은 상황이 됩니다. - 여기에 락을 잡은 채 대기하는 변형(
cv대신std::future::get()이나 바쁜 대기를 락 안에서 호출)이 더해지면, 완료 핸들러가 스레드를 얻더라도l2에서 막혀 진짜 데드락이 됩니다. - 부수적으로, 완료 핸들러가
[&]로on_read의 지역 변수를 참조로 캡처하는 것도 위험합니다. 대기 코드를 걷어 내고 나면on_read가 먼저 반환해 댕글링 참조가 되기 때문입니다.
이 패턴은 부하가 낮을 때는 멀쩡하다가 동시 연결 수가 워커 스레드 수를 넘는 순간 서버 전체가 응답을 멈추는 형태로 드러나기 때문에, 처음 겪으면 네트워크 문제로 오해하기 쉽습니다. 저는 이런 멈춤을 만나면 가장 먼저 gdb -p <pid>로 붙어 thread apply all bt를 찍어 봅니다. 모든 워커 스레드가 pthread_cond_wait이나 futex_wait에 걸려 있고 epoll_wait에 있는 스레드가 하나도 없다면, 거의 틀림없이 핸들러 안의 동기 대기가 원인입니다.
비동기 콜백 안에서는 “다른 비동기 완료를 동기적으로 기다리는” 코드를 넣지 말고, 다음 단계를 완료 핸들러 쪽으로 옮겨야 합니다. 모든 상태 갱신을 같은 실행 맥락(Strand)으로 직렬화하면 락 자체가 필요 없어져, 이런 교착의 재료가 사라집니다.
정리: Strand로 가는 길
| 문제 | 원인 | 방향 |
|---|---|---|
| Data Race | 같은 세션을 여러 스레드의 핸들러가 동시 접근 | 같은 세션의 핸들러를 한 줄로 실행 |
| 처리량 저하 | 전역/과도한 Mutex로 핸들러 전체 직렬화 | 락 없이 세션 단위로만 순서 보장 |
| 교착 | 핸들러 안에서 다른 완료를 동기 대기 | 대기 대신 완료 핸들러에서 다음 단계 진행 |
Strand는 “이 핸들러들은 서로 겹치지 않고 순차 실행된다”는 논리적 직렬화를 Asio 실행 큐 단에서 보장합니다. Mutex를 잡지 않으므로 락 경합과 데드락을 피하면서, 같은 연결에 대한 읽기/쓰기 핸들러가 동시에 실행되지 않게 할 수 있습니다.
보강: 레이스를 일부러 재현하는 코드
멀티스레드 io_context에서 세션 단위로 읽기·쓰기 콜백이 섞일 때를 가정한 최소 예시입니다. 아래는 의도적으로 race를 보여 주기 위한 것이며, 실제 코드에서는 Strand로 치환해야 합니다.
// 경고: 교육용 — 여러 스레드 run() 환경에서 UB(데이터 레이스) 유발 가능
struct BadSession {
boost::asio::ip::tcp::socket socket;
std::vector<char> read_buf;
std::string out_queue; // on_read와 on_write가 서로 다른 스레드에서 동시에 건드릴 수 있음
};
void on_read(std::shared_ptr<BadSession> s, boost::system::error_code ec, std::size_t n) {
if (ec) return;
s->read_buf.resize(n);
s->out_queue.append(s->read_buf.data(), n); // 스레드 A가 수정
async_write(s->socket, boost::asio::buffer(s->out_queue),
[s](const boost::system::error_code& ec2, std::size_t w) { on_write(s, ec2, w); });
}
void on_write(std::shared_ptr<BadSession> s, boost::system::error_code ec, std::size_t written) {
if (ec) return;
s->out_queue.erase(0, written); // 스레드 B가 같은 std::string을 수정 → 데이터 레이스
}
안전한 방향: 같은 연결의 모든 비동기 완료를 한 Strand에 묶거나, 단일 스레드만 io.run() 하도록 설계를 제한합니다. 단일 스레드 io_context를 코어 수만큼 만들어 연결을 나눠 배정하는 io_context-per-thread 구조도 흔한 대안입니다. 세션끼리 상태를 거의 공유하지 않는 서버라면 동기화 없이 코어를 모두 쓸 수 있지만, 연결이 특정 스레드에 몰리면 부하가 고르게 퍼지지 않는다는 단점이 있습니다.
보강: Data Race 실제 사례
- 카운터/통계: 여러 핸들러가
++session_count또는bytes_received += n을 동시에 실행하면 레이스가 납니다.atomic으로 바꿔도 여러 필드를 일관되게 갱신해야 하면 여전히 설계가 필요합니다. - 컨테이너:
std::vector에 한 스레드는push_back, 다른 스레드는 순회·erase를 하면 즉시 UB입니다. 연결별 상태는 한 실행 맥락에서만 수정하는 것이 근본 해결입니다. 전역sessions맵처럼 모든 연결이 공유하는 구조는 세션 strand와 별도로 보호해야 합니다. - 타임아웃 타이머:
async_wait완료와async_read완료가 동시에 들어와 세션을close하는 경우, 한쪽은 이미 닫힌 소켓을 다루게 됩니다.socket.close()자체도 스레드 안전하지 않으므로, 취소·상태 플래그와close호출은 같은 Strand(또는 단일 스레드)에서만 다루는 편이 안전합니다.
보강: Mutex의 한계 (요약)
| 관점 | 한계 |
|---|---|
| 범위 | 전역 뮤텍스는 모든 연결의 임계 구역을 직렬화해 처리량 붕괴. 세션별 뮤텍스는 낫지만 세션을 넘나드는 코드에서 락 순서 관리가 필요. |
| 성능 | 락을 기다리는 워커 스레드는 잠들어 다른 연결을 처리하지 못함. 경합·캐시 라인 이동으로 지연 분산이 커질 수 있음. |
| 모델 적합성 | 비동기는 “완료 시 다른 스레드”가 기본이라, 락 순서를 사람이 일관되게 유지하기 어렵고 진행 중인 I/O 버퍼는 락으로 보호되지 않음. |
Strand는 “락 대신 실행 큐에서 순서 보장”으로 위 문제를 구조적으로 줄입니다.
보강: 디버깅 팁
- ThreadSanitizer (TSan): GCC/Clang에서
-fsanitize=thread -g -O1로 빌드해 재현 테스트를 돌리면, 데이터 레이스가 나는 지점과 스택을 보고해 줍니다. 리포트는WARNING: ThreadSanitizer: data race로 시작하고, 충돌한 두 접근(Write of size 8 ... by thread T2,Previous write ... by thread T1)의 스택을 함께 보여 줍니다. - 재현: 부하를 낮추면 간헐적으로만 터지므로, 스레드 수를 늘리고 동시 연결 수를 늘려 타이밍을 흔들어 재현합니다. TSan은 실제로 겹친 접근만 보고하므로, 동시 부하 없이 돌린 테스트에서 경고가 없다고 안전하다는 뜻은 아닙니다.
- 로그:
std::this_thread::get_id()를 핸들러 진입 시 한 줄씩 남기면, 같은 세션에서 동시에 두 스레드가 찍히는지 확인할 수 있습니다. Strand를 도입한 뒤에는strand.running_in_this_thread()를assert로 걸어 두면 strand 밖에서 세션 상태를 건드리는 코드를 바로 찾을 수 있습니다. - 교착: cpp-series-49-3에서 다룬 것처럼, 모든 워커 스레드의 스택을 찍어 락 대기·
condition_variable대기에 걸린 스레드가 있는지 확인합니다.
보강: 성능 측정 방법
- 처리량(RPS) / p99 지연: 같은 부하 생성기(예:
wrk,hey, 자체 클라이언트)로 Strand 도입 전/후·뮤텍스 전역 vs 세션별를 비교합니다. - 프로파일러:
perf, Instruments, VTune에서pthread_mutex_lock이나futex관련 커널 함수,malloc비중이 상위면 동기화·할당 병목 신호입니다.perf sched나off-CPU분석으로 스레드가 락을 기다리며 잠든 시간을 보면 전역 락의 비용이 더 선명하게 보입니다. - TSan 빌드는 성능 측정용이 아니라 정확성 검증용입니다. 실행이 여러 배 느려지므로 수치 비교는 TSan 없이 Release 빌드로 합니다.
보강: 흔한 실수와 해결책
| 실수 | 해결 |
|---|---|
| ”연결마다 객체가 있으니 안전하다” | 같은 연결에 작업 사슬이 둘 이상이면 핸들러가 여러 스레드에서 동시 실행될 수 있음 → Strand 또는 단일 스레드 run. |
| 전역 뮤텍스로 모든 핸들러 보호 | 처리량 급락 → 연결/세션 단위 직렬화(Strand)로 전환. |
진행 중인 async_write의 버퍼를 수정 | 쓰기 큐(std::deque)에 쌓고, 완료 후 다음 메시지를 전송. |
| 핸들러 안에서 다른 완료를 동기적으로 대기 | 교착 → 완료 콜백/코루틴에서만 다음 단계 진행. |
| 레이스가 안 보인다고 TSan 생략 | 릴리스에서만 터지는 UB 방지를 위해 CI에 TSan 구성 검토. |
보강: ThreadSanitizer (TSan) 빠른 참조
# 예: Clang
clang++ -std=c++20 -g -O1 -fsanitize=thread -fno-omit-frame-pointer main.cpp -lboost_system -pthread -o app_tsan
./app_tsan
런타임에 레이스가 있으면 경고와 관련 스택이 출력됩니다. Boost.Asio는 대부분 헤더 전용이라 애플리케이션과 함께 계측되지만, 별도로 빌드된 라이브러리를 링크한다면 그 라이브러리도 같은 플래그로 빌드해야 누락과 오탐이 줄어듭니다.
자주 묻는 질문 (FAQ)
Q. ThreadSanitizer로 Asio 코드를 검사할 때 주의할 점은 무엇인가요?
A. TSan은 실제 실행 중에 동기화 없이 겹친 메모리 접근만 보고하므로, run()을 여러 스레드에서 돌리고 동시 연결 부하를 주는 테스트에서 실행해야 의미 있는 결과가 나옵니다. -fsanitize=thread는 애플리케이션뿐 아니라 가능한 한 함께 링크하는 라이브러리도 같은 옵션으로 빌드해야 오탐과 누락이 줄어들며, AddressSanitizer와 동시에 켤 수 없어 별도 빌드가 필요합니다. 리포트에 나오는 두 스택 중 어느 핸들러에서 공유 세션 상태를 건드렸는지 확인하면, 그 접근을 strand로 옮겨야 할지 판단할 수 있습니다.
Q. 단일 스레드로 run()을 돌리면 이 글의 문제가 모두 사라지나요?
A. 데이터 레이스는 사라지지만 전부는 아닙니다. 진행 중인 async_write의 버퍼를 수정하는 문제와 같은 소켓에 쓰기를 겹쳐 호출하는 문제는 스레드 수와 무관하게 남습니다. 또 핸들러 안에서 동기적으로 무언가를 기다리면, 단일 스레드에서는 그 즉시 이벤트 루프 전체가 멈춥니다.