C++ Boost.Asio io_context 이벤트 루프 | 동작 원리 정리 [#1]
이 글의 핵심
Boost.Asio io_context 이벤트 루프: run/poll, Proactor 심화(epoll·kqueue·IOCP), 실전 패턴(단일·멀티스레드, work_guard, graceful shutdown), 성능·디버깅, Echo 서버 예제.
들어가며: 이벤트 루프가 Asio의 심장이다
왜 io_context를 깊이 알아야 할까?
Boost.Asio(및 standalone Asio)로 고성능 네트워크 서버를 만들 때, io_context는 모든 비동기 연산이 몰려드는 단일 실행 허브입니다. run()과 poll()의 차이를 모르면 “왜 서버가 금방 끝나버리지?” 같은 현상을 겪고, Proactor 패턴(비동기 연산을 시작해 두고, 완료되면 핸들러가 호출되는 비동기 I/O 모델)을 이해하지 못하면 “완료 핸들러가 언제, 어느 스레드에서 호출되는지”를 예측할 수 없습니다. work_guard를 올바르게 쓰지 않으면 이벤트 루프가 작업이 없다고 판단해 곧바로 종료해 버립니다.
이 글에서는 io_context의 내부 동작을 해부해, 고성능 Asio 코드를 설계할 때 필요한 기초를 쌓습니다.
목표:
- run()과 poll()의 결정적 차이 — 언제 무엇을 쓸지
- Proactor 패턴 — 비동기 연산이 완료되면 핸들러가 어떻게 스케줄되는지
- work_guard — 이벤트 루프가 꺼지지 않게 유지하는 방법
- 심화: epoll/kqueue/IOCP, 단일·멀티스레드 모델, graceful shutdown, 성능·디버깅, Echo 서버 예제
선수 지식: C++ 실전 가이드 #29-1: Asio 라이브러리 입문에서 io_context와 async_* 기본 사용법을 알고 있으면 좋습니다.
실무에서 겪은 문제
실제 프로젝트에서 이 개념을 적용하며 겪었던 경험을 공유합니다.
문제 상황과 해결
대규모 C++ 프로젝트를 진행하며 이 패턴/기법의 중요성을 체감했습니다. 책에서 배운 이론과 실제 코드는 많이 달랐습니다.
실전 경험:
- 문제: 처음에는 이 개념을 제대로 이해하지 못해 비효율적인 코드를 작성했습니다
- 해결: 코드 리뷰와 프로파일링을 통해 문제를 발견하고 개선했습니다
- 교훈: 이론만으로는 부족하고, 실제로 부딪혀보며 배워야 합니다
이 글이 여러분의 시행착오를 줄여주길 바랍니다.
목차
- io_context란 무엇인가
- run() vs poll() — 결정적 차이
- Proactor 패턴의 이해
- work_guard로 이벤트 루프 유지
- 실전 패턴 정리
- 이벤트 루프 동작 원리 심화
- 실전 패턴: 스레드 모델·work_guard·graceful shutdown
- 성능 최적화
- 디버깅 가이드
- 실전 서버 예제: Echo·타임아웃·연결 관리
1. io_context란 무엇인가
실행 컨텍스트와 작업 큐
io_context는 두 가지 역할을 합니다.
- I/O 이벤트와의 연동: OS의 I/O 멀티플렉싱(epoll, kqueue, IOCP 등)과 연동해 “어떤 소켓이 읽기/쓰기 가능한지”를 알려 받습니다.
- 핸들러 실행: 등록된 완료 핸들러(completion handler) 를 한 스레드에서 순차적으로 실행하는 실행 큐를 제공합니다.
비동기 연산(async_read, async_write, async_accept 등)을 시작하면, Asio는 내부적으로 “이 연산이 완료되면 이 핸들러를 실행 큐에 넣는다”고 등록합니다. run()을 호출하는 스레드가 그 큐에서 핸들러를 꺼내 실행합니다.
io_context 이벤트 루프 동작을 한눈에 보면 아래와 같습니다.
flowchart LR A[비동기 연산 등록] --> B[완료 대기] B --> C[완료 시 핸들러 큐 적재] C --> D[run: 큐에서 꺼내 실행] D --> A
처음 Asio를 쓸 때 “run() 한 번 호출하면 서버가 영원히 돌 것”이라고 생각하기 쉽습니다. 실제로는 io_context가 “지금 처리할 작업이 있는지”를 세어 보고, 등록된 비동기 연산만 있고 아직 완료된 게 없으면 “할 일 없음”으로 간주해 run()이 곧바로 반환할 수 있습니다. 그래서 서버처럼 “연결을 기다리며 계속 돌아가게” 하려면 work_guard나 “핸들러 안에서 다음 비동기 연산을 다시 등록”하는 패턴이 필요합니다.
// 복사해 붙여넣은 뒤: 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()은 “할 일이 있을 때까지” 루프를 돌다가, 등록된 작업이 하나도 없고 완료 대기 중인 연산도 없으면 반환합니다.
- 그래서 서버처럼 “영원히 대기”하려면 work_guard로 “아직 일이 있다”고 표시하거나, 완료 핸들러 안에서 다음 비동기 연산을 계속 등록해야 합니다.
2. run() vs poll() — 결정적 차이
run(): 블로킹 until work
run()은 다음을 반복합니다.
- 완료된 I/O에 대응하는 핸들러를 실행 큐에 넣고,
- 큐에서 핸들러를 꺼내 실행하고,
- 할 일이 없으면 OS의 이벤트 대기(epoll_wait 등)에 들어가 블로킹합니다. 새 이벤트나 post된 작업이 생기면 깨어나 다시 1–2를 반복합니다.
- 더 이상 등록된 작업도 없고, 완료 대기 중인 비동기 연산도 없으면 run()이 반환합니다.
즉, run() = “작업이 있으면 처리하고, 없으면 기다리다가, 영원히 할 일이 없으면 끝낸다.”
poll(): 대기 없이 준비된 것만
poll()은 한 번 다음만 수행합니다.
- 이미 준비된(완료된) 핸들러만 실행 큐에 넣고 실행합니다.
- 블로킹하지 않습니다. OS의 “이벤트 대기”에 들어가지 않습니다.
- 한 번 호출하면 곧바로 반환합니다.
boost::asio::io_context io;
// poll(): 대기 없이, 지금 준비된 작업만 처리 후 반환
io.poll();
// run(): 할 일이 없으면 블로킹, 할 일이 없어지면 반환
io.run();
동작 시나리오 예: steady_timer로 1초 후에 한 번만 콜백을 등록하고 io.run()을 호출하면, run()은 “1초 후 타이머 완료 → 핸들러 실행 → 그 다음 등록된 작업 없음 → run() 반환”까지 블로킹합니다. 반면 같은 설정으로 io.poll()만 호출하면, 아직 1초가 안 지났으므로 “준비된 핸들러 없음 → 아무것도 안 하고 즉시 반환”합니다. 그래서 게임 루프처럼 “매 프레임 poll() 한 번씩 호출”하는 식으로 쓰면, 타이머·I/O는 그때그때 처리하면서도 메인 루프가 멈추지 않습니다.
언제 뭘 쓸까?
| 목적 | 사용 |
|---|---|
| 서버/클라이언트 메인 루프 — 이벤트 올 때까지 대기 | run() |
| 게임 루프 등 — 매 프레임 “Asio 작업만 처리하고 바로 반환” | poll() |
| run_one() | 최대 한 개의 핸들러만 처리하고 반환 (테스트·디버깅에 유용) |
고성능 네트워크 서버에서는 보통 run()을 여러 스레드에서 호출해 스레드 풀을 만들고, poll()은 “다른 메인 루프 안에 Asio를 끼워 넣을 때” 사용합니다.
3. Proactor 패턴의 이해
Reactor vs Proactor
- Reactor: “이 소켓이 읽기 가능해지면 알려 달라” → 준비 이벤트를 받고, 그때 직접 read()를 호출합니다. (이벤트 루프가 알려 주고, 사용자 코드가 I/O를 수행)
- Proactor: “이 버퍼에 읽기가 완료되면 이 핸들러를 호출해 달라” → 완료 이벤트를 받습니다. I/O는 라이브러리(Asio)가 이미 수행한 뒤, 완료 핸들러만 호출합니다.
Asio는 Proactor 스타일입니다. async_read를 호출하면:
- Asio가 내부적으로 “이 소켓에서 이 버퍼로 읽기”를 OS에 등록하고,
- OS가 읽기를 끝내면 Asio가 해당 완료 핸들러를 실행 큐에 넣고,
- run()을 실행 중인 스레드가 그 핸들러를 꺼내 실행합니다.
그래서 “완료 핸들러는 반드시 io_context의 실행 맥락(run() 호출 스레드) 에서 호출된다”는 점이 중요합니다. 여러 스레드가 같은 io_context의 run()을 돌리면, 어떤 스레드가 그 핸들러를 실행할지는 스케줄에 따라 달라지지만, 항상 run() 중인 스레드 중 하나입니다. 이걸 이해해야 Strand와 락 사용을 올바르게 설계할 수 있습니다.
4. work_guard로 이벤트 루프 유지
문제: run()이 금방 끝난다
boost::asio::io_context io;
boost::asio::ip::tcp::acceptor acceptor(io, endpoint);
acceptor.async_accept([&](const boost::system::error_code& ec, auto socket) {
// 새 연결 처리
});
io.run(); // ⚠️ async_accept만 있고 "작업 카운트"가 없으면
// 일부 구현에서는 run()이 바로 반환할 수 있음
io_context는 내부적으로 “현재 의미 있는 작업 수”를 추적합니다. 비동기 연산만 등록하고, 아직 완료되지 않았을 때 “작업이 없다”고 판단해 run()이 반환하는 경우가 있습니다. (구현·버전에 따라 동작이 다를 수 있음)
해결: work_guard
work_guard는 “이 io_context에는 아직 할 일이 있다”는 것을 나타내는 객체입니다. 이 객체가 살아 있는 한, io_context는 run()이 작업이 없어도 곧바로 끝나지 않도록 유지합니다.
#include <boost/asio.hpp>
boost::asio::io_context io;
auto work = boost::asio::make_work_guard(io);
boost::asio::ip::tcp::acceptor acceptor(io, endpoint);
acceptor.async_accept([&](const boost::system::error_code& ec, auto socket) {
if (!ec) { /* 새 소켓 처리 */ }
// 다시 async_accept 등록해 반복
acceptor.async_accept(/* ... */);
});
io.run(); // work가 살아 있는 한 run()은 끝나지 않음
- make_work_guard(io) 는 io_context의 “작업 카운트”를 올립니다.
- work_guard를 파괴하면(스코프를 벗어나거나 reset) 카운트가 내려가고, 그때 다른 작업도 없으면 run()이 반환할 수 있습니다.
- 서버를 “의도적으로 종료”할 때는 work_guard를 해제한 뒤 run()이 반환하도록 하는 패턴을 씁니다.
실무 팁: “서버가 시작하자마자 run()이 끝나버린다”면 work_guard를 아직 안 썼거나, 비동기 연산 등록 전에 run()이 호출된 경우가 많습니다. 먼저 acceptor.async_accept(…)로 “할 일”을 등록한 뒤 run()을 호출하고, 필요하면 make_work_guard(io)로 작업 카운트를 유지하는 순서를 지키면 됩니다.
주의점
- work_guard를 너무 오래 들고 있으면 “서버를 종료하고 싶은데 run()이 안 끝난다”는 상황이 됩니다. 종료 시나리오에서는 work_guard를 먼저 해제하는 것을 명확히 설계하세요.
5. 실전 패턴 정리
| 항목 | 요약 |
|---|---|
| run() | 블로킹; 작업 처리 → 없으면 대기 → 영원히 없으면 반환 |
| poll() | 비블로킹; 준비된 작업만 처리 후 즉시 반환 |
| Proactor | I/O 완료 후 핸들러가 실행 큐에 들어가 run() 맥락에서 실행됨 |
| work_guard | run()이 “할 일 없음”으로 곧바로 끝나지 않게 유지 |
6. 이벤트 루프 동작 원리 심화
Proactor 패턴을 조금 더 정확히
앞에서 말한 Proactor는 “비동기 연산을 시작하고, 완료 시점에 핸들러가 호출된다”는 큰 그림입니다. 구현 관점에서 한 단계 더 나누면 다음과 같습니다.
-
연산 제출(Initiation)
async_read,async_write,async_accept등은 “이 소켓·이 버퍼에 대해 I/O를 수행하고, 끝나면 이 핸들러를 실행 큐에 넣어라”는 비동기 연산 요청을 Asio에 남깁니다. -
OS·런타임과의 연동
Asio는 내부 reactor(준비 가능 여부를 알려 주는 계층)와 비동기 완료 처리 경로를 조합합니다. 플랫폼에 따라 “커널이 버퍼까지 채워 준 뒤 완료를 알려 주는” 경로와 “준비됐을 때 사용자 공간에서 read/write를 수행한 뒤 완료로 간주하는” 경로가 섞일 수 있습니다. 사용자 코드가 보는 API는 여전히 “완료 핸들러” 중심인 Proactor 스타일입니다. -
완료 디스패치
I/O가 끝나거나(또는 오류·취소), 타이머가 만료되면, 해당 completion handler가 io_context의 실행 큐에 들어가고,run()/poll()/run_one()을 돌리는 스레드가 꺼내 실행합니다.
즉 Proactor는 “이벤트가 오면 내가 read 한다(Reactor)”가 아니라, “읽기/쓰기 완료를 단위로 내 로직이 한 번 호출된다”에 가깝게 사고하는 패턴입니다. Asio에서 strand로 순서를 묶거나, executor를 바꿔 다른 큐로 보내는 것도 이 “완료 → 큐 적재 → 실행” 파이프라인 위에서 동작합니다.
epoll, kqueue, IOCP — 플랫폼별 차이 (개념)
Asio는 백엔드로 OS의 I/O 멀티플렉싱을 씁니다. 전부 “많은 소켓을 적은 스레드로 감시한다”는 목표는 같지만 모델이 다릅니다.
| API | 주요 플랫폼 | 특징 |
|---|---|---|
| epoll (Linux) | 리눅스 | epoll_create / epoll_ctl / epoll_wait로 관심 있는 fd 집합에 대해 준비 이벤트를 한 번에 대기. 수평 확장에 유리한 편. |
| kqueue | BSD, macOS | kevent로 이벤트를 등록·대기. 필터 단위로 동작이 잘 정리되어 있음. |
| IOCP (Windows) | Windows | 완료 포트에 연결·I/O를 걸고, I/O가 끝나면 완료 패킷이 큐에 쌓임. “완료 기반” 모델과 잘 맞음. |
중요한 점: 애플리케이션 코드는 보통 이 API를 직접 부르지 않고 Asio가 추상화합니다. 다만 윈도우에서 IOCP 경로와 리눅스에서 epoll 경로는 커널이 알려 주는 단계(준비 vs 완료)가 달라, 버퍼 수명·scatter/gather·오버랩 I/O 같은 세부는 문서와 플랫폼별 동작을 함께 봐야 합니다. “모든 OS에서 완전히 동일한 커널 의미”를 가정하면 디버깅에서 헷갈리기 쉽습니다.
이벤트 큐 “내부 구조”를 개발자 관점에서
공식 구현 디테일은 버전마다 다르지만, 이해에 쓸 모델은 다음과 같습니다.
- I/O 완료·타이머 만료·
post()/dispatch()로 넣은 작업은 결국 실행할 핸들러의 큐로 모입니다. run()을 호출한 스레드는 이 큐에서 꺼내 연속 실행합니다. 한 핸들러 실행 중에는 다른 핸들러가 끼어들지 않습니다(같은 스레드에서). 여러 스레드가 같은io_context::run()을 돌리면 여러 핸들러가 동시에 실행될 수 있어 공유 상태에 대한 동기화가 필요해집니다(다음 편 멀티스레드·data race 참고).- “이벤트 큐”와 “OS의 준비/완료 큐”는 계층이 다릅니다. OS 쪽에서 깨어난 뒤, Asio가 어떤 핸들러를 사용자 큐에 넣을지 결정하고, 그다음이
run()루프입니다.
이 모델을 머릿속에 두면 “왜 핸들러 안에서 오래 막히면 전체 처리량이 떨어지는지”, “왜 post한 작업 순서를 보장하려면 strand가 필요한지”가 자연스럽게 이어집니다.
7. 실전 패턴: 스레드 모델·work_guard·graceful shutdown
단일 스레드 vs 멀티 스레드 io_context
-
단일 스레드에서
run()하나
모든 완료 핸들러가 순차 실행되므로 연결 상태를 mutex 없이 다루기 쉽습니다. 대신 한 핸들러가 CPU를 오래 쓰면 그동안 다른 연결 처리가 멈춥니다. -
하나의
io_context에 여러 스레드가run()
처리량은 늘 수 있지만, 같은 소켓·같은 세션 객체에 여러 핸들러가 동시에 닿을 수 있어 락·strand·뮤텍스 설계가 필요합니다. -
io_context를 여러 개
리스닝 소켓을 고정 분할(예: 포트별·CPU별)하거나, 무엇을 어느 컨텍스트에 묶을지 명확할 때 씁니다. 운영·디버깅은 단순해질 수 있지만 연결 간 부하 분산 설계가 따로 필요합니다.
실무에서는 **단일 스레드 + 필요 시 워커 스레드 풀에 CPU 작업만 post**가 가장 예측 가능한 경우가 많습니다.
work_guard 활용 패턴 (정리)
- 서버 기동 직후: 아직
async_accept체인이 안정적으로 돌기 전에run()이 빠져나가지 않게 **make_work_guard(io)**를 두는 패턴이 흔합니다. - 유지보수:
work_guard를 명시적 스코프(함수·클래스 멤버)에 두고, 종료 시점에만reset()하거나 객체를 파괴해 “이제 진짜로 일이 없어도 된다”고 알립니다. - 테스트: 짧은 단위 테스트에서 이벤트 루프를 돌릴 때, 외부에서
stop()을 걸 타이밍을 맞추기 어렵다면work_guard로 기대 수명을 제어합니다.
graceful shutdown 구현
정상 종료는 보통 다음 순서를 밟습니다.
- 새 연결 막기:
acceptor.close()또는cancel()로 더 이상 accept하지 않음. - 기존 연결 정리: 세션에 “종료 중” 플래그를 세우고, 읽기/쓰기 완료 후 소켓
close. work_guard해제: 더 이상 “영구 작업”이 없다고 표시.io_context::stop()(필요 시): 대기 중인run()을 깨워 남은 핸들러를 처리한 뒤 빠져나가게 할 수 있음. 재시작하려면 이후restart()후 다시run().- 워커 스레드 조인:
run()을 돌린 스레드들이 반환될 때까지 대기.
// 개념 스케치: work_guard + stop으로 종료 유도
boost::asio::io_context io;
auto work = boost::asio::make_work_guard(io);
std::thread t([&]{ io.run(); });
// ... 종료 시그널 수신 후 ...
work.reset(); // 또는 스코프 종료
io.stop(); // run()이 반환되도록
t.join();
운영 환경에서는 시그널 핸들러나 관리 API에서 위 순서를 한곳에 모아 두는 것이 안전합니다. stop() 직후 아직 큐에 남은 핸들러가 있을 수 있으므로, “즉시 프로세스 exit”보다 짧은 드레인 정책을 문서화해 두면 좋습니다.
8. 성능 최적화
poll vs run 선택 기준 (다시 정리)
run(): “할 일이 없을 때 OS 이벤트 대기까지 포함” — 서버 메인 루프, 별도 스레드에서 이벤트 루프를 돌릴 때 기본값에 가깝습니다. CPU를 쓰지 않고 블로킹으로 대기합니다.poll(): 한 틱만 처리하고 즉시 반환 — 게임 루프·UI 스레드처럼 “내 루프 안에서 Asio를 한 번씩만 돌려야 할 때”. 빈 루프에서poll()만 연속 호출하면 CPU를 불필요하게 쓸 수 있어, 보통 sleep/steady_timer와 조합하거나 다른 이벤트 소스와 통합합니다.run_one()/poll_one(): 한 개의 핸들러만 처리 — 단계별 디버깅, 테스트, “이번 턴에 하나만” 같은 제어에 사용.
규칙: “백그라운드 스레드가 전담으로 I/O를 처리한다” → run(); “메인 스레드가 프레임 단위로 제어한다” → poll() 쪽을 검토.
이벤트 루프 병목 찾기
- 프로파일러로
io_context관련 스레드에서 시간이 어디로 가는지 확인합니다. 핸들러 본문이 상위에 오면 핸들러가 무겁다는 뜻입니다. - 로그에 타임스탬프를 찍어 같은 연결에서 핸들러 호출 간격이 벌어지는지 봅니다. 한 연결 처리에 수 ms 이상 걸리면 지연이 누적됩니다.
asio::buffer크기·read 루프가 작아서 시스템 콜 횟수가 과도하지 않은지 확인합니다.- **멀티스레드
run()**에서 락 경합이 심하면 strand 분리·샤딩·큐 분리를 검토합니다.
CPU 바운드 작업 처리
절대 하지 말 것: 완료 핸들러 안에서 큰 행렬 연산·압축·정규식 폭탄 등 긴 CPU 작업을 그대로 수행하는 것. 그동안 같은 io_context의 다른 I/O·타이머가 지연됩니다.
권장 패턴:
- 별도 워커 스레드 풀에서 CPU 작업을 실행하고, 끝나면
io_context::post(또는dispatch)로 결과만 io 스레드에 넘겨 소켓/상태 갱신을 하게 합니다. - C++20 이후에는 코루틴 + 실행자(executor) 로 네트워크와 계산 스레드를 분리하는 패턴도 많아졌습니다.
핵심은 “I/O 스레드는 얇게, CPU는 다른 곳에서” 입니다.
9. 디버깅 가이드
run()이 바로 끝나는 문제
- 원인: 등록된 비동기 연산이 없고,
work_guard도 없고, 완료 대기 중인 작업도 없음으로 판단되는 상태. - 조치:
async_accept/ 타이머 등을run()전에 등록했는지 확인. 서버라면make_work_guard(io)또는 지속적으로 다음async_*를 재등록하는 구조인지 확인. - 참고: 예제·버전에 따라 “pending async만 있을 때” 동작 설명이 미묘할 수 있어, 실제로는 work_guard나 연속 async 체인으로 의도를 명확히 하는 편이 안전합니다.
핸들러가 실행되지 않는 경우
io_context::stop()이 이미 호출됨 → 남은 핸들러 정책을 확인(run()재호출 전restart()필요 여부).- **잘못된 executor / 잘못된
io_context**에 연산을 걸었는지 확인(여러io_context혼용 시 흔한 실수). - 소켓이 닫힌 뒤에만 완료되는 경로에 콜백이 안 타는 경우 → 에러 코드를 로그로 남겼는지 확인.
- 예외가 핸들러 밖으로 새 나가
run()이 예외로 종료한 뒤, 재시도 없이 프로세스가 멈춘 것처럼 보이는 경우 → 상위에서 예외를 잡거나asio의 예외 처리 정책을 확인합니다.
데드락 디버깅
- 핸들러 안에서 같은 스레드의
io_context에 대해 동기적으로run()을 또 호출하는 패턴은 위험합니다(재진입·교착). - 락 순서: 콜백 A가 락1→락2, 콜백 B가 락2→락1이면 데드락 가능. strand로 순서를 고정하거나, 락 범위를 최소화하세요.
std::mutex를 길게 잡은 채 다른 비동기 연산 완료를 기다리는 형태(사실상 블로킹)도 이벤트 루프를 멈추게 만들 수 있습니다. 비동기 체인으로 풀거나, 조건을 post로 넘기세요.
스레드 덤프(각 스레드가 epoll_wait / run / 어떤 락에서 멈췄는지)를 보면 원인이 빨리 좁혀집니다.
10. 실전 서버 예제: Echo·타임아웃·연결 관리
아래는 교육용으로 읽기 쉬운 형태의 비동기 Echo 서버 스케치입니다. 실제 제품에서는 로깅, 한도, TLS, 시그널 처리 등을 더합니다.
Echo 세션 (읽기 → 쓰기 루프)
// g++ -std=c++17 echo_server.cpp -o echo_server -lboost_system -pthread
#include <boost/asio.hpp>
#include <iostream>
#include <memory>
#include <vector>
using boost::asio::ip::tcp;
class Session : public std::enable_shared_from_this<Session> {
public:
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;
auto work = boost::asio::make_work_guard(io);
Server srv(io, 4000);
// work는 서버 수명 동안 유지; 종료 시 reset + io.stop() 패턴으로 확장
io.run();
} catch (const std::exception& e) {
std::cerr << e.what() << "\n";
}
return 0;
}
연결 관리: Session을 shared_ptr로 보관하고, 소켓이 끊기면 핸들러가 반환되며 참조 카운트가 0이 되어 세션이 정리됩니다. **명시적 std::set<std::shared_ptr<Session>>에 넣어 “활성 연결 목록”을 유지하면, 종료 시 전부 close**하는 식으로 graceful shutdown과도 연결할 수 있습니다.
타임아웃 처리
읽기/쓰기 자체에 표준 타임아웃 API가 없다고 생각하면 구현이 단순해집니다. 관용적 방법은 다음과 같습니다.
- **
steady_timer**를 두고, 마지막 활동 시각을 갱신하며, 타이머 콜백에서 **idle 시간 초과 시socket.cancel()또는close()**로 세션을 끊습니다. - 두 개의 비동기 대기(읽기 vs 타이머)가 경쟁할 수 있으므로, 세션 종료 플래그로 한쪽이 이기면 다른 쪽은 무시하도록 합니다.
// 개념: idle 30초면 소켓 취소 (실제 코드는 strand/플래그로 경쟁 조건 방지)
boost::asio::steady_timer idle_timer(io);
idle_timer.expires_after(std::chrono::seconds(30));
idle_timer.async_wait([&socket](auto ec) {
if (!ec) socket.cancel();
});
연결 수·리소스 한도
listen백로그와 동시 연결 수는 별개입니다. **너무 많은Session**은 메모리·파일 디스크립터 한도에 걸립니다.- OS **
ulimit -n**과 애플리케이션std::set크기·카운터를 함께 모니터링하세요.
이 예제는 단일 스레드 io_context 기준입니다. 멀티스레드로 올릴 때는 acceptor/세션 접근을 strand 등으로 정리해야 합니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. run()과 poll()의 차이, Proactor 패턴의 이해, work_guard로 이벤트 루프가 꺼지지 않게 유지하는 방법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
다음 글: [C++ 고성능 네트워크 가이드 #2] 멀티스레드 Asio의 딜레마: Data Race와 Mutex의 한계
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ Boost.Asio 입문 | io_context·async_read
- C++ 비동기 I/O 이벤트 루프 완벽 가이드 | Asio run·post
- C++ 고성능 네트워크 가이드 시리즈 목차 | Boost.Asio·이벤트 루프·코루틴
관련 글
- C++ Boost.Asio 입문 | io_context·async_read
- C++ 비동기 I/O 이벤트 루프 완벽 가이드 | Asio run·post
- C++ 멀티스레드 네트워크 서버 완벽 가이드 | io_context 풀·strand·data race 방지
- C++ Redis 클론 | Modern C++ 인메모리 KV 스토어 [#48-1]
- C++ 멀티스레드 Asio의 딜레마 | Data Race와 Mutex의 한계 [#2]