Windows·Linux·macOS는 각자 고성능 비동기 소켓 API를 갖고 있다. Windows는 IOCP(I/O Completion Ports), Linux는 epollio_uring, macOS와 BSD는 kqueue다. 흔히 “IOCP가 빠르다 / epoll이 낫다”는 식으로 비교하지만, 실제로 만져 보면 성능 등급이 아니라 설계 모델이 다르다. 그리고 그 모델은 OS로 깔끔히 갈리지도 않는다 — 리눅스 하나가 epoll과 io_uring으로 두 모델을 다 갖고 있다. 우열을 매기는 것보다 “무엇이 어떻게 다르고, 각각 어떻게 쓰는가”를 아는 편이 실질적이다. 네 API를 같은 일(들어온 바이트를 그대로 돌려주는 echo 서버)로 각각 최소 구현해, 모델(reactor·proactor)로 묶어 본다.

두 모델 — 준비 통지(reactor)와 완료 통지(proactor)

핵심 갈림길은 하나다. I/O를 누가 하느냐.

  • 준비 통지(reactor) — epoll, kqueue. 커널은 “이 소켓이 이제 읽을 수 있다”고 준비 상태만 알려준다. 실제 read()/write()애플리케이션이 직접 한다.
  • 완료 통지(proactor) — IOCP, io_uring. 애플리케이션은 “이 소켓을 읽어 달라”고 작업 자체를 커널에 맡긴다. 커널이 읽기를 수행하고, 다 되면 “끝났다, 데이터는 네 버퍼에 넣어 뒀다”고 완료를 알린다.

같은 echo 루프인데 코드 구조가 갈리는 지점이 바로 여기다.

sequenceDiagram
    participant A as 앱
    participant K as 커널
    Note over A,K: 준비 통지 (epoll / kqueue)
    A->>K: 관심 등록 "이 소켓 읽기 준비되면 알려줘"
    K-->>A: "준비됨" (epoll_wait / kevent 반환)
    A->>K: read() — 앱이 직접 읽는다
    K-->>A: 데이터
sequenceDiagram
    participant A as 앱
    participant K as 커널
    Note over A,K: 완료 통지 (IOCP)
    A->>K: WSARecv "읽어 줘" (OVERLAPPED 첨부)
    Note over K: 커널이 실제 읽기를 수행
    K-->>A: "다 읽었음" (GetQueuedCompletionStatus 반환) + 데이터는 이미 버퍼에

reactor는 “준비됐으니 네가 읽어라”, proactor는 “내가 읽어 놨으니 가져가라”다. epoll·kqueue가 reactor, IOCP·io_uring이 proactor다 — OS로 갈리는 게 아니라(리눅스는 양쪽을 다 갖췄다) 모델로 갈린다. 네 API를 한눈에 정리하면 이렇다.

APIOS모델등록·제출대기·수신실제 I/O는멀티스레드 방식
epollLinuxreactorepoll_ctlepoll_wait스레드마다 epoll + SO_REUSEPORT
kqueuemacOS·BSDreactorkevent(changelist)kevent(eventlist)위와 유사
IOCPWindowsproactorWSARecv/WSASendGetQueuedCompletionStatus커널완료 포트에 워커 스레드 N개
io_uringLinuxproactorio_uring_prep_* + submitio_uring_wait_cqe커널링마다 스레드 / SQPOLL

reactor 둘(등록 호출과 대기 호출로 준비를 받고 앱이 I/O)과 proactor 둘(작업을 제출하고 완료를 받고 커널이 I/O)이 각각 형제다. 아래는 이 두 갈래를 차례로 판다 — 먼저 reactor(epoll·kqueue), 그다음 proactor(IOCP·io_uring).

준비 통지(reactor) — 커널은 알리고, 앱이 한다

epoll (Linux)

관심 있는 fd를 epoll_ctl로 등록하고, epoll_wait준비된 fd만 받아, 그 fd를 직접 읽는다.

