epoll의 세계
앞 장에서 C10K 문제의 해법은 준비된 소켓만 알려 받는 것이라고 했습니다. 리눅스에서 그 일을 하는 것이 epoll입니다. 현대 리눅스 고성능 서버의 심장이라 해도 지나치지 않습니다. 이 장에서 epoll을 직접 다뤄 보고, 그 안에 숨은 중요한 선택인 동작 방식의 차이까지 깊이 파고듭니다.
이 장의 epoll은 리눅스 고유의 기능입니다. 코드는 리눅스나 WSL에서 실행됩니다. macOS 사용자는 코드를 읽으며 개념을 익히고, 실행은 다음 장의 kqueue나 이 장 끝의 이식성 있는 방법으로 확인하기를 권합니다.
epoll의 세 동작
epoll은 세 가지 동작으로 이루어집니다. 이름만 알아 두면 코드가 쉽게 읽힙니다.
첫째는 만들기입니다. epoll 인스턴스를 하나 만듭니다. 지켜볼 소켓들을 등록해 둘 일종의 감시 본부입니다. 둘째는 등록과 해제입니다. 이 본부에 지켜볼 소켓을 추가하거나 뺍니다. select가 매번 소켓 목록을 통째로 넘겨야 했던 것과 달리, epoll은 한 번 등록해 두면 운영체제가 그 명단을 계속 기억합니다. 셋째는 기다리기입니다. 등록된 소켓 중 준비된 것이 생길 때까지 기다렸다가, 준비된 것들만 돌려받습니다. 바로 이 부분이 C10K를 푼 핵심입니다. 전부를 훑지 않고 준비된 것만 받습니다.
epoll 에코 서버
epoll로 에코 서버를 만듭니다. PART 04의 poll 서버와 견주어 보면 구조가 매우 비슷합니다.
# 새 파일: epoll_server.pyimport socketimport selectserver = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)server.bind(("127.0.0.1", 9000))server.listen()server.setblocking(False)epoll = select.epoll()epoll.register(server.fileno(), select.EPOLLIN)connections = {server.fileno(): server}print("epoll 에코 서버 대기 중")try: while True: events = epoll.poll() for fd, event in events: s = connections[fd] if s is server: conn, addr = s.accept() conn.setblocking(False) epoll.register(conn.fileno(), select.EPOLLIN) connections[conn.fileno()] = conn elif event & select.EPOLLIN: data = s.recv(1024) if data: s.sendall(data) else: epoll.unregister(fd) del connections[fd] s.close()finally: epoll.close() server.close()
select.epoll()로 감시 본부를 만듭니다. epoll.register(server.fileno(), select.EPOLLIN)으로 듣기 소켓을 등록하는데, 소켓 객체가 아니라 파일 디스크립터 번호로 등록하는 점을 눈여겨보세요. PART 04의 poll에서 본 것과 같습니다. 그래서 번호로 실제 소켓을 되찾을 수 있도록 connections 딕셔너리에 짝지어 둡니다. EPOLLIN은 읽을 데이터가 준비됐는지를 지켜보겠다는 표시입니다.
epoll.poll()이 이 장의 주인공입니다. 등록된 소켓 중 준비된 것들만 돌려줍니다. 1만 개를 등록했어도, 준비된 것이 열 개면 열 개만 돌려줍니다. 운영체제가 전부를 훑지 않고 준비된 것만 모아 건네주는 것입니다. 나머지 흐름은 PART 04의 poll 서버와 똑같습니다. 듣기 소켓이 준비됐으면 받아서 등록하고, 기존 소켓이 준비됐으면 읽어서 돌려주고, 끊겼으면 해제하고 닫습니다.
레벨 트리거와 엣지 트리거
epoll에는 select나 poll에 없는 중요한 선택지가 하나 있습니다. 동작 방식을 두 가지 중에서 고를 수 있다는 점입니다. 면접에서도 자주 묻는 주제이니 정확히 이해해 둡시다.
기본은 레벨 트리거입니다. 소켓에 읽을 데이터가 남아 있는 한, epoll은 계속 그 소켓이 준비됐다고 알려 줍니다. 마치 물이 차 있는 동안 계속 울리는 경보 같습니다. 우리가 한 번에 다 안 읽어도, 다음 번에 또 알려 주니 안심입니다. 위의 코드가 이 방식으로 동작합니다. 다루기 쉬워 대부분의 경우 이쪽을 씁니다.
다른 하나는 엣지 트리거입니다. 소켓의 상태가 바뀌는 그 순간에만, 단 한 번 알려 줍니다. 데이터가 새로 도착한 순간에 한 번 알리고, 그다음부터는 우리가 다 읽지 않아도 다시 알려 주지 않습니다. 변화의 모서리에서만 신호를 준다고 해서 엣지 트리거입니다.
엣지 트리거는 왜 쓸까요. 더 빠르기 때문입니다. 같은 데이터에 대해 거듭 알리지 않으니 알림 횟수가 줄어 효율이 높습니다. 대신 다루기가 까다롭습니다. 한 번 알려 줄 때 데이터를 끝까지 다 읽어 둬야 합니다. 안 그러면 남은 데이터를 영영 못 받을 수 있습니다. 그래서 엣지 트리거에서는 더 읽을 게 없을 때까지 반복해서 읽어야 하는데, 바로 여기서 다음 이야기가 나옵니다.
논블로킹과 EAGAIN
엣지 트리거에서 "더 읽을 게 없을 때까지 읽는다"는 것을 어떻게 알 수 있을까요. 여기서 논블로킹 소켓이 핵심 역할을 합니다.
논블로킹 소켓에서 recv를 불렀는데 지금 당장 읽을 데이터가 없으면, 멈춰 기다리는 대신 곧바로 특별한 에러를 냅니다. C에서는 이 에러를 EAGAIN 또는 EWOULDBLOCK이라 부르고, Python에서는 BlockingIOError라는 예외로 나타납니다. 이름은 "지금은 안 되니 다음에 다시 시도하라"는 뜻입니다.
그래서 엣지 트리거에서는 이렇게 합니다. 한 소켓이 준비됐다고 알려 오면, 그 소켓에서 recv를 계속 반복합니다. 데이터가 나오는 동안 계속 읽다가, EAGAIN이 나오면 "이제 다 읽었구나" 하고 멈춥니다. 이 에러가 읽기의 끝을 알리는 신호인 셈입니다. 우리가 PART 04에서 논블로킹 소켓을 쓰면서 만난 BlockingIOError가 바로 이것이며, 고성능 서버에서는 이렇게 적극적으로 활용됩니다.
이식성 있는 방법, selectors
epoll은 리눅스 전용이고 kqueue는 macOS 전용이라, 운영체제마다 코드를 다르게 짜야 한다면 번거롭습니다. 다행히 Python은 이 차이를 감춰 주는 고수준 도구를 제공합니다. selectors 모듈입니다.
selectors는 운영체제에 맞춰 가장 좋은 방법을 알아서 골라 씁니다. 리눅스에서는 epoll을, macOS에서는 kqueue를 내부적으로 사용합니다. 우리는 그 차이를 신경 쓰지 않고 같은 코드를 짜면 됩니다.
# 새 파일: selectors_server.pyimport socketimport selectorsselector = selectors.DefaultSelector()server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)server.bind(("127.0.0.1", 9000))server.listen()server.setblocking(False)selector.register(server, selectors.EVENT_READ, data=None)print("selectors 에코 서버 대기 중")while True: for key, mask in selector.select(): s = key.fileobj if key.data is None: conn, addr = s.accept() conn.setblocking(False) selector.register(conn, selectors.EVENT_READ, data=b"") else: data = s.recv(1024) if data: s.sendall(data) else: selector.unregister(s) s.close()
selectors.DefaultSelector()가 운영체제에 맞는 최선의 방법을 골라 줍니다. 등록할 때 소켓이 듣기 소켓인지 구별하려고 data에 표시를 두었습니다. 듣기 소켓은 None, 일반 클라이언트는 빈 바이트열로 등록해, 이벤트가 올 때 key.data로 둘을 구분합니다. 나머지는 지금까지의 흐름과 같습니다.
이 selectors 서버는 실제로 리눅스에서는 epoll로, macOS에서는 kqueue로 동작합니다. 운영체제를 가리지 않고 잘 돌아갑니다. 실무에서 Python으로 고성능 서버를 짠다면, 운영체제마다 다른 저수준 API를 직접 쓰기보다 이 selectors를 쓰는 편이 일반적입니다. 저수준 epoll을 배우는 이유는, 이 selectors가 그 아래에서 무엇을 하는지 이해하기 위해서입니다.
epoll의 세계를 들여다봤습니다. 다음 장에서 그 형제인 kqueue를 짧게 만나, macOS와 BSD에서는 같은 발상이 어떻게 구현되는지 확인하겠습니다.