💡 한 줄 요약
- 이 글은 멀티스레드 io_context::run() 환경에서 발생하는 공유 자원 race condition 문제를 strand의 락-프리 직렬화 메커니즘을 통해 어떻게 해결하는지 다루고, 유사 개념인 CUDA Stream과 비교해 그 원리를 더 깊이 파고든다.
🚨 1. 문제 상황 및 대상 독자
- 현상: io_context::run()을 여러 스레드에서 동시에 호출하면 처리량은 늘어나지만, 여러 핸들러가 공유 자원(카운터, 버퍼, 상태 머신 등)에 동시에 접근하면서 race condition이 발생한다. 뮤텍스로 막을 수는 있지만, 비동기 콜백 안에서 락을 오래 들고 있다가 다른 비동기 작업을 기다리게 되면 데드락 위험이 생기고, 락 경합 자체가 비동기 모델의 장점을 갉아먹는다.
- 대상 독자: 단일 스레드 io_context는 다뤄봤지만 멀티스레드로 확장하려는 개발자, 또는 CUDA 같은 GPU 프로그래밍 경험이 있어 asio의 동시성 모델을 그 관점에서 이해하고 싶은 개발자
⚠️ 공통적인 Pain Point "그냥 뮤텍스로 감싸면 되지 않나?"라는 생각이 가장 먼저 든다. 실제로는 되긴 하지만, 비동기 핸들러 안에서 락을 잡은 채로 co_await를 만나면 그 락이 다른 스레드가 완료시켜야 할 작업을 막아버리는 구조적 문제가 생긴다. strand는 이 문제를 락 없이 우회한다.
🛠️ 2. 사전 지식 & 개발 환경
2.1 사전 지식
- io_context, awaitable, co_spawn의 기본 동작 (이전 글 "C++20 코루틴 + Asio 비동기 기초" 참고)
- executor 개념과 io_context::get_executor() 표기법
- CUDA를 다뤄본 적이 있다면 stream/kernel 실행 모델을 알고 있으면 비교 파트가 더 와닿는다. 몰라도 무방하다.
2.2 개발 환경
분류 기술 스택 / 도구 버전 비고
| OS | [사용 환경 입력] | ||
| 언어/기반 | C++20 | g++ 13.3.0 | 스레드 지원 필요 (-pthread) |
| 라이브러리 | Asio (standalone 또는 Boost.Asio) | 1.28.1 | asio/strand.hpp 포함 |
| 비교 대상 | CUDA Toolkit | 13.3 | 5장 대안 비교 파트에서만 필요 |
🔍 3. 핵심 개념 및 원인 분석
3.1 핵심 개념 (Quick Concepts)
strand는 한 문장으로 정의된다.
strand = "이 executor에 붙인 핸들러들은, 몇 개의 스레드가 run()을 돌리든 절대 동시에 실행되지 않는다"는 직렬화 보장
핵심 API 표면은 세 가지다.
| API | 역할 |
| asio::make_strand(ctx) | io_context(또는 다른 executor)로부터 strand executor를 만듦 |
| asio::post(strand_ex, handler) | 핸들러를 해당 strand에 등록. 같은 strand에 등록된 핸들러끼리는 직렬화됨 |
| asio::co_spawn(strand_ex, coroutine(), token) | 코루틴 전체를 strand 위에서 실행 — co_await로 여러 번 재개돼도 직렬화가 유지됨 |
가장 먼저 깨야 할 오해는, strand가 전용 스레드를 갖지 않는다는 점이다. strand는 스레드를 소유하지 않고, "같은 strand에 묶인 핸들러끼리는 순서대로, 동시에는 안 돈다"는 정책만 강제한다. 실제 실행은 여전히 run()을 돌리는 아무 스레드에서나 일어날 수 있다.
3.2 문제 재현 (Reproduce)
뮤텍스도 strand도 없이, 멀티스레드 run() 위에서 공유 카운터를 증가시키는 최소 예제다.
#include <asio.hpp>
#include <iostream>
#include <thread>
#include <vector>
int shared_counter = 0; // 보호되지 않은 공유 자원
int main() {
asio::io_context ctx;
// 핸들러 10만 개를 등록 — 각각 counter를 1씩 증가
for (int i = 0; i < 100000; ++i) {
asio::post(ctx, [] { ++shared_counter; });
}
// io_context::run()을 4개의 스레드에서 동시에 호출
std::vector<std::thread> pool;
for (int i = 0; i < 4; ++i) {
pool.emplace_back([&ctx] { ctx.run(); });
}
for (auto& t : pool) t.join();
std::cout << "counter: " << shared_counter << "\n"; // 실행마다 100000이 안 나올 수 있음
}
++shared_counter는 "읽기 → 증가 → 쓰기" 세 단계로 이루어진 비원자적 연산이다. 여러 스레드가 이 세 단계를 동시에 밟으면 증가분이 사라지는 경우가 생겨, 실행할 때마다 결과가 달라지거나 100000보다 작은 값이 나온다.
3.3 원인 분석 (Underlying Principle)
멀티스레드 run()의 스케줄링 방식. io_context::run()을 여러 스레드에서 호출하면, 각 스레드는 내부 작업 큐에서 준비된 핸들러를 하나씩 꺼내 실행한다. 어떤 핸들러가 어느 스레드에 배정될지는 정해져 있지 않고, 여러 스레드가 동시에 서로 다른(혹은 같은 자원을 건드리는) 핸들러를 꺼내 병렬로 실행할 수 있다. asio::post(ctx, ...)로 등록한 핸들러는 이 공용 큐에 그대로 들어가기 때문에, 3.2 예제처럼 아무 보호 장치가 없으면 동시 실행이 그대로 허용된다.
strand가 락 없이 직렬화를 구현하는 방식. strand 내부에는 "지금 이 strand에 속한 핸들러가 실행 중인가"를 나타내는 상태와, 대기 중인 핸들러를 담는 큐가 있다. post(strand_ex, handler)가 호출되면 strand는 다음 둘 중 하나를 선택한다.
- 지금 이 strand에서 실행 중인 핸들러가 없다면, 방금 등록한 핸들러를 즉시(또는 run()을 돌리는 스레드 중 하나에) 넘겨 실행시킨다.
- 이미 다른 핸들러가 실행 중이라면, 새 핸들러는 큐에 쌓아두고 실행 중인 핸들러가 끝난 직후 큐에서 꺼내 다음 스레드가 실행하게 한다.
이 "실행 중이면 큐잉, 아니면 즉시 실행"이라는 상태 전이 자체가 컴페어-앤-스왑(CAS) 같은 원자적 연산으로 구현되기 때문에, 사용자 코드 입장에서는 뮤텍스를 잠그고 기다리는 블로킹이 전혀 없다.
CAS(Compare-And-Swap)란? "메모리의 값이 내가 예상한 값과 같으면 새 값으로 바꾸고, 다르면 바꾸지 않는다"를 하나의 하드웨어 명령어로 처리하는 연산이다. 예를 들어 strand 내부의 "지금 실행 중인가" 플래그를 0(비어있음)에서 1(실행 중)로 바꾸는 상황을 생각해보자. 일반적인 "읽고 → 비교하고 → 쓰기" 3단계로 처리하면 그 사이에 다른 스레드가 끼어들어 같은 판단을 내릴 수 있다. CAS는 이 세 단계를 CPU가 쪼갤 수 없는 단일 명령어(x86의 CMPXCHG 등)로 묶어버리기 때문에, 여러 스레드가 동시에 CAS를 시도해도 정확히 하나만 성공하고 나머지는 실패해서 재시도하거나 "이미 누군가 가져갔다"는 결과를 받는다. 뮤텍스처럼 "실패한 스레드를 잠재우는" 대신, 실패한 스레드는 그냥 큐에 자기 작업을 밀어넣고 넘어가면 되므로 블로킹이 생기지 않는다. strand는 바로 이 성질을 이용해 "실행 권한을 누가 가져가는가"를 락 없이 원자적으로 결정한다.
여러 스레드가 동시에 같은 strand에 post를 호출해도, "누가 지금 실행 권한을 가져가는가"는 원자적으로 결정되므로 두 핸들러가 동시에 몸통을 실행하는 일은 일어나지 않는다.
co_spawn에 strand를 넘겼을 때 코루틴 재개까지 직렬화되는 이유. 코루틴은 co_await를 만날 때마다 일시정지했다가, 기다리던 비동기 작업이 끝나면 "재개(resume)"라는 형태로 다시 실행된다. 이 재개 자체도 내부적으로는 executor에 핸들러를 하나 post하는 것과 동일하게 처리된다. 즉 co_spawn(strand_ex, coroutine(), ...)로 시작한 코루틴은, 최초 실행뿐 아니라 매번 재개될 때도 같은 strand의 executor를 통해 스케줄링되기 때문에, 코루틴이 몇 번을 일시정지·재개하더라도 "이 코루틴의 몸통과 다른 같은 strand 코루틴의 몸통이 동시에 실행되지 않는다"는 보장이 실행 전 구간에 걸쳐 유지된다.
✅ 4. 해결 방법 및 검증
4.1 해결 방법 (Solution)
3.2의 예제를 strand로 감싸면 다음과 같다.
#include <asio.hpp>
#include <asio/strand.hpp>
#include <iostream>
#include <thread>
#include <vector>
int shared_counter = 0;
int main() {
asio::io_context ctx;
auto strand_ex = asio::make_strand(ctx); // counter 전용 strand
for (int i = 0; i < 100000; ++i) {
// ctx가 아니라 strand_ex에 post — 직렬화 보장
asio::post(strand_ex, [] { ++shared_counter; });
}
std::vector<std::thread> pool;
for (int i = 0; i < 4; ++i) {
pool.emplace_back([&ctx] { ctx.run(); });
}
for (auto& t : pool) t.join();
std::cout << "counter: " << shared_counter << "\n"; // 항상 100000
}
바뀐 부분은 asio::post(ctx, ...) → asio::post(strand_ex, ...) 한 줄뿐이다. io_context는 여전히 4개의 스레드로 돌지만, shared_counter를 건드리는 핸들러들은 모두 같은 strand에 등록되어 있어 서로 겹치지 않는다.
코루틴에 적용하는 경우도 동일한 패턴이다.
auto strand_ex = asio::make_strand(ctx);
// 이 코루틴은 몇 번을 co_await로 재개되더라도
// 같은 strand의 다른 코루틴과 절대 동시에 실행되지 않는다
asio::co_spawn(strand_ex, camera_pipeline(), asio::detached);
4.2 결과 검증 (Before / After)
| 구분 | Before (strand 없음) | After (strand 적용) |
| 실행 결과 | 실행마다 달라짐, [측정값 입력] (100000보다 작은 값) | 항상 100000 |
| ThreadSanitizer(-fsanitize=thread) | race condition 경고 발생 | 경고 없음 |
| 반복 실행 100회 중 정답 일치 횟수 | 측정 시마다 다를 수 있음. | 100/100 |
💡 5. 대안 비교 및 적용 범위
5.1 대안 비교
| 구분 | strand | 뮤텍스 | CUDA Stream |
| 직렬화 메커니즘 | executor 내부의 큐잉 + 원자적 상태 전이 | OS 레벨 락 | 디바이스 큐 순서(program order) |
| 블로킹 여부 | 없음 (락-프리) | 있음 (대기 스레드는 블로킹) | 없음 (커널은 큐에 쌓이고 비동기로 진행) |
| 전용 실행 자원 | 없음 — 어떤 스레드가 실행할지는 그때그때 다름 | 없음 — 락을 획득한 스레드가 실행 | 없음 — 스트림은 SM을 전담하지 않음, GPU가 스케줄링 |
| 스트림/strand 간 동기화 | 자동 (같은 strand 안에서만) | 명시적 락 범위로 수동 관리 | 명시적 (cudaEvent, cudaStreamWaitEvent) |
| 코루틴/비동기 흐름과의 궁합 | 자연스러움 (co_await 중에도 보장 유지) | co_await 중 락을 들고 있으면 위험 | 해당 없음 (커널 실행 모델이 다름) |
strand와 CUDA Stream은 **"직렬화 단위를 물리적 실행 자원과 분리한다"**는 설계 철학을 공유한다. CUDA에서 스트림이 SM을 전담하지 않듯, strand도 특정 스레드를 전담하지 않는다. 다만 CUDA는 스트림 간 동기화를 cudaEvent 같은 명시적 신호로 처리하는 반면, strand는 executor 자체가 큐잉 여부를 자동으로 판단하기 때문에 사용자가 동기화 지점을 직접 걸 필요가 없다는 점이 다르다.
5.2 적용 범위 및 흔한 실수
- Scope: io_context::run()을 한 스레드에서만 호출한다면 애초에 핸들러들이 순차 실행되므로 strand가 필요 없다. strand는 멀티스레드 run()일 때만 의미가 있다.
- ❌ 흔한 실수 1: strand를 "전용 스레드"로 오해해서 "이 strand는 스레드 3번에서만 돈다"고 가정하는 것. 실제로는 어느 스레드에서 실행될지 매번 달라질 수 있다.
- ❌ 흔한 실수 2: 멀티스레드로 run()을 돌리면서 공유 자원에 접근하는 핸들러 일부만 strand에 묶고 나머지는 그냥 ctx에 post하는 것. strand는 "같은 strand에 묶인 핸들러끼리만" 직렬화를 보장하므로, 공유 자원을 건드리는 모든 경로가 같은 strand를 거쳐야 한다.
5.3 FAQ
- Q. strand와 뮤텍스를 같이 써야 하는 경우가 있나? A. strand로 감싼 자원을 strand 밖의 일반 스레드(예: run()을 돌리지 않는 UI 스레드)에서도 직접 접근해야 한다면, 그 경계에서는 여전히 뮤텍스나 다른 동기화가 필요하다. strand는 "asio executor 위에서 도는 핸들러들 사이"의 직렬화만 보장한다.
- Q. 여러 카메라 채널을 처리하는 파이프라인이라면 strand를 몇 개 둬야 하나? A. 채널마다 별도 strand를 두는 패턴이 흔하다. 채널 A의 핸들러끼리는 순서가 보장되지만, 채널 A와 채널 B는 서로 다른 strand이므로 여러 스레드에서 병렬로 진행될 수 있다.
🏁 6. 마무리
6.1 결론
strand는 "직렬화 정책이지 스레드 소유가 아니다." 멀티스레드 io_context::run() 환경에서 특정 자원에 접근하는 핸들러들을 같은 strand에 묶기만 하면, 뮤텍스 없이도 그 자원에 대한 동시 접근을 막을 수 있다. CUDA Stream과 마찬가지로 "직렬화 단위를 실행 자원과 분리한다"는 설계를 이해하면 두 개념 모두 훨씬 직관적으로 다가온다.
6.2 참고 자료