int ep = epoll_create1(0);
epoll_event ev{};
ev.events = EPOLLIN;           // "읽을 준비" 관심 등록
ev.data.fd = lst;
epoll_ctl(ep, EPOLL_CTL_ADD, lst, &ev);
 
for (;;) {
  int n = epoll_wait(ep, evs, 64, -1);   // 준비된 fd만 돌려준다
  for (int i = 0; i < n; i++) {
    int fd = evs[i].data.fd;
    if (fd == lst) {                      // 리스닝 소켓 준비 = 새 연결
      int c = accept(lst, nullptr, nullptr);
      set_nonblock(c);
      epoll_event ce{}; ce.events = EPOLLIN; ce.data.fd = c;
      epoll_ctl(ep, EPOLL_CTL_ADD, c, &ce);
    } else {
      ssize_t r = read(fd, buf, sizeof(buf));   // 앱이 직접 읽는다
      if (r <= 0) { epoll_ctl(ep, EPOLL_CTL_DEL, fd, nullptr); close(fd); }
      else        write(fd, buf, r);            // echo
    }
  }
}

읽기가 epoll_wait 다음에 온다는 점이 reactor의 지문이다. 커널은 “준비됐다”까지만 하고, 빠졌다. 검증(WSL): hello epoll (reactor)를 보내면 그대로 돌아온다(echo 일치 확인).

kqueue (macOS / BSD) — 더 일반적인 인터페이스

kqueue도 준비 통지다. 구조는 epoll과 같다 — 관심 등록(EV_SET+kevent) → 준비된 것만 통지 (kevent) → 앱이 직접 read/write. 이름만 바뀐다.

int kq = kqueue();
struct kevent ev;
EV_SET(&ev, lst, EVFILT_READ, EV_ADD, 0, 0, NULL);   // "읽기 준비" 관심 등록
kevent(kq, &ev, 1, NULL, 0, NULL);
 
for (;;) {
  int n = kevent(kq, NULL, 0, evs, 64, NULL);         // 준비된 이벤트만
  for (int i = 0; i < n; i++) {
    int fd = (int)evs[i].ident;
    if (fd == lst) {
      int c = accept(lst, NULL, NULL);
      set_nonblock(c);
      struct kevent ce; EV_SET(&ce, c, EVFILT_READ, EV_ADD, 0, 0, NULL);
      kevent(kq, &ce, 1, NULL, 0, NULL);
    } else {
      ssize_t r = read(fd, buf, sizeof(buf));          // 앱이 직접 읽는다
      if (r <= 0) { struct kevent de; EV_SET(&de, fd, EVFILT_READ, EV_DELETE, 0, 0, NULL);
                    kevent(kq, &de, 1, NULL, 0, NULL); close(fd); }
      else        write(fd, buf, r);                    // echo
    }
  }
}

epoll과 다른 점은 성능 모델이 아니라 범용성이다. epoll이 fd(주로 소켓) 준비를 보는 데 특화된 반면, kqueue는 EVFILT_READ 말고도 EVFILT_TIMER(타이머)·EVFILT_SIGNAL(시그널)·EVFILT_PROC(프로세스 종료)·EVFILT_VNODE(파일 변경)을 하나의 kevent 로 함께 기다린다. 소켓·타이머·시그널을 한 이벤트 루프에 모으기가 epoll보다 자연스럽다. 그래도 소켓 I/O만 보면 형태는 epoll과 판박이다. 검증(macOS 26.5.2, Apple clang 21.0.0, arm64): hello kqueue (reactor) echo 일치 확인.

등록과 대기가 한 호출이다

시그니처를 보면 kqueue의 진짜 구조적 차이가 드러난다.

int kevent(int kq,
           const struct kevent *changelist, int nchanges,   // 등록/해제 — 들어가는 것
           struct kevent       *eventlist,  int nevents,    // 준비된 이벤트 — 나오는 것
           const struct timespec *timeout);

