본문 바로가기

I/O 멀티플렉싱

연결마다 실행 흐름을 두는 방식의 한계에서 출발해, 스레드 하나가 준비된 소켓만 골라 처리하는 select, poll, epoll의 차이를 정리합니다. 이벤트 루프의 구조와, 한 번에 다 보내지 못한 응답을 연결마다 출력 큐에 두었다가 쓰기 준비가 될 때 이어 보내는 에코 서버를 일부러 잘게 나눈 송수신으로 확인합니다.

11장 2편에서는 연결마다 프로세스나 스레드를 두어 여러 클라이언트를 처리했습니다. 이 방식으로 연결 1만 개를 받으려면 실행 흐름도 1만 개가 필요하고, 그만큼의 메모리와 전환 비용이 듭니다. 1999년에 "한 서버로 동시 연결 1만 개를 어떻게 처리할까"라는 물음으로 정리된 이 문제를 C10K 문제라고 합니다. 이 글에서는 그 답인 I/O 멀티플렉싱, 곧 실행 흐름 하나가 여러 소켓을 함께 지켜보다가 준비된 소켓만 골라 처리하는 방법을 다룹니다.

기다리는 방법이 문제다

블로킹 소켓의 recv()는 읽을 데이터가 올 때까지 실행 흐름을 멈춥니다. 연결마다 스레드를 두는 이유는 이 멈춤 때문인데, 사실 대부분의 연결은 대부분의 시간 동안 아무 데이터도 보내지 않습니다. 1만 개의 스레드가 각자 recv()에서 잠들어 있는 셈입니다. I/O 멀티플렉싱은 기다리는 일을 하나로 모읍니다. "이 소켓들 가운데 어느 것이든 읽을 수 있게 되면 깨워 달라"고 커널에 한 번만 부탁하고, 깨어나면 준비된 소켓만 처리한 뒤 다시 기다립니다. 소켓은 논블로킹(non-blocking)으로 두어, 혹시 준비되지 않은 소켓을 건드려도 멈추지 않고 바로 돌아오게 합니다. 응용 프로그램이 I/O를 기다리는 블로킹, 논블로킹, 다중화(멀티플렉싱), 비동기 방식을 한자리에서 비교한 설명은 운영체제 교재의 I/O 처리 방식에 있습니다.

select와 poll

select()는 가장 오래된 방법입니다. 지켜볼 파일 디스크립터의 집합을 비트로 표시한 fd_set을 넘기면, 커널은 그 가운데 준비된 것만 표시를 남겨 돌려줍니다.

fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(server_fd, &read_fds);                  /* 새 연결을 기다리는 리스닝 소켓 */
int 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 && errno != EINTR) { perror("select"); exit(1); }
if (FD_ISSET(server_fd, &read_fds)) {
    int fd = accept(server_fd, NULL, NULL);
    if (fd >= FD_SETSIZE) close(fd);           /* fd_set에 넣을 수 없는 번호 */
    else if (fd >= 0) client_fds[num_clients++] = fd;
}
for (int i = 0; i < num_clients; i++) {
    if (FD_ISSET(client_fds[i], &read_fds)) {
        /* 이 연결에서 recv()를 불러도 멈추지 않는다 */
    }
}

select()에는 세 가지 약점이 있습니다. 부를 때마다 집합 전체를 커널에 복사하고, 커널과 프로그램이 모두 집합 전체를 훑어 준비된 것을 찾으므로 연결이 늘수록 느려집니다. 그리고 Linux의 fd_set은 번호가 FD_SETSIZE(1024)보다 작은 파일 디스크립터만 담을 수 있습니다. 연결 수가 아니라 디스크립터의 번호가 기준이어서, 다른 파일을 많이 열어 둔 프로세스라면 소켓이 몇 개뿐이어도 1024번 이상의 디스크립터가 나올 수 있고, 이를 FD_SET에 넣으면 메모리를 망가뜨립니다. select(2)도 새 프로그램이라면 poll이나 epoll을 쓰라고 권합니다.

poll()은 비트 집합 대신 struct pollfd의 배열을 넘겨 번호의 한도를 없앴습니다.

