epoll - select, poll과의 차이와 edge, level 트리거
“연결 하나에 스레드 하나”를 쓰지 않는 서버는 전부 이것 위에 서 있다. Netty, Nginx, Redis, Node.js가 리눅스에서 하는 일의 밑바닥이 epoll이고, 자바의 Selector도 리눅스에서는 이것으로 구현된다. 동작을 알면 이벤트 루프 모델이 무엇을 아끼고 무엇을 요구하는지가 보인다.
select와 poll이 느린 이유
select와 poll은 호출할 때마다 관심 있는 디스크립터 목록 전체를 커널에 넘긴다. 커널은 그 전부를 훑어 준비된 것을 표시하고, 애플리케이션은 돌려받은 목록을 다시 전부 훑어 어느 것이 준비됐는지 찾는다.
연결이 N개면 호출 한 번에 O(N)이 두 번 일어난다. 준비된 것이 하나뿐이어도 그렇다. 연결 10,000개에 활성이 10개인 상황(웹 서버의 일상)에서 낭비가 대부분이다.
select에는 추가 제약도 있다. FD_SETSIZE(보통 1024)라는 상한이 있고, 호출할 때마다 fd_set을 다시 채워야 한다.
epoll이 바꾼 것: 관심 목록을 커널에 둔다
epoll은 셋으로 나눈다.
| 호출 | 하는 일 |
|---|---|
epoll_create1() | 관심 목록을 담을 인스턴스를 만든다 |
epoll_ctl(ADD/MOD/DEL) | 디스크립터를 등록·수정·삭제한다 |
epoll_wait() | 준비된 것만 돌려받는다 |
관심 목록이 커널에 상주하므로 매번 넘길 필요가 없다. 커널은 각 소켓이 준비될 때 그것을 준비 목록에 넣어 두고, epoll_wait은 그 목록만 돌려준다. 반환된 배열의 크기가 곧 준비된 개수다.
결과적으로 비용이 전체 연결 수가 아니라 활성 연결 수에 비례한다. 이것이 C10K 문제의 답이었다. (BSD 계열의 kqueue, 솔라리스의 이벤트 포트가 같은 접근이고, 최근 리눅스에는 io_uring이라는 다른 방향의 인터페이스가 있다.)
level 트리거와 edge 트리거
epoll에는 두 가지 알림 방식이 있고, 이것이 구현 난이도를 가른다.
Level-triggered(LT, 기본). “지금 읽을 데이터가 있다”는 상태를 알린다. 데이터가 남아 있는 한 epoll_wait이 계속 알려 준다. 한 번에 다 읽지 않아도 다음에 또 온다. poll과 같은 의미라 옮기기 쉽다.
Edge-triggered(ET). “새 데이터가 도착했다”는 변화만 알린다. 알림은 한 번뿐이다. 그 한 번에 EAGAIN이 날 때까지 다 읽지 않으면, 남은 데이터는 새 데이터가 또 올 때까지 잊힌다. 연결이 멈춘 것처럼 보이는 버그가 여기서 나온다.
| LT | ET | |
|---|---|---|
| 알림 | 조건이 유지되는 동안 계속 | 변화 시 한 번 |
| 요구 | 없음 | 논블로킹 소켓 + EAGAIN까지 반복 읽기 |
| 깨어나는 횟수 | 많음 | 적음 |
| 난이도 | 낮음 | 높음 |
ET가 빠른 것은 불필요한 깨어남이 줄기 때문이다. 다만 잘못 쓰면 조용히 멈춘다. 대부분의 프레임워크가 LT를 기본으로 쓰고, 성능이 실제로 문제일 때만 ET를 고려하는 이유다.
이벤트 루프가 요구하는 것
epoll 위의 서버는 스레드 몇 개로 수만 연결을 다룬다. 대신 규칙이 생긴다.
이벤트 루프 스레드에서 블로킹하면 안 된다. 그 스레드가 담당하는 모든 연결이 함께 멈춘다. DB 호출, 파일 IO, 동기 외부 호출을 루프 안에서 하면 서버가 통째로 굳는다. Netty에서 블로킹 작업을 별도 EventExecutorGroup으로 빼는 이유이고, Node.js에서 동기 함수를 피하라는 조언의 근거다.
이것이 스레드 기반 모델과의 교환이다. 스레드 모델은 느린 호출 하나가 스레드 하나를 묶고, 이벤트 루프는 느린 호출 하나가 그 루프의 모든 연결을 묶는다. 전자는 상한(풀 크기)에 도달해야 무너지고, 후자는 한 번에 무너진다.
파일 IO는 epoll로 다룰 수 없다. 일반 파일 디스크립터는 항상 “준비됨”으로 보고된다. 디스크 읽기의 블로킹은 이 모델 밖에 있고, 그래서 별도 스레드 풀이나 io_uring이 필요하다.
이 설명이 깨지는 곳
epoll은 리눅스 전용이다. 자바의Selector는 플랫폼마다 다른 구현(epoll,kqueue,IOCP) 위에 있고, 성능 특성도 다르다.- thundering herd가 있다. 여러 스레드가 같은
epoll인스턴스를 기다리면 하나의 이벤트에 여럿이 깬다.EPOLLEXCLUSIVE플래그가 그 완화책이다. - 가상 스레드가 이 모델의 필요를 줄인다. JVM이 내부적으로 논블로킹 IO와
epoll을 쓰면서 개발자에게는 동기 코드를 허용한다(가상 스레드의 내부). - 연결 수가 적으면 차이가 없다. 수백 연결이면
poll로도 충분하고, 복잡도만 늘어난다.
무엇을 재면 확인되는가
- 연결 수를 100 → 10,000으로 늘려 가며
select/poll/epoll기반 서버의 CPU와 지연을 비교한다. 활성 비율을 낮게 유지해야 차이가 드러난다. - 이벤트 루프 스레드에서 일부러 100ms를 블로킹하고, 같은 루프의 다른 연결 지연을 본다.
- ET 모드에서 한 번만 읽고 마는 구현을 만들어 연결이 실제로 멈추는지 확인한다.
2번이 이 모델을 이해하는 데 가장 직관적이다. spring-ops-lab은 서블릿 스택으로 고정했으므로 이 실험은 그 범위 밖이고, 별도 하니스가 필요하다.
실무와의 접점
monticker에서 NioEventLoopGroup으로 다수 연결에 시세를 뿌렸다. 그때 “리액터 패턴”이라는 이름으로 정리했는데, 그 아래에 있던 것이 이 글의 내용이다. WebSocket과 폴링 비교에서 잰 수치도 결국 연결 유지 비용을 커널이 어떻게 다루는가의 결과였다.
정리
select·poll은 호출마다 전체 목록을 주고받아 O(전체 연결)이다.epoll은 관심 목록을 커널에 두고 준비된 것만 돌려줘 O(활성 연결)이다.- LT는 상태를, ET는 변화를 알린다. ET는
EAGAIN까지 읽지 않으면 조용히 멈춘다. - 이벤트 루프 스레드에서 블로킹하면 그 루프의 모든 연결이 멈춘다.
- 일반 파일 IO는
epoll의 범위 밖이다. - 스레드 모델은 상한에 도달해야 무너지고, 이벤트 루프는 한 번에 무너진다.
참고
- Linux man: epoll(7)
- Dan Kegel, The C10K problem
- 멀티플렉싱 정리
댓글
아직 댓글이 없습니다