개요

아파트 경비원 한 명이 수백 세대를 맡는다. 세대마다 문 앞에 서 있을 순 없으니 경비실 호출 패널만 본다. 불이 들어온 세대 번호로 가면 된다.

순찰도 돌아야 한다. 규칙은 “호출이 10분 동안 없으면 순찰을 돈다.” 택배가 몰리는 날 호출이 5분마다 오면 순찰은 한 번도 못 돈다..!

게임 서버가 이 경비원이다. 호출 패널은 epoll, 순찰은 틱( 몬스터를 움직이고 버프 시간을 줄이는, 일정 간격의 세계 갱신 )이다. 처음엔 서버도 이 규칙대로 짜면 된다고 생각했다. 패널을 기다리는 함수가 돌려주는 숫자가 0이면 “그동안 호출이 없었다”는 뜻이니 그때 틱을 돌리면 된다고. 그 숫자는 시간이 아니라 불이 들어온 세대 수였고, 전투가 치열할수록 서버의 세계가 멈춘다.

( 커널 내부는 Linux 기준이다. 코드는 설명용 예시이고, 이번에는 비용 측정이나 동작 재현을 하지 않았다 )


1. 멀티플렉싱은 블로킹을 한 곳에 모은다

기본 recv()는 블로킹이다. 수신 버퍼에 데이터가 없으면 호출한 스레드가 잠든다.

처음엔 블로킹을 “한 번에 하나씩 처리한다”로 이해하고 있었다. 그래서 멀티플렉싱이 블로킹을 없애서 여러 소켓을 동시에 처리하게 해준다고 봤다. 둘은 다른 성질이었다.

  • 블로킹: 데이터가 없을 때 스레드가 잠드는가
  • 순차 처리: 한 번에 하나씩 처리하는가

멀티플렉싱 서버도 준비된 소켓을 루프에서 하나씩 처리한다. 달라진 건 잠드는 자리다. 소켓 하나에서 잠드는 대신 “이 중 하나라도 준비되면 깨워 달라”고 소켓 집합에서 한 번 잠든다.

연결이 많을 때 고를 수 있는 길은 셋이다.

방식 대가
연결마다 스레드 스레드마다 스택·커널 스택·TCB, 패킷마다 컨텍스트 스위치
논블로킹 소켓을 계속 순회 ( busy polling ) 데이터가 없어도 코어 하나가 100%, “없음”을 듣는 syscall 반복
IO 멀티플렉싱 스레드 하나가 소켓 집합에서 한 번 잠든다

경비원으로 치면 첫 줄은 세대마다 경비원을 한 명씩 두는 것, 둘째 줄은 경비원이 쉬지 않고 모든 세대 문을 두드리고 다니는 것, 셋째 줄이 호출 패널이다. 경비원은 여전히 기다리지만, 기다리는 자리가 세대 문 앞에서 패널 앞으로 바뀌었다.

준비됐다는 알림을 받고 부르는 recv()는 여전히 블로킹 호출이지만, 버퍼에 데이터가 있으니 잠들지는 않는다. 그런데도 멀티플렉싱과 함께 논블로킹 소켓을 쓰는 이유가 있다. 알림이 틀릴 수 있어서다. Linux select(2) 매뉴얼은 데이터가 도착했다가 체크섬 오류로 버려지면 “읽을 수 있음”으로 보고된 소켓의 read가 블로킹될 수 있다고 적어 두었다. 블로킹 소켓이었다면 스레드 하나뿐인 서버 전체가 그 recv()에서 멈춘다.


2. select — 커널이 아무것도 기억하지 않는다

먼저 용어 두 개.

  • fd( file descriptor ): 프로세스가 연 파일·소켓마다 커널이 붙이는 번호표
  • fd_set: 감시할 번호를 표시하는 비트맵. glibc에서는 1024비트 고정이다( FD_SETSIZE )