struct pollfd fds[MAX_CLIENTS];
fds[0].fd = server_fd;
fds[0].events = POLLIN;                        /* 읽을 수 있게 되면 알려 달라 */
int ret = poll(fds, nfds, -1);                 /* -1이면 하나라도 준비될 때까지 기다린다 */
for (int i = 0; i < nfds; i++) {
    if (fds[i].revents & POLLIN) {
        /* 준비된 소켓 */
    }
}

인터페이스는 깔끔해졌지만, 부를 때마다 배열 전체를 넘기고 전체를 훑는 비용은 select()와 같습니다.

epoll과 kqueue

Linux의 epoll은 지켜볼 목록을 커널 안에 계속 두는 방식으로 이 비용을 없앴습니다. epoll_ctl()로 소켓을 한 번 등록해 두면, epoll_wait()는 목록 전체가 아니라 준비된 소켓의 목록만 돌려줍니다. 연결이 1만 개라도 그 순간 준비된 소켓이 10개라면 10개만 처리하면 됩니다.

int epfd = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = server_fd };
epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);     /* 한 번 등록하면 커널이 기억한다 */

struct epoll_event events[MAX_EVENTS];
for (;;) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);   /* 준비된 것만 돌아온다 */
    for (int i = 0; i < n; i++) {
        int fd = events[i].data.fd;
        /* fd가 리스닝 소켓이면 accept 후 EPOLL_CTL_ADD, 아니면 recv */
    }
}

I/O 멀티플렉싱 비교

관심 집합을 매번 넘기거나, 커널에 유지합니다

select·poll은 호출마다 감시 집합을 넘깁니다. epoll·kqueue는 관심을 등록해 이벤트를 받으며, 등록·처리 비용과 자원 한도까지 없어지는 것은 아닙니다.

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입니다. 준비 이벤트 중심이며 필터 의미와 자원 한도를 확인해야 합니다.

관심이 바뀌면 EPOLL_CTL_MOD로, 소켓을 닫기 전에는 EPOLL_CTL_DEL로 목록을 고칩니다. epoll에는 두 가지 알림 방식이 있습니다. 기본인 레벨 트리거(level-triggered)는 읽을 데이터가 남아 있는 동안 계속 알려 주고, EPOLLET으로 켜는 에지 트리거(edge-triggered)는 상태가 바뀌는 순간에만 한 번 알려 줍니다. 에지 트리거에서는 알림을 받았을 때 EAGAIN이 나올 때까지 읽어 두지 않으면 남은 데이터에 대한 알림이 다시 오지 않아 연결이 멈춘 것처럼 보이므로, 처음에는 레벨 트리거로 시작하는 편이 안전합니다. BSD와 macOS에는 비슷한 역할의 kqueue가 있고, Windows에는 준비가 아니라 작업의 완료를 알려 주는 IOCP가 있습니다.

더 깊이: 준비를 알려 주는 방식과 완료를 알려 주는 방식

epoll과 kqueue가 돌려주는 것은 "이제 이 소켓을 읽으면 기다리지 않는다"는 준비(readiness)의 알림입니다. 알림을 받은 프로그램이 다시 recv()를 불러 데이터를 꺼내야 하므로, 준비된 소켓마다 시스템 호출이 한 번씩 더 듭니다. 반대로 완료(completion) 방식에서는 프로그램이 "이 버퍼로 읽어 달라"는 작업을 먼저 맡겨 두고, 커널이 데이터를 버퍼에 채운 뒤 끝났다고 알려 줍니다.

Windows의 IOCP가 완료 방식입니다. Microsoft의 문서에 따르면 소켓 같은 핸들을 완료 포트에 연결해 두면, 그 핸들에 맡긴 비동기 작업이 끝날 때마다 완료 패킷이 그 포트의 큐에 쌓이고, 미리 만들어 둔 스레드들이 큐에서 꺼내 처리합니다. 문서는 동시에 돌릴 스레드 수의 한도로 CPU의 수를 가장 좋은 출발점으로 권합니다.

