이벤트 루프는 많은 소켓을 어떻게 한꺼번에 지켜볼까? 준비 상태와 epoll

반응형

서버가 연결 하나마다 read를 호출한 뒤 데이터가 올 때까지 멈춘다면, 첫 번째 연결이 조용한 동안 다른 연결의 요청도 처리하기 어렵다. 이벤트 루프는 각 연결을 계속 돌아보는 대신 운영체제에 “지금 처리할 수 있는 소켓만 알려 달라”고 요청한다.

소켓(socket: 프로세스가 네트워크 연결이나 통신 끝점을 파일 디스크립터처럼 다루게 해 주는 운영체제 객체)의 준비 상태를 이해하면 비동기 I/O가 작업을 없애는 방식이 아니라 기다리는 대상을 모아 두고 실행할 순간을 고르는 방식이라는 점이 더 분명해진다.

연결이 있다는 것과 읽을 데이터가 있다는 것은 다르다

TCP 연결이 성립한 소켓이 있어도 상대가 아직 요청을 보내지 않았을 수 있다. 이때 블로킹 read(blocking read: 데이터가 올 때까지 호출한 실행 흐름을 재우는 읽기)를 호출하면 해당 스레드는 대기 상태가 된다.

연결 A: 데이터 도착 → 지금 읽을 수 있음
연결 B: 조용함     → 읽으면 기다려야 함
연결 C: 연결 종료   → 종료 상태를 읽어야 함

준비 상태(readiness: 지금 I/O 연산을 시도했을 때 오래 기다리지 않고 진행할 수 있는 상태)는 작업 완료와 다르다. “읽을 수 있다”는 알림을 받았어도 실제로 몇 바이트를 읽을지, 한 요청이 완성됐는지, 상대가 연결을 닫았는지는 read 결과를 확인해야 한다.

select와 poll은 여러 fd를 한 번에 기다린다

select(select: 여러 파일 디스크립터의 읽기·쓰기 가능 상태를 한 번에 기다리는 시스템 콜)와 poll(poll: 파일 디스크립터 목록과 관심 이벤트를 넘겨 준비된 항목을 받는 시스템 콜)은 많은 소켓을 하나의 스레드에서 다룰 수 있게 한다.

관심 목록: [fd 3 읽기, fd 4 읽기, fd 7 쓰기]
              ↓ 운영체제에서 기다림
준비 결과: [fd 4 읽기, fd 7 쓰기]

애플리케이션은 준비된 fd만 처리하고 다시 기다린다. 연결마다 대기 스레드를 하나씩 만들 필요가 줄어든다.

다만 select와 poll은 호출할 때마다 관심 목록을 운영체제에 전달하거나 많은 항목을 다시 훑는 비용이 생길 수 있다. 연결 수가 매우 많고 실제로 준비되는 연결은 적을 때 이 비용이 커진다.

epoll은 관심 목록을 운영체제에 유지한다

epoll(event poll: 리눅스에서 관심 있는 fd를 등록해 두고 준비된 이벤트만 받아 보는 I/O 알림 방식)은 감시 목록을 운영체제 안에 유지한다. 애플리케이션은 fd를 등록·변경·삭제하고 대기 호출에서는 발생한 이벤트를 받는다.

한 번 등록: fd 3, 4, 7의 읽기 이벤트

반복:
  epoll_wait
    → 준비된 fd 4 반환
    → fd 4를 읽고 상태 갱신
    → 다시 epoll_wait

이 모델은 연결 전체를 매번 순회하는 일을 줄인다. 하지만 epoll이라는 이름만 쓴다고 서버가 자동으로 빠른 것은 아니다. 이벤트를 받은 뒤 한 연결의 CPU 작업을 너무 오래 수행하면 이벤트 루프 자체가 멈춘다.

레벨 트리거와 에지 트리거는 알림 규칙이 다르다

레벨 트리거(level-triggered: fd가 준비 상태인 동안 계속 알림을 받을 수 있는 방식)는 데이터를 조금만 읽고 돌아와도 다음 대기에서 다시 알려 줄 수 있다. 구현이 비교적 단순하지만 같은 상태를 여러 번 받을 수 있다.

에지 트리거(edge-triggered: 준비되지 않은 상태에서 준비된 상태로 바뀌는 순간을 중심으로 알리는 방식)는 알림 횟수를 줄일 수 있다. 대신 한 번 알림을 받았을 때 더 읽을 수 없다는 결과가 나올 때까지 데이터를 비워야 이벤트를 놓치지 않는다.