select는 호출할 때마다 감시할 목록을 통째로 넘기고, 커널은 끝나면 그 목록을 잊는다. 그래서 한 번 기다릴 때마다 이 일을 반복한다. 경비원이 기다릴 때마다 세대 명부를 통째로 관리실에 넘기고, 답도 명부 전체에 체크된 채로 돌려받는 식이다.

  1. fd_set을 유저 → 커널로 복사
  2. 커널이 범위 전체를 훑으며 소켓마다 대기 큐에 등록하고 잠든다
  3. 깨어나면 다시 훑으며 대기 큐에서 해제하고, 결과를 커널 → 유저로 복사
  4. 유저도 FD_ISSET으로 전체를 훑는다. 결과가 입력을 덮어쓰니 다음 호출 전에 다시 채운다

비용은 두 종류로 나뉜다.

종류 무엇에 비례하나 해당 작업
범위 비례 가장 큰 fd 번호( nfds ) 복사, 커널·유저 순회
개수 비례 감시하는 소켓 수 대기 큐 등록·해제

소켓이 fd 3과 fd 1000 두 개뿐이어도 select는 비싸다. 비트맵은 번호 = 위치라서, 어디에 비트가 켜져 있는지 알려면 0부터 1000까지 훑어야 한다.

select는 비트맵 범위 전체를, epoll은 준비 목록만 본다

커널이 상태를 들고 있지 않으면 구현이 단순하고, 유저가 fd를 닫아도 커널 쪽 목록과 꼬일 일이 없다. 그 대가를 호출마다 범위에 비례하는 작업으로 치른다. 연결이 수십 개이던 시절에는 합리적인 선택이었을 것이다( 지금 와서 추측하면 ). 매뉴얼은 fd_set을 입력 겸 출력으로 쓰는 설계를 “design error”라고까지 적고 있다.

poll은 비트맵 대신 구조체 배열을 받는다. 1024 제한과 덮어쓰기가 사라지고 범위 비례가 개수 비례로 바뀌지만, 매번 전부 넘기고 전부 훑는 구조는 그대로다.


3. epoll — 기억을 커널로 옮겼다

epoll은 커널 안에 epoll 인스턴스를 만들고 fd 하나로 다룬다. 인스턴스 안에는 목록이 두 개 있다.

  • 관심 목록: 등록된 소켓 전체. Linux 구현은 레드 블랙 트리다
  • 준비 목록: 지금 준비된 소켓만 모인 연결 리스트

그리고 등록과 기다리기를 나눴다.

int epfd = epoll_create1(0);                       // epoll 인스턴스 생성
struct epoll_event ev = { .events = EPOLLIN };     // 읽을 데이터가 생기면 알려 달라
ev.data.ptr = session;                             // 알림과 함께 돌려받을 세션 포인터
epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev);         // 등록은 한 번
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);  // 기다리기는 몇 번이든

EPOLL_CTL_ADD 때 커널은 소켓의 대기 큐에 콜백을 걸어 두고, 삭제하거나 닫을 때까지 유지한다. 데이터가 도착하면 콜백이 그 소켓을 준비 목록에 올리고, epoll 인스턴스에서 잠든 스레드를 깨운다. epoll_wait는 준비 목록만 꺼내 보면 되니 비용이 준비된 개수에 비례한다. 감시하는 쪽이 매번 훑는 게 아니라, 준비된 소켓이 스스로 줄을 선다. 세대마다 호출 버튼을 미리 달아 두고( ADD ), 누르면 그 번호가 패널에 뜨는( 준비 목록 ) 경비실 패널이 이 구조다.

select에서 매번 하던 일이 어떻게 바뀌었는지 맞춰 보면 이렇다.

select가 매번 하던 일 epoll
fd_set 전체 복사 준비된 것만 돌려줌
범위 전체 순회 없음
대기 큐 등록·해제 ADD 때 한 번
결과로 덮인 set 다시 채우기 없음

fd 3과 fd 1000의 예도 epoll에서는 문제가 아니다. 번호는 트리의 키일 뿐 위치가 아니다.