Linux에도 완료 방식의 인터페이스로 io_uring이 있습니다. io_uring(7)에 따르면 프로그램과 커널이 함께 쓰는 두 개의 고리 버퍼(ring buffer)를 두어, 프로그램은 제출 큐에 요청(SQE)을 쌓고 커널은 완료 큐에 결과(CQE)를 남깁니다. 요청 여러 개를 한 번의 호출로 제출할 수 있어 시스템 호출의 수가 줄고, 연결 받기와 TCP 송수신 같은 네트워크 작업도 이 방식으로 맡길 수 있습니다.

이벤트 루프와 출력 큐

이 기다림을 중심에 두고, 준비된 소켓마다 정해 둔 처리 함수를 부르는 반복문을 이벤트 루프(event loop)라고 합니다. Python의 selectors 모듈은 운영체제에 맞는 방법을 골라 감싸 주므로, Linux에서는 epoll, macOS에서는 kqueue, Windows에서는 select를 같은 코드로 씁니다.

이벤트 루프에서 가장 놓치기 쉬운 것은 쓰기입니다. 논블로킹 소켓의 send()는 커널의 송신 버퍼에 들어갈 만큼만 받고 나머지를 돌려보내므로, 응답을 한 번에 다 보내지 못할 수 있습니다. 블로킹 소켓처럼 sendall()로 다 보낼 때까지 기다리면 그동안 다른 모든 연결이 멈춥니다. 그래서 보내지 못한 부분은 연결마다 출력 큐에 두었다가, 그 소켓이 다시 쓸 수 있게 되었다는 알림을 받을 때 이어 보냅니다.

읽기와 쓰기 준비를 나누는 이벤트 루프

읽기 준비 후 recv한 데이터로 응답을 보관합니다. 남은 큐 상태에 따라 쓰기 관심을 유지하거나 끕니다. 각 단계의 값과 전환 이유를 비교합니다.

이벤트 루프의 송신 경로

쓰기 준비를 기다리고, 보낸 만큼만 출력 큐에서 제거합니다

받은 데이터로 응답을 만드는 정상 경로입니다. 읽기 준비가 전체 송신 완료를 뜻하지 않으므로 남은 출력을 보관합니다. 이 출력 큐는 원문의 짧은 callback 예제에 추가로 필요합니다.

출력 버퍼를 둔 이벤트 루프읽기 이벤트 처리 후 출력 데이터를 보관하고 쓰기 이벤트 때 일부를 전송한 뒤 다시 기다리는 정상 경로이다.준비 이벤트 대기읽기 또는 쓰기읽기 가능: recv받은 내용을 출력 큐에쓰기 관심 등록보낼 데이터 있음쓰기 가능: send보낸 만큼 큐에서 제거남은 출력은 보관다음 쓰기 이벤트
출력 버퍼를 둔 이벤트 루프정상 읽기 경로에서 받은 데이터로 응답을 만들어 출력 큐에 보관하고 쓰기 관심을 등록합니다. 다음 쓰기 준비에서 일부를 보낸 뒤 남은 큐를 보관한 채 준비 대기로 돌아갑니다. 빈 큐는 쓰기 관심을 끄고 EOF·오류·큐 상한은 별도 분기입니다.준비 이벤트 대기읽기 또는 쓰기읽기 가능: recv받은 내용을출력 큐에쓰기 관심 등록보낼 데이터 있음쓰기 가능: send보낸 만큼만 제거다음 쓰기남은 출력은 보관빈 큐는 쓰기 관심 해제

원문의 짧은 callback 예제에 추가로 필요한 송신 경로입니다. 빈 출력 큐는 쓰기 관심을 끄고, BlockingIOError는 다음 준비 이벤트를 기다립니다. EOF·오류·큐 크기 제한은 별도 분기로 처리합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 읽기 준비부터 다시 대기까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목읽기와 쓰기 준비를 나누는 이벤트 루프
예의 값과 변화
관찰·상황값·보충 정보변화·이유
읽기 준비

읽기 준비 후 recv한 데이터로 응답을 보관합니다.

  • 준비·출력 큐 1: read ready
  • 준비·출력 큐 2: 출력 큐 생성
  • 출력 큐 생성; 읽기 준비는 송신 완료를 뜻하지 않음
