C++ Asio 데드락 디버깅 | 비동기 콜백 실전
들어가며: “Asio 서버가 가끔 멈춘다”
Asio 기반 서버가 부하가 걸릴 때만 가끔 응답을 멈추고, CPU 사용률은 거의 0인데 프로세스는 살아 있다면 데드락을 먼저 의심합니다. 데드락은 둘 이상의 실행 흐름이 서로가 쥔 자원을 기다리며 영원히 진행하지 못하는 상태입니다. 동기 코드에서는 그 자원이 대개 뮤텍스지만, Asio에서는 io_context 스레드와 strand도 기다림의 대상이 된다는 점이 다릅니다. 완료 핸들러는 누군가 run()을 돌리는 스레드가 있어야 실행되므로, 그 스레드를 막아 버리면 아무 락도 없이 멈출 수 있습니다.
이 글은 Asio 서버에서 자주 나오는 세 가지 패턴과, 멈춘 프로세스에서 원인을 좁히는 방법을 다룹니다. 관련 배경은 고성능 네트워크 가이드 #2: Mutex의 한계와 #3 Strand에 있습니다.
패턴 1: 핸들러 안에서 비동기 완료를 동기로 기다리기
그림: 실제 실행 결과(Boost.Asio 1.87, 2초 안에 끝나지 않으면 감시 스레드가 “멈춤”으로 출력하도록 만든 최소 예제) — io 스레드가 1개일 때 핸들러 안의 send_and_wait는 끝나지 않지만 2개이면 통과한다. 락을 쥔 채 future.get()을 기다리면 스레드가 2개여도 멈추고, 락을 쥔 채 strand에 dispatch하면 콜백이 즉시 실행되어 멈추지만 post로 바꾸면 락을 놓은 뒤 실행된다. strand 안에서 같은 strand의 작업을 동기로 기다리면 스레드가 2개여도 멈춘다. A→B와 B→A 순서의 락은 멈추고 std::scoped_lock은 인자 순서가 반대여도 통과한다. 마지막 TSan 실행은 이 macOS에서 lock-order-inversion 경고를 내지 않았다. (환경: macOS 27.0.1 · Boost.Asio 1.87 · Apple clang 21.0.0 · macOS)
문제 코드
“메시지를 보내고, 전송이 끝난 뒤 다음 작업을 하고 싶다”는 요구를 동기 코드처럼 풀면 이런 모양이 됩니다.
// 위험한 패턴: 비동기 완료를 동기로 대기
void Session::send_and_wait(const std::string& msg) {
std::unique_lock<std::mutex> lock(mtx_);
done_ = false;
boost::asio::async_write(socket_, boost::asio::buffer(msg),
[this](boost::system::error_code, std::size_t) {
std::lock_guard<std::mutex> lk(mtx_);
done_ = true;
cv_.notify_one();
});
cv_.wait(lock, [this] { return done_; }); // 완료 핸들러가 실행되기를 기다림
}
흔히 “락을 쥔 채 cv.wait해서 데드락”이라고 설명되지만, 정확하지 않습니다. condition_variable::wait는 대기하는 동안 lock을 풀어 두므로, 완료 핸들러는 다른 스레드에서 mtx_를 문제없이 잡을 수 있습니다. 이 코드가 멈추는 진짜 이유는 완료 핸들러를 실행할 스레드가 없어지는 데 있습니다.
send_and_wait가 io_context 스레드에서, 예를 들어 읽기 완료 핸들러 안에서 호출되었다고 해 봅시다. run()을 돌리는 스레드가 하나뿐이라면 그 스레드는 cv_.wait에서 멈춰 있으므로 async_write의 완료 핸들러를 실행할 스레드가 없습니다. 스레드가 여러 개여도, 다른 세션들이 같은 방식으로 하나씩 스레드를 붙잡으면 결국 모든 io 스레드가 대기에 빠집니다. 또 완료 핸들러가 이 세션의 strand에 묶여 있다면, strand는 한 번에 하나만 실행하므로 현재 핸들러(send_and_wait를 호출한 쪽)가 끝나기 전에는 완료 핸들러가 시작되지 못합니다. 스레드가 몇 개든 멈춥니다.
sequenceDiagram
participant T as io 스레드 (유일)
participant Q as io_context 큐
T->>T: on_read 핸들러 실행 중
T->>Q: async_write 시작
T->>T: cv.wait 진입 (스레드 블록)
Q-->>Q: 쓰기 완료, 핸들러 대기열에 들어감
Note over T,Q: 큐의 핸들러를 실행할 스레드가 없음
Note over T: notify가 오지 않아 영원히 대기
스레드가 여러 개이고 strand도 없으면 대부분은 다른 스레드가 핸들러를 실행해 주므로 통과합니다. 그래서 동시 요청이 io 스레드 수만큼 겹칠 때만 멈추고, 개발 환경에서는 재현되지 않는 경우가 많습니다.
락을 쥔 채 다른 것을 기다리는 경우
같은 구조에 진짜 락 문제가 겹치는 경우도 있습니다. 대기 대상이 그 뮤텍스와 연결된 조건 변수가 아니라 std::future::get(), 다른 뮤텍스의 조건 변수, 동기 소켓 호출이라면 대기하는 동안 락이 풀리지 않습니다.
void Session::flush() {
std::lock_guard<std::mutex> lk(mtx_);
auto fut = write_async_with_future(); // 완료 핸들러가 promise를 채움
fut.get(); // mtx_를 쥔 채 블록
}
// 다른 io 스레드에서 실행되는 완료 핸들러
void Session::on_written() {
std::lock_guard<std::mutex> lk(mtx_); // flush가 mtx_를 놓지 않으므로 영원히 대기
// ...
promise_.set_value(); // 여기 도달하지 못함
}
이 경우는 io 스레드가 충분해도 멈춥니다. flush는 mtx_를 쥐고 완료를 기다리고, 완료 핸들러는 mtx_를 기다리는 순환입니다.
해결: 완료 이후의 일은 완료 핸들러에서
비동기 연산을 시작한 쪽은 기다리지 않고 바로 반환하고, 완료 이후에 할 일은 완료 핸들러에서 이어가는 것이 원칙입니다. 세션 상태를 하나의 strand에서만 건드리면 락도 필요 없습니다.
void Session::send(std::string msg) {
// 어느 스레드에서 불려도 세션 상태는 strand에서만 건드림
boost::asio::post(strand_, [self = shared_from_this(), msg = std::move(msg)]() mutable {
self->write_queue_.push_back(std::move(msg));
if (self->write_queue_.size() == 1) self->write_next();
});
}
void Session::write_next() {
boost::asio::async_write(socket_, boost::asio::buffer(write_queue_.front()),
boost::asio::bind_executor(strand_,
[self = shared_from_this()](boost::system::error_code ec, std::size_t) {
if (ec) return self->close();
self->write_queue_.pop_front(); // 전송 끝난 버퍼 제거
if (!self->write_queue_.empty()) self->write_next();
else self->on_send_done(); // "전송 후 다음 작업"은 여기서
}));
}
write_queue_는 std::deque<std::string>입니다. 버퍼를 큐에 보관하는 이유는 async_write가 끝날 때까지 버퍼가 살아 있어야 하기 때문입니다. 지역 변수나 값으로 받은 매개변수를 buffer()로 넘기면 함수가 반환된 뒤 버퍼가 파괴되어 쓰레기를 전송하거나 크래시가 납니다. 또 한 소켓에 async_write를 동시에 두 번 걸면 데이터가 섞일 수 있으므로, 큐에 쌓고 하나가 끝나면 다음을 시작하는 구조가 필요합니다.
정말 동기 대기가 필요한 코드가 있다면(예: 종료 시 마지막 전송을 기다리는 main 스레드), 그 대기는 io_context 스레드가 아닌 스레드에서만 해야 합니다.
패턴 2: 스레드마다 다른 락 순서
문제 코드
struct Session {
std::mutex mtx;
std::string data;
};
Session session_a, session_b;
// 경로 1: 요청 처리 (A → B)
void handle_request() {
std::lock_guard<std::mutex> la(session_a.mtx);
std::lock_guard<std::mutex> lb(session_b.mtx);
}
// 경로 2: 타이머 콜백 (B → A)
void on_timer() {
std::lock_guard<std::mutex> lb(session_b.mtx);
std::lock_guard<std::mutex> la(session_a.mtx); // 순서가 반대
}
스레드 1이 handle_request에서 A를 잡고, 그 사이 스레드 2가 on_timer에서 B를 잡으면, 각자 상대가 가진 락을 기다리며 멈춥니다.
Asio에서는 이 순서가 한 함수 안에 드러나지 않는 경우가 많습니다. 타이머 콜백은 “세션 락 → 전역 맵 락”, 읽기 완료 콜백은 “전역 맵 락 → 세션 락”처럼 서로 다른 파일의 핸들러가 각자 순서를 정하기 때문입니다. 외부 라이브러리 콜백 안에서 우리 코드가 다시 불리는 구조라면, 같은 스레드가 이미 잡은 std::mutex를 다시 잡으려 해서 멈추기도 합니다. std::mutex는 재진입을 허용하지 않고, 이 경우는 정의되지 않은 동작입니다.
해결
여러 락을 함께 잡아야 한다면 std::scoped_lock(C++17)이나 std::lock으로 한 번에 획득합니다.
void process_both() {
std::scoped_lock lock(session_a.mtx, session_b.mtx); // 인자 순서와 무관하게 교착 회피
// ...
}
// C++11/14
void process_both_cpp11() {
std::unique_lock<std::mutex> la(session_a.mtx, std::defer_lock);
std::unique_lock<std::mutex> lb(session_b.mtx, std::defer_lock);
std::lock(la, lb);
}
std::lock은 표준이 정한 교착 회피 알고리즘으로 여러 락을 획득합니다. 구현은 보통 하나를 블로킹으로 잡고 나머지를 try_lock으로 시도하다가 실패하면 잡은 것을 모두 풀고 순서를 바꿔 다시 시도하는 방식입니다. 호출 경로마다 인자 순서가 달라도 교착이 생기지 않습니다.
락이 한 번에 잡히지 않고 호출 체인을 따라 하나씩 잡히는 구조라면, 락마다 계층 번호를 정해 두고 항상 낮은 번호에서 높은 번호 순으로만 잡도록 규칙을 둡니다.
// 락 계층: 1 = session_mtx, 2 = global_cache_mtx, 3 = log_mtx
// 규칙: 낮은 번호 → 높은 번호 순으로만 잡는다
void process_session_then_cache() {
std::lock_guard<std::mutex> l1(session_mtx); // 1
std::lock_guard<std::mutex> l2(global_cache_mtx); // 2
}
void bad_process() {
std::lock_guard<std::mutex> l3(log_mtx); // 3
std::lock_guard<std::mutex> l1(session_mtx); // ❌ 1을 나중에: 규칙 위반
}
규칙은 문서화만으로는 지켜지기 어렵습니다. 디버그 빌드에서 스레드별로 “현재 잡고 있는 가장 높은 계층”을 thread_local로 기록하고, 더 낮은 계층을 잡으려 하면 assert로 멈추는 래퍼 뮤텍스를 두면 위반을 테스트 단계에서 잡을 수 있습니다.
패턴 3: 락을 쥔 채 dispatch나 외부 콜백 호출
dispatch는 호출 스레드가 해당 executor에서 실행 중이면 핸들러를 그 자리에서 바로 호출합니다. 락을 쥔 채 dispatch하면 핸들러가 같은 락을 잡으려다 같은 스레드에서 멈출 수 있습니다.
void Session::notify_all() {
std::lock_guard<std::mutex> lk(mtx_);
for (auto& cb : callbacks_)
boost::asio::dispatch(strand_, cb); // 이미 strand 안이면 cb가 즉시 실행됨
}
// cb 안에서 다시 mtx_를 잡으면 같은 스레드가 자기 락을 기다림
post로 바꾸면 핸들러는 항상 큐에 들어가 현재 함수가 락을 놓은 뒤 실행됩니다. 더 일반적인 원칙은 락을 쥔 채 내가 내용을 모르는 코드(사용자 콜백, 외부 라이브러리)를 호출하지 않는 것입니다. 필요한 데이터를 락 안에서 복사한 뒤, 락을 놓고 호출합니다.
Strand로 락 자체를 줄이기
strand는 그것을 통해 실행되는 핸들러가 동시에 실행되지 않고 하나씩 순서대로 실행된다는 보장을 줍니다. 세션의 읽기·쓰기·타이머 핸들러를 모두 그 세션의 strand에 묶으면, 세션 멤버에 락 없이 접근할 수 있습니다. 락이 없으니 락 순서 문제도 없습니다. 자세한 동작은 고성능 네트워크 가이드 #3에서 다룹니다.
class Session : public std::enable_shared_from_this<Session> {
public:
explicit Session(boost::asio::ip::tcp::socket socket)
: socket_(std::move(socket)),
strand_(boost::asio::make_strand(socket_.get_executor())) {}
void start() {
boost::asio::dispatch(strand_, [self = shared_from_this()] { self->do_read(); });
}
private:
void do_read() {
socket_.async_read_some(boost::asio::buffer(read_buf_),
boost::asio::bind_executor(strand_,
[self = shared_from_this()](boost::system::error_code ec, std::size_t n) {
if (!ec) self->on_read(n);
}));
}
void on_read(std::size_t n);
void write_next();
boost::asio::ip::tcp::socket socket_;
boost::asio::strand<boost::asio::any_io_executor> strand_;
std::array<char, 4096> read_buf_;
std::deque<std::string> write_queue_; // strand 안에서만 접근 → 락 불필요
};
소켓 자체를 strand executor로 만들면(tcp::socket socket(strand)), 이후 그 소켓의 비동기 연산 핸들러가 기본으로 strand에서 실행되어 bind_executor를 빠뜨리는 실수를 줄일 수 있습니다.
strand가 데드락을 완전히 없애 주지는 않습니다. strand 안의 핸들러가 같은 strand에 post한 작업의 결과를 동기로 기다리면, 그 작업은 현재 핸들러가 끝날 때까지 시작되지 않으므로 패턴 1과 똑같이 멈춥니다. 또 여러 세션이 공유하는 전역 맵처럼 strand 밖에 남은 자원은 여전히 락이나 별도 strand가 필요하고, 세션 strand A의 핸들러가 락을 쥔 채 세션 B의 상태를 건드리는 구조라면 패턴 2가 다시 생깁니다. 다른 세션에 무언가를 시키려면 락을 잡지 말고 그 세션의 strand에 post하는 편이 안전합니다.
멈춘 프로세스에서 원인 찾기
스레드 덤프 수집
서버가 멈췄다면 재시작하기 전에 모든 스레드의 스택을 확보하는 것이 가장 중요합니다. -g로 빌드한 바이너리(또는 별도로 보관한 디버그 심볼)가 있어야 함수 이름과 소스 위치가 보입니다.
# 모든 스레드의 backtrace를 파일로 저장하고 분리
gdb -p <pid> -batch -ex "set pagination off" -ex "thread apply all bt" > threads.txt
# 프로세스를 오래 멈추지 않고 코어 덤프만 떠서 나중에 분석
gcore -o /tmp/server <pid>
gdb ./server /tmp/server.<pid> -batch -ex "thread apply all bt"
gdb가 붙어 있는 동안 프로세스의 모든 스레드는 멈춥니다. 운영 환경에서는 gcore로 코어를 빠르게 떠 두고 오프라인에서 분석하는 편이 서비스 영향이 적습니다. pstack은 많은 배포판에서 gdb를 감싼 스크립트라 마찬가지로 잠깐 멈춥니다. macOS에서는 lldb -p <pid> 후 thread backtrace all, 또는 sample <pid> 1로 스택을 볼 수 있습니다.
덤프 읽기
패턴 2(락 순서)라면 두 스레드가 pthread_mutex_lock 아래 __lll_lock_wait(glibc)에서 멈춰 있고, 각 스레드의 더 위쪽 프레임을 보면 서로 다른 순서로 락을 잡는 함수가 보입니다.
Thread 3 (LWP 12346):
#0 __lll_lock_wait () at lowlevellock.c:52
#1 pthread_mutex_lock ()
#2 std::mutex::lock()
#3 on_timer() at server.cpp:88 ← session_b 보유 상태에서 session_a 대기
...
Thread 2 (LWP 12345):
#0 __lll_lock_wait () at lowlevellock.c:52
#1 pthread_mutex_lock ()
#2 std::mutex::lock()
#3 handle_request() at server.cpp:45 ← session_a 보유 상태에서 session_b 대기
어떤 스레드가 그 뮤텍스를 쥐고 있는지 확인하려면, 해당 프레임에서 뮤텍스 객체를 출력합니다. glibc의 pthread_mutex_t에는 소유 스레드의 LWP 번호가 __owner 필드로 들어 있습니다.
(gdb) thread 3
(gdb) frame 3
(gdb) print session_a.mtx
$1 = {... __data = {__lock = 2, __count = 0, __owner = 12345, ...}}
__owner = 12345는 Thread 2가 그 락을 쥐고 있다는 뜻이고, Thread 2는 다시 Thread 3이 쥔 락을 기다리므로 순환이 확인됩니다.
패턴 1(io 스레드 고갈)은 모양이 다릅니다. run()을 돌리는 스레드들이 전부 pthread_cond_wait나 std::future::get 같은 대기 함수에 있고, 그 스택의 아래쪽에 Asio 핸들러 호출 프레임(boost::asio::detail::...)이 있습니다. 즉 “핸들러 안에서 기다리는 중인 io 스레드”만 있고, 큐를 처리 중인 io 스레드는 하나도 없습니다. 이 경우 뮤텍스 소유자를 찾을 필요 없이 그 대기 호출을 비동기 체인으로 바꾸면 됩니다.
ThreadSanitizer
g++ -std=c++17 -fsanitize=thread -g -O1 -o server server.cpp -pthread
ThreadSanitizer는 데이터 레이스뿐 아니라 락 획득 순서도 추적합니다. 한 번은 A → B, 다른 때는 B → A로 잡는 것을 관찰하면 실제로 멈추지 않았더라도 lock-order-inversion (potential deadlock)을 보고합니다. 그래서 패턴 2는 테스트에서 미리 잡을 수 있는 경우가 많습니다. 반면 패턴 1처럼 락이 아니라 스레드 고갈로 멈추는 경우는 TSan이 알 수 없습니다. 다만 이 경고는 Linux의 GCC·Clang TSan 기준 설명입니다. macOS의 Apple clang 21로 같은 순서 역전 코드를 순차 실행해 TSan으로 돌려 봤을 때는(TSAN_OPTIONS=detect_deadlocks=1을 줘도) 경고가 나오지 않았으므로, 락 순서 검사는 Linux 환경에서 확인하세요. 실행 속도가 수 배 느려지므로 CI나 스트레스 테스트에서 돌립니다. TSan이 Boost.Asio 내부의 원자적 연산이나 std::atomic_thread_fence를 이해하지 못해 거짓 경고를 내는 경우도 있으니, 경고가 Asio 내부에서만 나오면 버전별 알려진 문제인지 확인합니다.
재현율 높이기
타이밍에 의존하는 문제이므로 동시 연결 수와 요청 빈도를 높이면 재현이 쉬워집니다. 패턴 1은 io 스레드 수를 1로 줄이면 거의 항상 재현되므로, 의심되는 코드를 단일 스레드 run()으로 테스트해 보는 것이 빠른 확인 방법입니다. 락 순서 문제는 의심 구간의 락 획득 직후에 디버그 빌드에서만 짧은 sleep_for를 넣으면 경쟁 구간이 넓어져 재현이 쉬워집니다.
#ifdef DEBUG_DEADLOCK_REPRO
std::this_thread::sleep_for(std::chrono::milliseconds(1));
#endif
운영 중 감지
멈춤을 빨리 알아차리려면 io 스레드 밖에서 동작하는 워치독이 필요합니다. 워치독을 같은 io_context의 타이머로 만들면, io 스레드가 모두 멈췄을 때 워치독 핸들러도 실행되지 않으므로 아무것도 감지하지 못합니다.
std::atomic<std::uint64_t> heartbeat{0};
// io_context에서 주기적으로 heartbeat 증가
void tick(boost::asio::steady_timer& t) {
++heartbeat;
t.expires_after(std::chrono::seconds(1));
t.async_wait([&t](boost::system::error_code ec) { if (!ec) tick(t); });
}
// 별도 스레드에서 감시
std::thread watchdog([] {
auto last = heartbeat.load();
for (;;) {
std::this_thread::sleep_for(std::chrono::seconds(30));
auto now = heartbeat.load();
if (now == last) std::fputs("WARNING: io_context made no progress for 30s\n", stderr);
last = now;
}
});
경고가 뜨면 그 시점에 gcore를 실행하도록 운영 스크립트를 연결해 두면, 재현이 어려운 멈춤도 원인 분석에 필요한 덤프를 남길 수 있습니다. 시그널 핸들러 안에서 backtrace()로 스택을 찍는 방법은 신호를 받은 스레드 하나의 스택만 보여 주고, backtrace_symbols가 내부에서 malloc을 호출해 시그널 안전하지 않으므로 데드락 분석에는 맞지 않습니다.
원인별 정리
| 원인 | 덤프에서 보이는 모습 | 해결 |
|---|---|---|
| 핸들러 안에서 비동기 완료를 동기 대기 | io 스레드 전부가 핸들러 프레임 아래 cond_wait/future::get에서 대기 | 완료 핸들러에서 이어서 처리 |
| 락을 쥔 채 블로킹 대기 | 한 스레드는 대기 함수, 다른 스레드는 그 락의 mutex_lock에서 대기 | 락 밖에서 대기하거나 비동기 체인으로 |
| 락 순서 불일치 | 두 스레드가 서로의 락을 기다림 (__owner로 확인) | std::scoped_lock, 락 계층 |
락을 쥔 채 dispatch/외부 콜백 | 같은 스레드가 자기 락을 기다림 | post 사용, 락 밖에서 호출 |
| strand 안에서 같은 strand 작업 대기 | strand 핸들러가 대기 중이고 큐에 같은 strand 작업이 쌓임 | 동기 대기 제거 |
이 표에서 보이듯, Asio 데드락의 해결책은 대부분 “기다리지 않는 구조로 바꾸기”입니다. 비동기 연산을 시작했다면 그 결과를 기다리는 대신 완료 핸들러에 다음 단계를 맡기고, 세션 상태는 strand로 직렬화해 락을 줄이고, 그래도 남는 락은 한 번에 잡거나 순서를 강제합니다.