한 함수가 등록과 대기를 겸한다. epoll은 epoll_ctl(등록)과 epoll_wait(대기)가 별개 시스템 콜이라, 새 연결 100개를 등록하려면 epoll_ctl을 100번 부르고 그다음 epoll_wait를 부른다. kqueue는 그 100개를 배열에 모아 뒀다가 다음 대기 호출에 실어 한 번에 보낼 수 있다. 위 샘플은 이해를 돕느라 등록과 대기를 따로 불렀지만, 이렇게 합칠 수 있다.

std::vector<struct kevent> changes;      // 쌓아 두는 변경분
for (;;) {
  // 한 호출로: 쌓인 등록/해제를 반영 + 준비된 이벤트 수신
  int n = kevent(kq, changes.data(), (int)changes.size(), evs, 64, NULL);
  changes.clear();
  for (int i = 0; i < n; i++) {
    int fd = (int)evs[i].ident;
    if (fd == lst) {
      int c;
      while ((c = accept(lst, NULL, NULL)) >= 0) {
        set_nonblock(c);
        struct kevent ce; EV_SET(&ce, c, EVFILT_READ, EV_ADD, 0, 0, NULL);
        changes.push_back(ce);           // 지금 등록하지 않고 다음 회차에 실어 보낸다
      }
    } else { /* read/write */ }
  }
}

연결이 몰릴 때 시스템 콜 횟수가 “등록 N번 + 대기 1번”에서 “합쳐서 1번”으로 준다.

여기서 한 번 막혔다. 위 구조로 바꾸면서 연결 종료 처리를 원래 샘플처럼 EV_DELETEchanges에 넣고 곧바로 close(fd) 하도록 뒀더니, 첫 연결은 정상인데 두 번째 연결부터 응답이 오지 않았다. 원인은 순서다 — close()가 먼저 일어나면 그 fd의 등록은 커널에서 이미 사라진 상태이고, 다음 회차에 뒤늦게 도착한 EV_DELETE는 없는 등록을 지우려 해 changelist 처리 도중 에러가 난다.

고치는 방법은 EV_DELETE를 지우는 것이다. fd를 닫으면 kqueue 등록은 자동으로 해제된다(man page: “Calling close() on a file descriptor will remove any kevents that reference the descriptor”) — 애초에 그 삭제 호출이 불필요했다. 그렇게 바꾸니 연속 연결 5개가 모두 정상 echo됐다. 등록/해제를 즉시 반영하지 않고 모아 보내는 순간, “닫기와 해제의 순서”라는 새 책임이 생긴다는 뜻이다.

완료 통지(proactor) — 앱이 맡기고, 커널이 한다

IOCP (Windows)

여기서 코드 모양이 갈린다. 소켓을 완료 포트에 붙이고, WSARecv로 읽기를 미리 걸어 둔다(post). 그 자리에서 읽지 않는다. 커널이 읽기를 마치면 GetQueuedCompletionStatus완료를 돌려주고, 그 시점엔 데이터가 이미 버퍼에 들어와 있다.

// 읽기를 "건다" — 지금 읽는 게 아니라, 커널에 맡긴다
void post_recv(IoCtx *c) {
  c->op = IoCtx::RECV;
  ZeroMemory(&c->ov, sizeof(OVERLAPPED));
  c->wsabuf.buf = c->buf; c->wsabuf.len = sizeof(c->buf);
  DWORD flags = 0, n = 0;
  WSARecv(c->sock, &c->wsabuf, 1, &n, &flags, &c->ov, NULL);  // "읽어 줘"
}
 
