C++ Boost.Asio io_context 이벤트 루프 | 동작 원리 정리 [#1]
이 글의 핵심
Asio 코드가 이상하게 동작할 때는 대부분 io_context가 언제 작업을 꺼내고 언제 멈추는지를 오해한 경우입니다. epoll, kqueue, IOCP 위에서 이벤트 루프가 어떻게 돌아가는지 이해한 뒤, 핸들러가 실행되지 않거나 데드락이 걸리는 상황을 디버깅하고, 타임아웃과 연결 수 제한을 갖춘 Echo 서버로 마무리합니다.
왜 io_context를 깊이 알아야 할까
Boost.Asio(및 standalone Asio)로 네트워크 서버를 만들면 모든 비동기 연산의 완료 핸들러가 결국 io_context를 거쳐 실행됩니다. run()과 poll()의 차이를 모르면 “왜 프로그램이 바로 끝나지?” 같은 현상을 이해하기 어렵고, Asio가 따르는 Proactor 패턴(비동기 연산을 시작해 두고, 완료되면 핸들러가 호출되는 모델)을 모르면 완료 핸들러가 언제, 어느 스레드에서 호출되는지 예측할 수 없습니다. 이 글은 io_context가 작업을 세고, 꺼내고, 멈추는 방식을 정리한 뒤 이를 바탕으로 Echo 서버를 구성합니다.
그림: 핸들러가 다음 비동기 작업을 다시 시작하는 한 루프가 계속 돈다. 핸들러는 run()을 호출한 스레드에서만 실행된다.
선수 지식으로 C++ 실전 가이드 #29-1: Asio 라이브러리 입문에서 다룬 io_context와 async_* 기본 사용법을 알고 있으면 좋습니다.
io_context가 맡는 두 가지 일
실행 컨텍스트와 미완료 작업 수
io_context는 두 가지 역할을 합니다. 첫째, OS의 I/O 멀티플렉싱(epoll, kqueue, IOCP 등)과 연동해 소켓 I/O와 타이머의 진행 상황을 받아 옵니다. 둘째, 완료된 연산의 완료 핸들러(completion handler)를 큐에 모아 두고, run()을 호출한 스레드가 이를 꺼내 실행하도록 합니다.
async_read, async_write, async_accept 같은 비동기 연산을 시작하면 Asio는 “이 연산이 끝나면 이 핸들러를 큐에 넣는다”고 등록하고, 동시에 io_context가 관리하는 미완료 작업(outstanding work) 수를 하나 늘립니다. 핸들러가 실행되면 그 수가 줄어듭니다. run()이 언제 반환하는지는 전적으로 이 카운트에 달려 있습니다.
flowchart LR A[비동기 연산 시작] --> B[완료 대기] B --> C[완료 시 핸들러 큐 적재] C --> D[run: 큐에서 꺼내 실행] D --> A
처음 Asio를 쓸 때는 “run()을 한 번 호출하면 서버가 영원히 돌 것”이라고 생각하기 쉽습니다. 실제로 run()은 미완료 작업이 0이 되는 순간 반환합니다. 서버가 계속 돌아가는 이유는 accept 핸들러 안에서 다음 async_accept를 다시 걸어 카운트가 0으로 떨어지지 않기 때문입니다.
// g++ -std=c++17 -o io_run io_run.cpp -lboost_system -pthread && ./io_run
#include <boost/asio.hpp>
#include <iostream>
int main() {
boost::asio::io_context io;
boost::asio::steady_timer timer(io, std::chrono::seconds(1));
timer.async_wait([&](const boost::system::error_code& ec) {
if (!ec) std::cout << "1초 후 실행\n";
});
io.run();
return 0;
}
약 1초 후 1초 후 실행이 한 줄 출력되고 프로그램이 끝납니다. 타이머 대기가 유일한 미완료 작업이었고, 그 핸들러가 실행된 뒤에는 남은 작업이 없으므로 run()이 반환하기 때문입니다.
run()과 poll()의 차이
run(): 작업이 남아 있는 동안 블로킹
run()은 큐에 실행 가능한 핸들러가 있으면 꺼내 실행하고, 없으면 OS의 이벤트 대기(epoll_wait 등)에 들어가 블로킹합니다. 새 I/O 완료나 post()된 작업이 생기면 깨어나 다시 핸들러를 실행합니다. 미완료 작업이 하나도 남지 않거나 stop()이 호출되면 반환합니다.
poll(): 블로킹 없이 지금 실행 가능한 것만
poll()은 OS 이벤트 대기에 들어가지 않습니다. 호출 시점에 이미 실행 가능한 핸들러를 모두 실행한 뒤 바로 반환합니다. 한 개만 처리하려면 poll_one(), 블로킹하면서 한 개만 처리하려면 run_one()을 씁니다.
boost::asio::io_context io;
// poll(): 대기 없이, 지금 실행 가능한 핸들러만 처리 후 반환
io.poll();
// run(): 미완료 작업이 남아 있으면 블로킹, 모두 끝나면 반환
io.run();
같은 1초 타이머를 등록하고 io.run()을 호출하면, 타이머가 만료되어 핸들러가 실행될 때까지 블로킹한 뒤 반환합니다. 반면 io.poll()만 호출하면 아직 1초가 지나지 않았으므로 실행 가능한 핸들러가 없어 아무것도 하지 않고 즉시 반환합니다. 그래서 게임 루프처럼 매 프레임 poll()을 한 번씩 호출하면, 타이머와 I/O는 그때그때 처리하면서도 메인 루프가 멈추지 않습니다.
| 목적 | 사용 |
|---|---|
| 서버·클라이언트 메인 루프, 이벤트가 올 때까지 대기 | run() |
| 게임 루프 등, 매 프레임 Asio 작업만 처리하고 바로 반환 | poll() |
| 블로킹하며 핸들러 하나만 처리 (테스트·디버깅) | run_one() |
| 블로킹 없이 핸들러 하나만 처리 | poll_one() |
서버에서는 보통 run()을 전담 스레드(또는 여러 스레드)에서 호출하고, poll()은 다른 메인 루프 안에 Asio를 끼워 넣을 때 씁니다. 빈 루프에서 poll()만 연속 호출하면 CPU를 계속 소모하므로, 프레임 대기나 다른 이벤트 소스와 함께 쓰는 것이 전제입니다.
Proactor 패턴
Reactor와 Proactor
Reactor 모델은 “이 소켓이 읽기 가능해지면 알려 달라”는 방식입니다. 준비 이벤트를 받은 뒤 사용자 코드가 직접 read()를 호출합니다. Proactor 모델은 “이 버퍼로 읽기가 끝나면 이 핸들러를 호출해 달라”는 방식으로, I/O는 라이브러리나 OS가 수행하고 사용자 코드는 완료 핸들러만 받습니다.
Asio의 API는 Proactor 스타일입니다. async_read를 호출하면 Asio가 읽기 연산을 등록하고, 읽기가 끝나면 완료 핸들러를 큐에 넣고, run()을 실행 중인 스레드가 그 핸들러를 꺼내 실행합니다. 내부 구현은 플랫폼마다 다릅니다. Windows에서는 IOCP가 실제로 완료 통지를 주지만, Linux에서는 epoll로 준비 이벤트를 받은 뒤 Asio가 사용자 공간에서 read/write를 수행하고 그 결과를 완료로 간주합니다. 즉 Linux에서는 Reactor 위에 Proactor 인터페이스를 얹은 구조입니다.
여기서 중요한 성질은 완료 핸들러가 반드시 그 io_context의 run()(또는 poll() 등)을 호출 중인 스레드에서 실행된다는 점입니다. 여러 스레드가 같은 io_context의 run()을 돌리면 어떤 스레드가 핸들러를 실행할지는 정해져 있지 않지만, 항상 그 스레드들 중 하나입니다. 이 성질을 알아야 strand와 락을 올바르게 설계할 수 있습니다.
실행 큐를 개발자 관점에서 보기
공식 구현 세부는 버전마다 다르지만, 이해를 위한 모델은 다음과 같습니다. I/O 완료, 타이머 만료, post()/dispatch()로 넣은 작업은 모두 실행할 핸들러의 큐로 모입니다. run()을 호출한 스레드는 이 큐에서 핸들러를 꺼내 연속으로 실행하며, 한 스레드 안에서는 한 핸들러가 끝나기 전에 다른 핸들러가 끼어들지 않습니다. 반면 여러 스레드가 같은 run()을 돌리면 여러 핸들러가 동시에 실행될 수 있어 공유 상태에 동기화가 필요해집니다. 이 내용은 다음 편 멀티스레드·data race에서 다룹니다.
이 모델을 머릿속에 두면 왜 핸들러 안에서 오래 막히면 전체 처리량이 떨어지는지, 왜 멀티스레드에서 post한 작업의 실행 순서를 보장하려면 strand가 필요한지가 자연스럽게 설명됩니다.
epoll, kqueue, IOCP
Asio는 OS의 I/O 멀티플렉싱을 백엔드로 씁니다. 적은 스레드로 많은 소켓을 감시한다는 목표는 같지만 모델이 다릅니다.
| API | 플랫폼 | 특징 |
|---|---|---|
| epoll | Linux | epoll_create1 / epoll_ctl / epoll_wait로 관심 fd 집합의 준비 이벤트를 한 번에 대기합니다. 준비 기반 모델입니다. |
| kqueue | BSD, macOS | kevent로 이벤트를 등록하고 대기합니다. 소켓 외에 타이머·시그널 같은 필터도 다룹니다. 준비 기반 모델입니다. |
| IOCP | Windows | 소켓을 완료 포트에 연결하고 overlapped I/O를 걸면, I/O가 끝났을 때 완료 패킷이 큐에 쌓입니다. 완료 기반 모델입니다. |
애플리케이션 코드는 보통 이 API를 직접 부르지 않습니다. 다만 IOCP에서는 커널이 비동기 연산 도중 버퍼를 직접 채우므로, 어느 플랫폼에서든 완료 핸들러가 호출될 때까지 버퍼를 살려 두어야 한다는 Asio의 규칙을 지키지 않으면 플랫폼마다 다른 형태로 문제가 드러납니다.
work_guard의 역할
미완료 작업이 없으면 run()은 반환한다
앞에서 본 것처럼 진행 중인 async_accept나 타이머 대기는 그 자체로 미완료 작업입니다. 따라서 async_accept를 먼저 걸고 run()을 호출하면, 연결이 들어올 때까지 run()은 반환하지 않습니다. 문제는 아직 아무 비동기 연산도 걸려 있지 않은 상태에서 run()을 호출하는 경우입니다.
boost::asio::io_context io;
std::thread t([&] { io.run(); }); // 이 시점엔 미완료 작업이 0 → 즉시 반환
// ... 나중에 다른 스레드에서 post하거나 소켓을 열 계획이었지만
boost::asio::post(io, [] { /* 실행되지 않음 */ });
t.join();
run()을 돌릴 전담 스레드를 먼저 띄우고 작업은 나중에 다른 스레드에서 post()하는 구조, 또는 클라이언트가 연결 요청 전에 io 스레드를 준비해 두는 구조에서 흔히 생기는 문제입니다. 이렇게 run()이 반환한 io_context는 stopped 상태가 되므로, 다시 돌리려면 restart()를 호출해야 합니다.
해결: make_work_guard
executor_work_guard는 살아 있는 동안 미완료 작업 수를 하나 올려 두는 객체입니다. 이 객체가 있는 한 실제 비동기 연산이 하나도 없어도 run()은 반환하지 않고 대기합니다.
boost::asio::io_context io;
auto work = boost::asio::make_work_guard(io);
std::thread t([&] { io.run(); }); // work가 있으므로 대기
boost::asio::post(io, [] { /* io 스레드에서 실행됨 */ });
// 종료할 때
work.reset(); // 카운트를 내린다. 남은 작업이 끝나면 run()이 반환
t.join();
work.reset()을 호출하거나 객체가 파괴되면 카운트가 내려가고, 다른 미완료 작업도 없으면 run()이 반환합니다. 반대로 work_guard를 해제하지 않으면 종료하고 싶어도 run()이 끝나지 않습니다. 그래서 work_guard는 클래스 멤버처럼 수명이 분명한 곳에 두고, 종료 경로에서 해제하는 위치를 명확히 정해 두는 것이 좋습니다.
async_accept 체인으로 계속 다음 accept를 거는 서버에서는 엄밀히 말해 work_guard가 필요하지 않습니다. 다만 acceptor를 닫는 순간부터 세션이 모두 끝날 때까지의 동작을 예측하기 쉽게 하려고, 서버 수명 동안 work_guard를 유지하다가 종료 시 해제하는 구성도 흔히 씁니다.
스레드 모델과 graceful shutdown
단일 스레드와 멀티 스레드 io_context
run()을 하나의 스레드에서만 호출하면 모든 완료 핸들러가 순차 실행되므로 연결 상태를 뮤텍스 없이 다루기 쉽습니다. 대신 한 핸들러가 CPU를 오래 쓰면 그동안 다른 연결 처리가 모두 멈춥니다.
하나의 io_context에 여러 스레드가 run()을 호출하면 처리량은 늘 수 있지만, 같은 소켓이나 세션 객체에 여러 핸들러가 동시에 닿을 수 있습니다. 준비된 핸들러는 먼저 비어 있는 스레드가 가져가므로, 같은 연결의 핸들러라도 서로 다른 스레드에서 동시에 실행될 수 있고 post한 순서대로 끝난다는 보장도 없습니다. 같은 자원을 다루는 핸들러는 strand로 묶어 직렬화해야 합니다.
io_context를 여러 개 두고 스레드마다 하나씩 run()하는 방식도 있습니다. 연결 하나가 항상 같은 스레드에서 처리되므로 세션 내부는 단일 스레드처럼 다룰 수 있지만, 새 연결을 어느 컨텍스트에 배정할지 부하 분산을 직접 설계해야 합니다.
예측 가능성 측면에서는 io 스레드 하나에 CPU 작업만 별도 워커 풀로 넘기는 구성이 가장 다루기 쉬운 경우가 많습니다.
graceful shutdown 순서
정상 종료는 보통 다음 순서로 진행합니다.
acceptor.close()로 새 연결을 받지 않습니다. 대기 중인async_accept는operation_aborted로 완료됩니다.- 세션에 종료 플래그를 세우고, 진행 중인 쓰기가 끝나면 소켓을 닫습니다.
- work_guard를 해제해 더 이상 영구적인 작업이 없다고 알립니다.
- 남은 작업이 모두 끝나면
run()이 스스로 반환합니다. 기다릴 수 없는 상황이라면io.stop()을 호출합니다. run()을 돌린 스레드들을join()합니다.
stop()의 의미는 정확히 알아 두어야 합니다. stop()은 run()이 가능한 한 빨리 반환하게 만들 뿐이며, 큐에 남은 핸들러를 실행해 주지 않습니다. 남은 핸들러는 실행되지 않은 채 큐에 남아 있다가 restart() 후 다시 run()해야 실행됩니다. 그래서 1~3단계로 작업을 스스로 소진시키는 방식이 먼저이고, stop()은 시간 제한을 넘겼을 때의 강제 종료 수단으로 두는 편이 안전합니다.
// 개념 스케치: work_guard 해제 후 run() 반환을 기다림
boost::asio::io_context io;
auto work = boost::asio::make_work_guard(io);
std::thread t([&] { io.run(); });
// ... 종료 시그널 수신 후 ...
// acceptor.close(), 세션 종료 요청 등을 io 스레드로 post
work.reset(); // 남은 작업이 끝나면 run()이 반환
t.join(); // 필요하면 타임아웃 후 io.stop()으로 강제 종료
종료 시그널은 boost::asio::signal_set으로 받아 io 스레드 안에서 위 순서를 실행하면, 종료 로직이 다른 핸들러와 경쟁하지 않습니다.
성능 측면에서 볼 것
이벤트 루프 병목 찾기
프로파일러로 io 스레드의 시간이 어디에 쓰이는지 먼저 확인합니다. 핸들러 본문이 상위에 보인다면 핸들러가 무겁다는 뜻입니다. 같은 연결에서 핸들러 호출 간격을 로그 타임스탬프로 보면 어느 구간에서 지연이 쌓이는지 드러납니다. 읽기 버퍼가 지나치게 작으면 같은 양을 읽는 데 시스템 콜 횟수가 늘어나므로 버퍼 크기도 확인 대상입니다. 멀티스레드 run()에서 락 경합이 심하다면 strand 분리나 io_context 샤딩을 검토합니다.
CPU 바운드 작업 처리
완료 핸들러 안에서 큰 압축, 암호화, 정규식 처리처럼 오래 걸리는 CPU 작업을 그대로 수행하면 그동안 같은 io_context의 다른 I/O와 타이머가 모두 지연됩니다. CPU 작업은 boost::asio::thread_pool 같은 별도 워커 풀에 post하고, 끝나면 boost::asio::post(io, ...)로 결과만 io 스레드에 돌려보내 소켓과 세션 상태를 갱신하게 합니다. C++20 코루틴을 쓰면 이 왕복을 co_await 흐름으로 표현할 수 있습니다. 원칙은 I/O 스레드는 얇게 유지하고, 계산은 다른 곳에서 하는 것입니다.
디버깅
run()이 바로 끝나는 경우
run()을 호출한 시점에 미완료 작업이 하나도 없었던 경우입니다. 비동기 연산을 run() 호출 전에 걸었는지, 핸들러 안에서 다음 비동기 연산을 다시 걸고 있는지, 에러 경로에서 체인이 끊기지 않았는지 확인합니다. 작업을 나중에 post하는 구조라면 work_guard가 필요합니다. 한 번 반환한 io_context를 다시 쓰려면 restart()를 호출했는지도 봅니다.
핸들러가 실행되지 않는 경우
이미 stop()이 호출된 io_context라면 restart() 전까지 핸들러가 실행되지 않습니다. io_context를 여러 개 쓰는 코드에서는 연산을 엉뚱한 io_context나 executor에 건 것은 아닌지 확인합니다. 핸들러에서 던진 예외는 run() 밖으로 전파되어 그 스레드의 run()을 끝내므로, 예외 하나로 이벤트 루프가 멈춘 것처럼 보일 수 있습니다. run()을 try/catch로 감싸 로그를 남기고 필요하면 다시 run()을 호출합니다. 에러 코드를 무시하고 바로 return하는 핸들러는 어디서 체인이 끊겼는지 알 수 없게 만들므로, 최소한 operation_aborted가 아닌 에러는 기록해 두는 편이 좋습니다.
데드락
핸들러 안에서 같은 io_context의 run()을 다시 호출하거나, 핸들러 안에서 다른 핸들러의 완료를 동기적으로 기다리면(future.get() 등) 단일 스레드에서는 그대로 교착 상태가 됩니다. 기다리는 핸들러가 실행될 스레드를 자기 자신이 막고 있기 때문입니다. 콜백 A가 락1→락2, 콜백 B가 락2→락1 순서로 잡는 일반적인 락 순서 문제도 멀티스레드 run()에서 그대로 생깁니다. 락 범위를 줄이거나 strand로 직렬화해 락 자체를 없애는 쪽이 낫습니다. 멈춘 프로세스에서 스레드 덤프를 떠 각 스레드가 epoll_wait, 핸들러, 락 중 어디에 있는지 보면 원인이 빨리 좁혀집니다.
Echo 서버 예제: 타임아웃과 연결 관리
아래는 읽기 쉽게 줄인 비동기 Echo 서버입니다. 실제 서비스에서는 로깅, 연결 한도, TLS, 시그널 처리 등을 더해야 합니다.
// g++ -std=c++17 echo_server.cpp -o echo_server -lboost_system -pthread
#include <boost/asio.hpp>
#include <array>
#include <iostream>
#include <memory>
using boost::asio::ip::tcp;
class Session : public std::enable_shared_from_this<Session> {
public:
explicit Session(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(data_),
[this, self](const boost::system::error_code& ec, std::size_t n) {
if (ec) return;
do_write(n);
});
}
void do_write(std::size_t n) {
auto self = shared_from_this();
boost::asio::async_write(
socket_, boost::asio::buffer(data_, n),
[this, self](const boost::system::error_code& ec, std::size_t /*sent*/) {
if (ec) return;
do_read();
});
}
tcp::socket socket_;
std::array<char, 4096> data_{};
};
class Server {
public:
Server(boost::asio::io_context& io, unsigned short port)
: acceptor_(io, tcp::endpoint(tcp::v4(), port)) {
do_accept();
}
private:
void do_accept() {
acceptor_.async_accept(
[this](const boost::system::error_code& ec, tcp::socket socket) {
if (!ec) {
std::make_shared<Session>(std::move(socket))->start();
}
do_accept();
});
}
tcp::acceptor acceptor_;
};
int main() {
try {
boost::asio::io_context io;
Server srv(io, 4000);
io.run(); // 대기 중인 async_accept가 있으므로 반환하지 않음
} catch (const std::exception& e) {
std::cerr << e.what() << "\n";
}
return 0;
}
Session은 shared_ptr로 관리되며, 각 비동기 연산의 람다가 self를 붙잡고 있는 동안만 살아 있습니다. 상대가 연결을 끊으면 읽기 핸들러가 에러 코드를 받고 새 연산을 걸지 않으므로 마지막 참조가 사라지면서 세션이 정리됩니다. 종료 시 모든 연결을 닫으려면 세션을 std::set<std::shared_ptr<Session>> 같은 목록에 등록해 두고 graceful shutdown 단계에서 순회하며 닫으면 됩니다. 이때 목록은 io 스레드에서만 건드려야 합니다.
타임아웃 처리
소켓의 비동기 읽기·쓰기에는 타임아웃 인자가 없으므로, steady_timer를 함께 두는 것이 관용적인 방법입니다. 활동이 있을 때마다 만료 시각을 뒤로 미루고, 타이머 핸들러에서 idle 시간이 초과되면 socket.cancel()이나 close()로 진행 중인 연산을 operation_aborted로 끝냅니다. 읽기 완료와 타이머 만료가 거의 동시에 일어날 수 있으므로, 세션 종료 플래그를 두어 한쪽이 먼저 처리되면 다른 쪽은 무시하게 합니다.
// 개념: idle 30초면 소켓 취소 (실제 코드는 세션 멤버로 두고 플래그로 경쟁 조건 처리)
boost::asio::steady_timer idle_timer(io);
idle_timer.expires_after(std::chrono::seconds(30));
idle_timer.async_wait([&socket](const boost::system::error_code& ec) {
if (!ec) socket.cancel();
});
연결 수와 리소스 한도
listen 백로그는 accept되기 전 대기열의 크기이고, 동시에 처리 중인 연결 수와는 별개입니다. 세션이 계속 늘어나면 메모리와 파일 디스크립터 한도(ulimit -n)에 걸리므로, 애플리케이션에서 활성 세션 수를 세어 한도를 넘으면 새 연결을 바로 닫는 식으로 제한합니다.
이 예제는 단일 스레드 io_context 기준입니다. 여러 스레드에서 run()하도록 바꿀 때는 세션 접근을 strand 등으로 직렬화해야 합니다.
실행해서 확인한 결과
이 글의 예제와 동작 설명은 Ubuntu 24.04 aarch64 컨테이너(g++ 13.3, Boost 1.83)에서 확인했습니다.
- 예제 코드: 1초 타이머 예제는
1초 후 실행을 출력하고 끝났습니다. Echo 서버(포트 4000)에는hello,second를 차례로 보내 같은 문자열이 돌아왔고, 클라이언트가 연결을 끊은 뒤에도 서버는 계속 실행 중이었습니다(run()이 반환하지 않음). - run()과 작업 수: 등록된 작업이 없을 때
run()은 바로 0을 반환했고stopped()가true가 됐습니다. - poll 계열: 핸들러 3개를
post한 뒤poll_one()은 1개, 이어서poll()은 나머지 2개를 실행했습니다. 300ms 타이머만 걸려 있을 때poll()은 0개를 실행하고 0ms 만에 반환했고,run_one()은 약 300ms 기다렸다가 1개를 실행했습니다. - stop과 restart: 첫 핸들러에서
stop()을 부르면 두 번째 핸들러는 실행되지 않았고,restart()없이run()을 다시 불러도 실행되지 않았습니다.restart()뒤run()에서 남은 핸들러가 실행됐습니다. - work_guard:
work_guard를 잡고 있으면 작업이 없어도run()이 반환하지 않았고, 200ms 타이머 핸들러에서reset()을 부르자 그 시점에 반환했습니다.
Windows(IOCP)나 macOS(kqueue)에서의 동작은 확인하지 않았습니다.