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

[2026] 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. 정리


1. Strand란 무엇인가

논리적 직렬화 (Logical serialization)

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

2. make_strand와 executor

Strand 만들기

아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고. 코드를 직접 실행해보면서 동작을 확인해보세요.

#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로 바인딩하면 됩니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

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 보관

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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에 묶습니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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의 결정적 차이

아키텍처 다이어그램

아래 코드는 mermaid를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

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

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

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

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

관련 글

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3