버퍼에 100바이트 도착

레벨 트리거: 남아 있는 동안 다시 알림 가능
에지 트리거: 도착 변화에 한 번 알림 → 남은 데이터를 스스로 끝까지 읽어야 함

논블로킹(non-blocking: 지금 진행할 수 없으면 기다리지 않고 즉시 상태를 돌려주는 I/O 모드) 소켓과 함께 쓰는 이유도 여기에 있다. 반복해서 읽다가 EAGAIN(EAGAIN: 지금은 더 처리할 데이터가 없으니 나중에 다시 시도하라는 오류 상태)을 만나면 현재 버퍼를 비웠다고 판단한다.

읽을 수 있다는 알림은 요청 하나가 완성됐다는 뜻이 아니다

TCP는 메시지 경계 없는 바이트 흐름을 제공한다. HTTP 헤더의 절반만 도착했어도 소켓은 읽기 가능 상태가 된다. 애플리케이션은 연결마다 아직 해석하지 못한 바이트를 버퍼에 보관해야 한다.

첫 read : "POST /orders HT"
둘째 read: "TP/1.1\r\nContent-Length: 5\r\n\r\nhello"

두 번의 read 결과를 이어 붙여야 요청 하나가 완성됨

쓰기 역시 마찬가지다. 쓰기 가능 알림은 운영체제 송신 버퍼에 공간이 있다는 뜻이지 응답 전체가 상대에게 전달됐다는 뜻은 아니다. 일부만 썼다면 남은 위치를 기억하고 다음 쓰기 가능 이벤트에서 이어야 한다.

이벤트 루프가 막히면 모든 연결이 함께 늦어진다

하나의 콜백이 큰 압축, 암호 계산, 느린 파일 읽기를 동기적으로 수행하면 그동안 다른 준비 이벤트를 처리하지 못한다. 이벤트 루프 지연(event-loop lag: 처리할 이벤트가 준비된 시점과 실제 실행된 시점 사이의 늦어짐)이 커진다.

CPU 작업을 작업 풀로 넘기거나, 긴 계산을 나누거나, 별도 프로세스로 격리하는 이유다. 비동기 I/O는 기다림을 효율적으로 다루지만 CPU 시간을 새로 만들지는 않는다.

이해 확인 질문과 답변

소켓이 읽기 가능하다는 말은 요청 하나가 도착했다는 뜻일까?

아니다. 지금 read가 기다리지 않고 일부 바이트나 연결 종료 상태를 돌려줄 수 있다는 뜻이다. 애플리케이션 프로토콜의 요청 경계는 읽은 바이트를 모아 따로 판단해야 한다.

epoll이 select보다 항상 빠를까?

항상 그렇지는 않다. fd가 적거나 대부분 늘 준비되어 있다면 차이가 작을 수 있다. 연결은 많지만 한 번에 준비되는 수가 적고 관심 목록을 반복해서 전달·순회하는 비용이 클 때 장점이 커진다.

에지 트리거에서 왜 EAGAIN이 나올 때까지 읽어야 할까?

준비 상태로 바뀐 순간의 알림만 받고 남은 데이터를 두면 다음 상태 변화가 일어나지 않아 다시 알려 주지 않을 수 있다. 현재 읽을 수 있는 데이터를 모두 소진했다는 경계를 EAGAIN으로 확인한다.

이벤트 루프에서 CPU 계산을 오래 하면 무엇이 깨질까?

다른 소켓 이벤트가 준비되어도 실행 차례를 얻지 못한다. 연결 하나의 계산이 전체 연결의 응답 시간을 늘리므로 긴 CPU 작업을 분리하거나 잘게 나누어야 한다.

AI에게 이렇게 요청할 수 있다

연결 A, B, C 가운데 일부만 읽기 가능한 상황을 select, poll, epoll 방식으로 비교해 줘. 레벨 트리거와 에지 트리거에서 알림이 어떻게 달라지는지, 부분 읽기와 EAGAIN까지 상태 흐름으로 설명해 줘.

이벤트 루프는 많은 연결을 빠르게 도는 반복문이 아니다. 운영체제가 알려 준 준비 상태만 처리하고, 연결마다 남은 바이트와 다음 관심 이벤트를 기억하는 상태 관리자에 가깝다.

반응형
KEEP READING
카테고리 전체 보기 →

댓글