쓰기 관심

출력 큐가 비어 있지 않아 쓰기 관심을 등록합니다.

  • 준비·출력 큐 1: queue nonempty
  • 준비·출력 큐 2: write interest on
  • 쓰기 관심을 켜고 다음 쓰기 준비를 기다림
일부 송신

send가 반환한 길이만 출력 큐에서 제거합니다.

  • 준비·출력 큐 1: 보낸 길이만 dequeue
  • 준비·출력 큐 2: 나머지 유지
  • 미송신 부분 유지; BlockingIOError면 다음 준비를 기다림
다시 대기

남은 큐 상태에 따라 쓰기 관심을 유지하거나 끕니다.

  • 준비·출력 큐 1: 남음→write on
  • 준비·출력 큐 2: 빈 큐→write off
  • 빈 큐는 쓰기 관심 해제; EOF·오류·버퍼 상한은 별도 처리

읽기와 쓰기 준비를 나누는 이벤트 루프

읽기 준비 후 recv한 데이터로 응답을 보관합니다. 남은 큐 상태에 따라 쓰기 관심을 유지하거나 끕니다. 각 단계의 값과 전환 이유를 비교합니다.

이벤트 루프의 송신 경로

쓰기 준비를 기다리고, 보낸 만큼만 출력 큐에서 제거합니다

받은 데이터로 응답을 만드는 정상 경로입니다. 읽기 준비가 전체 송신 완료를 뜻하지 않으므로 남은 출력을 보관합니다. 이 출력 큐는 원문의 짧은 callback 예제에 추가로 필요합니다.

출력 버퍼를 둔 이벤트 루프읽기 이벤트 처리 후 출력 데이터를 보관하고 쓰기 이벤트 때 일부를 전송한 뒤 다시 기다리는 정상 경로이다.준비 이벤트 대기읽기 또는 쓰기읽기 가능: recv받은 내용을 출력 큐에쓰기 관심 등록보낼 데이터 있음쓰기 가능: send보낸 만큼 큐에서 제거남은 출력은 보관다음 쓰기 이벤트
출력 버퍼를 둔 이벤트 루프정상 읽기 경로에서 받은 데이터로 응답을 만들어 출력 큐에 보관하고 쓰기 관심을 등록합니다. 다음 쓰기 준비에서 일부를 보낸 뒤 남은 큐를 보관한 채 준비 대기로 돌아갑니다. 빈 큐는 쓰기 관심을 끄고 EOF·오류·큐 상한은 별도 분기입니다.준비 이벤트 대기읽기 또는 쓰기읽기 가능: recv받은 내용을출력 큐에쓰기 관심 등록보낼 데이터 있음쓰기 가능: send보낸 만큼만 제거다음 쓰기남은 출력은 보관빈 큐는 쓰기 관심 해제

원문의 짧은 callback 예제에 추가로 필요한 송신 경로입니다. 빈 출력 큐는 쓰기 관심을 끄고, BlockingIOError는 다음 준비 이벤트를 기다립니다. EOF·오류·큐 크기 제한은 별도 분기로 처리합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 읽기 준비부터 다시 대기까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목읽기와 쓰기 준비를 나누는 이벤트 루프
예의 값과 변화
관찰·상황값·보충 정보변화·이유
읽기 준비

읽기 준비 후 recv한 데이터로 응답을 보관합니다.

  • 준비·출력 큐 1: read ready
  • 준비·출력 큐 2: 출력 큐 생성
  • 출력 큐 생성; 읽기 준비는 송신 완료를 뜻하지 않음
쓰기 관심

출력 큐가 비어 있지 않아 쓰기 관심을 등록합니다.

  • 준비·출력 큐 1: queue nonempty
  • 준비·출력 큐 2: write interest on
  • 쓰기 관심을 켜고 다음 쓰기 준비를 기다림
일부 송신

