C++ Strand | 락(Lock) 없는 동시성 제어 [#3]

C++ Strand | 락(Lock) 없는 동시성 제어 [#3]

이 글의 핵심

C++ Strand에 대한 실전 가이드입니다. 락(Lock) 없는 동시성 제어 [#3] 등을 예제와 함께 상세히 설명합니다.

들어가며: 락 없이 “한 줄로” 실행하고 싶다

Strand가 해결하는 문제

이전 글에서 여러 스레드가 같은 io_context::run()을 돌릴 때, 같은 세션(연결)에 대한 on_readon_write가 서로 다른 스레드에서 동시에 실행되면 Data Race가 난다고 했습니다. Mutex로 감싸면 성능이 떨어지고 데드락 위험도 있습니다.

Strand는 “이 핸들러들은 서로 겹치지 않고 순차적으로만 실행된다”는 실행 순서 보장을 Asio 실행 큐 단에서 해줍니다. 락을 전혀 쓰지 않습니다. 같은 Strand에 바인딩된 모든 핸들러는, 마치 한 줄로 세워진 큐처럼 하나씩만 실행되므로, 같은 연결에 대한 읽기/쓰기 핸들러를 한 Strand에 묶으면 자동으로 스레드 안전해집니다.

처음 보면 “Strand가 락을 대체한다”는 말이 직관적으로 와닿지 않을 수 있습니다. Mutex는 “진입할 때 잠그고 나갈 때 푸는” 방식인데, Strand는 “이 작업들은 아예 동시에 실행되지 않도록 큐에서 순서만 지킨다”는 방식입니다. 그래서 락 경합이나 데드락 없이 “이 연결의 일은 한 스레드가 순서대로만 처리한다”를 보장할 수 있습니다.

목표:

  • Strand의 개념 — 논리적 직렬화가 어떻게 동작하는지
  • make_strand로 Strand 만들기
  • bind_executor(strand, handler) 로 비동기 연산의 완료 핸들러를 Strand에 묶기
  • 실전: 연결당 하나의 Strand 패턴

목차

  1. Strand란 무엇인가
  2. make_strand와 executor
  3. bind_executor로 핸들러를 Strand에 묶기
  4. 실전: 연결당 하나의 Strand
  5. 정리

실무에서 겪은 문제

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

문제 상황과 해결

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

실전 경험:

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

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


1. Strand란 무엇인가

논리적 직렬화 (Logical serialization)

  • io_context는 여러 스레드가 run()을 돌리면, 완료된 핸들러를 아무 스레드나 가져가 실행할 수 있습니다.
  • Strand는 “이 executor를 통해 스케줄된 작업들은 한 번에 하나씩만 실행된다”는 제약을 붙인 Executor입니다.
  • 같은 Strand를 통해 post/dispatch되거나, 비동기 연산의 완료 핸들러가 그 Strand에 바인딩되면, 그 핸들러들은 동시에 두 개가 실행되지 않습니다. 항상 순서가 보장된 하나의 “논리적 큐”로 실행됩니다.

즉, 락(Lock)이 아니라 “실행 순서”로 동시성을 제어합니다. 그래서 락 경합과 데드락 없이 “이 연결에 대한 모든 일은 한 줄로 처리된다”를 보장할 수 있습니다.


2. make_strand와 executor

Strand 만들기

#include <boost/asio.hpp>

boost::asio::io_context io;

// io_context의 executor를 기반으로 Strand 생성
auto strand = boost::asio::make_strand(io);

// strand 자체가 Executor다
strand.execute( { /* 이 작업은 이 Strand에서만 순차 실행 */ });
  • make_strand(io) 는 해당 io_context에 붙은 strand executor를 만듭니다.
  • strand에 post/dispatch하거나, 비동기 연산에 strand를 executor로 바인딩하면, 그 작업들은 서로 겹치지 않고 순차 실행됩니다.
  • 여러 개의 Strand를 만들 수 있습니다. 연결(세션)마다 하나의 Strand를 두면, “연결 A의 일”과 “연결 B의 일”은 서로 다른 Strand이므로 병렬로 실행되고, “연결 A의 일”끼리는 순차로 실행됩니다.

정리: 세션 객체의 read_buf, write_queue 같은 멤버는 “이 세션의 Strand에서만” 접근하도록 핸들러를 모두 그 Strand에 바인딩하면, 락 없이 Data Race가 발생하지 않습니다. Mutex를 잡을 필요가 없어지고, 락 경합과 데드락 위험도 사라집니다.


3. bind_executor로 핸들러를 Strand에 묶기

비동기 연산의 완료 핸들러를 Strand에서 실행

async_read, async_write 등에는 완료 핸들러를 넘깁니다. 이 핸들러가 어느 executor에서 실행될지를 지정하려면, 핸들러를 strand로 바인딩하면 됩니다.

auto strand = boost::asio::make_strand(io);

socket.async_read_some(
    boost::asio::buffer(buf),
    boost::asio::bind_executor(strand, [this](const boost::system::error_code& ec, size_t n) {
        // 이 핸들러는 반드시 strand에서 실행됨 → 다른 strand 핸들러와 겹치지 않음
        if (!ec) do_read(n);
    })
);
  • bind_executor(strand, handler) 는 “이 핸들러를 strand가 관리하는 큐에서 실행해 달라”고 Asio에 알려 줍니다.
  • 같은 strand에 바인딩된 모든 핸들러는 한 번에 하나씩만 실행되므로, do_read 안에서 write_queue 등을 수정해도 다른 스레드와 겹치지 않습니다.

post / dispatch도 Strand로

boost::asio::post(strand,  { /* Strand 큐에 넣음 */ });
boost::asio::dispatch(strand,  { /* 현재 Strand 실행 중이면 즉시, 아니면 큐에 */ });
  • post(strand, …) : 해당 람다를 Strand의 큐에 넣습니다. 다른 Strand 핸들러와 순서가 맞춰져 순차 실행됩니다.
  • dispatch(strand, …) : “지금 이 스레드가 이 Strand의 핸들러를 실행 중이면” 즉시 실행할 수 있으면 실행하고, 아니면 큐에 넣습니다. (다음 글에서 post/dispatch/defer 차이를 다룸)

4. 실전: 연결당 하나의 Strand

세션에 Strand 보관

class Session : public std::enable_shared_from_this<Session> {
public:
    Session(boost::asio::ip::tcp::socket socket)
        : socket_(std::move(socket))
        , strand_(boost::asio::make_strand(socket_.get_executor()))
    {}

    void start() {
        // 모든 비동기 연산의 완료 핸들러를 이 연결 전용 strand에 묶는다
        boost::asio::async_read_until(socket_, buf_, '\n',
            boost::asio::bind_executor(strand_,
                [self = shared_from_this()](const boost::system::error_code& ec, size_t n) {
                    if (!ec) self->on_read(n);
                }));
    }

private:
    void on_read(size_t n) {
        // 이미 strand에서 실행 중이므로, 여기서 버퍼/상태 수정해도 안전
        std::string line;
        std::istream is(&buf_);
        std::getline(is, line);
        out_queue_ += process(line);
        async_write(socket_, boost::asio::buffer(out_queue_),
            bind_executor(strand_, [self = shared_from_this()](...) { self->on_write(); }));
    }

    boost::asio::ip::tcp::socket socket_;
    boost::asio::strand<boost::asio::io_context::executor_type> strand_;
    boost::asio::streambuf buf_;
    std::string out_queue_;
};
  • 연결(세션)마다 자신만의 strand_를 가집니다.
  • 이 세션에서 시작하는 모든 async_read_until, async_write의 완료 핸들러를 bind_executor(strand_, …) 로 묶습니다.
  • 그러면 이 연결에 대한 on_readon_write는 절대 동시에 실행되지 않고, 락 없이 스레드 안전이 보장됩니다.

5. 정리

항목내용
Strand같은 Strand에 묶인 핸들러는 한 번에 하나씩만 실행되는 Executor
make_strand(io)io_context 기반 Strand 생성
bind_executor(strand, handler)비동기 완료 핸들러를 해당 Strand에서 실행하도록 바인딩
연결당 Strand세션별로 하나의 Strand를 두고, 해당 연결의 모든 핸들러를 그 Strand에 묶으면 락 없이 안전

보강: 실전 코드 예제 확장

타임아웃 + 읽기를 같은 세션에서 다룰 때도 완료 핸들러는 모두 동일 Strand에 묶습니다.

void Session::do_read() {
    boost::asio::async_read_until(socket_, buf_, '\n',
        boost::asio::bind_executor(strand_,
            [self = shared_from_this()](const boost::system::error_code& ec, std::size_t) {
                if (!ec) self->handle_line();
            }));
}

void Session::arm_timer() {
    timer_.expires_after(std::chrono::seconds(30));
    timer_.async_wait(
        boost::asio::bind_executor(strand_,
            [self = shared_from_this()](const boost::system::error_code& ec) {
                if (!ec) self->on_idle_timeout();
            }));
}

steady_timersocket과 같은 executor를 쓰고, **bind_executor(strand_, ...)**로 감싸야 “타임아웃 콜백”과 “읽기 콜백”이 서로 끼어들지 않습니다.


보강: Strand 실전 활용 패턴

  • 연결당 Strand: TCP 세션 전체(읽기·쓰기·타임아웃·graceful shutdown)를 한 Strand에 묶는 가장 흔한 패턴입니다.
  • 공유 파이프라인: 여러 연결이 같은 무상태 워커 큐로만 이벤트를 넘기는 경우, 연결별 Strand + post(worker_pool, ...)처럼 역할별 executor를 나눌 수 있습니다.
  • 순서가 필요한 로그/직렬화: 한 스레드에만 쓰고 싶은 로거에 post(strand, ...)로 넘겨 순서 보장 로그를 만들 수 있습니다(단, 로거 Strand는 I/O 부하에 맞게 설계).

보강: make_strand vs bind_executor

구분make_strandbind_executor
역할io_context 또는 기존 executor로부터 새 Strand executor 객체를 만든다.이미 가진 strand(또는 executor)이 핸들러만 실행을 맡긴다.
언제세션 생성 시 strand_(boost::asio::make_strand(socket_.get_executor()))처럼 한 번 만든다.async_*·timer_.async_wait마다 완료 핸들러를 감싼다.
관계둘 다 필요합니다. Strand가 없으면 bind_executor에 넘길 대상이 없고, Strand만 있고 바인딩이 없으면 완료가 기본 executor로 가서 직렬화가 깨질 수 있습니다.

요약: make_strandStrand 자원 생성, bind_executor(strand, handler)그 Strand에서 돌아가게 하는 접착제입니다.


보강: 디버깅 팁

  • Strand를 썼는데도 레이스가 의심되면, bind_executor를 빠뜨린 async_* 호출이 없는지 코드 검색합니다.
  • dispatch(strand, ...) vs post(strand, ...): 재진입 최적화가 꼬이면 예상과 다른 순서로 보일 수 있어, 의심 시 한동안 post만 써서 재현 여부를 확인합니다.

보강: 성능 측정 방법

  • Strand는 연결 간에는 여전히 병렬이므로, 처리량은 워커 스레드 수·CPU에 맞게 늘어나는지 확인합니다.
  • 불필요한 post 남발은 지연만 늘릴 수 있어, dispatch가 안전한 지점은 프로파일러로 확인합니다.

보강: 흔한 실수와 해결책

실수해결
Strand를 만들었지만 일부 핸들러만 bind_executor모든 연결 관련 완료를 Strand에 묶기.
co_spawn(io, ...)만 쓰고 연결마다 Strand 없음co_spawn(make_strand(...), session(...), ...)처럼 세션 executor를 맞추기(#6 참고).
Strand끼리 데드락을 기대함Strand는 큐 직렬화일 뿐, 서로 다른 Strand에서 서로 post를 기다리면 여전히 데드락 설계가 가능합니다. 교차 락 패턴을 피합니다.

자주 묻는 질문 (FAQ)

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

A. 콜백들을 논리적으로 직렬화(순차 실행)하여 스레드 경합을 없애는 원리. make_strand와 bind_executor 실전 활용법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

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

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

Q. 더 깊이 공부하려면?

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

다음 글: [C++ 고성능 네트워크 가이드 #4] 실행 큐 정밀 제어: post, dispatch, defer의 결정적 차이

아키텍처 다이어그램

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

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


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

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

  • C++ 멀티스레드 Asio의 딜레마 | Data Race와 Mutex의 한계 [#2]
  • C++ 고성능 네트워크 가이드 시리즈 목차 | Boost.Asio·이벤트 루프·코루틴
  • C++ Asio post, dispatch, defer | 실행 큐 정밀 제어 [#4]

관련 글

  • C++ 멀티스레드 Asio의 딜레마 | Data Race와 Mutex의 한계 [#2]
  • C++ Asio post, dispatch, defer | 실행 큐 정밀 제어 [#4]
  • C++ 핸들러 메모리 최적화 | 동적 할당 오버헤드 제거 [#5]
  • C++ Asio Composed Operation | 비동기 함수 설계 [#7]
  • C++ 고성능 네트워크 가이드 시리즈 목차 | Boost.Asio·이벤트 루프·코루틴