Python os.eventfd()로 이벤트 알림 다루기 — 카운터 누적부터 poll 이벤트 루프 깨우기까지

스레드 간, 또는 프로세스 간에 “무슨 일이 일어났다”는 신호 하나만 가볍게 보내고 싶을 때 파이프나 소켓은 다소 과하다. eventfd는 커널이 관리하는 64비트 정수 카운터 하나를 파일 디스크립터로 노출해서, select/poll/epoll 같은 기존 이벤트 루프에 자연스럽게 끼워 넣을 수 있는 가장 가벼운 알림 수단이다. Python 3.10부터 os.eventfd()로 표준 라이브러리에서 바로 쓸 수 있다. 이 글에서는 기본 카운터 동작, EFD_SEMAPHORE 모드, 그리고 poll 기반 이벤트 루프를 다른 스레드에서 깨우는 실전 패턴까지 실제로 확인한다.

기본 사용법 — 스레드 간 신호

os.eventfd_read()는 카운터가 0보다 커질 때까지 블로킹된다. 다른 스레드가 os.eventfd_write()로 값을 더할 때까지 기다리는 가장 단순한 형태다.

import os
import threading
import time

efd = os.eventfd(0)


def worker():
    time.sleep(1)
    os.eventfd_write(efd, 1)
    print("worker: 이벤트 전송")


t = threading.Thread(target=worker)
t.start()

print("main: 이벤트 대기 중... (블로킹)")
value = os.eventfd_read(efd)
print(f"main: 이벤트 수신, value={value}")

t.join()
os.close(efd)
$ python3 basic.py
main: 이벤트 대기 중... (블로킹)
worker: 이벤트 전송
main: 이벤트 수신, value=1

카운터 누적 동작 — 여러 번 write, 한 번에 drain

기본 모드에서 eventfd_write()를 여러 번 호출하면 값이 그냥 더해지고, eventfd_read() 한 번이 그 누적값 전체를 가져가면서 카운터를 0으로 리셋한다.

>>> efd = os.eventfd(0)
>>> os.eventfd_write(efd, 1)
>>> os.eventfd_write(efd, 1)
>>> os.eventfd_write(efd, 3)
>>> os.eventfd_read(efd)
5

1, 1, 3을 따로 썼는데 한 번의 read로 5가 통째로 돌아온다. “몇 번 일어났는지”보다 “총 몇이 쌓였는지”가 중요한 상황(예: 처리해야 할 작업량 누적)에 맞는 동작이다.

EFD_SEMAPHORE — 세마포어처럼 하나씩 소비하기

반대로 “정확히 발생한 횟수만큼 소비 루프를 돌리고 싶다”면 os.EFD_SEMAPHORE 플래그를 준다. 이 모드에서는 read 한 번이 카운터를 1만 감소시킨다.

>>> sem_efd = os.eventfd(0, os.EFD_SEMAPHORE)
>>> os.eventfd_write(sem_efd, 3)
>>> os.eventfd_read(sem_efd)
1
>>> os.eventfd_read(sem_efd)
1
>>> os.eventfd_read(sem_efd)
1

같은 write(3)인데 기본 모드는 read 한 번에 3을 돌려주고, EFD_SEMAPHORE 모드는 read를 세 번 해야 다 소비된다. 생산자-소비자 패턴에서 “큐에 항목이 몇 개 쌓였는지”를 그대로 세마포어 카운트로 쓰고 싶을 때는 이쪽이 자연스럽다.

poll 이벤트 루프를 다른 스레드에서 깨우기

이전 글에서 pidfdselect.poll()에 등록해 프로세스 종료를 기다렸던 것과 같은 방식으로, eventfd도 등록해두면 “다른 스레드가 종료 신호를 보내면 블로킹 중이던 poll을 즉시 깨우는” 패턴을 만들 수 있다.

import os
import select
import threading
import time

wakeup_fd = os.eventfd(0, os.EFD_NONBLOCK)

poller = select.poll()
poller.register(wakeup_fd, select.POLLIN)


def canceller():
    time.sleep(1.5)
    print("canceller: 이벤트 루프 종료 신호 전송")
    os.eventfd_write(wakeup_fd, 1)


threading.Thread(target=canceller).start()

print("event loop: poll()로 대기 (최대 10초)")
events = poller.poll(10_000)
print(f"event loop: 깨어남, events={events}")

os.eventfd_read(wakeup_fd)
os.close(wakeup_fd)
print("event loop: 정상 종료")
$ python3 poll_wakeup.py
event loop: poll()로 대기 (최대 10초)
canceller: 이벤트 루프 종료 신호 전송
event loop: 깨어남, events=[(3, 1)]
event loop: 정상 종료

10초 타임아웃을 걸어뒀지만 1.5초 만에 깨어났다. 소켓이나 다른 fd들과 함께 하나의 poll()/epoll() 루프에 이 eventfd를 “종료 신호” 전용 채널로 끼워 넣으면, 별도의 타임아웃 폴링 없이도 다른 스레드에서 깔끔하게 루프를 깨울 수 있다.

주의사항

  • EFD_NONBLOCK 없이 만든 eventfd는 카운터가 0일 때 eventfd_read()가 그대로 블로킹된다. poll/epoll과 조합할 때는 보통 EFD_NONBLOCK을 함께 줘서, poll이 이벤트를 알려준 뒤에만 논블로킹으로 읽도록 만든다.
  • 카운터가 UINT64_MAX - 1에 가까워지면 write가 블로킹되거나(EFD_NONBLOCK이면 EAGAIN) 실패한다. 실무에서 마주칠 일은 거의 없지만 존재하는 제약이다.
  • eventfd는 리눅스 전용 시스템 콜이라 os.eventfd()도 리눅스에서만 동작한다. 크로스플랫폼 코드라면 hasattr(os, "eventfd")로 분기하거나 대체 수단(소켓 페어 등)을 준비해야 한다.
  • eventfd는 pidfd와 달리 “특정 프로세스”가 아니라 그냥 평범한 fd 하나일 뿐이다. 별도 프로세스에서 같은 eventfd를 쓰고 싶다면 fork()로 상속시키거나, 이미 실행 중인 다른 프로세스에는 유닉스 소켓의 SCM_RIGHTS로 fd 자체를 전달해야 한다.

마무리

eventfd는 “커널이 관리하는 정수 카운터 하나”라는 단순함 덕분에 파이프보다 가볍고, 그러면서도 기존 이벤트 루프에 fd 하나로 자연스럽게 섞여 들어간다. 기본 모드(누적 후 한 번에 drain)와 EFD_SEMAPHORE(하나씩 소비) 중 어느 쪽이 맞는지는 결국 “발생 횟수 자체가 의미 있는가, 아니면 누적된 총량만 중요한가”로 갈린다. pidfd와 마찬가지로 poll 계열 이벤트 루프에 그대로 등록할 수 있다는 점에서, 스레드/비동기 코드 사이의 신호 전달을 표준 라이브러리만으로 깔끔하게 처리하고 싶을 때 첫 번째로 고려할 만하다.

참고

답글 남기기