관심 목록이 레드 블랙 트리인 이유는 연산을 보면 나온다. 연결이 맺히고 끊길 때마다 추가·삭제가 잦고, 같은 소켓을 두 번 ADD하면 EEXIST로 거절해야 하고, MOD·DEL도 대상을 찾아야 한다. 추가·삭제·검색이 모두 O(log n)으로 보장되는 균형 트리가 맞는다.

기억한 만큼 생명주기를 챙겨야 한다

커널이 목록을 들고 있으니, 목록에서 언제 빠지는지가 새 문제가 된다. close()하면 자동으로 빠지지만, epoll(7) 매뉴얼 기준으로는 같은 열린 파일을 가리키는 fd가 전부 닫혀야 빠진다. dup()이나 fork()로 복제된 fd가 남아 있으면 닫은 소켓의 알림이 계속 온다. 복제하기 전에 EPOLL_CTL_DEL로 먼저 빼는 게 안전하다.

select는 기억하지 않으니 단순하지만 매번 전부 하고, epoll은 기억하니 빠르지만 생명주기를 관리해야 한다.


4. LT와 ET — 준비 목록에서 언제 빠지는가

이름은 디지털 회로의 레벨 트리거와 에지 트리거에서 왔다. 여기서 신호는 “버퍼에 읽을 데이터가 있는가”다.

100바이트가 도착했는데 50바이트만 읽은 경우를 보면 차이가 바로 드러난다.

LT는 남은 50바이트를 다시 알리고, ET는 새 데이터가 와야 알린다

  • LT( Level-Triggered, 기본값 ): 다음 epoll_wait도 다시 알린다. 남은 50바이트를 마저 읽는다
  • ET( EPOLLET ): 새 데이터가 와야 다시 알린다. 남은 50바이트는 방치되고, 요청-응답 구조라면 상대는 응답을 기다리고 서버는 알림을 기다리면서 그 연결만 조용히 멈춘다

패널로 치면 LT는 경비원이 응대를 마칠 때까지 불이 켜져 있는 패널이고, ET는 누르는 순간 ‘딩동’ 한 번만 울리는 초인종이다. 못 들었으면 그 세대가 다시 누를 때까지 모른다.

커널 안에서 둘의 차이는 한 줄이다. epoll_wait가 결과를 돌려줄 때 준비 목록에서 항목을 꺼내는데, LT는 아직 준비 상태면 다시 넣고 ET는 넣지 않는다.

그래서 ET를 쓰면 EAGAIN이 나올 때까지 반복해서 읽는 책임이 코드로 넘어온다. 마지막 확인용 recv()는 빈 버퍼에서 호출되므로 소켓이 논블로킹이어야 한다.

// ET에서 읽기: 버퍼가 빌 때까지 반복
while (true)
{
    ssize_t len = recv(sock, buf, sizeof(buf), 0);
    if (len > 0) { append(session, buf, len); continue; }  // 받은 만큼 연결별 버퍼에 이어 붙임
    if (len == 0) { close_session(session); break; }      // 상대가 연결을 닫음
    if (errno == EAGAIN || errno == EWOULDBLOCK) break;   // 다 비웠다, 다음 알림까지 대기
    close_session(session); break;                        // 그 밖의 오류
}

EPOLLOUT은 밀려 있을 때만 켠다

EPOLLIN은 읽을 데이터가 있다는 뜻이고, EPOLLOUT은 송신 버퍼에 빈자리가 있다는 뜻이다. LT에서 EPOLLOUT을 항상 켜 두면 문제가 생긴다. 보낼 게 없을 때 송신 버퍼는 거의 늘 비어 있어서 조건이 항상 참이고, epoll_wait가 즉시 반환하기를 반복한다. 1절에서 피하려던 busy polling으로 돌아간다.

그래서 send()가 다 못 보냈을 때만 EPOLLOUT을 켜고, 밀린 데이터를 다 보내면 끈다.


5. 스레드 여럿이 같은 epoll을 기다리면

ET와 EPOLLONESHOT이 왜 있는지는 스레드를 늘려 보면 보인다.

접속 하나에 워커 8개가 깨어난다

