I/O 멀티플렉싱
select·poll·epoll이 여러 소켓의 준비 상태를 한 대기 루프에서 감시하는 방식과 이벤트 루프 구조를 이해합니다.
이전 절에서 TCP 다중 클라이언트 처리를 위해 fork나 스레드를 사용했습니다.
단순한 blocking-per-connection 모델로 클라이언트 10,000개를 처리하려면 연결 수에 가까운 실행 단위가 필요해집니다.
각 스레드는 메모리를 소비하고, 컨텍스트 스위칭 비용이 누적됩니다.
이것이 바로 C10K 문제(10,000개의 동시 연결을 처리하는 문제)의 핵심입니다.
Blocking I/O의 한계
기본적으로 recv()는 블로킹(blocking) 호출입니다.
데이터가 도착할 때까지 스레드가 멈추고 기다립니다.
데이터가 없는 연결마다 스레드를 멈춰 세우는 대신, 커널이 준비된 fd만 알려주면 짧게 처리한다.
- 1 register
register 관심 fd 등록 read/write readiness 지정
- 2 wait
wait 커널 이벤트 대기 준비될 때까지 루프가 잠듦
- 3 dispatch
dispatch ready fd 처리 handler는 짧게 실행
- 4 re-arm
re-arm 관심 이벤트 갱신 남은 작업을 다시 대기
해결 방법은 I/O 멀티플렉싱입니다.
하나의 대기 루프가 여러 소켓을 동시에 감시하면서, 데이터가 준비된 소켓만 골라서 처리합니다.
실제 서버에서는 이 대기 루프와 함께 non-blocking I/O, worker thread pool, backpressure 같은 장치를 조합합니다.
select
select()는 가장 오래된 I/O 멀티플렉싱 시스템 콜입니다.
감시할 소켓들의 집합을 등록하고, 그 중 하나 이상이 준비될 때까지 대기합니다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/select.h>
int main() {
int server_fd, max_fd, client_fds[FD_SETSIZE];
fd_set read_fds;
struct sockaddr_in addr;
char buffer[1024];
int num_clients = 0;
server_fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(8080);
bind(server_fd, (struct sockaddr *)&addr, sizeof(addr));
listen(server_fd, 128);
while (1) {
FD_ZERO(&read_fds);
FD_SET(server_fd, &read_fds);
max_fd = server_fd;
for (int i = 0; i < num_clients; i++) {
FD_SET(client_fds[i], &read_fds);
if (client_fds[i] > max_fd) max_fd = client_fds[i];
}
select(max_fd + 1, &read_fds, NULL, NULL, NULL);
// 새 연결 수락
if (FD_ISSET(server_fd, &read_fds)) {
int client_fd = accept(server_fd, NULL, NULL);
if (client_fd >= 0) {
client_fds[num_clients++] = client_fd;
}
}
// 기존 클라이언트 처리
for (int i = 0; i < num_clients; i++) {
if (FD_ISSET(client_fds[i], &read_fds)) {
ssize_t n = recv(client_fds[i], buffer, sizeof(buffer) - 1, 0);
if (n <= 0) {
close(client_fds[i]);
client_fds[i] = client_fds[--num_clients];
i--;
} else {
buffer[n] = '\0';
send(client_fds[i], buffer, n, 0);
}
}
}
}
}하지만 select()에는 한계가 있습니다.
매 호출마다 전체 fd_set을 커널에 복사하고, 커널이 모든 소켓을 순회하며 검사합니다.
FD_SETSIZE(보통 1024) 제한도 있습니다.
poll
poll()은 select()의 FD_SETSIZE 제한을 제거한 개선 버전입니다.
배열 기반이므로 감시할 소켓 수에 제한이 없습니다.
struct pollfd fds[MAX_CLIENTS];
fds[0].fd = server_fd;
fds[0].events = POLLIN;
int nfds = 1;
int ret = poll(fds, nfds, -1); // -1은 무한 대기
for (int i = 0; i < nfds; i++) {
if (fds[i].revents & POLLIN) {
// 이 소켓에서 읽을 데이터가 있음
}
}select()보다 인터페이스가 깔끔하고 FD_SETSIZE 같은 고정 비트셋 제한은 없지만, 매 호출마다 관심 fd 배열을 커널에 전달하고 준비 여부를 순회하는 문제는 동일합니다.
epoll (Linux)
select/poll처럼 매번 전체 fd 배열을 넘기지 않고, 등록된 관심 이벤트 중 준비된 항목만 받는다.
- 1 epoll_create
epoll_create 감시 객체 생성 커널 쪽 registry 준비
- 2 epoll_ctl ADD
epoll_ctl ADD fd와 관심 이벤트 등록 read/write/edge 조건 저장
- 3 epoll_wait
epoll_wait ready list 수신 준비된 이벤트만 사용자 공간으로
- 4 handler
handler 짧게 처리 버퍼를 비우거나 queue 갱신
- 5 MOD/DEL
MOD/DEL 관심 변경 또는 제거 수명 종료 fd 정리
epoll은 Linux에서 대규모 동시 연결을 처리하기 위해 만들어진 시스템 콜입니다.
관심 fd를 한 번 등록해 커널 안에 유지하고, 대기 시에는 준비된 이벤트 목록을 돌려받기 때문에 select()와 poll()의 반복 복사·전체 순회 비용을 크게 줄입니다.
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = server_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);
struct epoll_event events[MAX_EVENTS];
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
// events[i].data.fd에서 이벤트 발생
}| 항목 | select | poll | epoll | kqueue |
|---|---|---|---|---|
| 플랫폼 | 모든 OS | 모든 OS | Linux | macOS/BSD |
| FD 제한 | 1024 | 없음 | 없음 | 없음 |
| 성능 | O(n) | O(n) | 준비 이벤트 중심 | 준비 이벤트 중심 |
| 등록 | 매번 복사 | 매번 복사 | 한 번 등록 | 한 번 등록 |
이벤트 루프의 원형
Node.js, Nginx, Redis의 공통 원형은 fd를 등록하고, 커널 이벤트를 기다리고, 짧은 handler만 실행하는 반복이다.
- 1 Register
Register fd + callback server/client socket을 관심 목록에 등록
- 2 Wait
Wait kernel event 준비될 때까지 루프가 잠든다
- 3 Dispatch
Dispatch short handler 가능한 만큼 읽고 쓰고 오래 막지 않는다
- 4 Re-arm
Re-arm queue/timer 남은 작업은 worker, timer, 다음 이벤트로 넘김
epoll(또는 kqueue)을 감싸는 무한 루프가 바로 이벤트 루프입니다.
Node.js, Nginx, Redis 같은 고성능 서버의 핵심 구조입니다.
import selectors
import socket
sel = selectors.DefaultSelector()
def accept(server):
client, addr = server.accept()
client.setblocking(False)
sel.register(client, selectors.EVENT_READ, data=echo)
def echo(client):
data = client.recv(1024)
if data:
client.sendall(data)
else:
sel.unregister(client)
client.close()
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("0.0.0.0", 8080))
server.listen(128)
server.setblocking(False)
sel.register(server, selectors.EVENT_READ, data=accept)
# 이벤트 루프
while True:
events = sel.select()
for key, mask in events:
callback = key.data
callback(key.fileobj)Python의 selectors 모듈은 플랫폼에 따라 자동으로 최적의 메커니즘을 선택합니다.
Linux에서는 epoll, macOS에서는 kqueue, Windows에서는 select를 사용합니다.
서버/프레임워크 I/O 메커니즘 사용 언어
──────────────────────────────────────────────────
Nginx epoll/kqueue C
Node.js libuv(epoll/IOCP) JavaScript
Redis epoll C
Tornado epoll/kqueue Python
asyncio epoll/kqueue Python
Netty epoll/NIO Java
Node.js의 이벤트 루프:
libuv라는 라이브러리가 epoll/kqueue/IOCP를 추상화
JavaScript 코드가 콜백을 등록
app.get("/", handler)에서 handler가 콜백
요청이 들어오면 이벤트 루프가 handler 호출결국 네트워크 프로그래밍의 핵심은 기다리는 방법입니다.
블로킹으로 기다리면 스레드가 낭비되고, 이벤트 루프로 기다리면 적은 수의 스레드로 수많은 연결의 준비 상태를 효율적으로 감시할 수 있습니다.
이벤트가 왔다는 사실과 애플리케이션 frame이 완성됐다는 사실을 분리해야 한다.
- Read ready 읽
바이트나 EOF가 있음 메시지 1개가 완성됐다는 보장 아님
- Write ready 송신 버퍼에 일부
쓸 수 있음 전체 payload가 즉시 전송된다는 뜻 아님
- Hangup/Error 연결 종료나 reset 감지
원인 분류 없이 재시도하면 안 됨
- Backpressure output queue
커짐 계속 읽어도 안전하다는 뜻 아님
I/O 멀티플렉싱에서는 소켓 상태, 송수신 경계, 오류 반환, 재시도 조건을 확인합니다.
멀티플렉싱 서버는 모든 소켓을 계속 읽는 것이 아니라, 관심 이벤트를 등록하고 커널이 준비됐다고 알려준 소켓만 처리합니다. 처리 뒤에는 관심 상태를 다시 정합니다.
- 관심 이벤트 등록
register 관심 이벤트 등록 읽기, 쓰기, 종료 이벤트를 fd와 함께 등록합니다.
- 준비될 때까지 대기
wait 준비될 때까지 대기 select/poll/epoll_wait가 준비된 fd를 알려줍니다.
- 가능한 만큼 처리
handle 가능한 만큼 처리 읽기/쓰기/accept를 수행하고 EAGAIN과 종료를 구분합니다.
- 관심 이벤트 갱신
update 관심 이벤트 갱신 쓸 데이터가 생기면 write 관심을 켜고, 끝나면 끕니다.
- 서버 fd가 준비되면 가능한 연결을 모두 수락한다
accept loop 서버 fd가 준비되면 가능한 연결을 모두 수락한다 대기 큐가 빌 때까지 accept하고 EAGAIN에서 멈춥니다.
- edge-triggered에서는 버퍼를 비울 때까지 읽는다
read loop edge-triggered에서는 버퍼를 비울 때까지 읽는다 한 번만 읽고 돌아가면 남은 데이터 알림을 놓칠 수 있습니다.
- 쓸 데이터가 있을 때만 쓰기 이벤트를 켠다
write interest 쓸 데이터가 있을 때만 쓰기 이벤트를 켠다 항상 켜 두면 준비 알림이 과도하게 반복됩니다.
다음 장에서는 HTTP 프로토콜의 진화 — HTTP/2, HTTP/3, WebSocket — 을 다루겠습니다.