← System Programming DUJINLABS.COM

Signal / Event / IPC · Linux userspace / kernel ABI

epoll readiness와 edge-triggered loop

readiness가 작업 완료 통지가 아니라 다음 nonblocking I/O의 진행 가능성이라는 점과 EPOLLET drain 규칙을 코드로 확인합니다.

Series
22 / 37
Build
cc -std=c17 -Wall -Wextra -O2 epoll_pipe.c -o epoll_pipe
Run
./epoll_pipe
Kernel
Linux 6.18.37 LTS

EPOLLIN을 받았으면 read가 원하는 길이만큼 반드시 성공하는가?

epoll은 등록한 file의 poll callback과 wait queue를 이용해 상태 변화를 관심 목록에 모은다. EPOLLIN은 read가 적어도 일부 진행되거나 EOF/error를 관찰할 수 있다는 뜻이며 message 전체가 도착했다는 뜻이 아니다.

edge-triggered mode에서는 준비되지 않음에서 준비됨으로 바뀌는 edge를 놓치지 않도록 fd를 nonblocking으로 만들고 EAGAIN이 나올 때까지 drain한다. 일부만 읽고 event loop로 돌아가면 buffer에 데이터가 남아도 새 edge가 오지 않을 수 있다.

구조 그림

그림 1. eventpoll 안의 interest set과 ready list
interest rb-tree
fd 4 · IN|ETfd 7 · OUTfd 9 · IN|ONESHOT
target wait queues
pipe waitsocket write waiteventfd wait
ready list
epitem fd 4epitem fd 9empty slot
userspace events[]
data.u64=conn4data.u64=timer9

interest set은 등록 관계를 보관하고 ready list는 현재 전달 후보를 보관한다. 하나의 fd가 여러 epoll instance에 등록될 수도 있다.

호출 흐름

그림 2. 사용자 코드에서 관찰 가능한 결과까지
epoll_ctl interest 등록
producer fd 상태 변경
wake callback ready list 연결
epoll_wait event batch
drain EAGAIN까지 I/O

readiness 관찰과 실제 I/O 사이에는 다른 thread/process가 상태를 바꿀 수 있다. 모든 syscall 반환값을 다시 판정해야 한다.

그림 3. 커널 내부에서 지나가는 주요 지점
eventpoll interest rb-tree
ep_ptable_queue_proc target wait queue
ep_poll_callback ready list
epoll_wait events copy
file read state 재검사

함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.

Linux 6.18.37 LTS 소스 위치

glibc 함수에서 멈추지 않고 syscall 구현과 커널 객체가 만나는 파일까지 내려간다. 링크는 동일한 태그의 원본 파일을 가리킨다.

파일함수·구조체여기서 볼 것
fs/eventpoll.c do_epoll_ctl(), ep_poll_callback() interest item과 target wait queue 연결
fs/eventpoll.c do_epoll_wait(), ep_send_events() ready list를 userspace event array로 복사
fs/pipe.c pipe_poll(), pipe_read() readiness와 실제 read 결과를 비교하기 쉬운 target

실행 예제 원본

아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.

빌드cc -std=c17 -Wall -Wextra -O2 epoll_pipe.c -o epoll_pipe
01#define _GNU_SOURCE
02#include <errno.h>
03#include <fcntl.h>
04#include <stdio.h>
05#include <sys/epoll.h>
06#include <unistd.h>
07
08int main(void)
09{
10    int p[2];
11    if (pipe2(p, O_NONBLOCK | O_CLOEXEC) < 0)
12        return 1;
13    int ep = epoll_create1(EPOLL_CLOEXEC);
14    struct epoll_event add = { .events = EPOLLIN | EPOLLET, .data.fd = p[0] };
15    if (ep < 0 || epoll_ctl(ep, EPOLL_CTL_ADD, p[0], &add) < 0)
16        return 1;
17
18    if (write(p[1], "abcdef", 6) != 6)
19        return 1;
20    struct epoll_event event;
21    if (epoll_wait(ep, &event, 1, -1) != 1)
22        return 1;
23
24    char buffer[4];
25    for (;;) {
26        ssize_t n = read(p[0], buffer, sizeof(buffer));
27        if (n > 0)
28            fwrite(buffer, 1, (size_t)n, stdout);
29        else if (n < 0 && errno == EINTR)
30            continue;
31        else if (n < 0 && errno == EAGAIN)
32            break;
33        else
34            return n < 0;
35    }
36    putchar('\n');
37    close(p[0]); close(p[1]); close(ep);
38    return 0;
39}