워커 스레드 8개가 같은 epoll에서 리스닝 소켓을 기다린다고 해보자. 접속이 하나 들어오면 대기 큐의 스레드가 전부 깨어나고, accept()에 성공하는 건 하나뿐이다. 나머지 7개는 EAGAIN을 받고 다시 잠든다. 헛된 컨텍스트 스위치 7번이다( thundering herd ). 경비원 8명이 한 패널 앞에 있다가 호출 하나에 전부 뛰어나가는 장면이다.

대기 큐 깨우기의 기본값이 “기다리는 쪽 전부”라서 생긴다. 일을 놓치지 않는 쪽을 택한 설계다. 대응은 두 가지다.

  • EPOLLEXCLUSIVE: 기다리는 스레드 중 일부만 깨운다
  • SO_REUSEPORT: 스레드마다 리스닝 소켓을 따로 두고 커널이 접속을 나눠 준다

한 메시지가 두 스레드로 찢어진다

같은 구조에서 LT로 연결 소켓을 감시하면 더 조용한 사고가 난다. 소켓에 100바이트가 오면 스레드 A가 알림을 받는다. A가 읽기 전에 LT가 그 소켓을 준비 목록에 다시 넣으니 스레드 B도 같은 소켓을 받는다. A는 앞 60바이트, B는 뒤 40바이트를 읽는다. 한 세대의 택배 한 상자를 경비원 둘이 반씩 뜯어 들고 간 꼴이다.

LT에서 두 스레드가 같은 소켓을 받아 메시지가 나뉜다

바이트는 각각 정확히 한 번씩 읽혔다. 문제는 순서다. TCP는 바이트 스트림이라 메시지 경계가 없고, 보통은 [길이 헤더 + 본문]을 연결별 버퍼에 이어 붙여 메시지를 복원한다. 이 방식은 한 연결은 한 스레드가 순서대로 읽는다는 전제 위에 서 있다. 전제가 깨지면 이 메시지뿐 아니라 그 뒤의 메시지까지 전부 경계가 어긋난다.

EPOLLONESHOT이 이걸 막는다. 한 번 알리고 그 소켓의 감시를 끈다. 처리한 스레드가 다 읽은 뒤 epoll_ctl(EPOLL_CTL_MOD)로 다시 켠다. 그 사이에는 다른 스레드가 이 소켓을 받을 수 없다. 한 경비원이 맡은 세대의 불을 바로 꺼 두는 것과 같다.


6. 그래서 틱은 누가 돌리나

개요의 순찰 규칙으로 돌아왔다. “호출이 없으면 순찰”을 서버 코드로 쓰면 이렇다.

int n = epoll_wait(epfd, events, MAX_EVENTS, 16);  // 최대 16ms 기다린다, n은 준비된 소켓 개수
if (n == 0) tick();                                 // 0이면 시간이 됐다고 보고 틱 ( 잘못된 판단 )

타임아웃을 -1로 두면 패킷이 없을 때 영원히 잠들고, 위처럼 n == 0으로 판단하면 패킷이 많을 때 영원히 틱이 안 돈다.

n으로 시간을 판단하면 틱이 굶고, 시계를 보면 제때 돈다

epoll_wait에게 묻는 건 “무슨 일이 있었나”이고, 틱이 알아야 하는 건 “시간이 됐나”다. 질문이 다르니 따로 물어야 한다. 경비원도 순찰은 패널이 아니라 시계를 보고 돌아야 한다. 방법은 세 가지가 있다.

① 매 루프 시계를 본다. n과 상관없이 루프마다 현재 시각을 확인하고, 타임아웃은 다음 틱까지 남은 시간으로 건다.

int64_t nextTick = now_ms() + TICK_MS;               // 다음 틱 예정 시각 ( 절대 시각 )
while (running)
{
    int timeout = (int)std::max<int64_t>(0, nextTick - now_ms());  // 남은 시간만큼만 잔다
    int n = epoll_wait(epfd, events, MAX_EVENTS, timeout);
    for (int i = 0; i < n; ++i) handle(events[i]);   // 준비된 소켓 처리

    while (now_ms() >= nextTick)                     // n과 무관하게 시계로 판단
    {
        tick();
        nextTick += TICK_MS;                         // "지금 + 16"이 아니라 예정 시각에 더한다
    }
}