// 워커: 완료를 꺼낸다 — 이 시점에 I/O는 이미 끝나 있다
for (;;) {
  GetQueuedCompletionStatus(iocp, &bytes, &key, &ov, INFINITE);
  IoCtx *c = (IoCtx *)ov;                 // ov는 IoCtx의 첫 멤버
  if (bytes == 0) { closesocket(c->sock); delete c; continue; }
  if (c->op == IoCtx::RECV) {             // 데이터는 이미 c->buf에 있다
    c->op = IoCtx::SEND; c->wsabuf.len = bytes;
    ZeroMemory(&c->ov, sizeof(OVERLAPPED));
    WSASend(c->sock, &c->wsabuf, 1, &sent, 0, &c->ov, NULL);   // echo도 "보내 줘"로 건다
  } else {
    post_recv(c);                         // 전송 완료 → 다시 읽기 건다
  }
}

read()가 이벤트 뒤에 오던 epoll과 달리, IOCP는 WSARecv먼저 가고 완료가 뒤에 온다. 버퍼 수명 관리도 달라진다 — 읽기를 걸어 둔 동안 OVERLAPPED와 버퍼가 살아 있어야 해서, 연결마다 컨텍스트 구조체(IoCtx)를 힙에 두고 그 첫 멤버를 OVERLAPPED로 삼아 완료 때 되찾는다. 이 “미리 걸어 두는 버퍼”가 proactor 특유의 부담이자 특징이다. 검증(MinGW g++ 13.2.0): hello iocp (proactor) echo 일치 확인.

io_uring (Linux) — 리눅스가 건너온 자리

앞에서 “리눅스·BSD는 reactor, Windows만 proactor”인 것처럼 말했다. 오래 그랬지만 지금은 아니다. 리눅스는 2019년(커널 5.1) io_uring으로 완료 통지 모델을 손에 넣었다. epoll과 같은 리눅스인데 모델은 IOCP 쪽이다.

io_uring은 커널과 공유하는 두 링 버퍼로 돈다 — 제출 큐(SQ)에 “이 작업 해 줘”를 넣고, 완료 큐(CQ)에서 “다 했다”를 꺼낸다. 코드 흐름이 IOCP와 판박이다.

// 읽기를 "제출한다" — 지금 읽는 게 아니라 커널에 맡긴다
io_uring_prep_recv(sqe, fd, r->buf, BUFSZ, 0);
io_uring_sqe_set_data(sqe, r);
io_uring_submit(&ring);
 
// 완료를 꺼낸다 — 이 시점에 I/O는 이미 끝나 있다
io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
int res = cqe->res;                // res = 읽은 바이트 수, 데이터는 이미 buf에
if (res > 0)                       // 그대로 echo도 "제출"
  io_uring_prep_send(sqe2, fd, r->buf, res, 0);

io_uring_prep_recv가 IOCP의 WSARecv, io_uring_wait_cqeGetQueuedCompletionStatus에 대응한다. epoll처럼 “준비됐다”를 받고 내가 읽는 게 아니라, IOCP처럼 “읽어 놨다”를 받는다. 한 발 더 나간 점은 여러 작업을 SQ에 쌓아 한 번의 io_uring_submit으로 제출할 수 있다는 것 — kqueue의 “등록+대기 한 호출”을 배치 제출로 더 밀어붙인 형태라, 연결이 몰릴 때 시스템 콜을 크게 줄인다. 검증(WSL 커널 6.18, liburing 2.1): hello io_uring (proactor on linux) echo 일치 확인.

이걸로 “OS가 모델을 정한다”는 깔끔한 대응은 깨진다. 리눅스는 epoll(reactor)과 io_uring(proactor)을 둘 다 가진 유일한 축이고, 이건 뒤 성능 측정에서 “같은 OS·같은 기계에서 모델만 바꿔” 나란히 잴 드문 기회를 준다.

성능 측정 결과 — 각 측정엔 장비가 붙는다

각 API가 부하를 어떻게 견디는지 직접 쟀다. 다만 하나의 수치는 그것을 낸 장비·환경과 떼면 무의미하다. 그래서 모든 측정에 하드웨어·OS·컴파일러를 함께 적는다. 이 라벨이 왜 반드시 붙어야 하는지가 곧 이 절의 결론이다.

