C++ 멀티스레드 Asio의 딜레마 | Data Race와 Mutex의 한계 [#2]

C++ 멀티스레드 Asio의 딜레마 | Data Race와 Mutex의 한계 [#2]

이 글의 핵심

C++ 멀티스레드 Asio의 딜레마에 대한 실전 가이드입니다. Data Race와 Mutex의 한계 [#2] 등을 예제와 함께 상세히 설명합니다.

들어가며: io_context 하나를 여러 스레드가 돌리면?

스레드 풀과 공유 자원

고성능을 위해 하나의 io_context에 대해 여러 스레드run()을 호출하는 패턴을 많이 씁니다. 그러면 완료 핸들러가 스레드 풀에 분산되어 실행되므로 CPU 코어를 활용할 수 있습니다. 대신 여러 스레드가 같은 연결 상태, 같은 버퍼, 같은 세션 객체를 건드리게 되고, 그때 Data Race동기화 문제가 터집니다.

직관적으로 “그럼 Mutex로 감싸면 되지 않나?”라고 생각하기 쉽습니다. 하지만 비동기 콜백 안에서 Mutex를 잡는 순간 성능이 폭락하고, 잘못된 순서로 락을 잡으면 데드락에 빠집니다. 이 글에서는 그 이유를 분석하고, 다음 글(Strand)으로 이어지는 “락 없는” 해법의 동기를 제시합니다. 요약하면: 같은 연결에 대한 핸들러를 “한 줄로만” 실행하게 만드는 Strand를 쓰면, Mutex 없이도 Data Race를 피할 수 있습니다. 이 글에서는 “왜 Mutex만으로는 부족한지”를 먼저 이해하는 데 집중합니다.

목표:

  • 여러 스레드가 하나의 io_context::run()을 공유할 때 어떤 문제가 생기는지
  • 비동기 핸들러 안에서 Mutex를 쓰면 왜 성능이 나빠지는지
  • 데드락에 빠지기 쉬운 패턴과, 그래서 Strand가 필요한 이유

목차

  1. 멀티스레드 run()과 핸들러 분산
  2. Data Race가 나는 전형적 상황
  3. 핸들러 안에서 Mutex를 쓰면 왜 성능이 폭락하는가
  4. 데드락에 빠지기 쉬운 패턴
  5. 정리: Strand로 가는 길

실무에서 겪은 문제

실제 프로젝트에서 이 개념을 적용하며 겪었던 경험을 공유합니다.

문제 상황과 해결

대규모 C++ 프로젝트를 진행하며 이 패턴/기법의 중요성을 체감했습니다. 책에서 배운 이론과 실제 코드는 많이 달랐습니다.

실전 경험:

  • 문제: 처음에는 이 개념을 제대로 이해하지 못해 비효율적인 코드를 작성했습니다
  • 해결: 코드 리뷰와 프로파일링을 통해 문제를 발견하고 개선했습니다
  • 교훈: 이론만으로는 부족하고, 실제로 부딪혀보며 배워야 합니다

이 글이 여러분의 시행착오를 줄여주길 바랍니다.


1. 멀티스레드 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() 환경에서는 동시에 두 스레드가 같은 세션 객체를 건드리는 상황이 발생합니다.


2. 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);
}
  • on_readon_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이고, 결과가 비결정적이거나 크래시로 이어질 수 있습니다.

그래서 “같은 세션(연결)“에 속한 핸들러들이 동시에 한 스레드에서만 실행되도록 강제할 필요가 있습니다. 그게 다음 글의 Strand입니다.


3. 핸들러 안에서 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: async_write의 완료은 “나중에” 다른 스레드에서 올 수 있습니다. 그동안 다른 연결의 on_read/on_write도 같은 mtx를 기다려야 합니다. 즉, 연결 A의 I/O 완료가 연결 B, C, D의 핸들러 실행까지 막아서 전체 처리량이 급격히 떨어집니다.
  • 문제 2: 락을 “연결 단위”로 나누더라도, 락을 잡은 상태에서 다른 비동기 연산을 기다리면 안 됩니다. (아래 데드락 참고)

요약

  • 전역 Mutex 하나로 모든 세션을 감싸면: 동시성이 사라지고, I/O 완료가 몰릴 때 대기만 길어져 성능 폭락.
  • 세션별 Mutex를 써도: 락 범위를 잘못 잡거나 “락 잡고 완료 대기”를 하면 데드락 위험이 있습니다. 그리고 락 자체의 경합캐시 라인 bouncing으로 고성능 목표에선 부담이 됩니다.

Asio 설계 철학은 “핸들러를 논리적으로 직렬화해서, 아예 동시에 실행되지 않게 하자”입니다. 그게 Strand이고, 락 없이 큐 레벨에서 순서를 보장합니다.


4. 데드락에 빠지기 쉬운 패턴

락을 잡은 채로 다른 핸들러가 끝나기를 기다리는 경우

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; });  // ⚠️ 락을 잡은 채 대기
}
  • on_read를 실행한 스레드가 mtx를 잡은 채 cv.wait로 대기합니다.
  • async_write 완료 핸들러는 다른 스레드에서 실행될 수 있습니다. 그 핸들러가 mtx를 잡으려 하면, 이미 on_read 스레드가 락을 쥐고 잠들어 있어 데드락입니다.

비동기 콜백 안에서는 “지금 이 스레드가 잡은 락을 풀기 전에” 다른 비동기 완료를 기다리면 안 됩니다. 그래서 “완료될 때까지 동기적으로 기다리는” 코드를 핸들러 안에 넣는 것은 위험합니다. 대신 모든 상태 갱신을 같은 실행 맥락(Strand)으로 직렬화하고, 락을 최소화하는 쪽이 안전합니다.