예정 시각을 절대 시각으로 들고 있어야 오차가 쌓이지 않는다. now + 16으로 다시 잡으면 처리에 걸린 시간과 스케줄러 지연이 매 틱 뒤로 밀린다. epoll_wait의 타임아웃이 ms 정수 단위라는 점도 이 방식이 감수하는 오차다.

② 타이머를 fd로 만든다. timerfd로 만든 타이머를 소켓처럼 epoll에 등록하면 시간도 “준비된 이벤트 중 하나”가 된다. 루프는 이벤트만 처리하면 된다.

③ 스레드를 나눈다. 네트워크 스레드는 받은 패킷을 큐에 넣기만 하고, 로직 스레드가 자기 주기로 틱을 돌며 큐를 비운다.

Node.js의 setTimeout도 ①과 같은 구조다. 가장 가까운 타이머까지 남은 시간을 이벤트 루프의 대기 타임아웃으로 쓴다.


7. readiness와 completion — epoll은 비동기가 아니다

epoll을 “비동기 I/O”라고 부르기 쉬운데, 두 축으로 나눠 보면 아니다.

  • 블로킹 / 논블로킹: 호출한 스레드가 잠드는가
  • 동기 / 비동기: 커널 버퍼 → 유저 버퍼 복사를 누가 하는가

epoll은 알림을 받은 뒤 유저 스레드가 recv()로 직접 복사한다. 동기 I/O를 효율적으로 기다리는 도구다. Windows의 IOCP는 요청할 때 버퍼를 넘기고, 복사까지 끝난 뒤에 알린다.

  epoll ( readiness, Reactor ) IOCP ( completion, Proactor )
알림 읽을 수 있다 읽기가 끝났다
버퍼 알림 후 준비 요청할 때 미리 넘김, 완료까지 커널이 쥔다
스레드 깨우기 애플리케이션이 정한다 완료 포트가 관리( LIFO, 동시 실행 수 제한 )

경비실로 치면 epoll은 “부르셨네요, 가 보겠습니다”이고, IOCP는 세대마다 빈 상자를 미리 맡겨 두고 택배가 오면 담아 둔 뒤 “다 받았습니다”라고 알리는 방식이다. 아무것도 안 오는 세대에도 상자는 맡겨져 있다.

이 차이가 메모리로 나타나는 장면이 있다. 접속만 하고 아무것도 보내지 않는 1만 명을 기다린다고 해보자.

epoll은 알림 뒤에 버퍼를 꺼내고, IOCP는 요청할 때 버퍼를 넘긴다

IOCP는 1만 개의 수신 요청에 버퍼를 하나씩 걸어 둬야 하니, 4KB씩이면 약 40MB를 아무 일 없이 붙잡고 있다. epoll은 준비된 순간에만 버퍼가 필요하다. 커널 쪽 소켓 버퍼는 양쪽 모두 연결마다 있으니, 차이는 애플리케이션이 미리 내주는 버퍼다.

IOCP에서 이걸 피하는 기법으로 zero-byte recv가 알려져 있다. 0바이트 버퍼로 WSARecv를 걸어 두고, 완료 알림이 오면 그때 실제 버퍼로 읽는다. completion 모델 위에서 readiness를 흉내 낸다.

IOCP의 스레드 깨우기는 5절의 thundering herd도 구조적으로 피한다. 완료 하나에 스레드 하나만, 가장 최근에 잠든 스레드부터( LIFO ) 깨운다. 방금까지 돌던 스레드는 캐시에 자기 데이터가 아직 남아 있어서, 다른 스레드로 바꿀 때 생기는 캐시 미스를 줄인다.