공통 측정 방법. 측정 대부분은 같은 노트북 한 대에서 쟀다(kqueue만 예외 — 아래 측정 C). 최소 echo 서버에 부하 클라이언트가 64바이트를 보내고 그대로 돌려받는 핑퐁을 5초간 반복해 초당 왕복 수(req/s)를 센다. 클라이언트·서버가 같은 기계에서 CPU를 나눠 쓰는 루프백이라, 이 수치는 “서버 단독 처리량”이 아니라 루프백 왕복 처리율이다. 양쪽 모두 TCP_NODELAY를 켰다. 측정용 서버는 위 샘플에 NODELAY와 (IOCP는) 워커 수 인자만 더한 변형이다.

호스트: Intel Core i9-13900HX (24코어[8P+16E] / 32스레드), 32 GB RAM, Windows 11 (빌드 26200).

측정 A — epoll (Linux)

환경: WSL2(커널 6.18) — 위 호스트 위의 경량 VM, g++ 11.4, 단일 스레드 서버.

동시 연결req/s
136,394
5097,367
20096,887

단일 스레드라 연결을 50→200으로 늘려도 ~97k에서 평평하다. 한 코어가 한계다.

측정 B — IOCP (Windows)

환경: 네이티브 Windows 11(빌드 26200), MinGW g++ 13.2.0. 200연결로 고정하고 IOCP 워커 스레드 수만 바꿨다.

IOCP 워커req/s배수
110,0261.0×
218,2711.8×
436,0113.6×
847,2294.7×

코드도 API도 그대로다. 워커 수라는 설정 하나로 10k에서 47k까지 4.7배 움직였다.

측정 C — kqueue (macOS) · 여기서 장비가 바뀐다

앞의 A·B는 같은 노트북에서 났지만, kqueue는 <sys/event.h>가 BSD 전용이라 그 노트북에서 아예 컴파일되지 않는다. 그래서 이 측정만 다른 기계에서 냈다.

호스트 2: Apple M1 Pro (8코어[6P+2E] / 8스레드), 32 GB RAM, macOS 26.5.2(빌드 25F84), Apple clang 21.0.0, arm64. 단일 스레드 서버.

동시 연결req/s
149,936
50136,789
200141,233

모양은 측정 A(epoll)와 닮았다 — 연결을 50에서 200으로 늘려도 단일 스레드라 평평해진다. 닮은 건 모양뿐이고 숫자끼리는 비교할 수 없다. CPU 아키텍처(arm64 vs x86-64), 코어 수(8 vs 24), OS, 컴파일러가 전부 다르다. 이 표가 말하는 건 “M1 Pro에서 단일 스레드 kqueue echo가 루프백 핑퐁으로 이 정도”까지다.

측정 D — epoll vs io_uring, 같은 리눅스에서 (환경 완전 통제)

앞의 A·B·C는 환경이 서로 달라 모델을 정면으로 비교할 수 없었다. 딱 하나, 리눅스가 reactor(epoll)와 proactor(io_uring)를 둘 다 가진 덕에 그게 가능하다 — 같은 WSL2, 같은 머신, 단일 스레드, NODELAY. OS도 루프백도 커널도 같고 오직 API 모델만 다르다.

동시 연결epoll (reactor)io_uring (proactor)
133,11136,211
5094,42293,153
20093,62091,279

사실상 같다. 포화점에서 둘 다 ~93k다(1연결에서 io_uring이 살짝 앞서지만 오차 범위). 이 글에서 유일하게 환경을 완전히 통제한 모델 대 모델 비교이고, 결론은 명확하다 — reactor냐 proactor냐는 코드 구조를 바꾸지, 적어도 이 워크로드의 처리율 등급을 만들지 않는다. (같은 실행에서 연달아 냈다. 측정 A의 epoll 97k와 미세하게 다른 건 실행마다의 편차다.)

이 숫자들로 무엇을 말할 수 있나

두 가지다. 하나는 되고, 하나는 안 된다.

