I/O 멀티플렉싱
select·poll·epoll이 여러 소켓의 준비 상태를 한 대기 루프에서 감시하는 방식과 이벤트 루프 구조를 이해합니다.
이전 절에서 TCP 다중 클라이언트 처리를 위해 fork나 스레드를 사용했습니다.
단순한 blocking-per-connection 모델로 클라이언트 10,000개를 처리하려면 연결 수에 가까운 실행 단위가 필요해집니다.
각 스레드는 메모리를 소비하고, 컨텍스트 스위칭 비용이 누적됩니다.
이것이 바로 C10K 문제(10,000개의 동시 연결을 처리하는 문제)의 핵심입니다.
Blocking I/O의 한계
기본적으로 recv()는 블로킹(blocking) 호출입니다.
블로킹 소켓에서 읽을 데이터가 없으면 보통 대기하며, EOF·오류·timeout으로도 호출이 끝날 수 있습니다.
해결 방법은 I/O 멀티플렉싱입니다.
하나의 대기 루프가 여러 소켓을 동시에 감시하면서, 데이터가 준비된 소켓만 골라서 처리합니다.
실제 서버에서는 이 대기 루프와 함께 non-blocking I/O, worker thread pool, backpressure 같은 장치를 조합합니다.
select
select()는 가장 오래된 I/O 멀티플렉싱 시스템 콜입니다.
호출마다 감시할 소켓 집합을 넘기고 준비 상태를 기다립니다. 다음 C 코드는 읽기 집합을 다루는 예시입니다. fd 범위는 검사하지만 초기화 실패 처리 일부, 비블로킹 설정, 부분 송신·SIGPIPE·쓰기 준비 처리는 생략되어 있습니다. 읽기 준비만 확인한 뒤 send한다고 전체 응답이 처리되는 것은 아닙니다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <errno.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);
if (server_fd < 0 || server_fd >= FD_SETSIZE) {
fprintf(stderr, "server fd outside select range\n");
if (server_fd >= 0) close(server_fd);
return EXIT_FAILURE;
}
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];
}
int ready = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
if (ready < 0) {
if (errno == EINTR) continue;
perror("select failed");
return EXIT_FAILURE;
}
// 새 연결 수락
if (FD_ISSET(server_fd, &read_fds)) {
int client_fd = accept(server_fd, NULL, NULL);
if (client_fd >= 0) {
if (client_fd >= FD_SETSIZE || num_clients >= FD_SETSIZE) {
close(client_fd);
} else {
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 제한을 제거한 개선 버전입니다.
고정 fd_set의 번호 제한은 없지만, 프로세스의 파일 디스크립터 한도와 메모리·배열 크기 등의 제한은 남습니다.
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)
epoll은 Linux에서 대규모 동시 연결을 처리하기 위해 만들어진 시스템 콜입니다.
관심 fd를 커널에 등록해 유지하고 대기 시 준비된 이벤트 목록을 돌려받습니다. 관심 상태가 바뀌거나 소켓을 닫을 때는 MOD·DEL 등으로 갱신합니다. 모든 부하에서 전체 처리 비용이 일정하다는 뜻은 아닙니다.
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에서 이벤트 발생
}I/O 감시 집합을 관리하는 방식
| API | 관심 집합과 반환 | 환경과 한계 |
|---|---|---|
| select | 호출마다 fd_set을 넘기고 반환된 집합에서 준비 fd를 찾습니다. | POSIX와 Windows Winsock 등에서 제공하지만 세부 규칙은 다릅니다. Linux fd_set은 FD_SETSIZE 미만 번호만 허용합니다. |
| poll | 호출마다 fd 배열을 넘기고 항목별 반환 이벤트를 찾습니다. | POSIX 계열 API입니다. 고정 비트셋 제한은 없지만 OS fd 한도·메모리·배열 크기는 제한됩니다. |
| epoll | 커널에 관심을 유지하고 준비 이벤트 목록을 받습니다. | Linux API입니다. ADD·MOD·DEL로 갱신하며 등록/처리 비용과 리소스 한도는 남습니다. |
| kqueue | 필터별 관심을 등록·변경하고 발생 이벤트를 받습니다. | BSD·macOS API입니다. 준비 이벤트 중심이며 필터 의미와 자원 한도를 확인해야 합니다. |
- API: select
- 관심 집합과 반환: 호출마다 fd_set을 넘기고 반환된 집합에서 준비 fd를 찾습니다.
- 환경과 한계: POSIX와 Windows Winsock 등에서 제공하지만 세부 규칙은 다릅니다. Linux fd_set은 FD_SETSIZE 미만 번호만 허용합니다.
- API: poll
- 관심 집합과 반환: 호출마다 fd 배열을 넘기고 항목별 반환 이벤트를 찾습니다.
- 환경과 한계: POSIX 계열 API입니다. 고정 비트셋 제한은 없지만 OS fd 한도·메모리·배열 크기는 제한됩니다.
- API: epoll
- 관심 집합과 반환: 커널에 관심을 유지하고 준비 이벤트 목록을 받습니다.
- 환경과 한계: Linux API입니다. ADD·MOD·DEL로 갱신하며 등록/처리 비용과 리소스 한도는 남습니다.
- API: kqueue
- 관심 집합과 반환: 필터별 관심을 등록·변경하고 발생 이벤트를 받습니다.
- 환경과 한계: BSD·macOS API입니다. 준비 이벤트 중심이며 필터 의미와 자원 한도를 확인해야 합니다.
이벤트 루프의 원형
이벤트 루프는 I/O 준비, 완료 통지, 타이머와 콜백 등을 반복해서 처리하는 구조입니다. epoll이나 kqueue는 그 대기 수단의 예이며 이벤트 루프 자체와 같은 API는 아닙니다.
Node.js, Nginx, Redis 같은 고성능 서버의 핵심 구조입니다.
다음은 callback 등록과 dispatch를 보여 주는 축약 예제입니다. non-blocking 소켓의 recv·accept는 BlockingIOError를 낼 수 있고 sendall도 일부를 보낸 뒤 실패할 수 있습니다. 현재 코드는 이 경우의 출력 큐·쓰기 이벤트·오류 정리를 구현하지 않아 완전한 에코 서버로 사용할 수 없습니다.
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)읽기와 쓰기 준비를 나누는 이벤트 루프
원문의 짧은 callback 예제에 추가로 필요한 송신 경로입니다. 빈 출력 큐는 쓰기 관심을 끄고, BlockingIOError는 다음 준비 이벤트를 기다립니다. EOF·오류·큐 크기 제한은 별도 분기로 처리합니다.
Python의 DefaultSelector는 플랫폼에서 지원하는 구현 중 우선순위가 높은 메커니즘을 선택합니다. 실제 작업의 성능 최적화를 자동으로 보장하지는 않습니다.
Linux에서는 epoll, macOS에서는 kqueue, Windows에서는 select를 사용합니다.
플랫폼과 빌드에 따라 달라지는 대표 구성
Nginx (C): epoll / kqueue
Node.js (JavaScript): libuv가 epoll / kqueue / IOCP 추상화
Redis (C): Linux에서는 epoll
Tornado (Python): 이벤트 루프의 플랫폼별 대기 구현 사용
asyncio (Python): Unix는 epoll / kqueue 등
Windows 기본: ProactorEventLoop / IOCP
Netty (Java): epoll 전송 구현 / NIO 등
Node.js의 이벤트 루프:
libuv가 플랫폼별 I/O를 처리
JavaScript 코드가 콜백을 등록
app.get("/", handler)는 Express의 경로 핸들러 등록 예
요청은 HTTP 처리와 라우팅을 거쳐 handler에 전달결국 네트워크 프로그래밍의 핵심은 기다리는 방법입니다.
이벤트 루프는 많은 대기 연결을 적은 실행 단위로 감시할 수 있습니다. 콜백에서 긴 CPU 작업이나 블로킹 호출을 수행하면 다른 연결 처리가 지연되므로 작업 분리와 출력 큐 제한도 필요합니다.
Linux epoll의 edge-triggered 모드에서는 논블로킹 처리를 진행해 EAGAIN 등으로 준비 상태가 소진되었음을 확인해야 합니다. 이 원문의 epoll 예시는 기본 level-triggered 등록입니다.
다음 장에서는 HTTP 프로토콜의 진화 — HTTP/2, HTTP/3, WebSocket — 을 다루겠습니다.