Linux에서 completion 모델에 해당하는 건 io_uring이다. libuv( Node.js의 이벤트 루프 )는 Linux에서는 epoll, Windows에서는 IOCP를 같은 콜백 인터페이스로 감싼다.


8. Unity 클라이언트 — 받는 스레드와 만지는 스레드

클라이언트에서도 같은 모양의 문제가 나온다. 수신을 별도 스레드에서 블로킹으로 기다리다가, 받은 위치로 transform.position을 바꾸면 예외가 난다. Unity의 엔진 객체는 메인 스레드 하나만 쓴다는 전제라 다른 스레드의 접근을 막는다.

수신 스레드는 큐에 넣고, 메인 스레드가 Update에서 꺼내 적용한다

수신 스레드는 스레드 안전 큐에 넣기만 하고, 메인 스레드가 Update()에서 꺼내 적용한다.

// 두 스레드가 공유하는 유일한 통로
readonly ConcurrentQueue<Packet> _inbox = new ConcurrentQueue<Packet>();

// 수신 스레드: 받기만 한다
void ReceiveLoop()
{
    while (_running)
    {
        Packet p = ReadPacket(_socket);  // 블로킹 수신
        _inbox.Enqueue(p);               // 엔진 객체는 건드리지 않는다
    }
}

// 메인 스레드: 쌓인 만큼 꺼내 적용한다
void Update()
{
    while (_inbox.TryDequeue(out Packet p))
        Apply(p);                        // 여기서 transform을 바꾼다
}

큐 대신 일반 List<T>를 두 스레드가 같이 쓰면 데이터 레이스가 난다.

  • 유실: 메인 스레드가 목록을 다 읽고 Clear()하기 직전에 수신 스레드가 Add()하면, 처리되지 않은 패킷이 같이 지워진다. 사망·획득 같은 패킷이면 서버와 상태가 어긋난다
  • 손상: 내부 배열을 늘리는 도중에 읽으면 null을 읽을 수 있고, foreach 중에 추가되면 컬렉션 수정 예외가 난다

IOCP의 “완료 알림은 워커 스레드에서 온다”와 같은 모양이다. 받는 건 어느 스레드든 괜찮고, 엔진 객체를 만지는 건 메인 스레드만. 그 사이에 스레드 안전 큐를 둔다.


정리

  • 멀티플렉싱은 블로킹을 없앤 게 아니라 잠드는 대상을 소켓 집합으로 옮긴 것이다. 블로킹과 순차 처리는 다른 성질이다
  • select의 비용은 소켓 개수보다 번호 범위에, epoll의 비용은 준비된 개수에 묶인다
  • 갈림길은 “커널이 상태를 기억하는가”. 기억하지 않으면 단순하지만 매번 전부, 기억하면 빠르지만 생명주기( dup 함정 )가 생긴다
  • LT는 “아직 남았어”를 계속, ET는 “새로 왔어”만. ET는 EAGAIN까지 비우는 책임이 코드로 온다
  • EPOLLOUT은 보낼 게 밀려 있을 때만 켠다
  • 스레드 여럿이 같은 epoll을 기다리면 EPOLLONESHOT으로 한 연결은 한 번에 한 스레드만 읽게 한다
  • “무슨 일이 있었나( n )”와 “시간이 됐나( 시계 )”는 따로 묻는다
  • 받는 건 어느 스레드든, 엔진 객체를 만지는 건 메인 스레드만

아직 확인 못 한 것

  • select와 epoll의 비용 차이, LT/ET 동작, thundering herd를 직접 재현하거나 측정하지 않았다
  • Linux select 구현이 nfds 범위만 복사한다는 점, epoll의 LT 재삽입이 일어나는 커널 함수 위치는 소스로 확인하지 않았다
  • Unity에서 메인 스레드 밖 접근 시 나오는 예외의 정확한 문구와, 막히는 API의 범위
  • .NET 비동기 소켓의 콜백이 어느 스레드에서 실행되는지는 API와 런타임 사용 방식에 따라 다르다

더 읽어볼 만한 것들


소감

점심 나가서 먹을 것 같아