C++ Strand | 락(Lock) 없는 동시성 제어 [#3]
이 글의 핵심
Strand는 특정 스레드에 고정되는 것이 아니라 같은 strand에 묶인 핸들러끼리만 동시에 실행되지 않도록 보장합니다. 그래서 소켓 핸들러와 타이머 핸들러처럼 같은 세션 상태를 건드리는 경로를 하나라도 빠뜨리면 레이스가 다시 생깁니다. post와 dispatch를 strand로 보내는 방법과 디버깅, 성능 측정 방법도 함께 다룹니다.
들어가며: 락 없이 “한 줄로” 실행하고 싶다
Strand가 해결하는 문제
이전 글에서 여러 스레드가 같은 io_context::run()을 돌릴 때, 같은 세션(연결)에 대한 on_read와 on_write가 서로 다른 스레드에서 동시에 실행되면 Data Race가 난다고 했습니다. Mutex로 감싸면 성능이 떨어지고 데드락 위험도 있습니다. Strand는 “이 핸들러들은 서로 겹치지 않고 순차적으로만 실행된다”는 실행 순서 보장을 Asio 실행 큐 단에서 해줍니다. 애플리케이션 코드에는 락이 등장하지 않습니다(Strand 구현 내부에서 큐를 다룰 때 아주 짧은 잠금을 쓰지만, 핸들러 실행 동안 락을 쥐고 있지는 않습니다). 같은 Strand에 바인딩된 모든 핸들러는, 마치 한 줄로 세워진 큐처럼 하나씩만 실행되므로, 같은 연결에 대한 읽기/쓰기 핸들러를 한 Strand에 묶으면 자동으로 스레드 안전해집니다. 처음 보면 “Strand가 락을 대체한다”는 말이 직관적으로 와닿지 않을 수 있습니다. Mutex는 “진입할 때 잠그고 나갈 때 푸는” 방식인데, Strand는 “이 작업들은 아예 동시에 실행되지 않도록 큐에서 순서만 지킨다”는 방식입니다. 따라서 락 경합이나 데드락 없이 “이 연결의 일은 한 스레드가 순서대로만 처리한다”를 보장할 수 있습니다. 목표:
- Strand의 개념 — 논리적 직렬화가 어떻게 동작하는지
- make_strand로 Strand 만들기
- bind_executor(strand, handler) 로 비동기 연산의 완료 핸들러를 Strand에 묶기
- 실전: 연결당 하나의 Strand 패턴
Strand란 무엇인가
논리적 직렬화 (Logical serialization)
- io_context는 여러 스레드가 run()을 돌리면, 완료된 핸들러를 아무 스레드나 가져가 실행할 수 있습니다.
- Strand는 “이 executor를 통해 스케줄된 작업들은 한 번에 하나씩만 실행된다”는 제약을 붙인 Executor입니다.
- 같은 Strand를 통해 post/dispatch되거나, 비동기 연산의 완료 핸들러가 그 Strand에 바인딩되면, 그 핸들러들은 동시에 두 개가 실행되지 않습니다. 항상 순서가 보장된 하나의 “논리적 큐”로 실행됩니다. 즉, 락(Lock)이 아니라 “실행 순서”로 동시성을 제어합니다. 따라서 락 경합과 데드락 없이 “이 연결에 대한 모든 일은 한 줄로 처리된다”를 보장할 수 있습니다.
여기서 자주 오해하는 부분이 “Strand = 전용 스레드”라는 생각입니다. Strand는 특정 스레드에 고정되지 않습니다. 핸들러 a1은 Thread 1에서, 바로 다음 핸들러 a2는 Thread 2에서 실행될 수 있고, Strand가 보장하는 것은 “a1이 끝나기 전에 a2가 시작되지 않는다”는 순서와 배타성뿐입니다. 그래서 thread_local 변수나 “현재 스레드 ID”에 의존하는 코드는 Strand 안에서도 핸들러마다 다른 값을 볼 수 있습니다. 또 Strand는 핸들러 사이의 메모리 가시성도 보장하므로(a1에서 쓴 값을 a2가 다른 스레드에서 실행되어도 올바르게 읽음), 세션 멤버를 atomic으로 만들 필요가 없습니다.
내부 동작 원리: 실행기(executor) 큐와 직렬화
Strand는 뮤텍스로 공유 자원을 감싸는 대신, io_context의 작업 큐에 “이 핸들러들은 서로 인터리브되면 안 된다”는 직렬화 계약을 추가합니다. 완료 핸들러가 여러 워커 스레드에 분배되더라도, 동일 Strand에 바인딩된 작업은 한 번에 하나의 스레드에서만 실행됩니다. 이는 데이터 구조 불변 조건을 “스레드 안전한 락”이 아니라 스케줄링 불변 조건으로 옮긴 것입니다.
주의할 점은 Strand가 CPU 연산까지 직렬화한다는 뜻이 아니라는 것입니다. 핸들러 내부에서 다른 스레드 풀에 작업을 던지면 그 작업은 Strand 밖에서 돌아갑니다. 따라서 세션 상태는 Strand 안에서만, 블로킹 I/O는 별도 큐로 분리하는 식의 경계 설계가 필요합니다.
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를 잡을 필요가 없어지고, 락 경합과 데드락 위험도 사라집니다.
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 차이를 다룸)
실전: 연결당 하나의 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);
boost::asio::async_write(socket_, boost::asio::buffer(out_queue_),
boost::asio::bind_executor(strand_,
[self = shared_from_this()](const boost::system::error_code& ec, std::size_t) {
if (!ec) self->on_write();
}));
}
boost::asio::ip::tcp::socket socket_;
// socket_.get_executor()는 any_io_executor를 돌려주므로 strand 타입도 그에 맞춘다
boost::asio::strand<boost::asio::any_io_executor> strand_;
boost::asio::streambuf buf_;
std::string out_queue_;
};
- 연결(세션)마다 자신만의 strand_를 가집니다.
- 이 세션에서 시작하는 모든 async_read_until, async_write의 완료 핸들러를 bind_executor(strand_, …) 로 묶습니다.
- 그러면 이 연결에 대한 on_read와 on_write는 절대 동시에 실행되지 않으며, 락 없이 스레드 안전이 보장됩니다.
멤버 타입을 strand<any_io_executor>로 둔 데는 이유가 있습니다. Boost 1.74 이후 tcp::socket의 기본 executor 타입은 any_io_executor라서 make_strand(socket_.get_executor())는 strand<any_io_executor>를 만듭니다. 멤버를 strand<io_context::executor_type>으로 선언하면 타입이 맞지 않아 긴 템플릿 에러가 납니다. 예전 예제를 옮겨 올 때 가장 먼저 부딪히는 컴파일 에러 중 하나입니다.
이 예제는 Strand 사용법을 보여 주기 위해 단순화했는데, 실제 코드로 옮기기 전에 짚어야 할 함정이 하나 있습니다. 한 소켓에 async_write는 동시에 하나만 진행되어야 합니다. async_write는 내부적으로 async_write_some을 여러 번 호출하는 합성 연산이라, 앞의 쓰기가 끝나기 전에 두 번째 async_write를 시작하면 두 데이터가 섞여 나갈 수 있습니다. 또 위 코드처럼 쓰기가 진행 중인 out_queue_ 문자열에 새 데이터를 +=로 이어 붙이면, 재할당으로 쓰기 중인 버퍼가 무효화될 수 있습니다. Strand는 핸들러끼리 겹치지 않게 해 줄 뿐 이런 논리적 순서까지 챙겨 주지 않으므로, 보통은 std::deque<std::string> 쓰기 큐를 두고 “큐가 비어 있다가 첫 항목이 들어올 때만 쓰기를 시작하고, 쓰기 완료 핸들러에서 다음 항목을 이어서 보낸다”는 패턴을 씁니다.
Strand를 더 간단하게 적용하는 방법도 있습니다. acceptor.async_accept(boost::asio::make_strand(io), ...)처럼 소켓을 처음부터 strand executor로 만들면, 그 소켓에서 시작한 비동기 연산의 완료 핸들러는 bind_executor 없이도 기본적으로 그 strand에서 실행됩니다. 같은 세션의 타이머도 steady_timer timer_{socket_.get_executor()}로 만들면 같은 strand를 공유합니다. 모든 호출에 bind_executor를 붙이는 방식은 하나라도 빠뜨리면 레이스가 생기므로, 새 코드라면 이쪽이 실수를 줄이는 설계입니다.
정리
| 항목 | 내용 |
|---|---|
| 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_timer도 socket과 같은 executor를 쓰며, bind_executor(strand_, …)로 감싸야 “타임아웃 콜백”과 “읽기 콜백”이 서로 끼어들지 않습니다.
타임아웃과 읽기가 같은 strand에 있으면 “타임아웃 핸들러에서 socket_.close()를 호출하는 동시에 읽기 핸들러가 소켓을 쓰는” 경쟁은 사라지지만, 순서 문제는 남습니다. 읽기가 완료된 직후 타이머도 만료되면 두 완료 핸들러가 모두 strand 큐에 들어가 차례로 실행됩니다. 그래서 on_idle_timeout은 “이미 새 데이터가 들어와 타이머를 다시 걸었는지”를 확인해야 하고, 흔한 방법은 타이머의 만료 시각이 아직 미래인지(timer_.expiry() > steady_clock::now()) 확인하는 것입니다. 타이머를 cancel()해도 이미 큐에 들어간 핸들러는 operation_aborted가 아니라 성공 코드로 실행될 수 있다는 점이 처음 겪을 때 가장 헷갈리는 부분입니다.
보강: Strand 실전 활용 패턴
- 연결당 Strand: TCP 세션 전체(읽기·쓰기·타임아웃·graceful shutdown)를 한 Strand에 묶는 가장 흔한 패턴입니다.
- 공유 파이프라인: 여러 연결이 같은 무상태 워커 큐로만 이벤트를 넘기는 경우, 연결별 Strand +
post(worker_pool, ...)처럼 역할별 executor를 나눌 수 있습니다. - 순서가 필요한 로그/직렬화: 한 스레드에만 쓰고 싶은 로거에
post(strand, ...)로 넘겨 순서 보장 로그를 만들 수 있습니다(단, 로거 Strand는 I/O 부하에 맞게 설계).
보강: make_strand vs bind_executor
| 구분 | make_strand | bind_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_strand는 Strand 자원 생성, bind_executor(strand, handler)는 그 Strand에서 돌아가게 하는 접착제입니다.
보강: 디버깅 팁
- Strand를 썼는데도 레이스가 의심되면,
bind_executor를 빠뜨린async_*호출이 없는지 코드 검색합니다. dispatch(strand, ...)vspost(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. 소켓 핸들러와 타이머 핸들러가 같은 세션 상태를 건드리면 둘 다 strand에 묶어야 하나요?
A. 네. strand는 같은 strand를 거쳐 실행되는 핸들러끼리만 직렬화하므로, 읽기 핸들러만 strand에 묶고 타이머의 async_wait 핸들러를 기본 실행기에 두면 두 핸들러가 서로 다른 스레드에서 동시에 실행될 수 있습니다. 세션의 소켓과 타이머를 make_strand로 만든 실행기로 생성하면 두 객체의 완료 핸들러가 자연스럽게 같은 strand에서 실행됩니다. 세션 밖의 스레드에서 상태를 바꿔야 할 때도 직접 접근하지 말고 post(strand_, ...)로 넘기는 것이 본문의 연결당 하나의 Strand 패턴의 전제입니다.
아키텍처 다이어그램 (Strand와 io_context)
flowchart LR
subgraph workers[워커 스레드 풀]
W1[Thread 1]
W2[Thread 2]
end
subgraph io[io_context]
Q[공통 완료 큐]
end
subgraph s1[Strand A]
A1[핸들러 a1]
A2[핸들러 a2]
end
subgraph s2[Strand B]
B1[핸들러 b1]
end
Q --> s1
Q --> s2
W1 --> Q
W2 --> Q
A1 --> A2
설명: 완료 이벤트는 io_context로 들어오지만, Strand A에 묶인 작업은 논리적으로 직렬이라 a1과 a2가 동시에 실행되지 않습니다. Strand B는 별도 직렬 큐이므로 A와는 병렬로 진행될 수 있습니다.
같이 보면 좋은 글
- C++ 멀티스레드 Asio의 딜레마 | Data Race와 Mutex의 한계 [#2]
- C++ 고성능 네트워크 가이드 시리즈 목차 | Boost.Asio·이벤트 루프·코루틴
- C++ Asio post, dispatch, defer | 실행 큐 정밀 제어 [#4]
- C++ 핸들러 메모리 최적화 | 동적 할당 오버헤드 제거 [#5]
- C++ Asio Composed Operation | 비동기 함수 설계 [#7]