코드 조각별 설명

실제 코드 11행O_NONBLOCK | O_CLOEXEC

EPOLLET drain loop가 마지막 read에서 잠들지 않고 EAGAIN으로 event loop에 제어를 돌려주게 한다.

실제 코드 14행EPOLLIN | EPOLLET

readable 상태의 level 반복이 아니라 상태 변화 edge를 요청한다. one-shot과는 다른 flag다.

실제 코드 21행epoll_wait(ep

ready event를 최대 한 개 가져오지만 target 상태를 잠그지는 않는다. event.data에는 등록 때 넣은 application key가 돌아온다.

실제 코드 24행char buffer[4]

6 byte보다 작은 buffer로 여러 read가 필요한 상황을 일부러 만든다.

실제 코드 31행errno == EAGAIN

현재 pipe를 모두 비운 완료 조건이다. 다음 producer write가 새 edge를 만들 수 있다.

세부 동작

01

level, edge, one-shot을 구분한다

level-triggered는 상태가 계속 ready이면 epoll_wait마다 다시 보고할 수 있다. edge-triggered는 변화 시점을 줄여 event 수를 줄이지만 drain 의무가 생긴다. EPOLLONESHOT은 한 event 뒤 명시적 MOD로 rearm할 때까지 비활성화한다.

worker pool이 같은 fd를 처리할 때 one-shot으로 동시 소비를 제한할 수 있다.

02

HUP와 ERR는 데이터와 함께 올 수 있다

peer close로 EPOLLRDHUP/HUP가 와도 socket receive buffer에 마지막 데이터가 남아 있을 수 있다. HUP를 보자마자 fd를 닫지 말고 read가 0이 될 때까지 처리한다.

ERR/HUP는 interest mask에 넣지 않아도 보고될 수 있으므로 항상 분기한다.

03

fd 번호 재사용과 event data

close한 fd 번호가 새 file에 재사용될 수 있다. 비동기 event object에 숫자 fd만 저장하면 stale event가 새 connection state에 잘못 적용될 수 있다.

generation을 포함한 connection object pointer/index를 data.u64에 넣고 close/reuse protocol을 둔다.

객체와 수명

대상언제 생기고 없어지는가확인할 값
eventpollepoll_create1에서 생기고 epoll fd close에서 해제된다interest tree, ready list, wait queue
epitemEPOLL_CTL_ADD에서 target file과 연결되고 DEL/close에서 제거된다event mask, user data
ready eventcallback에서 ready list에 연결되고 epoll_wait가 전달한다level 재검사, one-shot state

실패 조건과 오해하기 쉬운 부분

겉으로 보이는 현상실제 원인 후보확인 방법
EPOLLET 연결이 멈춤EAGAIN까지 drain하지 않음마지막 read/write 반환값
EOF data 손실HUP에서 즉시 closeread 0 전 남은 buffer 확인
엉뚱한 connection 처리fd 재사용 stale eventgeneration/owner와 close 순서

직접 확인

  1. read loop를 한 번만 읽도록 바꿔 남은 2 byte에 새 EPOLLET event가 오지 않는지 확인한다.
  2. EPOLLET를 제거해 level-triggered에서 남은 데이터가 다시 보고되는지 비교한다.
  3. socketpair와 EPOLLRDHUP를 사용해 peer close 전 전송한 마지막 data와 HUP 순서를 기록한다.
실행./epoll_pipe
추적strace -e trace=pipe2,epoll_ctl,epoll_wait,read,write,close ./epoll_pipe

원문