할 수 있는 말 — 같은 플랫폼 안에서, 노브 하나의 효과. 측정 B가 그것이다. 같은 Windows, 같은 하드웨어, 같은 코드에서 워커 수만 바꿨더니 처리율이 4.7배 흔들렸다. 이건 “IOCP가 몇 req/s다”가 아니라 **“IOCP를 어떻게 설정했느냐가 그 숫자를 만든다”**는 뜻이다. IOCP가 epoll을 크게 앞선다던 유명한 비교 (구승모, 아래 참고)에서 “IOCP 우위”가 NIC 멀티큐(RSS) 설정 하나로 사라져 두 쪽 CPU 사용률이 사실상 같아졌던 것(epoll 7.38% vs IOCP 6.8%) — 그와 정확히 같은 구조의, 내 노트북에서 재현된 로컬판이다. 측정 D도 같은 부류다 — 환경을 완전히 고정하고 모델만 바꿔, “reactor냐 proactor냐는 처리율을 가르지 않는다”를 통제된 조건에서 보였다. 유효한 비교는 늘 이렇게 하나만 빼고 나머지를 붙들어 맬 때 나온다.

할 수 없는 말 — 측정 A와 B를 나란히 놓고 우열 매기기. epoll이 97k, IOCP가 (8워커) 47k라고 해서 “epoll이 두 배 빠르다”가 되지 않는다. 측정 A는 WSL2의 가상 루프백 위에서, B는 네이티브 Windows 루프백 위에서 났다. 커널도, 루프백 구현도, 가상화 여부도 다른 — 애초에 다른 환경이다. 그리고 이 환경 차이는 상수 하나로 나눠 없앨 수 있는 게 아니다. 설정 노브 하나(측정 B의 워커 수, 슬라이드의 RSS)가 배수를 통째로 바꾸는 마당에, 환경이 어긋난 두 수치를 “같다고 치고” 정규화한 값은 믿을 수 없다. 그래서 이 글은 OS별 절대치를 한 표에 모아 순위를 내지 않는다 — 크로스OS 우열은 이 방법으로 추론 불가이고, 그게 결함이 아니라 결론이다. 각 수치는 자기 장비 라벨을 달고 “이 환경에서 이 설정일 때 이만큼”이라고만 말한다.

측정 C가 이 점을 더 분명하게 만든다. kqueue 수치(141k)는 A·B와 아예 다른 기계, 다른 CPU 아키텍처에서 났다. 셋 중 가장 큰 숫자지만 그건 kqueue가 우수해서가 아니라 그 자리에 M1 Pro가 있었기 때문이다 — 이걸 “macOS가 제일 빠르다”로 읽으면 정확히 이 글이 경계하는 오독이다. 애초에 kqueue를 같은 노트북에서 잴 방법이 없었다(헤더가 BSD 전용이라 컴파일 자체가 안 된다). 크로스OS 비교가 어려운 게 아니라, 환경을 맞출 길이 없어서 성립하지 않는다는 사실이 여기서 드러난다.

마무리 — 넷을 하나로 감추는 asio

여기까지 네 API를 날것으로 봤다. 정작 실무에서 이 셋을 직접 만질 일은 드물다. 크로스플랫폼 라이브러리가 셋을 한 소스 뒤로 감추기 때문이다. 대표가 asio(및 Boost.Asio)다. asio로 쓴 echo는 이렇게 생겼다.

void read() {
  auto self = shared_from_this();
  // "읽어서 콜백 불러줘" — 이 한 줄이 밑에서 epoll/IOCP/kqueue 중 하나가 된다
  sock_.async_read_some(asio::buffer(buf_), [this, self](std::error_code ec, std::size_t n) {
    if (!ec) write(n); // 받은 만큼 그대로 echo
  });
}
void write(std::size_t n) {
  auto self = shared_from_this();
  asio::async_write(sock_, asio::buffer(buf_, n), [this, self](std::error_code ec, std::size_t) {
    if (!ec) read();
  });
}

