레벨 트리거 vs 엣지 트리거

epoll은 두 가지 통지 모드를 제공한다. 레벨 트리거(LT)는 fd에 처리할 데이터가 남아 있는 한 계속 이벤트를 올린다. 엣지 트리거(ET)는 상태가 "변할 때" 한 번만 올린다. 소켓 버퍼에 데이터가 도착하는 순간에만 EPOLLIN이 통지되고, 커널 버퍼에 데이터가 남아 있어도 새 데이터가 추가되기 전까지는 다시 통지되지 않는다. ET는 통지 횟수가 적어 wakeup 오버헤드와 이벤트 큐 순회 비용을 줄이지만, 그만큼 애플리케이션이 상태 관리를 정확히 책임져야 한다.

항목레벨 트리거(LT)엣지 트리거(ET)
통지 조건데이터가 남아 있으면 매번상태 변화 시 1회
루프당 read 횟수1회로도 안전EAGAIN까지 반복 필수
소켓 모드블로킹/논블로킹 모두논블로킹 필수
기아(starvation) 위험낮음높음(설계 필요)

왜 read를 EAGAIN까지 돌려야 하는가

ET에서 가장 흔한 버그는 "이벤트 한 번에 read 한 번"이다. 예를 들어 클라이언트가 8KB를 보냈는데 read 버퍼가 4KB라면, 첫 read로 4KB만 읽고 루프로 돌아간다. 남은 4KB는 커널 버퍼에 그대로 있지만, 이미 소진된 "엣지"이므로 새 데이터가 오기 전까지 EPOLLIN은 다시 오지 않는다. 결과적으로 요청이 멈춘 것처럼 보이고, 커넥션이 무한 대기에 빠진다. 해결책은 하나다. EAGAIN(또는 EWOULDBLOCK)을 만날 때까지 반복해서 읽어 커널 버퍼를 완전히 비운다.

import socket, select, errno

ep = select.epoll()

def add(fd):
    # EPOLLET로 엣지 트리거 등록
    ep.register(fd, select.EPOLLIN | select.EPOLLET)

def drain_read(conn):
    while True:
        try:
            data = conn.recv(4096)
        except OSError as e:
            if e.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
                break          # 버퍼 소진 -> 다음 엣지까지 대기
            raise
        if not data:           # 상대가 close
            return False
        handle(data)
    return True

쓰기 이벤트와 EPOLLOUT의 함정

쓰기도 대칭이다. write가 EAGAIN을 반환하면 송신 버퍼가 가득 찬 것이므로, 남은 데이터를 애플리케이션 큐에 보관하고 그때만 EPOLLOUT을 등록한다. 흔한 실수는 EPOLLOUT을 항상 켜 두는 것인데, 송신 버퍼가 비는 순간마다 이벤트가 쏟아져 CPU를 100%까지 태우는 busy loop가 된다. 보낼 데이터가 없으면 반드시 EPOLLOUT을 다시 꺼야 한다.

def try_send(conn, buf):
    total = 0
    while total < len(buf):
        try:
            n = conn.send(buf[total:])
            total += n
        except OSError as e:
            if e.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
                # 남은 데이터 저장 후에만 EPOLLOUT 등록
                pending[conn.fileno()] = buf[total:]
                ep.modify(conn, select.EPOLLIN | select.EPOLLOUT | select.EPOLLET)
                return
            raise
    # 다 보냈으면 EPOLLOUT 해제 (busy loop 방지)
    ep.modify(conn, select.EPOLLIN | select.EPOLLET)

기아 문제와 공정성

drain_read를 EAGAIN까지 돌리면 데이터를 계속 보내는 소켓 하나가 루프를 독점할 수 있다. 다른 fd의 처리가 늦어지는 기아 현상이다. 실무에서는 한 fd당 read 횟수나 바이트 수에 상한을 두고, 상한에 도달하면 아직 데이터가 남았음을 자체 큐에 기록해 "ready 리스트"에 다시 넣는 2단계 처리를 쓴다. 처리량과 지연(latency)의 트레이드오프를 코드로 명시하는 것이 핵심이다.

thundering herd와 EPOLLEXCLUSIVE

여러 워커가 하나의 listen 소켓을 공유해 epoll_wait할 때, 커넥션 하나에 모든 워커가 깨어나 경합하는 thundering herd가 발생한다. 리눅스 4.5+에서는 등록 시 EPOLLEXCLUSIVE 플래그로 하나의 대기자만 깨우도록 제한할 수 있다. 소켓 자체를 워커별로 나누고 싶다면 SO_REUSEPORT로 커널이 커넥션을 분산하게 하는 방식이 더 단순하다.

# SO_REUSEPORT 관련 커널 동작 확인
ss -ltn | grep :8080

# 소켓/연결 상태 스냅샷으로 버퍼 적체 여부 점검
ss -ti state established '( dport = :8080 or sport = :8080 )'

EPOLLONESHOT와 멀티스레드

여러 스레드가 같은 epoll을 공유하면 동일 fd 이벤트를 두 스레드가 동시에 잡아 데이터 경합이 생긴다. EPOLLONESHOT을 쓰면 한 번 통지된 fd는 자동으로 비활성화되어 처리 중 재통지가 없다. 다만 처리 완료 후 반드시 epoll_ctl(MOD)로 다시 활성화해야 하며, 이를 잊으면 해당 커넥션이 영구히 멈춘다.

정리: ET를 쓸 때의 체크리스트

ET는 성능이 아니라 "제어권"을 얻는 대가로 복잡성을 떠안는 선택이다. 논블로킹 소켓을 강제하고, read/write는 EAGAIN까지 반복하며, EPOLLOUT은 필요할 때만 켜고, 기아를 막을 상한을 둔다. 이 네 가지가 지켜지지 않으면 ET는 LT보다 느리고 불안정하다. 특별한 이유가 없다면 LT로 시작하고, 프로파일링으로 wakeup 비용이 병목임이 확인된 뒤 ET로 전환하는 편이 안전하다.