send가 반환한 길이만 출력 큐에서 제거합니다.

  • 준비·출력 큐 1: 보낸 길이만 dequeue
  • 준비·출력 큐 2: 나머지 유지
  • 미송신 부분 유지; BlockingIOError면 다음 준비를 기다림
다시 대기

남은 큐 상태에 따라 쓰기 관심을 유지하거나 끕니다.

  • 준비·출력 큐 1: 남음→write on
  • 준비·출력 큐 2: 빈 큐→write off
  • 빈 큐는 쓰기 관심 해제; EOF·오류·버퍼 상한은 별도 처리

출력 큐에 보낼 것이 있을 때만 쓰기 준비를 기다리는 것도 중요합니다. 송신 버퍼에는 대개 여유가 있어서 쓰기 준비 알림은 거의 언제나 오므로, 보낼 것이 없는데 쓰기 관심을 켜 두면 루프가 쉬지 않고 헛돌며 CPU를 씁니다. 다음 코드는 11장 2편의 길이 헤더 프로토콜로 에코하는 이벤트 루프 서버를 스레드로 띄우고, 클라이언트가 42개의 프레임을 3바이트씩 잘게 나누어 쓰고 3바이트씩 읽어도 같은 바이트를 돌려받는지 확인합니다. 서버도 한 번의 send()에 2바이트만 넘기도록 제한해, 출력 큐에 남은 응답을 여러 번에 걸쳐 보내는 경로를 일부러 지나게 했습니다.

import selectors
import socket
import struct
import threading

MAX_FRAME, MAX_QUEUE = 64 * 1024, 256 * 1024
SEND_LIMIT = 2                                         # send 한 번에 넘기는 최대 바이트(실험용)
sel = selectors.DefaultSelector()
closed = []

def close_client(client, reason):
    closed.append(reason)
    sel.unregister(client)                             # 닫기 전에 감시 목록에서 뺀다
    client.close()

def update_interest(client, state):
    events = 0 if state["eof"] else selectors.EVENT_READ
    if state["output"]:
        events |= selectors.EVENT_WRITE                # 보낼 것이 있을 때만 쓰기 준비를 기다린다
    if events:
        sel.modify(client, events, data=state)
    else:
        close_client(client, "EOF; output drained")

def queue_frames(state):
    incoming, outgoing = state["input"], state["output"]
    while len(incoming) >= 4:
        (length,) = struct.unpack("!I", incoming[:4])
        if length > MAX_FRAME:
            raise ValueError("frame too large")
        size = 4 + length
        if len(incoming) < size:
            return                                     # 덜 온 프레임은 다음 읽기까지 남겨 둔다
        incoming[4:size].decode("utf-8")               # 본문이 UTF-8인지 확인한다
        if len(outgoing) + size > MAX_QUEUE:
            raise ValueError("output queue limit")
        outgoing.extend(incoming[:size])               # 받은 프레임을 그대로 출력 큐에 넣는다
        del incoming[:size]

def service(client, state, mask):
    try:
        if mask & selectors.EVENT_READ and not state["eof"]:
            try:
                chunk = client.recv(4096)
            except BlockingIOError:
                chunk = None                           # 알림을 받았어도 읽을 것이 없을 수 있다
            if chunk:
                state["input"].extend(chunk)
                queue_frames(state)
            elif chunk == b"":
                state["eof"] = True                    # 상대가 보내기를 마쳤다
                if state["input"]:
                    raise ValueError("EOF inside frame")
        if mask & selectors.EVENT_WRITE and state["output"]:
            try:
                sent = client.send(state["output"][:SEND_LIMIT])
                del state["output"][:sent]             # 실제로 보낸 만큼만 큐에서 지운다
            except BlockingIOError:
                pass                                   # 남은 출력은 다음 쓰기 준비 때 보낸다
        update_interest(client, state)
    except (OSError, ValueError) as error:
        close_client(client, str(error))

def run():
    while True:
        for key, mask in sel.select():
            if key.data is None:                       # 리스닝 소켓
                try:
                    client, _ = key.fileobj.accept()
                except BlockingIOError:
                    continue
                client.setblocking(False)
                state = {"input": bytearray(), "output": bytearray(), "eof": False}
                sel.register(client, selectors.EVENT_READ, data=state)
            else:
                service(key.fileobj, key.data, mask)

