Asio 이벤트 루프 동작 원리: run·poll 차이, post vs dispatch, work_guard, strand
들어가며: “서버가 바로 종료돼요. run()이 끝나지 않게 하려면?”
// ❌ 문제: 서버가 바로 종료됨
boost::asio::io_context io;
tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080));
// async_accept 한 번만 등록
acceptor.async_accept([](boost::system::error_code, tcp::socket) {
std::cout << "Connected!\n";
// 여기서 끝
});
io.run(); // 💥 연결 하나 받고 바로 종료!
std::cout << "Server stopped\n"; // 즉시 출력됨
io_context::run()은 등록된 비동기 작업이 모두 완료되면 반환합니다. 위 코드는 async_accept 하나만 등록했으므로, 연결 하나를 받으면 더 이상 할 일이 없어서 run()이 종료됩니다.
이벤트 루프를 처음 다룰 때 마주치는 문제는 대부분 이 “할 일” 개념과 관련이 있습니다. 반대로 run()이 끝나지 않아 서버를 종료할 수 없는 경우도 있는데, work_guard를 reset()해도 대기 중인 async_accept나 읽기 작업이 남아 있으면 그것들도 할 일이라 run()이 계속 돌기 때문입니다. 한 번 반환한 io_context는 stopped 상태라서 restart() 없이 다시 run()을 부르면 아무것도 실행되지 않습니다. 여러 스레드가 같은 io_context::run()을 호출하면 핸들러가 동시에 실행되므로 공유 상태를 strand나 mutex로 보호해야 하고, post와 dispatch의 차이를 모르면 핸들러가 예상과 다른 순서로, 혹은 호출 스택 위에서 바로 실행됩니다.
이 글은 이 문제들을 차례로 다룹니다. 예제는 Boost.Asio 1.70 이상을 기준으로 하며(코루틴의 as_tuple은 더 최신 버전이 필요합니다), 독립형 Asio도 네임스페이스만 asio::로 바꾸면 같습니다.
이벤트 루프 동작 원리
이벤트 루프란?
flowchart TB
Start["io_context run 시작"]
Check{등록된\n작업 있음?}
Wait[I/O 이벤트 대기]
Execute[완료 핸들러 실행]
Done[run 종료]
Start --> Check
Check -->|Yes| Wait
Wait --> Execute
Execute --> Check
Check -->|No| Done
style Wait fill:#ffeb3b
style Execute fill:#4caf50
style Done fill:#f44336
run()은 남은 작업이 있는지 확인하고, 있으면 OS의 이벤트 통지 기능(Linux의 epoll, BSD·macOS의 kqueue, Windows의 IOCP)으로 I/O 완료를 기다렸다가 해당 완료 핸들러를 실행하는 과정을 반복합니다. 남은 작업이 없으면 반환합니다.
시퀀스 다이어그램: run() 동작
sequenceDiagram
participant Main as 메인 스레드
participant IO as io_context
participant Kernel as OS (epoll/kqueue)
Main->>IO: post(핸들러1), post(핸들러2)
Main->>IO: run() 호출
IO->>IO: 작업 큐 확인 (2개)
loop 이벤트 루프
IO->>Kernel: 이벤트 대기 (또는 즉시 실행)
Kernel-->>IO: 준비된 작업
IO->>IO: 핸들러1 실행
IO->>IO: 핸들러2 실행
IO->>IO: 작업 없음?
end
IO-->>Main: run() 반환
내부 동작 이해
#include <boost/asio.hpp>
#include <iostream>
#include <thread>
using boost::asio::ip::tcp;
using boost::system::error_code;
void demonstrate_event_loop() {
boost::asio::io_context io;
std::cout << "1. run() 호출 전\n";
// 비동기 작업 등록
boost::asio::post(io, [] {
std::cout << "2. 첫 번째 핸들러 실행\n";
});
boost::asio::post(io, [] {
std::cout << "3. 두 번째 핸들러 실행\n";
});
std::cout << "4. run() 호출\n";
io.run(); // 여기서 2, 3번 핸들러 실행
std::cout << "5. run() 종료 (더 이상 작업 없음)\n";
}
// 출력:
// 1. run() 호출 전
// 4. run() 호출
// 2. 첫 번째 핸들러 실행
// 3. 두 번째 핸들러 실행
// 5. run() 종료 (더 이상 작업 없음)
여기서 “할 일”의 정의를 정확히 알아 두면 이후의 거의 모든 문제가 설명됩니다. io_context는 내부에 미완료 작업 카운트(outstanding work)를 들고 있습니다. post한 핸들러, 완료를 기다리는 async_read/async_accept, 만료를 기다리는 타이머가 각각 이 카운트를 하나씩 올리고, 핸들러가 실행되면 내려갑니다. run()은 이 카운트가 0이 되는 순간 반환하고 io_context를 stopped 상태로 만듭니다. 소켓을 열어 두기만 하고 비동기 작업을 걸지 않았다면 그것은 “할 일”이 아니므로, 연결이 살아 있어도 run()은 끝납니다. 반대로 서버가 “종료되지 않는” 버그는 대개 어딘가에 아직 완료되지 않은 타이머나 읽기 작업이 남아 있는 경우입니다.
run/run_one/poll 비교
세 가지 실행 방식
#include <boost/asio.hpp>
#include <iostream>
void compare_run_methods() {
// 1. run(): 모든 작업 실행
{
boost::asio::io_context io1;
for (int i = 1; i <= 3; ++i) {
boost::asio::post(io1, [i]() {
std::cout << "run: Task " << i << "\n";
});
}
io1.run(); // Task 1, 2, 3 모두 실행
std::cout << "run() completed\n";
}
// 2. run_one(): 한 번에 하나씩
{
boost::asio::io_context io2;
for (int i = 1; i <= 3; ++i) {
boost::asio::post(io2, [i]() {
std::cout << "run_one: Task " << i << "\n";
});
}
io2.run_one(); // Task 1만 실행
std::cout << "First run_one() completed\n";
io2.run_one(); // Task 2만 실행
std::cout << "Second run_one() completed\n";
io2.run(); // Task 3 실행
}
// 3. poll(): 대기 없이 준비된 작업만
{
boost::asio::io_context io3;
// 즉시 실행 가능한 작업
boost::asio::post(io3, [] {
std::cout << "poll: Immediate task\n";
});
// I/O 대기가 필요한 작업 (타이머)
boost::asio::steady_timer timer(io3, std::chrono::seconds(1));
timer.async_wait([](const boost::system::error_code&) {
std::cout << "poll: Timer expired\n";
});
io3.poll(); // Immediate task만 실행 (타이머는 실행 안 됨)
std::cout << "poll() completed (no blocking)\n";
// 타이머 만료까지 대기하려면 run() 필요
io3.run(); // Timer expired 실행
}
}
비교표
| 메서드 | 동작 | 사용 사례 |
|---|---|---|
run() | 모든 작업 완료까지 블로킹 | 서버 메인 루프 |
run_one() | 핸들러 하나를 실행할 때까지 블로킹 후 반환 | 작업 단위 제어 |
poll() | 대기 없이 준비된 작업만 | 게임 루프, UI 업데이트 |
poll_one() | 대기 없이 준비된 핸들러 하나만 | 프레임당 처리량 제한 |
run_for(duration) | 시간 제한 실행 | 타임아웃 필요 시 |
run_until(time_point) | 특정 시각까지 실행 | 스케줄링 |
poll()을 게임 루프에 쓸 때 주의할 점은 “준비된 작업”의 범위입니다. poll()은 이미 완료된 I/O의 핸들러와 큐에 들어 있는 핸들러를 실행하지만, 그 핸들러가 새로 post한 핸들러까지 같은 호출에서 실행할 수 있어서, 핸들러가 계속 자신을 다시 post하면 poll()이 한 프레임 안에서 빠져나오지 못합니다. 프레임 시간을 엄격히 지켜야 한다면 poll_one()을 정해진 횟수만큼 돌리거나 run_for()로 시간 예산을 주는 편이 안전합니다. 또 poll()도 작업이 모두 끝나면 io_context를 stopped 상태로 만들기 때문에, 매 프레임 호출하는 구조라면 work guard를 두거나 restart()를 불러야 다음 프레임에 새 작업이 실행됩니다.
work_guard로 서버 유지
문제: 서버가 바로 종료됨
// ❌ 잘못된 서버 코드
void broken_server() {
boost::asio::io_context io;
tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080));
acceptor.async_accept([](boost::system::error_code, tcp::socket) {
std::cout << "Client connected\n";
// 한 번만 실행되고 끝
});
io.run(); // 연결 하나 받고 종료!
std::cout << "Server stopped\n";
}
해결책 1: work_guard 사용
#include <boost/asio.hpp>
#include <iostream>
void server_with_work_guard() {
boost::asio::io_context io;
// work_guard 생성: "아직 할 일이 있다"고 표시
auto work = boost::asio::make_work_guard(io);
tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080));
acceptor.async_accept([](boost::system::error_code, tcp::socket) {
std::cout << "Client connected\n";
});
std::cout << "Server started on port 8080\n";
// work_guard가 있으므로 run()이 종료되지 않음
std::thread server_thread([&io]() {
io.run();
});
// 5초 후 종료
std::this_thread::sleep_for(std::chrono::seconds(5));
// work_guard 해제 → run() 종료
work.reset();
server_thread.join();
std::cout << "Server stopped\n";
}
이 예제에는 work guard를 처음 쓸 때 빠지기 쉬운 함정이 들어 있습니다. work.reset()은 “work guard가 올린 카운트 하나”만 내릴 뿐이라, async_accept가 아직 연결을 기다리고 있으면 그 작업이 카운트를 붙잡고 있어 run()은 여전히 반환하지 않습니다. 5초 뒤 join()에서 프로그램이 멈춘 것처럼 보이는 이유가 이것입니다. 즉시 종료하려면 acceptor.close()로 대기 중인 accept를 operation_aborted로 완료시키거나, 모든 작업을 버리고 멈추는 io.stop()을 호출해야 합니다. work guard는 “아직 작업을 등록하기 전인데 스레드 풀의 run()이 먼저 끝나 버리는” 상황을 막는 도구이지, 서버를 켜고 끄는 스위치가 아닙니다.
해결책 2: 완료 핸들러에서 재등록
서버를 계속 돌게 하는 정석은 accept가 완료될 때마다 다음 accept를 다시 등록하는 것입니다. 그러면 대기 중인 accept가 항상 하나 있으므로 run()이 반환하지 않고, 종료할 때는 acceptor를 닫기만 하면 됩니다.
class Server {
boost::asio::io_context& io_;
tcp::acceptor acceptor_;
public:
Server(boost::asio::io_context& io, uint16_t port)
: io_(io), acceptor_(io, tcp::endpoint(tcp::v4(), port)) {
start_accept();
}
private:
void start_accept() {
// ✅ 핵심: 완료 핸들러에서 다시 start_accept() 호출
acceptor_.async_accept(
[this](error_code ec, tcp::socket socket) {
if (ec == boost::asio::error::operation_aborted) {
return; // acceptor가 닫힘: 다시 등록하지 않음
}
if (!ec) {
// 클라이언트 처리 (세션 시작)
handle_client(std::move(socket));
}
// 다음 연결 대기
start_accept();
}
);
}
void handle_client(tcp::socket socket) {
// 클라이언트와 통신: 소켓과 버퍼를 shared_ptr로 두고 모든 핸들러가 공유
auto sock = std::make_shared<tcp::socket>(std::move(socket));
auto buffer = std::make_shared<std::array<char, 1024>>();
sock->async_read_some(
boost::asio::buffer(*buffer),
[sock, buffer](error_code ec, size_t bytes) {
if (!ec) {
std::cout << "Received " << bytes << " bytes\n";
// Echo back (쓰기가 끝날 때까지 sock·buffer가 살아 있도록 캡처)
boost::asio::async_write(
*sock,
boost::asio::buffer(*buffer, bytes),
[sock, buffer](error_code, size_t) {}
);
}
}
);
}
};
void run_server() {
boost::asio::io_context io;
Server server(io, 8080);
std::cout << "Server started on port 8080\n";
io.run(); // 계속 실행됨 (async_accept가 계속 등록되므로)
}
handle_client에서 흔히 보는 잘못된 형태는 socket.async_read_some(buf, [socket = std::move(socket)](...) {...})처럼 같은 소켓에 대해 멤버 함수를 호출하면서 동시에 그 소켓을 핸들러로 옮기는 코드입니다. C++17 규칙상 socket.async_read_some이라는 호출 대상은 먼저 정해지지만, 인자인 람다가 만들어지면서 소켓이 이동되고 그다음에 함수 본문이 실행되므로, 비동기 읽기는 이미 비어 버린(moved-from) 소켓에 걸립니다. 결과는 핸들러가 곧바로 Bad file descriptor 에러로 호출되는 것입니다. 또 쓰기 핸들러가 소켓과 버퍼를 캡처하지 않으면 바깥 람다가 끝나는 순간 둘 다 소멸해 쓰기가 취소되거나 해제된 메모리를 보내게 됩니다. 위 코드는 shared_ptr로 두 객체를 모든 핸들러가 공유하게 해서 이 두 문제를 함께 피합니다. 실무에서는 뒤에서 볼 세션 클래스 + shared_from_this 패턴이 같은 목적을 더 깔끔하게 달성합니다.
post와 dispatch
post: 항상 큐에 넣기
void demonstrate_post() {
boost::asio::io_context io;
std::cout << "Main thread: " << std::this_thread::get_id() << "\n";
// post: 항상 나중에 실행
boost::asio::post(io, [] {
std::cout << "Handler thread: " << std::this_thread::get_id() << "\n";
std::cout << "This runs later\n";
});
std::cout << "Before run()\n";
io.run();
std::cout << "After run()\n";
}
// 출력:
// Main thread: 123456
// Before run()
// Handler thread: 123456
// This runs later
// After run()
dispatch: 가능하면 즉시 실행
void demonstrate_dispatch() {
boost::asio::io_context io;
// dispatch: run() 실행 중이면 즉시 실행 가능
boost::asio::dispatch(io, [&io] {
std::cout << "Dispatch 1\n";
// 핸들러 내부에서 dispatch → 즉시 실행
boost::asio::dispatch(io, [] {
std::cout << "Dispatch 2 (immediate)\n";
});
// 핸들러 내부에서 post → 큐에 넣음
boost::asio::post(io, [] {
std::cout << "Post (queued)\n";
});
std::cout << "Dispatch 1 end\n";
});
io.run();
}
// 출력:
// Dispatch 1
// Dispatch 2 (immediate)
// Dispatch 1 end
// Post (queued)
demonstrate_dispatch의 바깥 dispatch는 run() 밖의 메인 스레드에서 불렸기 때문에 즉시 실행되지 않고 큐에 들어갔다가 run()에서 실행됩니다. 안쪽 dispatch는 이미 run() 안에서 호출되었으므로 그 자리에서 실행됩니다.
언제 무엇을 사용할까?
post와 dispatch 모두 다른 스레드에서 호출해도 안전합니다. 차이는 핸들러가 어느 시점에 실행되느냐입니다. dispatch는 호출한 스레드가 지금 그 실행 컨텍스트 안에서 돌고 있으면(io_context::run() 안이거나 해당 strand 안) 핸들러를 호출 스택 위에서 바로 실행합니다. 그래서 락을 잡은 상태에서 dispatch를 부르면, 핸들러가 같은 락을 다시 잡으려다 교착 상태가 될 수 있습니다. 호출자의 상태가 핸들러 실행 전후로 바뀌면 안 되는 코드, 등록한 순서대로 실행되어야 하는 코드라면 post가 맞습니다. 그래서 기본값은 post로 두는 편이 안전합니다. dispatch가 이득인 대표적인 경우는 라이브러리 내부에서 “이미 strand 안이면 바로 이어서 처리”하는 composed operation입니다.
멀티스레드 이벤트 루프
단일 스레드 vs 멀티스레드
// 단일 스레드: 한 번에 하나씩 처리
void single_threaded_server() {
boost::asio::io_context io;
// ... acceptor 설정 ...
io.run(); // 메인 스레드에서만 실행
}
// 멀티스레드: 여러 핸들러 동시 처리
void multi_threaded_server() {
boost::asio::io_context io;
// ... acceptor 설정 ...
// 4개 스레드가 같은 io_context 처리
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([&io]() {
io.run();
});
}
for (auto& t : threads) {
t.join();
}
}
스레드 풀 구현
class ThreadPool {
boost::asio::io_context io_;
boost::asio::executor_work_guard<boost::asio::io_context::executor_type> work_;
std::vector<std::thread> threads_;
public:
ThreadPool(size_t num_threads)
: work_(boost::asio::make_work_guard(io_)) {
for (size_t i = 0; i < num_threads; ++i) {
threads_.emplace_back([this]() {
io_.run();
});
}
}
~ThreadPool() {
work_.reset(); // work_guard 해제
for (auto& t : threads_) {
t.join();
}
}
// 작업 추가
template<typename F>
void post(F&& f) {
boost::asio::post(io_, std::forward<F>(f));
}
boost::asio::io_context& get_io_context() {
return io_;
}
};
// 사용 예시
void use_thread_pool() {
ThreadPool pool(4); // 4개 스레드
// 작업 추가
for (int i = 0; i < 10; ++i) {
pool.post([i]() {
std::cout << "Task " << i
<< " on thread " << std::this_thread::get_id() << "\n";
std::this_thread::sleep_for(std::chrono::milliseconds(100));
});
}
std::this_thread::sleep_for(std::chrono::seconds(2));
}
동기화 주의사항
여러 스레드가 run()을 돌리면 핸들러가 서로 다른 스레드에서 동시에 실행되므로, 핸들러가 공유하는 상태는 보호해야 합니다. 방법은 mutex로 잠그거나, 그 상태를 건드리는 핸들러를 모두 같은 strand에서 실행하는 것입니다.
// 방법 1: mutex
class Counter {
int count_ = 0;
std::mutex mutex_;
public:
void increment() {
std::lock_guard<std::mutex> lock(mutex_);
++count_;
}
int get() {
std::lock_guard<std::mutex> lock(mutex_);
return count_;
}
};
// 방법 2: strand (count_는 strand 안에서만 접근)
class StrandCounter {
boost::asio::strand<boost::asio::io_context::executor_type> strand_;
int count_ = 0; // strand로 보호되므로 mutex 불필요
public:
explicit StrandCounter(boost::asio::io_context& io)
: strand_(boost::asio::make_strand(io)) {}
void increment() {
boost::asio::post(strand_, [this]() {
++count_; // strand 내에서 실행 → 순차 보장
});
}
void get(std::function<void(int)> callback) {
boost::asio::post(strand_, [this, callback]() {
callback(count_);
});
}
};
strand 버전은 결과를 반환값이 아니라 콜백으로 돌려준다는 점이 다릅니다. 값을 읽는 일도 strand 안에서 일어나야 하기 때문입니다.
strand로 세션 상태 보호하기
strand는 자신을 통해 실행되는 핸들러들이 동시에 실행되지 않고 하나씩 순서대로 실행되도록 보장하는 executor입니다. 세션의 상태를 그 세션의 strand에서만 건드리면 mutex 없이 안전하게 접근할 수 있습니다.
strand로 보호된 Echo 세션
#include <boost/asio.hpp>
#include <memory>
using boost::asio::ip::tcp;
using boost::system::error_code;
class StrandEchoSession : public std::enable_shared_from_this<StrandEchoSession> {
tcp::socket socket_;
boost::asio::strand<tcp::socket::executor_type> strand_; // socket_ 뒤에 선언 (초기화 순서)
std::array<char, 1024> buffer_;
public:
StrandEchoSession(tcp::socket socket)
: socket_(std::move(socket)),
strand_(boost::asio::make_strand(socket_.get_executor())) {}
void start() {
boost::asio::dispatch(strand_, [self = shared_from_this()]() {
self->do_read();
});
}
private:
void do_read() {
auto self = shared_from_this();
socket_.async_read_some(
boost::asio::buffer(buffer_),
boost::asio::bind_executor(strand_, [this, self](error_code ec, size_t bytes) {
if (!ec) do_write(bytes);
})
);
}
void do_write(size_t bytes) {
auto self = shared_from_this();
boost::asio::async_write(
socket_, boost::asio::buffer(buffer_, bytes),
boost::asio::bind_executor(strand_, [this, self](error_code ec, size_t) {
if (!ec) do_read();
})
);
}
};
io_context::strand는 io_context&를 받는 예전 클래스이고 Boost 1.70 이후 사용 중단(deprecated) 대상이라, 소켓의 executor로부터 만들 때는 boost::asio::make_strand로 strand<Executor>를 만드는 것이 맞습니다. 더 간단한 방법도 있습니다. acceptor에서 acceptor.async_accept(boost::asio::make_strand(io), handler)처럼 strand executor를 넘기면, 받아진 소켓 자체가 strand에 묶여서 그 소켓의 모든 완료 핸들러가 자동으로 strand에서 실행됩니다. 그러면 위처럼 핸들러마다 bind_executor를 붙일 필요가 없고, 하나를 빠뜨려 그 핸들러만 strand 밖에서 도는 실수도 사라집니다.
strand는 “같은 strand에 속한 핸들러끼리는 절대 동시에 실행되지 않는다”는 것만 보장합니다. 세션 A와 세션 B가 각자의 strand를 갖고 둘 다 전역 통계 맵을 건드린다면 여전히 데이터 경쟁이므로, 공유 자원에는 그 자원 전용 strand를 두거나 mutex가 필요합니다.
strand와 mutex 중 무엇을 쓸지는 상태에 접근하는 코드가 어디에 있느냐로 정하면 됩니다. 상태를 건드리는 코드가 모두 Asio 핸들러라면 strand가 자연스럽습니다. 스레드가 락을 기다리며 블로킹하지 않고, 락 순서를 잘못 잡아 생기는 교착도 없습니다. 반면 Asio 바깥의 일반 스레드나 동기 코드도 같은 상태를 읽어야 한다면, 결과를 콜백으로 받아야 하는 strand보다 짧은 임계 구역을 가진 mutex가 더 단순합니다.
C++20 코루틴
Boost.Asio는 C++20 코루틴을 지원합니다. co_await로 콜백 지옥을 피하고 동기 코드처럼 작성할 수 있습니다.
Echo 서버 (코루틴)
#if __cplusplus >= 202002L
#include <cstdio>
namespace asio = boost::asio;
using asio::ip::tcp;
asio::awaitable<void> echo_session(tcp::socket socket) {
try {
char data[1024];
for (;;) {
std::size_t n = co_await socket.async_read_some(
asio::buffer(data), asio::use_awaitable);
co_await asio::async_write(
socket, asio::buffer(data, n), asio::use_awaitable);
}
} catch (const std::exception& e) {
std::printf("Echo exception: %s\n", e.what());
}
}
asio::awaitable<void> listen(tcp::acceptor& acceptor) {
for (;;) {
tcp::socket socket = co_await acceptor.async_accept(asio::use_awaitable);
asio::co_spawn(
acceptor.get_executor(),
echo_session(std::move(socket)),
asio::detached);
}
}
void run_coroutine_server() {
asio::io_context io;
tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080));
asio::co_spawn(io, listen(acceptor), asio::detached);
io.run();
}
#endif // C++20
에러 처리: as_tuple
auto [ec, n] = co_await socket.async_read_some(
asio::buffer(data), asio::as_tuple(asio::use_awaitable));
if (ec) {
std::cerr << "Read error: " << ec.message() << "\n";
co_return;
}
코루틴 버전의 echo_session은 콜백 버전과 달리 shared_from_this가 없는데도 안전합니다. 소켓과 data 배열이 코루틴 프레임 안에 있고, 프레임은 코루틴이 끝날 때까지 힙에 유지되기 때문입니다. 대신 listen(tcp::acceptor& acceptor)처럼 참조로 받는 인자는 여전히 수명 문제가 있습니다. 코루틴이 중단된 동안 원래 객체가 소멸하면 프레임 안의 참조가 댕글링이 되므로, 코루틴의 인자는 가능하면 값으로 받는 것이 원칙입니다. 또 use_awaitable만 쓰면 연결이 끊길 때마다 boost::system::system_error 예외(End of file 등)가 던져지는데, 정상 종료를 예외로 처리하는 것이 부담스럽다면 위의 as_tuple로 에러 코드를 값으로 받는 편이 깔끔합니다.
완료 핸들러 체이닝
패턴: 완료 시 다음 작업 등록
class EchoSession : public std::enable_shared_from_this<EchoSession> {
tcp::socket socket_;
std::array<char, 1024> buffer_;
public:
EchoSession(tcp::socket socket)
: socket_(std::move(socket)) {}
void start() {
do_read();
}
private:
void do_read() {
auto self = shared_from_this();
socket_.async_read_some(
boost::asio::buffer(buffer_),
[this, self](error_code ec, size_t bytes) {
if (!ec) {
do_write(bytes); // ✅ 읽기 완료 → 쓰기 시작
}
}
);
}
void do_write(size_t bytes) {
auto self = shared_from_this();
boost::asio::async_write(
socket_,
boost::asio::buffer(buffer_, bytes),
[this, self](error_code ec, size_t) {
if (!ec) {
do_read(); // ✅ 쓰기 완료 → 다시 읽기
}
}
);
}
};
타이머 체이닝
class PeriodicTimer {
boost::asio::steady_timer timer_;
std::chrono::milliseconds interval_;
std::function<void()> callback_;
public:
PeriodicTimer(
boost::asio::io_context& io,
std::chrono::milliseconds interval,
std::function<void()> callback
) : timer_(io), interval_(interval), callback_(std::move(callback)) {}
void start() {
schedule_next();
}
private:
void schedule_next() {
timer_.expires_after(interval_);
timer_.async_wait([this](error_code ec) {
if (!ec) {
callback_();
schedule_next(); // ✅ 타이머 완료 → 다시 등록
}
});
}
};
// 사용
void use_periodic_timer() {
boost::asio::io_context io;
PeriodicTimer timer(io, std::chrono::seconds(1), [] {
std::cout << "Tick: " << std::time(nullptr) << "\n";
});
timer.start();
io.run();
}
이 타이머에는 두 가지 미묘한 점이 있습니다. 첫째, expires_after(interval_)는 “지금부터 1초 뒤”이므로 콜백 실행 시간과 스케줄링 지연만큼 주기가 조금씩 밀립니다(drift). 정확한 주기가 필요하면 timer_.expires_at(timer_.expiry() + interval_)처럼 이전 만료 시각을 기준으로 다음 시각을 정해야 합니다. 둘째, 핸들러가 this를 캡처하므로 PeriodicTimer 객체가 먼저 소멸하면 댕글링입니다. 소멸자에서 timer_.cancel()을 불러도 핸들러는 operation_aborted로 나중에 실행되기 때문에, 그때 this는 이미 사라져 있습니다. 수명이 io_context보다 짧을 수 있는 객체라면 세션처럼 shared_from_this를 캡처해야 합니다.
간단한 HTTP 서버, 타임아웃, 클라이언트 연결 풀
예시 1: HTTP 서버 (간단한 버전)
class SimpleHttpServer {
boost::asio::io_context& io_;
tcp::acceptor acceptor_;
public:
SimpleHttpServer(boost::asio::io_context& io, uint16_t port)
: io_(io), acceptor_(io, tcp::endpoint(tcp::v4(), port)) {
start_accept();
}
private:
void start_accept() {
acceptor_.async_accept([this](error_code ec, tcp::socket socket) {
if (!ec) {
handle_request(std::move(socket));
}
start_accept(); // 다음 연결 대기
});
}
void handle_request(tcp::socket socket) {
auto sock = std::make_shared<tcp::socket>(std::move(socket));
auto buffer = std::make_shared<boost::asio::streambuf>();
boost::asio::async_read_until(
*sock,
*buffer,
"\r\n\r\n",
[sock, buffer](error_code ec, size_t) {
if (!ec) {
// 응답 문자열도 쓰기가 끝날 때까지 살아 있어야 함
auto response = std::make_shared<std::string>(
"HTTP/1.1 200 OK\r\n"
"Content-Length: 13\r\n"
"\r\n"
"Hello, World!");
boost::asio::async_write(
*sock,
boost::asio::buffer(*response),
[sock, response](error_code, size_t) {}
);
}
}
);
}
};
타임아웃 처리 패턴
// 타이머로 읽기 타임아웃 구현 (콜백 방식)
// buffer가 가리키는 메모리는 handler가 호출될 때까지 호출자가 유지해야 함
void read_with_timeout(std::shared_ptr<tcp::socket> socket,
boost::asio::mutable_buffer buffer,
std::chrono::seconds timeout,
std::function<void(error_code, size_t)> handler) {
auto timer = std::make_shared<boost::asio::steady_timer>(
socket->get_executor(), timeout);
timer->async_wait([socket](error_code ec) {
if (!ec) socket->cancel(); // 타임아웃 시 읽기 취소
});
socket->async_read_some(buffer,
[timer, handler](error_code ec, size_t n) {
timer->cancel(); // 읽기 완료 시 타이머 취소
handler(ec, n);
});
}
이 패턴에서 타임아웃이 나면 읽기 핸들러는 boost::asio::error::operation_aborted로 호출되므로, 호출자는 그것을 “타임아웃”으로 해석해야 합니다(원인을 구분하려면 타이머 쪽에서 플래그를 세워 둡니다). 여러 스레드가 run()을 돌린다면 타이머 핸들러와 읽기 핸들러가 동시에 실행되어 socket->cancel()과 읽기 완료가 경쟁할 수 있으므로, 두 핸들러를 같은 strand에 묶어야 합니다. Boost 1.70 이상의 Beast를 쓴다면 beast::tcp_stream::expires_after()가 이 전체 패턴을 대신해 줍니다.
연결 풀 (클라이언트 측)
class ConnectionPool {
boost::asio::io_context& io_;
std::queue<std::shared_ptr<tcp::socket>> pool_;
std::string host_, port_;
size_t max_size_;
public:
void acquire(std::function<void(error_code, std::shared_ptr<tcp::socket>)> cb) {
if (!pool_.empty()) {
auto sock = std::move(pool_.front());
pool_.pop();
cb(error_code{}, std::move(sock));
return;
}
// 풀이 비었으면 resolver로 새 연결을 만든 뒤 cb 호출 (생략)
}
void release(std::shared_ptr<tcp::socket> sock) {
if (pool_.size() < max_size_ && sock->is_open())
pool_.push(std::move(sock));
}
};
이 풀은 골격만 보여 줍니다. is_open()은 상대가 연결을 끊었는지 알려 주지 않으므로, 풀에서 꺼낸 연결은 첫 요청이 실패하면 버리고 새로 연결하는 처리가 필요합니다. 또 여러 스레드에서 acquire/release를 호출한다면 pool_을 strand나 mutex로 보호해야 합니다.
bad_weak_ptr, 멈춘 io_context 재사용, dispatch 재귀 같은 에러
에러 1: shared_from_this() 호출 시 bad_weak_ptr
객체가 아직 shared_ptr로 관리되지 않은 상태에서 shared_from_this()를 호출하면 std::bad_weak_ptr 예외가 납니다(C++17부터 보장되는 동작).
// ❌ 잘못된 코드
(new Session(std::move(socket)))->start(); // shared_ptr가 아님 → bad_weak_ptr
세션은 처음부터 make_shared로 만들어야 합니다.
// ✅ 올바른 코드
auto session = std::make_shared<Session>(std::move(socket));
session->start();
에러 2: run() 후 io_context 재사용
작업이 떨어져 run()이 반환된 io_context는 stopped 상태이므로, 다시 run()을 불러도 바로 반환합니다.
// ❌ 잘못된 코드
io.run(); // 완료 후
boost::asio::post(io, [] {});
io.run(); // 아무것도 실행 안 됨
다시 돌리기 전에 restart()를 호출합니다.
// ✅ 올바른 코드
io.restart();
io.run();
에러 3: 소켓/버퍼 수명 관리
비동기 작업이 완료되기 전에 소켓이나 버퍼가 소멸하면, 커널이나 Asio가 이미 해제된 메모리에 데이터를 쓰게 됩니다.
// ❌ 잘못된 코드
std::array<char, 1024> buffer; // 스택
socket.async_read_some(boost::asio::buffer(buffer), [](error_code, size_t) {});
// 함수 종료 → buffer 소멸
버퍼와 소켓을 핸들러가 함께 소유하게 합니다.
// ✅ 올바른 코드: 버퍼와 소켓을 핸들러가 공유 소유
auto buffer = std::make_shared<std::array<char, 1024>>();
auto sock = std::make_shared<tcp::socket>(std::move(socket));
sock->async_read_some(
boost::asio::buffer(*buffer),
[buffer, sock](error_code ec, size_t n) {});
// (주의: socket.async_read_some(..., [socket = std::move(socket)]...)처럼 쓰면
// 읽기가 이미 이동된 소켓에 걸려 Bad file descriptor 에러가 남)
에러 4: 멀티스레드에서 공유 변수 접근
여러 스레드가 io.run()을 실행하면 핸들러가 서로 다른 스레드에서 동시에 실행됩니다. 공유 변수에 접근하는 핸들러를 같은 strand로 보내거나 std::mutex로 보호합니다.
// ✅ strand 사용
auto strand = boost::asio::make_strand(io);
boost::asio::post(strand, [&]() { ++counter; });
에러 5: dispatch 재귀 깊이
핸들러 안에서 dispatch로 자기 자신을 다시 부르면, 즉시 실행이 반복되면서 호출 스택이 계속 쌓여 스택 오버플로가 날 수 있습니다. 반복되는 작업은 post로 등록해 현재 핸들러가 반환한 뒤 실행되게 합니다.
비슷한 걱정은 비동기 읽기가 즉시 완료되는 경우에도 생깁니다. 소켓 버퍼에 데이터가 이미 쌓여 있으면 읽기가 곧바로 끝나는데, Asio는 이런 경우에도 완료 핸들러를 시작 함수 안에서 바로 부르지 않고 큐를 거쳐 실행하도록 보장합니다. 그래서 do_read → do_write → do_read 체인 자체는 스택을 쌓지 않습니다. 직접 만든 비동기 함수에서 완료 콜백을 그 자리에서 호출하면 이 보장이 깨지므로, 직접 구현할 때도 “완료 핸들러는 시작 함수가 반환한 뒤에 실행된다”는 규칙을 지키기 위해 post를 사용해야 합니다.
SIGINT로 accept 루프를 멈추는 graceful shutdown
std::signal로 전역 플래그를 세우고 accept 루프 안에서 확인하는 방식은 흔히 보이지만 문제가 있습니다. 다음 연결이 들어와 async_accept 핸들러가 실행되기 전까지는 플래그를 아무도 확인하지 않으므로, 트래픽이 없는 서버는 Ctrl+C를 눌러도 끝나지 않습니다. 또 시그널 핸들러 안에서 할 수 있는 일은 극히 제한적이라(async-signal-safe 함수만 호출 가능) Asio 객체를 직접 건드릴 수도 없습니다. Asio에는 이 문제를 위한 signal_set이 있어서, 시그널을 일반 완료 핸들러로 이벤트 루프 안에서 받을 수 있습니다.
boost::asio::signal_set signals(io, SIGINT, SIGTERM);
signals.async_wait([&](const error_code&, int /*signo*/) {
acceptor.close(); // 대기 중인 async_accept를 operation_aborted로 완료시킴
// 각 세션에 종료를 알리거나(소켓 shutdown), 정 급하면 io.stop()
});
io.run(); // 모든 작업이 정리되면 자연스럽게 반환
acceptor.close() 후 accept 핸들러는 operation_aborted를 받으므로, 앞의 Server 예제처럼 그때는 start_accept()를 다시 부르지 않도록 에러 코드를 확인해야 합니다. 진행 중인 세션까지 정리되면 미완료 작업이 0이 되어 run()이 스스로 반환하는데, 이것이 요청을 중간에 끊지 않는 graceful shutdown입니다.
스레드 수는 어떻게 정할까
같은 io_context에 스레드를 늘렸을 때 처리량이 얼마나 오르는지는 핸들러가 하는 일에 따라 완전히 달라집니다. 핸들러가 짧고 대부분의 시간이 커널의 I/O 대기라면 스레드 하나로도 많은 연결을 처리할 수 있고, 스레드를 늘려도 이득이 작습니다. 핸들러 안에서 JSON 파싱, 압축, 암호화처럼 CPU를 쓰는 작업이 많을수록 코어 수까지는 거의 비례해서 좋아집니다. 코어 수를 넘기면 문맥 전환만 늘어나고, 여러 스레드가 하나의 io_context 내부 큐를 두고 경쟁하는 비용이 드러나기 시작합니다. 그래서 고부하 서버에서는 “io_context 하나 + 스레드 N개” 대신 “스레드마다 io_context 하나”(io_context-per-thread) 구조를 쓰기도 합니다. 락 경쟁이 없고 세션이 한 스레드에 고정되어 strand도 필요 없어지는 대신, 연결을 스레드 간에 고르게 분배하는 로직이 필요합니다. 이 구조는 #29-3 멀티스레드 서버에서 다룹니다.
따라서 코어 수(std::thread::hardware_concurrency())에서 시작하고, 실제 부하로 측정하며 조정합니다. 핸들러 안에서 블로킹 호출(동기 DB 쿼리, sleep)을 한다면 스레드 수를 늘리기 전에 그 작업을 별도 스레드 풀로 옮기는 것이 먼저입니다.
콜백과 코루틴 중에서는, C++20을 쓸 수 있다면 코루틴 쪽이 읽고 고치기 쉽습니다. 세션 수명이 코루틴 프레임으로 자연스럽게 관리되고, 읽기→쓰기 체인이 일반 반복문으로 표현되기 때문입니다. C++20을 쓸 수 없는 환경이라면 이 글의 콜백 + shared_from_this 패턴이 표준적인 선택입니다.
다음 글: C++ 멀티스레드 네트워크 서버: io_context 스레드 풀, strand로 순서 보장, 세션 수명과 data race
이전 글: Boost.Asio 입문: io_context, 비동기 타이머·읽기·쓰기, 핸들러 수명과 work_guard
참고 자료
같이 보면 좋은 글
- Boost.Asio 입문: io_context, 비동기 타이머·읽기·쓰기, 핸들러 수명과 work_guard
- C++ 멀티스레드 네트워크 서버: io_context 스레드 풀, strand로 순서 보장, 세션 수명과 data race
- C++ std::thread와 jthread 기초: 생성, join·detach, 공유 데이터 보호
- C++로 HTTP 클라이언트·서버 구현하기: 파서, Beast, Keep-Alive, 청크 인코딩, 타임아웃
- C++ WebSocket 심화 | Boost.Beast·Ping/Pong·재연결·운영
- C++ TLS 통신: OpenSSL과 Asio 연동, 인증서 관리, mTLS, 흔한 SSL 에러