이 소스 하나가 Windows에서 IOCP, Linux에서 epoll, macOS에서 kqueue로 컴파일된다. asio는 프로그래머에게 완료 통지(proactor) 인터페이스 하나만 보여주고 — async_read_some에 콜백을 건다 — 그 밑에서 OS에 맞는 백엔드를 고른다. Windows는 IOCP가 그대로 proactor라 자연스럽게 얹히고, Linux·macOS 에서는 epoll·kqueue(reactor) 위에 proactor를 흉내 내 같은 인터페이스를 준다(리눅스에서 asio를 io_uring 백엔드로 빌드하면 — ASIO_HAS_IO_URING, liburing 필요 — 흉내가 아니라 네이티브 완료 통지 위에 바로 얹힌다). 앞의 날 샘플에서 epoll_wait 뒤에 read가 오던 것과 WSARecv를 먼저 걸던 차이가, 여기선 async_read_some 한 줄로 합쳐진다. 검증: 이 코드를 WSL(epoll)·Windows(IOCP)·macOS(kqueue) 세 곳에서 각각 컴파일·echo 확인했다.

백엔드가 정말 바뀌는지는 짐작하지 말고 확인할 수 있다. asio는 고른 백엔드를 매크로로 노출한다.

#if   defined(ASIO_HAS_IOCP)     puts("IOCP");
#elif defined(ASIO_HAS_IO_URING) puts("io_uring");
#elif defined(ASIO_HAS_KQUEUE)   puts("kqueue");
#elif defined(ASIO_HAS_EPOLL)    puts("epoll");
#endif

macOS(standalone asio 1.38.2)에서 이 프로그램은 kqueue를, Windows에서는 IOCP를, WSL에서는 기본으로 epoll을 찍는다. 리눅스에서 -DASIO_HAS_IO_URING -DASIO_DISABLE_EPOLL로 빌드하면 io_uring을 찍고, strace로 봐도 io_uring_setup/io_uring_enter가 실제로 돈다 — asio는 리눅스에서 epoll뿐 아니라 io_uring 백엔드도 지원한다.

그럼 리눅스에서 asio는 epoll을 쓰나 io_uring을 쓰나? 고르는 것이고, 기본값은 epoll이다. 아무 플래그도 안 주면 안전한 기본값인 epoll이 붙고, io_uring은 위 매크로로 opt-in한다(ASIO_HAS_IO_URING 정의 + liburing 링크, 주 백엔드로 강제하려면 ASIO_DISABLE_EPOLL까지). io_uring이 새 커널(5.1+)을 요구하고 구커널·컨테이너·보안 샌드박스에서 막히는 경우가 있어, asio가 기본을 epoll로 두는 것이다.

그럼 추상화 비용은? 같은 백엔드·같은 스레드에서 날 API와 asio를 나란히 쟀다(200연결, NODELAY, 단일 스레드).

백엔드(단일 스레드)모델rawasio차이
IOCP (Windows, 호스트 1)proactor~10,600 req/s~10,800 req/s노이즈 안 (사실상 0)
io_uring (WSL2, 호스트 1)proactor~92,500 req/s~92,000 req/s노이즈 안 (사실상 0)
epoll (WSL2, 호스트 1)reactor96,887 req/s90,764 req/sasio ~6%
kqueue (macOS, 호스트 2)reactor~140,000 req/s~129,000 req/sasio ~8%

이 표는 가로로 읽는 표다. 각 행은 같은 기계·같은 백엔드에서 raw와 asio만 바꿔 잰 쌍이라 그 안에서는 비교가 성립한다. 행끼리는 앞서 말한 이유로 비교할 수 없다.