server = socket.create_server(("127.0.0.1", 0))
server.setblocking(False)
sel.register(server, selectors.EVENT_READ, data=None)
threading.Thread(target=run, daemon=True).start()

messages = ["", "한글🙂"] + [f"m{i}" for i in range(40)]
expected = b"".join(struct.pack("!I", len(m.encode())) + m.encode() for m in messages)
with socket.create_connection(server.getsockname(), timeout=5) as client:
    for offset in range(0, len(expected), 3):
        client.sendall(expected[offset:offset + 3])    # 3바이트씩 잘게 나누어 쓴다
    client.shutdown(socket.SHUT_WR)
    received = bytearray()
    while part := client.recv(3):                      # 3바이트씩 읽는다
        received.extend(part)
print("같은 바이트를 돌려받았는가:", received == expected, f"({len(messages)}개 프레임, {len(received)}바이트)")
print("서버가 연결을 닫은 이유:", closed)
같은 바이트를 돌려받았는가: True (42개 프레임, 288바이트)
서버가 연결을 닫은 이유: ['EOF; output drained']

프레임은 빈 메시지의 4바이트, 한글🙂의 14바이트, m0부터 m9까지 6바이트씩 60바이트, m10부터 m39까지 7바이트씩 210바이트로 모두 288바이트입니다. 클라이언트가 3바이트씩 쓴 데이터가 서버에서 어떻게 나뉘어 읽혔든, 서버가 2바이트씩 보낸 응답이 클라이언트에서 어떻게 나뉘어 읽혔든, 바이트가 하나도 바뀌지 않고 돌아왔습니다. 클라이언트가 shutdown()으로 송신을 닫으면 서버는 읽기 관심만 끄고 남은 출력을 끝까지 보낸 뒤 연결을 닫습니다.

출력 큐에 상한을 둔 이유는 느린 클라이언트 때문입니다. 받는 쪽이 읽지 않으면 송신 버퍼가 차서 send()가 계속 BlockingIOError를 내고, 그동안 계속 요청을 읽어 응답을 큐에 쌓으면 서버의 메모리가 끝없이 늘어납니다. 이 예는 상한을 넘으면 연결을 끊지만, 큐가 차면 그 연결에서 읽기를 잠시 멈추는 배압(backpressure)이 더 부드러운 방법입니다. 실제 서버에는 연결 수의 한도, 오래 조용한 연결을 끊는 유휴 타임아웃, TLS 처리도 필요합니다.

이벤트 루프를 쓰는 서버들

nginx와 Redis는 epoll이나 kqueue 위에 직접 만든 이벤트 루프로 수많은 연결을 적은 스레드로 처리합니다. Node.js는 libuv라는 라이브러리가 운영체제마다 epoll, kqueue, IOCP를 감싸고 그 위에서 자바스크립트의 콜백을 부르며, Python의 asyncio와 Java의 Netty도 같은 구조입니다. 이런 서버에서 가장 피해야 하는 것은 이벤트 루프 안에서 오래 걸리는 일을 하는 것입니다. 처리 함수 하나가 무거운 계산이나 블로킹 호출로 1초를 쓰면, 그 1초 동안 다른 모든 연결이 멈춥니다. 그런 일은 별도의 작업 스레드나 프로세스로 넘기고, 이벤트 루프는 기다리는 일과 짧은 처리만 맡깁니다.

정리

  • 연결마다 실행 흐름을 두면 대부분의 시간을 기다림에 쓰는 흐름이 연결 수만큼 생기므로, I/O 멀티플렉싱은 기다리는 일을 하나로 모아 준비된 소켓만 처리합니다.
  • select()는 부를 때마다 집합 전체를 넘기고 훑으며, Linux에서는 1024보다 작은 디스크립터 번호만 다룰 수 있습니다. poll()은 번호의 한도를 없앴지만 전체를 훑는 비용은 같습니다.
  • epoll은 지켜볼 목록을 커널에 두고 준비된 소켓만 돌려주며, 에지 트리거에서는 EAGAIN이 나올 때까지 읽어야 합니다. BSD와 macOS에는 kqueue, Windows에는 IOCP가 있습니다.
  • 논블로킹 소켓의 send()는 일부만 보낼 수 있으므로, 남은 부분은 연결마다 출력 큐에 두었다가 쓰기 준비 알림 때 이어 보내고, 보낼 것이 있을 때만 쓰기 관심을 켭니다.
  • 출력 큐에는 상한이나 배압을 두어 느린 클라이언트가 메모리를 채우지 못하게 하고, 이벤트 루프 안에서는 오래 걸리는 일을 하지 않습니다.