5. 정리: Strand로 가는 길

문제원인방향
Data Race같은 세션을 여러 스레드의 핸들러가 동시 접근같은 세션의 핸들러를 한 줄로 실행
성능 폭락전역/과도한 Mutex로 핸들러 전체 직렬화락 없이 실행 순서만 보장
데드락락 잡은 채로 다른 핸들러 완료 대기락을 잡지 않고 Strand로 순서 보장

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() 하도록 설계를 제한합니다.


보강: Data Race 실제 사례

  • 카운터/통계: 여러 핸들러가 ++session_count 또는 bytes_received += n을 동시에 실행하면 레이스가 납니다. atomic으로 바꿔도 여러 필드를 일관되게 갱신해야 하면 여전히 설계가 필요합니다.
  • 컨테이너: std::vector에 한 스레드는 push_back, 다른 스레드는 순회·erase를 하면 즉시 UB입니다. 연결별 상태는 한 실행 맥락에서만 수정하는 것이 근본 해결입니다.
  • 타임아웃 타이머: async_wait 완료와 async_read 완료가 동시에 들어와 세션을 close하는 경우, 한쪽은 이미 파괴된 객체를 건드릴 수 있습니다. 취소·상태 플래그도 같은 Strand(또는 단일 스레드)에서만 다루는 편이 안전합니다.

보강: Mutex의 한계 (요약)

관점한계
범위전역 뮤텍스는 모든 연결을 직렬화해 처리량 붕괴. 세션별 뮤텍스는 맞지만, 락 잡은 채 비동기 완료 대기는 여전히 데드락 위험.
성능락 경합·캐시 라인 이동으로 지연 분산이 커질 수 있음.
모델 적합성비동기는 “완료 시 다른 스레드”가 기본이라, 락 순서를 사람이 일관되게 유지하기 어렵습니다.

Strand는 “락 대신 실행 큐에서 순서 보장”으로 위 문제를 구조적으로 줄입니다.


보강: 디버깅 팁

  • ThreadSanitizer (TSan): GCC/Clang에서 -fsanitize=thread -g -O1로 빌드해 재현 테스트를 돌리면, 데이터 레이스가 나는 지점과 스택을 보고해 줍니다. Asio 샘플도 TSan 빌드로 한 번씩 돌려 보는 것이 좋습니다.
  • 재현: 부하를 낮춰도 간헐적으로만 터지므로, 스레드 수를 늘리고 io_context 워커를 여럿 두어 타이밍을 흔들어 재현합니다.
  • 로그: std::this_thread::get_id()를 핸들러 진입 시 한 줄씩 남기면, 같은 세션에서 동시에 두 스레드가 찍히는지 확인할 수 있습니다.
  • 데드락: 이미 cpp-series-49-3에서 다룬 것처럼, 락 순서·condition_variable 대기를 의심합니다.

보강: 성능 측정 방법

  • 처리량(RPS) / p99 지연: 같은 부하 생성기(예: wrk, hey, 자체 클라이언트)로 Strand 도입 전/후·뮤텍스 전역 vs 세션별를 비교합니다.
  • 프로파일러: perf, Instruments, VTune에서 __pthread_mutex_lock, malloc 비중이 상위면 동기화·할당 병목 신호입니다.
  • TSan 빌드는 성능 측정용이 아니라 정확성 검증용입니다. 수치 비교는 TSan 없이 Release 빌드로 합니다.

보강: 흔한 실수와 해결책

실수해결
”연결마다 객체가 있으니 안전하다”같은 연결의 핸들러가 여러 스레드에서 동시 실행될 수 있음 → Strand 또는 단일 스레드 run.
전역 뮤텍스로 모든 async_* 보호처리량 급락 → 연결/세션 단위 직렬화(Strand)로 전환.
락 잡은 채 async_write 완료를 동기적으로 대기데드락 → 완료 콜백/코루틴에서만 다음 단계 진행.
레이스가 안 보인다고 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와 함께 쓸 때는 링크된 모든 .cpp를 동일 플래그로 빌드하는 것이 일반적입니다. (라이브러리 혼용 시 문서 확인.)


자주 묻는 질문 (FAQ)

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

A. 여러 스레드가 하나의 io_context를 공유할 때 발생하는 문제점. 비동기 콜백에서 Mutex를 잡으면 성능이 폭락하고 데드락에 빠지기 쉬운 이유를 분석합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

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

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

다음 글: [C++ 고성능 네트워크 가이드 #3] Strand 완벽 이해: 락(Lock) 없는 동시성 제어의 마법

아키텍처 다이어그램

graph TD
    A[시작] --> B{조건 확인}
    B -->|예| C[처리 1]
    B -->|아니오| D[처리 2]
    C --> E[완료]
    D --> E

설명: 위 다이어그램은 전체 흐름을 보여줍니다.


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

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

  • C++ Asio 데드락 디버깅 | 비동기 콜백 실전 [#49-3]
  • C++ Strand | 락(Lock) 없는 동시성 제어 [#3]
  • C++ Data Race | “Mutex 대신 Atomic을 써야 하는 상황은?” 면접 단골 질문 정리

관련 글

  • C++ Data Race |
  • C++ 멀티스레드 크래시 |
  • C++ Strand | 락(Lock) 없는 동시성 제어 [#3]
  • C++ Asio post, dispatch, defer | 실행 큐 정밀 제어 [#4]
  • C++ 핸들러 메모리 최적화 | 동적 할당 오버헤드 제거 [#5]