패턴이 모델로 깔끔하게 갈린다. proactor 백엔드 둘(IOCP·io_uring)에서는 asio 오버헤드가 노이즈에 묻혔고(사실상 0, 오히려 asio가 근소하게 더 높게 나오기도 했다), reactor 백엔드 둘(epoll·kqueue)에서는 6~8%로 나왔다. 이유는 구조다 — IOCP·io_uring은 그 자체가 완료 통지라 asio의 proactor 인터페이스가 거의 그대로 얹히지만, epoll·kqueue 위에서는 asio가 준비 통지를 받아 자기가 읽고 완료 콜백으로 바꿔 주는 층을 한 겹 더 지난다. 그 한 겹이 한 자릿수 %다. 어느 쪽이든 한 자릿수 %를 내고 이식성과 strand 기반 멀티스레딩을 얻는다 — 대개 남는 거래다. (다만 마이크로 echo 기준이다. 핸들러가 무겁거나 할당이 잦아지면 asio의 핸들러 메모리 재활용 같은 요소가 더 크게 작용한다.)

멀티스레드에서 겪은 것 하나. Windows(MinGW) 빌드에서는 io_context를 여러 스레드로 돌리고 연결마다 make_strand를 거는 정석 형태가 segfault로 죽어, raw↔asio 비교를 단일 스레드로 한정해야 했다. 같은 소스를 macOS(Apple clang 21, asio 1.38.2)에서 2·4·8스레드로 돌려 보니 크래시가 재현되지 않았다. 그래서 이건 asio 코드나 모델의 문제가 아니라 그 Windows 툴체인/빌드 쪽 문제로 좁혀진다 — 크로스플랫폼 라이브러리를 쓸 때 문제가 라이브러리인지 툴체인인지 가르는 가장 싼 방법이 다른 플랫폼에서 같은 소스를 한 번 돌려 보는 것이라는 실례이기도 하다.

standalone asio 버전 이정표 (이 글과 관련된 기능만). asio도 버전마다 비동기 모델·백엔드가 바뀌어 왔다. 아래는 standalone asio 기준이고(Boost.Asio는 번호 체계가 다르다), asio 자신의 history.qbk에서 확인한 것이다.

  • 1.12.0 — Networking TS 인터페이스 정리. 이 글의 샘플이 쓰는 async_read_some/async_write 형태가 이때 자리 잡았다.
  • 1.13.0 — C++ 코루틴 지원(awaitable<>·co_spawn·use_awaitable). 콜백 대신 co_await socket.async_read_some(...)로 쓸 수 있게 됐다.
  • 1.17.0 — executor 모델 재설계. 표준 executor 제안을 우선 지원하고 any_io_executor를 기본 런타임 executor로 도입.
  • 1.21.0io_uring 백엔드 추가. 기본은 꺼져 있고, 소켓 등 기존 I/O 객체에 쓰려면 ASIO_HAS_IO_URINGASIO_DISABLE_EPOLL둘 다 정의하고 -luring으로 링크한다(위에서 이 글이 쓴 조합 그대로다). ASIO_HAS_IO_URING만 주면 파일 같은 io_uring 필수 객체에만 쓰고 소켓은 그대로 epoll이다.
  • 1.30.x — 이 글이 측정에 쓴 계열(호스트1은 1.30.2, macOS는 Homebrew 1.38.2).

정리하면, epoll·kqueue·IOCP·io_uring은 “더 나은 하나”를 고르는 선택지가 아니라 비동기 I/O를 바라보는 두 방식 — reactor(“준비됐으니 네가 해라”)와 proactor(“맡기면 내가 해서 돌려주마”) — 을 각 OS가, 때로는 한 OS(리눅스)가 양쪽 다, 구현한 결과다. 성능은 API 등급이 아니라 환경과 설정이 좌우하고(측정 B의 4.7배, 측정 D의 동률), 그 방식들은 결국 asio 같은 라이브러리의 한 인터페이스 뒤로 모인다. 직접 셋을 만질 일은 그 밑을 이해하거나 마지막 한 방울을 짜낼 때다. 그때 필요한 건 “누가 빠른가”가 아니라 “각 모델이 어떤 관용구로 쓰이는가”이고, 이 글의 샘플들이 그 출발점이다.

참고