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는 같은 연결의 핸들러가 동시에 실행되지 않도록 보장할 뿐, 같은 스레드에서 실행된다는 뜻은 아니다.
Strand는 스레드가 아니라 실행 순서를 묶는다
논리적 직렬화 (Logical serialization)
여러 스레드가 같은 io_context::run()을 돌리면, 완료된 핸들러는 그중 아무 스레드나 가져가 실행할 수 있습니다. Strand는 여기에 “이 executor를 통해 스케줄된 작업들은 한 번에 하나씩만 실행된다”는 제약을 붙인 executor입니다. 같은 Strand로 post/dispatch되거나 완료 핸들러가 그 Strand에 바인딩된 작업들은 동시에 두 개가 실행되지 않고, 하나의 논리적 큐처럼 순서대로 실행됩니다. 즉 락이 아니라 실행 순서로 동시성을 제어합니다.
여기서 자주 오해하는 부분이 “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를 거쳐 실행되는 핸들러에만 적용된다는 것입니다. 핸들러 내부에서 다른 스레드 풀에 작업을 던지면 그 작업은 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는 얼마든지 여러 개 만들 수 있습니다. 연결(세션)마다 하나씩 두면 연결 A의 일과 연결 B의 일은 서로 다른 Strand이므로 병렬로 실행되고, 연결 A의 일끼리만 순차로 실행됩니다. 세션 객체의 read_buf, write_queue 같은 멤버를 그 세션의 Strand에서 실행되는 핸들러에서만 건드리면 mutex 없이도 data race가 생기지 않습니다.
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)는 핸들러에 “연관 executor(associated executor)“를 붙여, 비동기 연산이 끝났을 때 이 핸들러를 strand를 통해 실행하도록 Asio에 알려 줍니다. async_read_until처럼 내부에서 여러 번 읽는 합성 연산도 중간 단계 핸들러를 같은 연관 executor로 실행하므로, 연산 전체가 strand 위에서 진행됩니다. 같은 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 밖에서 실행되면 이 보장은 깨집니다.
멤버 타입을 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에 묶습니다.
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();
}));
}
timer_를 io_context의 executor로 만들었다면 async_wait 핸들러도 bind_executor(strand_, ...)로 감싸야 타임아웃 콜백과 읽기 콜백이 서로 끼어들지 않습니다.
타임아웃과 읽기가 같은 strand에 있으면 “타임아웃 핸들러에서 socket_.close()를 호출하는 동시에 읽기 핸들러가 소켓을 쓰는” 경쟁은 사라지지만, 순서 문제는 남습니다. 읽기가 완료된 직후 타이머도 만료되면 두 완료 핸들러가 모두 strand 큐에 들어가 차례로 실행됩니다. 그래서 on_idle_timeout은 “이미 새 데이터가 들어와 타이머를 다시 걸었는지”를 확인해야 하고, 흔한 방법은 타이머의 만료 시각이 아직 미래인지(timer_.expiry() > steady_clock::now()) 확인하는 것입니다. 타이머를 cancel()해도 이미 큐에 들어간 핸들러는 operation_aborted가 아니라 성공 코드로 실행될 수 있다는 점이 처음 겪을 때 가장 헷갈리는 부분입니다.
make_strand와 bind_executor의 관계
make_strand는 strand executor 객체를 만들고, bind_executor(strand, handler)는 개별 완료 핸들러를 그 strand에서 실행되도록 묶습니다. 세션 생성 시 strand를 만들고 async_* 호출마다 bind_executor로 감싸는 방식은 하나라도 빠뜨리면 그 핸들러가 기본 executor로 가서 직렬화가 깨집니다. 반대로 앞에서 본 것처럼 소켓과 타이머 자체를 strand executor로 만들면 bind_executor 없이도 완료 핸들러가 그 strand에서 실행되므로, 둘 중 하나만 써도 됩니다.
Strand는 세션 말고도 쓸모가 있습니다. 한 번에 한 작업만 써야 하는 로거나 파일 출력처럼 순서가 중요한 공유 자원을 strand 하나로 감싸고 post(log_strand, ...)로 넘기면 락 없이 순서가 보장된 출력을 얻을 수 있습니다. 다만 그 strand에 몰리는 작업량이 많으면 해당 strand가 병목이 되므로, 무거운 작업은 별도 스레드 풀로 넘기고 strand에서는 상태 갱신만 하는 편이 좋습니다.
흔한 실수
가장 흔한 실수는 strand를 만들어 놓고 일부 async_* 호출에서 bind_executor를 빠뜨리는 것입니다. strand를 썼는데도 레이스가 의심되면 세션 상태를 건드리는 모든 완료 핸들러가 strand를 거치는지 먼저 확인합니다. 코루틴을 쓸 때도 마찬가지로, co_spawn(io, session(...), ...)처럼 io_context에 직접 띄우면 세션 안의 co_await 재개가 strand를 거치지 않습니다. 세션마다 co_spawn(make_strand(io), session(...), ...)처럼 strand executor를 넘겨야 합니다.
또 하나는 strand 핸들러 안에서 같은 strand에 post한 작업의 결과를 동기적으로 기다리는 것입니다. 예를 들어 핸들러 안에서 post(strand_, ...)로 넘긴 작업의 future.get()을 호출하면, 그 작업은 현재 핸들러가 끝나야 실행될 수 있으므로 영원히 끝나지 않습니다. strand는 락이 아니지만 “한 번에 하나”라는 성질 때문에 이런 자기 대기는 데드락과 같은 결과를 냅니다. strand 안에서는 결과를 기다리지 말고 완료 핸들러나 co_await로 이어 붙여야 합니다.
dispatch(strand, ...)는 이미 strand 안에 있으면 그 자리에서 바로 실행하므로, 호출한 쪽 코드가 끝나기 전에 다른 핸들러 코드가 끼어드는 것처럼 보일 수 있습니다. 순서가 예상과 다르면 우선 post로 바꿔 재현되는지 확인해 보면 원인이 좁혀집니다.
실행해서 확인한 결과
이 글의 설명은 Ubuntu 24.04 aarch64 컨테이너(g++ 13.3, Boost 1.83)(8코어, 스레드 4개로 run())에서 확인했습니다.
- 직렬화: 핸들러 20만 개가 공유 카운터를
++할 때 strand 없이는 199,161(갱신 손실), strand에 post하면 200,000이었습니다. strand 핸들러 2만 개의 동시 실행 수를 재면 최대 1, strand 없이 같은 방식으로 재면 최대 4였습니다. - 스레드에 고정되지 않음: 한꺼번에 post한 핸들러는 한 스레드가 연달아 실행해서 스레드 종류가 1개였습니다. 200µs 간격으로 post하자 2,000개 핸들러가 4개 스레드에서 실행됐고, 연속된 핸들러 사이에서 스레드가 바뀐 경우가 1,999번 중 1,999번이었습니다. “strand는 스레드가 아니다”라는 설명은 맞지만, 핸들러가 몰려 있으면 한 스레드가 이어서 실행하는 일도 흔합니다.
any_io_executor타입:strand<any_io_executor> st = make_strand(socket.get_executor());는 컴파일됐고,strand<io_context::executor_type>으로 받으면no matching function ... basic_executor_type(boost::asio::any_io_executor)오류와 긴 템플릿 메시지(33줄)가 나왔습니다.async_accept(make_strand(io), ...): 이렇게 받은 소켓의 executor를 조회하자strand<io_context::executor_type>이었고 일반io_contextexecutor는 아니었습니다. 이 소켓의 완료 핸들러가bind_executor없이 실제로 strand에서 실행되는지까지는 확인하지 않았습니다.
글의 Session 클래스 전체는 process()와 on_write()가 정의돼 있지 않은 설명용 코드라 컴파일하지 않았습니다.
참고 자료
자주 묻는 질문 (FAQ)
소켓 핸들러와 타이머 핸들러가 같은 세션 상태를 건드리면 둘 다 strand에 묶어야 하나요?
네. 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와는 병렬로 진행될 수 있습니다.