연습 문제

1. 디스크립터의 번호

FD_SETSIZE가 1024인 Linux에서, 다른 파일을 많이 열어 둔 프로세스가 새 연결을 받았더니 디스크립터 번호가 1024였습니다. 지금 열린 소켓은 10개뿐입니다. 이 디스크립터를 FD_SET에 넣어도 되나요? poll이나 epoll을 쓰면 어떻게 되나요?

해설 보기

넣으면 안 됩니다. fd_set은 0부터 1023까지의 번호만 담을 수 있으므로, 1024번을 넣으면 배열의 범위를 넘어 다른 메모리를 덮어씁니다. 연결이 몇 개인지가 아니라 번호가 기준이라는 점이 함정입니다. 본문의 코드가 accept() 직후 번호를 확인하고 범위를 넘으면 닫는 것은 이 때문입니다. poll과 epoll은 번호를 비트 위치로 쓰지 않으므로 이런 한도가 없고, 프로세스가 열 수 있는 디스크립터의 수만큼 다룰 수 있습니다.

2. 출력 큐를 따라가기

어느 연결의 입력은 모두 처리되었고 출력 큐에 7바이트가 남아 있습니다. 쓰기 준비 알림에 send()가 3을 돌려주었고, 다음 알림에서는 BlockingIOError가 났습니다. 그 뒤 상대가 송신을 닫아 recv()가 빈 바이트열을 돌려주었습니다. 각 단계 뒤 출력 큐의 길이와 지켜보는 이벤트는 어떻게 되나요?

해설 보기

send()가 3을 돌려주면 앞의 3바이트만 지우므로 큐에는 4바이트가 남고, 보낼 것이 있으니 읽기와 쓰기를 함께 지켜봅니다. BlockingIOError가 나면 아무것도 보내지 못했으므로 4바이트와 쓰기 관심을 그대로 둡니다. 빈 바이트열을 받으면 입력이 끝났으므로 읽기 관심을 끄지만, 큐에 4바이트가 남아 있으므로 쓰기 관심은 남겨 두고 계속 보냅니다. 남은 4바이트를 모두 보내 큐가 비면 지켜볼 이벤트가 없어지므로, 그때 감시 목록에서 빼고 연결을 닫습니다.

3. 출력 큐의 상한

본문이 MAX_FRAME인 64 × 1024바이트를 꽉 채운 프레임 네 개가 연달아 도착했습니다. 그동안 쓰기는 한 번도 일어나지 않았고 출력 큐는 처음에 비어 있었습니다. MAX_QUEUE가 256 × 1024바이트라면 네 프레임을 모두 큐에 넣을 수 있나요?

해설 보기

본문은 65,536바이트까지지만 출력 큐에 들어가는 응답 프레임은 길이 헤더 4바이트를 더해 65,540바이트입니다. 세 프레임은 196,620바이트로 들어가지만, 네 번째를 더하면 262,160바이트가 되어 상한 262,144바이트를 16바이트 넘습니다. 그래서 네 번째 프레임을 넣으려는 순간 output queue limit 오류가 나고, 본문의 코드는 연결을 닫으면서 아직 보내지 못한 앞의 세 응답도 버립니다. 상한을 정할 때는 본문만이 아니라 헤더까지 포함한 크기로 계산해야 합니다.

다음 장 HTTP/2에서는 연결 하나에 여러 요청을 섞어 보내는 HTTP/2의 프레임과 스트림, 헤더 압축을 살펴봅니다.