C++ Asio 데드락 디버깅 | 비동기 콜백 실전 [#49-3]

C++ Asio 데드락 디버깅 | 비동기 콜백 실전 [#49-3]

이 글의 핵심

C++ Asio 데드락 디버깅에 대한 실전 가이드입니다. 비동기 콜백 실전 [#49-3] 등을 예제와 함께 상세히 설명합니다.

들어가며: “Asio 서버가 가끔 멈춘다”

검색해서 들어오는 분들을 위해

Asio 기반 서버에서 비동기 콜백 안에서 뮤텍스를 잡은 채 다른 비동기 연산의 완료를 기다리거나, 여러 락을 서로 다른 순서로 잡으면 데드락(두 스레드가 서로가 가진 락을 기다리며 영원히 멈추는 상태)이 발생할 수 있습니다. 단일 스레드에서 재현되지 않고, 부하가 걸리거나 타이밍에 따라 가끔만 나타나서 “은밀한” 데드락으로 불리기도 합니다. 이 글은 그런 패턴실전에서 어떻게 추적·해결하는지를 정리합니다.

이 글에서 다루는 것:

  • 패턴 1: 콜백 안에서 락 잡고 → 완료를 동기 대기 → 완료 핸들러가 같은 락을 필요로 함 → 데드락
  • 패턴 2: 여러 락을 스레드마다 다른 순서로 잡음 → 데드락
  • 해결 방향: Strand 사용(락 제거), 락 순서 통일, 락 잡은 채 대기 금지
  • 추적 방법: 스레드 덤프, 호출 스택에서 대기 지점 확인
  • 완전한 디버깅 예제: gdb, pstack, 로그 기반 추적
  • 프로덕션 패턴: 데드락 방지 체크리스트, 모니터링

관련 글: 고성능 네트워크 가이드 #2: Mutex의 한계·데드락, #3 Strand.


문제 시나리오: 데드락이 발생하는 실제 상황

시나리오 1: 채팅 서버에서 메시지 전송 대기

채팅 서버에서 클라이언트 A가 B에게 메시지를 보내고, 전송 완료를 동기적으로 기다린 뒤 다음 작업을 하려고 합니다. async_write를 호출한 뒤 condition_variable::wait로 대기하는데, 이때 이미 std::mutex를 잡은 상태라면 완료 핸들러가 같은 뮤텍스를 잡으려 할 때 데드락이 발생합니다.

시나리오 2: 세션 풀에서 연결 재사용

여러 세션 객체를 풀에서 관리할 때, 세션 A의 락을 잡은 채로 세션 B의 상태를 확인해야 하는 경우가 있습니다. 호출 경로에 따라 “A → B” 순서로 락을 잡는 경로와 “B → A” 순서로 락을 잡는 경로가 공존하면, 두 스레드가 서로 다른 순서로 락을 잡아 데드락이 발생합니다.

시나리오 3: HTTP 프록시에서 업스트림 응답 대기

프록시 서버가 클라이언트 요청을 받아 업스트림 서버에 async_write로 전달한 뒤, 응답을 동기적으로 기다리는 코드가 있습니다. 이때 락을 잡은 채 cv.wait를 호출하면, 업스트림 응답 핸들러가 같은 락을 필요로 할 때 데드락이 됩니다.

시나리오 4: 로그 버퍼 플러시 대기

비동기 로깅 시스템에서 로그를 버퍼에 쌓고 async_write로 디스크에 쓰기를 시작한 뒤, 플러시 완료를 기다리는 코드가 락을 잡은 채 대기하면, 쓰기 완료 핸들러가 락을 잡으려 할 때 데드락이 발생합니다.

시나리오 5: 분산 캐시 조회

여러 노드에 캐시를 분산 저장한 서버에서, 노드 A의 락을 잡은 채 노드 B의 비동기 조회를 시작하고 완료를 기다립니다. B의 응답 핸들러가 전역 맵(락 필요)을 갱신하려 할 때, 이미 A의 락을 잡은 스레드가 B 응답을 기다리는 구조라 데드락이 발생할 수 있습니다.

시나리오 6: 타이머와 비동기 I/O 혼합

steady_timer::async_waitasync_read/async_write가 같은 세션 객체를 공유할 때, 타이머 콜백은 “세션 락 → 전역 맵 락” 순서로 잡고, I/O 완료 콜백은 “전역 맵 락 → 세션 락” 순서로 잡으면 데드락이 됩니다. 두 콜백을 같은 Strand에 묶으면 순서가 보장되어 데드락을 피할 수 있습니다.

시나리오 7: 외부 라이브러리 콜백

SSL 핸드셰이크, HTTP 파서 등 외부 라이브러리의 콜백을 호출할 때, 그 콜백이 내부적으로 우리가 만든 함수를 호출할 수 있습니다. 그 함수가 락을 잡고 있고, 콜백 체인 어딘가에서 같은 스레드가 같은 락을 다시 잡으려 하면 재귀적 락 문제가 됩니다. std::mutex는 재진입 불가이므로 데드락이 됩니다. 이 경우 락 범위를 최소화하거나, 콜백 호출 전에 락을 해제하는 것이 안전합니다.

개념을 잡는 비유

소켓과 비동기 I/O는 우편함 주소와 배달 경로로 이해하면 편합니다. 주소(IP·포트)만 맞으면 데이터가 들어오고, Asio는 한 우체국에서 여러 배달부(스레드·핸들러)가 일을 나누는 구조로 보시면 됩니다.


목차

  1. 은밀한 데드락 패턴 1: 락 잡고 완료 대기
  2. 은밀한 데드락 패턴 2: 락 순서 불일치
  3. 해결 방향: Strand·락 최소화·순서 통일
  4. 실전 추적: 스레드 덤프·호출 스택
  5. 완전한 데드락 디버깅 예제
  6. 흔한 원인 정리
  7. 디버깅 전략 체계화
  8. 프로덕션 패턴
  9. 자주 발생하는 에러와 해결법
  10. 구현 체크리스트

1. 은밀한 데드락 패턴 1: 락 잡고 완료 대기

어떤 코드가 문제인가

  • 비동기 연산(예: async_write)을 시작한 뒤, 그 완료condition_variable::wait 같은 동기 대기로 기다리는 경우입니다.
  • 대기하기 전에 이미 뮤텍스를 잡은 상태이고, 완료 핸들러다른 스레드에서 실행되며 같은 뮤텍스를 잡으려 하면 데드락입니다. (핸들러가 락을 잡아야 완료 플래그를 설정하고 notify할 수 있는데, 락은 이미 대기 중인 스레드가 쥐고 있음)

on_sendlock 을 잡은 채 async_write 를 걸고 cv.wait(…) 로 대기합니다. async_write 완료는 io.run() 을 돌리는 다른 스레드에서 실행되므로, 그 스레드가 lk(mtx) 를 잡으려 할 때 on_send 가 이미 mtx 를 쥔 채 wait 중이라 서로 영원히 대기하는 데드락이 됩니다. 비동기 완료를 기다릴 때는 락을 잡은 상태에서 cv.wait 하지 말고, 완료 콜백 안에서 다음 단계를 하거나 strand.post 로 같은 스레드에서 이어가도록 설계해야 합니다.

코드 상세 설명:

  • std::unique_lock<std::mutex> lock(mtx): 호출 스레드가 mtx를 획득합니다.
  • socket.async_write(..., [&](...)): 람다가 [&]로 캡처하므로 mtx, cv, done을 참조로 사용합니다. 주의: done이 스택 변수이면 람다가 나중에 실행될 때 이미 스택이 무효화되었을 수 있어, 이 예제 자체도 수명 문제가 있지만, 데드락 설명을 위해 단순화했습니다.
  • 완료 핸들러 내부의 std::lock_guard<std::mutex> lk(mtx): 다른 스레드에서 실행되므로, on_sendcv.wait에서 mtx를 쥔 채 대기 중일 때 이 줄에서 mtx를 잡으려 하면 영원히 대기합니다.
  • cv.wait(lock, [&] { return done; }): donetrue가 되려면 완료 핸들러가 실행되어야 하고, 완료 핸들러는 mtx를 잡아야 done = true를 설정할 수 있습니다. 그런데 mtx는 이미 on_send가 쥔 상태이므로 순환 대기가 됩니다.
// 위험한 패턴 (데드락)
std::mutex mtx;
std::condition_variable cv;
bool done = false;

void on_send() {
    std::unique_lock<std::mutex> lock(mtx);
    socket.async_write(..., [&](error_code ec, size_t n) {
        std::lock_guard<std::mutex> lk(mtx);  // 다른 스레드에서 대기 → 데드락
        done = true;
        cv.notify_one();
    });
    cv.wait(lock, [&] { return done; });  // 락 쥔 채 대기
}

데드락 발생 시퀀스 다이어그램

sequenceDiagram
    participant T1 as 스레드 1 (on_send)
    participant T2 as 스레드 2 (io.run)
    participant Mtx as mutex

    T1->>Mtx: lock(mtx) 획득
    T1->>T2: async_write 시작
    T1->>T1: cv.wait(lock) - mtx 쥔 채 대기
    Note over T1: mtx 보유 중
    T2->>T2: async_write 완료
    T2->>Mtx: lock(mtx) 시도
    Note over T2: T1이 mtx 보유 → 대기
    Note over T1,T2: T1: cv 대기(notify 안 옴), T2: mtx 대기 → 데드락

왜 은밀한가

  • 한 스레드에서만 실행되면 재현되지 않습니다. 여러 스레드run()을 돌릴 때, “이 스레드”는 wait에 빠지고 “저 스레드”가 완료 핸들러를 실행하려다 락에서 대기하는 구조라 타이밍에 따라 가끔만 나타납니다.

해결

  • 비동기 콜백 안에서는 “락을 잡은 채로 다른 비동기 완료를 기다리지 않는다”는 원칙을 지킵니다. 완료 후에 할 일은 완료 핸들러 안에서 하거나, post(strand, …) 로 같은 Strand에 넣어서 “다음 턴”에서 처리합니다. 고성능 네트워크 가이드 #2에서 다룬 내용입니다.
  • 동기적으로 기다려야 하는 요구가 있다면, 별도 스레드에서 기다리거나, 비동기 체인(async → 완료 핸들러에서 다음 단계)으로 설계를 바꾸는 것이 안전합니다.

안전한 대안 코드

// ✅ 안전: 완료 핸들러에서 다음 단계 수행 (락 없이)
void on_send_safe() {
    socket.async_write(
        boost::asio::buffer(data),
        boost::asio::bind_executor(strand_, [self = shared_from_this()](error_code ec, size_t n) {
            if (!ec) {
                self->on_send_done();  // 여기서 다음 작업 - 락 불필요
            }
        })
    );
}

2. 은밀한 데드락 패턴 2: 락 순서 불일치

어떤 코드가 문제인가

  • 스레드 A: 락 1 → 락 2 순서로 잡음.
  • 스레드 B: 락 2 → 락 1 순서로 잡음.
  • A가 1을 잡고 2를 기다리는 동안, B가 2를 잡고 1을 기다리면 데드락입니다.
  • Asio에서는 서로 다른 세션·객체를 보호하는 락을 “세션 A 처리 중에 세션 B의 락도 잡아야 하는” 식으로 설계하면, 호출 경로에 따라 순서가 뒤바뀌는 경우가 생길 수 있습니다.

순환 대기 다이어그램

flowchart LR
    subgraph deadlock["데드락 상태"]
        A["스레드 Abr/락1 보유, 락2 대기"]
        B["스레드 Bbr/락2 보유, 락1 대기"]
        A -->|대기| B
        B -->|대기| A
    end

위험한 코드 예시

// 세션 A와 B가 각각 자신의 락을 가짐
struct Session {
    std::mutex mtx;
    std::string data;
};

Session session_a, session_b;

// 경로 1: A → B 순서 (예: 클라이언트 요청 처리)
void handle_request_from_a() {
    std::lock_guard<std::mutex> la(session_a.mtx);
    std::lock_guard<std::mutex> lb(session_b.mtx);  // B도 필요
}

// 경로 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 → 락 2”. 또는 std::lock으로 여러 락을 한꺼번에 잡아 데드락을 피합니다.
  • Asio에서는 세션(연결) 단위로 하나의 Strand를 두고, 해당 세션의 모든 콜백을 Strand에 묶으면 락 없이 순차 실행이 보장되므로, 세션 간에만 락이 필요할 때(예: 전역 저장소) 그 부분만 순서를 정해 락을 잡는 편이 좋습니다.

std::lock으로 안전하게 여러 락 획득

// ✅ 안전: std::lock으로 데드락 방지
void safe_access_both() {
    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의 동작: std::lock(la, lb)는 두 락을 한꺼번에 획득하려 시도합니다. 한쪽을 잡고 다른 쪽을 기다리는 동안 데드락이 발생할 수 있으므로, 내부적으로 try_lock을 반복하며 양쪽을 모두 획득할 때까지 시도합니다. 따라서 호출 경로와 관계없이 데드락 없이 두 락을 얻을 수 있습니다.

C++17 std::scoped_lock: std::scoped_lock lock(mtx_a, mtx_b);std::lock과 RAII를 결합한 형태로, 스코프를 벗어나면 자동으로 두 락이 해제됩니다.


3. 해결 방향: Strand·락 최소화·순서 통일

Strand 사용

같은 연결에 대한 읽기/쓰기 콜백을 한 Strand에 묶어 락 없이 직렬화합니다. 고성능 네트워크 가이드 #3 참고.

Strand의 핵심: Strand는 “이 executor를 통해 스케줄된 작업은 한 번에 하나씩만 실행된다”는 실행 순서 보장을 제공합니다. Mutex처럼 “진입 시 잠그고 퇴장 시 푸는” 방식이 아니라, 큐에서 순서만 지키는 방식이므로 락 경합과 데드락이 없습니다. 같은 세션의 do_read 완료 핸들러와 do_write 완료 핸들러가 절대 동시에 실행되지 않으므로, write_queue_ 같은 멤버에 락 없이 접근해도 됩니다.

// 연결당 Strand 패턴
class Session : public std::enable_shared_from_this<Session> {
    boost::asio::ip::tcp::socket socket_;
    boost::asio::strand<boost::asio::io_context::executor_type> strand_;
    std::string write_queue_;  // Strand 내에서만 접근 → 락 불필요

public:
    Session(boost::asio::ip::tcp::socket socket, boost::asio::io_context& io)
        : socket_(std::move(socket))
        , strand_(boost::asio::make_strand(io)) {}

    void do_read() {
        boost::asio::async_read_some(
            socket_,
            boost::asio::buffer(read_buf_),
            boost::asio::bind_executor(strand_, [self = shared_from_this()](auto ec, auto n) {
                if (!ec) self->on_read(n);
            })
        );
    }

    void do_write(std::string data) {
        boost::asio::async_write(
            socket_,
            boost::asio::buffer(data),
            boost::asio::bind_executor(strand_, [self = shared_from_this()](auto ec, auto n) {
                if (!ec) self->on_write(n);
            })
        );
    }
};

핵심 원칙

  • 락을 잡은 채로 다른 비동기 완료를 기다리지 않기: 완료 후 로직은 콜백 안 또는 post로 처리.
  • 락이 꼭 필요할 때: 순서 통일 또는 std::lock으로 데드락을 방지합니다.

4. 실전 추적: 스레드 덤프·호출 스택

서버가 멈췄을 때

  • 스레드 덤프를 뜹니다. Linux: gdb -p <pid>thread apply all bt. 또는 pstack <pid>.
  • macOS에서는 lldb -p <pid>thread backtrace all로 유사하게 확인할 수 있습니다.
  • 모든 스레드의 backtrace를 보면, “몇 개 스레드가 같은 뮤텍스에서 대기 중”이거나 “A는 락 1 보유·락 2 대기, B는 락 2 보유·락 1 대기”처럼 순환 대기가 보입니다.
  • 대기 중인 프레임pthread_mutex_lock, std::condition_variable::wait 등이면, 해당 락·조건 변수가 어디서 잡혀 있는지 다른 스레드의 bt에서 확인합니다.

로그로 재현

  • 데드락이 의심되는 구간로그(어떤 락을 잡았다/풀었다, 어떤 완료를 기다린다)를 넣어 두면, 재현 시 “락 A 잡음 → 대기” / “다른 스레드에서 락 B 잡음 → 락 A 대기” 같은 순서가 보입니다. 그 순서를 바꾸거나 Strand로 바꾸면 해결됩니다.

5. 완전한 데드락 디버깅 예제

빠른 진단 플로우

  1. 서버가 멈췄는가? → CPU 사용률 확인 (거의 0이면 데드락 의심)
  2. 스레드 덤프 수집gdb -p <pid> -batch -ex "thread apply all bt" 또는 pstack <pid>
  3. 순환 대기 찾기 → 두 개 이상 스레드가 pthread_mutex_lock 또는 cond_wait에서 대기하는지 확인
  4. 락 소유자 확인 → 대기 중인 스레드가 기다리는 락을, 다른 스레드가 보유하고 있는지 backtrace에서 확인
  5. 소스 코드 수정 → Strand 도입, 락 순서 통일, 락+대기 제거

예제 1: gdb로 스레드 덤프 뜨기

서버가 멈춘 상태에서 프로세스 ID를 알고 있다면. 심볼이 포함된 빌드(-g 옵션)를 사용하면 backtrace에 파일명과 라인 번호가 나와 원인 파악이 쉽습니다.

# 프로세스에 gdb 붙이기
gdb -p <pid>

# gdb 프롬프트에서 모든 스레드의 backtrace 출력
(gdb) thread apply all bt full
# 예상 출력 (데드락 시)
Thread 2 (LWP 12345):
#0  __lll_lock_wait () at lowlevellock.c:52
#1  pthread_mutex_lock () at pthread_mutex_lock.c:80
#2  std::mutex::lock() at mutex:100
#3  on_write_callback(...) at server.cpp:45
#4  boost::asio::detail::...  (완료 핸들러)
...

Thread 3 (LWP 12346):
#0  pthread_cond_wait () at pthread_cond_wait.c:68
#1  std::condition_variable::wait(...) at condition_variable:120
#2  on_send() at server.cpp:38
#3  ...

해석: 스레드 3이 on_send에서 cv.wait 중이고, 스레드 2가 on_write_callback에서 mutex::lock에서 대기 중입니다. on_send가 락을 쥔 채 wait하고, 완료 핸들러(on_write_callback)가 같은 락을 잡으려 해서 데드락입니다.

다음 단계: server.cpp:38server.cpp:45를 열어 on_send 함수를 확인하고, cv.wait 전에 락을 해제하거나, 완료 핸들러에서 락을 사용하지 않도록 수정합니다. 이상적으로는 Strand를 도입해 락 자체를 제거하는 것이 좋습니다.

예제 2: pstack으로 빠르게 확인

# pstack (Linux)
pstack <pid>
Thread 2:
#0  0x00007f8a1c2b3e10 in __lll_lock_wait ()
#1  0x00007f8a1c2b3f80 in pthread_mutex_lock ()
#2  0x0000555a1b2c4d20 in std::mutex::lock()
#3  0x0000555a1b2c4e10 in on_write_callback(...)
...

Thread 3:
#0  0x00007f8a1c2b4a20 in pthread_cond_wait ()
#1  0x0000555a1b2c4c80 in std::condition_variable::wait
#2  0x0000555a1b2c4b90 in on_send()
...

예제 3: 로그 기반 데드락 추적

락 획득/해제 시점에 로그를 남겨 재현 시 순서를 확인합니다.

#define LOCK_LOG(mtx, op) \
    do { \
        std::cerr << "[" << std::this_thread::get_id() << "] " \
                  << op << " " #mtx << " at " << __FILE__ << ":" << __LINE__ << "\n"; \
    } while(0)

void on_send() {
    LOCK_LOG(mtx, "LOCK");
    std::unique_lock<std::mutex> lock(mtx);
    socket.async_write(..., [&](error_code ec, size_t n) {
        LOCK_LOG(mtx, "WAIT");  // 여기서 멈추면 → 다른 스레드가 mtx 보유 중
        std::lock_guard<std::mutex> lk(mtx);
        LOCK_LOG(mtx, "GOT");
        done = true;
        cv.notify_one();
    });
    LOCK_LOG(mtx, "WAIT_CV");
    cv.wait(lock, [&] { return done; });
    LOCK_LOG(mtx, "DONE");
}
# 데드락 재현 시 로그 예시
[140234567890] LOCK mtx at server.cpp:38
[140234567890] WAIT_CV mtx at server.cpp:45
[140234567891] WAIT mtx at server.cpp:42   ← 이 스레드는 mtx 대기에서 멈춤
# 140234567890은 cv.wait 중, 140234567891은 mutex 대기 → 데드락

예제 4: 부하 테스트로 데드락 재현

데드락이 타이밍에 의존하므로, 재현률을 높이려면 동시 연결 수요청 빈도를 늘립니다.

# 여러 클라이언트가 동시에 연결해 요청을 반복 (예: 50개 연결, 각 1000회 요청)
for i in {1..50}; do
  (for j in {1..1000}; do echo "GET key$j"; done | nc localhost 6379) &
done
wait
# Apache Bench로 HTTP 서버 부하 (100 동시, 10000 요청)
ab -n 10000 -c 100 http://localhost:8080/

재현이 어렵다면 의도적으로 지연을 넣어 타이밍을 조절할 수 있습니다.

// 디버그 빌드에서만: 락 획득 직후 짧은 sleep으로 경쟁 조건 유발
#ifdef DEBUG_DEADLOCK_REPRO
    std::this_thread::sleep_for(std::chrono::milliseconds(1));
#endif

예제 5: ThreadSanitizer로 잠재적 데드락 탐지

컴파일 시 -fsanitize=thread를 사용하면 일부 데드락 패턴을 사전에 탐지할 수 있습니다.

# ThreadSanitizer로 빌드
g++ -std=c++17 -fsanitize=thread -g -O1 -o server server.cpp -lboost_system -pthread
# 실행 시 잠재적 데드락 경고 (순환 대기 등)
WARNING: ThreadSanitizer: lock-order-inversion (potential deadlock)

6. 흔한 원인 정리

원인설명해결
락 잡고 cv.wait비동기 완료를 락 쥔 채 동기 대기완료 핸들러에서 처리, post 사용
락 순서 불일치스레드마다 다른 순서로 여러 락 획득순서 통일 또는 std::lock
콜백에서 동기 I/Oasync 완료 핸들러 안에서 blocking read/write비동기 체인으로 설계
재귀적 락 시도같은 스레드가 이미 잡은 락을 다시 잡음 (std::mutex)std::recursive_mutex 또는 설계 변경
외부 코드 호출 시 락 유지락 쥔 채로 알 수 없는 콜백 호출락 범위 최소화, 호출 전 해제
타이머와 비동기 혼합타이머 콜백과 read/write 콜백이 다른 락 순서 사용Strand로 통합 또는 순서 통일

락 계층 구조 (Lock Hierarchy)

여러 락을 사용할 때 숫자로 순서를 부여해 두면, 모든 코드 경로에서 그 순서를 지키기 쉬워집니다.

// 락 계층: 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 process_cache_then_log() {
    std::lock_guard<std::mutex> l2(global_cache_mtx);  // 2
    std::lock_guard<std::mutex> l3(log_mtx);   // 3
    // ...
}

// ❌ 금지: 3 → 1 순서 (역순)
void bad_process() {
    std::lock_guard<std::mutex> l3(log_mtx);   // 3 먼저
    std::lock_guard<std::mutex> l1(session_mtx);  // 1 나중 - 역순!
}

코드 리뷰 시 “락 계층을 준수했는지” 체크하면 데드락을 사전에 막을 수 있습니다.


7. 디버깅 전략 체계화

1단계: 증상 확인

  • 서버가 응답 없음 (hang)
  • CPU 사용률 거의 0 (진행 중인 작업 없음)
  • 로그가 특정 시점에서 멈춤

2단계: 스레드 덤프 수집

# Linux
gdb -p <pid> -batch -ex "thread apply all bt" > threads.txt

# 또는 pstack
pstack <pid> > threads.txt

배치 모드: -batch 옵션으로 gdb가 대화형 없이 명령만 실행하고 종료합니다. CI나 스크립트에서 자동화할 때 유용합니다.

# 한 번에 덤프 후 종료
gdb -p <pid> -batch -ex "set pagination off" -ex "thread apply all bt full" 2>/dev/null

3단계: 순환 대기 식별

  • 두 개 이상 스레드가 mutex_lock 또는 cond_wait에서 대기
  • 스레드 A가 락 X 보유, 락 Y 대기
  • 스레드 B가 락 Y 보유, 락 X 대기
  • 순환 대기 확인

4단계: 락 소유자 추적

  • 대기 중인 스레드의 backtrace에서 어떤 락을 기다리는지 확인
  • 다른 스레드의 backtrace에서 그 락을 보유한 프레임 찾기
  • 해당 소스 코드로 이동해 락 순서 또는 락+대기 패턴 분석

5단계: 수정 및 검증

  • Strand 도입, 락 순서 통일, 락 범위 축소
  • 부하 테스트로 재현 시도
  • 로그 또는 ThreadSanitizer로 재발 방지

디버깅 도구 비교

도구장점단점사용 시점
gdb thread apply all bt정확한 심볼, 모든 스레드프로세스 정지 필요개발/스테이징
pstack빠름, 정지 시간 짧음심볼 없을 수 있음프로덕션 긴급
로그 (락 획득/해제)재현 시 순서 확인코드 수정 필요재현 가능할 때
ThreadSanitizer사전 탐지느림, 일부만 탐지CI/부하 테스트
시그널 핸들러 backtrace프로세스 내 처리메인 스레드만 가능제한적 프로덕션

8. 프로덕션 패턴

패턴 1: 데드락 방지 원칙 (Lock Ordering)

// 전역 락 순서를 문서화하고 준수
// 순서: session_mtx -> global_cache_mtx -> log_mtx
void process_with_order() {
    std::scoped_lock lock(session_mtx, global_cache_mtx);
}

패턴 2: Strand 기반 세션 설계

// 연결당 Strand - 락 최소화
class TcpSession {
    boost::asio::strand<boost::asio::io_context::executor_type> strand_;
    // strand 내에서만 접근하는 멤버 → 락 불필요
};

패턴 3: 비동기 체인 (락 없이 완료 후 처리)

// ❌ 위험: 락 쥔 채 대기
// ✅ 안전: 완료 핸들러에서 다음 단계
void send_then_next() {
    async_write(socket_, buffer(data),
        boost::asio::bind_executor(strand_, [self = shared_from_this()](auto ec, auto n) {
            if (!ec) {
                self->on_send_done();  // 여기서 다음 작업
            }
        }));
}

패턴 4: 데드락 감지 타이머 (선택)

데드락이 발생하면 서버가 응답을 멈추지만 프로세스는 살아 있습니다. 주기적으로 “진행 플래그”를 확인해, 일정 시간 동안 갱신되지 않으면 경고를 남기는 방식입니다. 완벽한 해결은 아니지만, 모니터링·알림에 활용할 수 있습니다.

// 주기적으로 "진행 중인지" 확인
boost::asio::steady_timer watchdog_timer_(io_);
void start_watchdog() {
    watchdog_timer_.expires_after(std::chrono::seconds(30));
    watchdog_timer_.async_wait([this](auto ec) {
        if (!ec && !progress_flag_) {
            std::cerr << "WARNING: possible deadlock, no progress for 30s\n";
        }
        start_watchdog();
    });
}

패턴 5: 시그널 핸들러로 프로덕션 스레드 덤프

gdb attach가 어려운 프로덕션에서는 시그널 핸들러에서 backtrace를 출력해 로그로 남깁니다.

#include <execinfo.h>
#include <signal.h>
#include <unistd.h>

void signal_handler(int sig) {
    void* array[64];
    size_t size = backtrace(array, 64);
    char** strings = backtrace_symbols(array, size);
    std::cerr << "=== Thread dump (signal " << sig << ") ===\n";
    for (size_t i = 0; i < size; ++i) {
        std::cerr << strings[i] << "\n";
    }
    free(strings);
    _exit(1);
}

// main에서
signal(SIGUSR1, signal_handler);  // kill -USR1 <pid> 로 덤프 생성

kill -USR1 <pid>를 보내면 해당 프로세스가 스레드 덤프를 stderr에 출력하고 종료합니다. (주의: 멀티스레드에서 backtrace는 메인 스레드만 보여줄 수 있으므로, 모든 스레드의 스택을 얻으려면 pstack 또는 gdb가 더 적합합니다.)

패턴 6: 로그에 락 획득/해제 기록 (디버그 빌드)

#ifdef DEBUG_DEADLOCK
    #define LOCK_GUARD(mtx) \
        (std::cerr << "LOCK " << #mtx << "\n"), \
        std::lock_guard<std::mutex> _guard(mtx)
#else
    #define LOCK_GUARD(mtx) std::lock_guard<std::mutex> _guard(mtx)
#endif

9. 자주 발생하는 에러와 해결법

문제 1: “서버가 가끔만 멈춘다”

원인: 타이밍 의존 데드락. 단일 스레드나 낮은 부하에서는 재현되지 않음.

해결법:

  • 스레드 덤프를 여러 번 수집해 패턴 확인
  • thread apply all bt로 순환 대기 식별
  • Strand 도입 또는 락 순서 통일

문제 2: gdb attach 시 서버가 멈춤

원인: gdb가 프로세스에 붙을 때 모든 스레드를 일시 정지시킴.

해결법:

  • 프로덕션에서는 SIGUSR1 등으로 시그널 핸들러에서 backtrace 출력
  • 또는 pstack 사용 (프로세스 정지 시간 최소화)

문제 3: ThreadSanitizer가 데드락을 못 찾음

원인: TSan은 주로 데이터 레이스 탐지에 강함. 데드락은 순환 대기가 실제로 발생해야 탐지됨.

해결법:

  • 부하 테스트로 재현률 높이기
  • 로그 기반 추적으로 락 순서 확인

문제 4: std::lock 사용 시 컴파일 에러

원인: std::lock은 C++11부터, std::scoped_lock은 C++17부터.

해결법:

// C++11
std::lock(la, lb);

// C++17 - 더 간결
std::scoped_lock lock(mtx_a, mtx_b);

문제 5: Strand를 썼는데도 데드락 발생

원인: 전역 자원(맵, 캐시) 접근 시 Strand 외부에서 락을 사용하거나, Strand 간 호출 시 락 순서가 뒤섞임.

해결법:

  • 전역 자원 접근은 별도 Strand 또는 순서가 정해진 락으로 보호
  • Strand A → Strand B 호출 시, 락을 잡지 않고 post로 전달

문제 6: backtrace에 심볼이 안 보인다

원인: -g 옵션 없이 빌드했거나, 스트립된 바이너리를 배포했을 수 있습니다.

해결법:

  • 개발/스테이징에서는 -g -O1 또는 -g -O0로 빌드
  • 프로덕션에서는 심볼을 별도 파일로 보관 (objcopy --only-keep-debug)하고, 덤프 분석 시 해당 파일과 함께 사용

문제 7: macOS에서 pstack이 없다

원인: pstack은 Linux 유틸리티입니다. macOS에는 기본 제공되지 않습니다.

해결법:

  • lldb -p <pid>thread backtrace all 사용
  • 또는 sample <pid> 1 (1초 샘플링)로 스레드 상태 확인

실전 사례: 데드락 해결 전후 비교

Before: 락 잡고 완료 대기 (데드락 발생)

// 채팅 서버: 메시지 전송 후 동기적으로 완료 대기
void ChatSession::send_message(const std::string& msg) {
    std::unique_lock<std::mutex> lock(mtx_);
    pending_send_ = true;
    boost::asio::async_write(
        socket_,
        boost::asio::buffer(msg),
        [this, &lock](boost::system::error_code ec, std::size_t) {
            std::lock_guard<std::mutex> lk(mtx_);  // 데드락!
            pending_send_ = false;
            cv_.notify_one();
        }
    );
    cv_.wait(lock, [this] { return !pending_send_; });
}

문제: send_message를 호출한 스레드가 mtx_를 쥔 채 cv_.wait에서 대기합니다. async_write 완료는 io_context의 다른 스레드에서 실행되므로, 그 스레드가 mtx_를 잡으려 할 때 데드락이 발생합니다.

After: Strand + 비동기 체인 (데드락 해결)

// Strand로 직렬화, 완료 후 콜백에서 다음 단계
void ChatSession::send_message(const std::string& msg) {
    auto self = shared_from_this();
    boost::asio::async_write(
        socket_,
        boost::asio::buffer(msg),
        boost::asio::bind_executor(strand_, [self](boost::system::error_code ec, std::size_t n) {
            if (!ec) {
                self->on_send_done();  // 여기서 다음 작업 (락 불필요)
            }
        })
    );
}

void ChatSession::on_send_done() {
    // Strand 내에서만 실행 → 동시 접근 없음
    if (!write_queue_.empty()) {
        do_write_next();
    }
}

해결 포인트: 락과 cv.wait를 제거하고, 완료 후 로직을 Strand에 바인딩된 콜백에서 처리합니다. 같은 세션의 모든 I/O 콜백이 한 Strand에서만 실행되므로 락 없이도 스레드 안전합니다.

Before/After: 락 순서 불일치 해결

Before: 세션 A와 B를 호출 경로에 따라 다른 순서로 잠금.

// 경로 1: handle_request → A then B
// 경로 2: on_timer → B then A  → 데드락!

After: std::scoped_lock으로 동시 획득.

void safe_process_both() {
    std::scoped_lock lock(session_a.mtx, session_b.mtx);
    // 순서 무관, 데드락 없음
}

또는 락 계층을 정해 “항상 A → B” 순서만 사용하도록 코드를 통일합니다.


10. 구현 체크리스트

데드락 방지 체크리스트

  • 락 잡은 채 cv.wait 하지 않기: 비동기 완료 대기는 콜백 또는 post로
  • 여러 락 사용 시 순서 통일: 문서화하고 모든 경로에서 준수
  • 가능하면 std::lock / scoped_lock 사용: 여러 락 동시 획득
  • 연결당 Strand 사용: 세션 내부 상태는 Strand로 직렬화
  • 락 범위 최소화: 필요한 구간만 잡고 빨리 해제
  • 외부 콜백 호출 전 락 해제: 알 수 없는 코드는 락 밖에서 호출

디버깅 준비 체크리스트

  • 심볼 포함 빌드: -g 옵션으로 backtrace 가독성 확보
  • 스레드 덤프 방법 문서화: gdb, pstack 명령어 팀 공유
  • 의심 구간에 로그 추가: 디버그 빌드에서 락 획득/해제 로깅
  • 부하 테스트로 재현 시도: 멀티 스레드 run(), 동시 연결 다수

코드 리뷰 시 확인할 점

  • cv.wait 또는 cv.wait_for 호출 전에 락을 잡고 있는가? 그 락을 완료 핸들러에서도 사용하는가?
  • 두 개 이상의 std::mutex를 사용하는가? 모든 경로에서 획득 순서가 같은가?
  • async_* 완료 핸들러 안에서 std::lock_guard 또는 unique_lock을 사용하는가? Strand로 대체 가능한가?
  • 타이머 콜백과 I/O 콜백이 같은 객체의 락을 사용하는가? 순서가 일치하는가?

요약: 데드락 방지 원칙 한눈에 보기

flowchart TD
    subgraph avoid["데드락 방지"]
        A[락 잡은 채 cv.wait 금지]
        B["락 순서 통일 또는 std lock"]
        C[연결당 Strand 사용]
        D[락 범위 최소화]
    end

    subgraph detect["데드락 추적"]
        E[스레드 덤프: thread apply all bt]
        F[순환 대기 식별]
        G[락 소유자 추적]
    end

    A --> C
    B --> D
    E --> F --> G
상황하지 말 것할 것
비동기 완료 대기락 쥔 채 cv.wait완료 핸들러에서 처리, post
여러 락순서 불일치std::lock 또는 순서 통일
세션 상태Mutex로 보호Strand로 직렬화
디버깅증상만 확인스레드 덤프로 순환 대기 찾기

gdb 유용 명령어 정리

명령어설명
thread apply all bt모든 스레드의 backtrace 출력
thread apply all bt full지역 변수 포함 전체 backtrace
info threads스레드 목록과 현재 스레드 확인
thread <n>n번 스레드로 전환
frame <n>n번 프레임으로 이동 후 list로 소스 확인

마지막으로

Asio 데드락은 타이밍에 의존해 가끔만 재현되기 때문에, “한 번 고쳤다”고 해도 비슷한 패턴이 다른 곳에 숨어 있을 수 있습니다. 락을 잡은 채로 비동기 완료를 기다리지 않는다, 여러 락은 순서를 통일하거나 std::lock이라는 두 원칙을 팀 전체가 지키고, 코드 리뷰 시 체크리스트로 확인하면 재발을 방지할 수 있습니다. Strand를 적극 활용해 락 자체를 줄이는 것이 가장 근본적인 해결책입니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 멀티스레드 Asio의 딜레마 | Data Race와 Mutex의 한계 [#2]
  • C++ Asio Composed Operation | 비동기 함수 설계 [#7]
  • C++ Boost.Asio 입문 | io_context·async_read

이 글에서 다루는 키워드 (관련 검색어)

Asio 데드락, Boost.Asio 데드락, 비동기 콜백 데드락, 스레드 덤프, gdb thread apply all bt, Strand, 락 순서 등으로 검색하시면 이 글이 도움이 됩니다.

정리

  • 락 잡은 채로 비동기 완료 대기 → 데드락. 완료 후 작업은 콜백 안 또는 post로 처리.
  • 여러 락순서 통일 또는 std::lock.
  • Asio에서는 Strand로 락 없이 직렬화하는 설계가 데드락을 근본적으로 줄입니다.
  • 재현 시 스레드 덤프(thread apply all bt)로 순환 대기를 찾고, 해당 락·호출 경로를 수정합니다.
  • 프로덕션에서는 데드락 방지 원칙을 문서화하고, 체크리스트로 코드 리뷰 시 확인합니다.

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Asio 비동기 콜백 내부에서 락을 잡은 채 대기하거나, 잘못된 순서로 락을 잡을 때 발생하는 데드락 패턴과 실전 디버깅 사례를 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다. 고성능 네트워크 가이드 #2, #3 Strand를 먼저 읽으면 이해가 빠릅니다.

Q. 더 깊이 공부하려면?

A. cppreference와 Boost.Asio 공식 문서를 참고하세요. std::mutex, std::lock, condition_variable 관련 문서와 Asio의 Strand, executor 문서를 함께 보면 좋습니다.

Q. 데드락이 재현되지 않을 때는?

A. 부하를 높이거나, 동시 연결 수를 늘리거나, 타이밍에 민감한 코드 경로를 반복 실행하는 스크립트를 돌려 보세요. ThreadSanitizer로 빌드해 실행하면 잠재적 순환 대기를 탐지할 수 있습니다.

한 줄 요약: Asio 비동기 콜백에서 락·대기 순서를 정리하고 Strand를 쓰면 은밀한 데드락을 줄일 수 있습니다. 다음으로 C++ 시리즈 목차에서 다른 주제를 골라 읽어보면 좋습니다.

참고 자료

재현·운영 시 참고

  • 재현이 어려우면: 부하를 높이고, DEBUG_DEADLOCK_REPRO 플래그로 의도적 지연을 넣어 타이밍을 조절해 보세요.
  • 프로덕션에서 덤프: pstack이 가장 덜 침습적입니다. gdb attach는 프로세스를 정지시키므로, 가능하면 짧은 시간만 붙어 있는 것이 좋습니다.
  • Strand 우선: 새 코드에서는 “연결당 Strand”를 기본으로 두고, 락은 전역 자원에만 최소한으로 사용하는 설계를 권장합니다.

이전 글: [에러 해결·트러블슈팅 #49-2] CMake 빌드 시 흔한 링크 에러 (LNK2019, undefined reference to) 원인과 해결책 관련 시리즈: 고성능 네트워크 가이드 — Strand·post/dispatch로 비동기 설계를 안전하게 가져가는 방법을 다룹니다.


관련 글

  • C++ Segmentation fault | core dump
  • C++ DB 엔진 기초 완벽 가이드 | 저장 엔진·쿼리 파서·실행기·트랜잭션 실전 [#49-1]
  • C++ CMake 링크 에러 LNK2019 | 원인과 해결 [#49-2]
  • C++ 쿼리 최적화 완벽 가이드 | 인덱스 선택·실행 계획·통계·비용 모델·프로덕션 패턴 [#49-3]
  • C++ 로드 밸런서 구현 | Round-Robin·Least Connections·헬스 체크 가이드