Skip to Content
Operating System블로킹 I/O와 이벤트 루프

블로킹 I/O와 이벤트 루프

블로킹은 스레드가 커널의 대기 큐로 옮겨지고 스케줄러가 CPU를 회수하는 상태를 말한다. 이벤트 루프는 그걸 피하려고 유저 공간 스레드 하나가 epoll_wait를 무한 반복하며 준비된 소켓만 처리하는 구조다.

언제 찾게 되는가 — “논블로킹인데 왜 스레드가 필요하지?”, “커널이 콜백을 불러주는 건가?”가 궁금할 때. 또는 스레드 풀은 꽉 찼는데 CPU 사용률은 3%일 때.

핵심 답 (먼저 읽을 3줄)

  1. read()가 막히는 건 스레드가 “기다리는” 게 아니라 커널이 그 스레드를 재우는 것이다. 태스크 상태가 TASK_INTERRUPTIBLE로 바뀌고 소켓의 대기 큐에 매달린 뒤 컨텍스트 스위치가 일어난다. 데이터가 도착해야 다시 실행 큐로 돌아온다.
  2. OS에는 “이벤트 루프”라는 기능이 없다. 커널이 주는 건 epoll/kqueue라는 준비 알림 창구뿐이다. 루프를 도는 것도, 콜백을 호출하는 것도 전부 유저 공간 코드(Netty의 EventLoop 스레드 등)다.
  3. 그래서 논블로킹의 본질은 “스레드 수를 연결 수에서 떼어내는 것”이다. 연결 1만 개 : 스레드 1만 개 → 연결 1만 개 : 이벤트 루프 스레드 몇 개.

read() 한 번의 전 여정 (Linux 기준)

소켓에서 아직 데이터가 안 온 상태로 read(fd, buf, n)을 부르면:

[유저] read(fd) 호출 └─ syscall 진입: CPU가 커널 모드로 전환 [커널] tcp_recvmsg → 소켓 수신 큐 확인 → 비어 있음 ├─ 현재 태스크 상태를 TASK_INTERRUPTIBLE 로 변경 ├─ 소켓의 wait queue(sk_wq)에 이 태스크를 매달아 둠 └─ schedule() 호출 → 실행 큐에서 빠짐 → 컨텍스트 스위치 (다른 스레드가 CPU 획득) ⋯ (이 스레드는 CPU를 1%도 쓰지 않음. 대신 커널 태스크 구조체 + 스택 메모리는 계속 점유) ⋯ [NIC] 패킷 도착 → DMA로 링 버퍼에 적재 → 하드웨어 인터럽트 [커널] softirq(NET_RX) → ip_rcv → tcp_v4_rcv → 데이터를 소켓 수신 큐에 적재 └─ sk_data_ready() 콜백 → wait queue의 태스크를 깨움(TASK_RUNNING) → 실행 큐에 넣음 [스케줄러] 차례가 오면 이 스레드 재개 [커널] copy_to_user()로 수신 버퍼 → 유저 버퍼 복사 → read() 반환

📄 문서 기반 (미검증 — 리눅스 커널 네트워크 스택 구조)

여기서 두 가지가 중요하다.

  • sk_data_ready가 모든 이야기의 분기점이다. 블로킹 모드에선 이 콜백이 “잠든 스레드를 깨우고”, 논블로킹+epoll 모드에선 같은 콜백이 “준비된 fd 목록에 등록”한다. 커널이 하는 일은 거기까지고, 애플리케이션 콜백을 대신 실행해주지는 않는다.
  • 컨텍스트 스위치는 공짜가 아니다. 직접 비용 자체는 수 μs지만, 캐시와 TLB가 날아가는 간접 비용이 더 크다. 요청마다 스레드가 자고 깨는 구조는 이 비용을 요청 수만큼 낸다. (📄 문서 기반)

스레드는 그 자체로 비용이다

블로킹 대기 중인 스레드 N개를 유지할 때의 실제 비용:

방식개수생성 + 전원 대기 진입프로세스 RSS 증가개당OS(플랫폼) 스레드 수
플랫폼 스레드2,00097ms+125.7 MB~62 KB2,007
플랫폼 스레드4,000250ms+208.1 MB~52 KB4,007
플랫폼 스레드10,000생성 실패 (아래)
가상 스레드2,00015ms+7.6 MB~3 KB16
가상 스레드10,00056ms+40.6 MB~4 KB16

✅ 실측 (JDK 25 / macOS aarch64, 2026-08. 각 스레드가 latch.await()로 대기 중인 상태에서 ps -o rss 측정)

  • 플랫폼 스레드 스택은 힙이 아니라 네이티브 메모리다. 힙 사용량은 거의 안 늘고 RSS만 뛴다. 이 JVM의 예약 크기는 스레드당 stacksize: 2048k, guardsize: 16k인데(JVM 경고 로그로 확인), 실제로 만진 페이지만 물리 메모리를 먹어 52~62KB로 관측된다. 예약(가상 주소)과 상주(물리 메모리)를 구분해야 한다.
  • 가상 스레드 스택은 **힙 위의 객체(Continuation)**다. 그래서 힙이 늘고 개당 3~4KB로 끝난다. 그리고 1만 개를 띄워도 플랫폼 스레드는 16개 — 캐리어 풀 크기 그대로다.
  • 메모리보다 스레드 개수 상한이 먼저 온다. 1만 개를 시도하면 4,067번째에서 이렇게 죽는다:
[warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 2048k, guardsize: 16k, detached. Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached

RAM이 부족해서가 아니라 프로세스당 스레드 수 제한에 걸린 것이다 (sysctl kern.num_taskthreads = 4096). 이름은 OutOfMemoryError지만 원인은 리소스 한도다. 리눅스라면 /proc/sys/kernel/threads-maxRLIMIT_NPROC이 같은 역할을 한다. “블로킹이라도 스레드를 많이 만들면 되지”가 안 통하는 이유 — 연결 수와 스레드 수를 묶어두는 한 커널이 정한 천장을 넘을 수 없다.

”논블로킹 소켓”의 실체는 플래그 하나다

논블로킹은 대단한 장치가 아니라 fd에 붙는 플래그다.

fcntl(fd, F_SETFL, O_NONBLOCK); // 이 소켓은 이제 나를 재우지 마라 ssize_t n = read(fd, buf, len); // 데이터 있음 → 읽은 바이트 수 반환 // 데이터 없음 → -1 반환, errno == EAGAIN (== EWOULDBLOCK) ← 잠들지 않고 즉시 돌아온다

즉 논블로킹 read()는 “기다리지 않는 read”이지 “결과를 나중에 주는 read”가 아니다. 데이터가 없으면 그냥 실패로 즉시 반환한다. 그래서 이것만으로는 쓸 수가 없다 — EAGAIN이 돌아올 때마다 다시 부르면 CPU를 태우는 바쁜 대기(busy polling)가 된다.

여기서 epoll이 필요해진다. “지금 읽을 게 있는 fd만 알려줘”를 커널에 물어보고, 알려준 fd에만 논블로킹 read()를 거는 것 — 이 조합이 논블로킹 I/O의 전부다. 커널이 콜백을 실행해주는 게 아니라, 애플리케이션이 물어보는 시점을 커널이 최적화해주는 것이다.

이벤트 루프는 커널이 아니라 유저 공간에 있다

epoll은 커널이 제공하는 자료구조 + 알림 창구다.

int ep = epoll_create1(0); epoll_ctl(ep, EPOLL_CTL_ADD, fd, &ev); // "이 fd 준비되면 알려줘" (1만 개 등록 가능) int n = epoll_wait(ep, events, MAX, -1); // 준비된 것만 배열로 받아옴. 없으면 이 스레드 하나만 잠듦
  • 소켓에 데이터가 오면 앞서 본 sk_data_ready가 이번엔 ep_poll_callback을 타고 커널의 ready list에 fd를 추가하고, epoll_wait에서 자던 스레드 하나를 깨운다.
  • select/poll은 호출할 때마다 전체 fd 집합을 커널에 복사하고 전부 훑는다(O(n)). epoll은 등록을 한 번만 하고 준비된 것만 받는다(사실상 O(1)). 그래서 C10K 이후의 서버는 전부 epoll 계열이다.
  • 통보 방식이 두 가지다. 레벨 트리거(기본) 는 “버퍼에 데이터가 남아 있는 동안 계속” 알려주고, 엣지 트리거(EPOLLET) 는 “새로 도착한 순간 한 번만” 알려준다. 엣지 트리거는 EAGAIN이 날 때까지 다 읽지 않으면 남은 데이터를 영영 못 받는다 — Netty 등이 성능을 위해 쓰지만 직접 다루면 잘 물리는 함정.
  • 플랫폼별 이름만 다르다: Linux epoll, macOS/BSD kqueue, Windows IOCP(준비 통보가 아니라 완료 통보 모델 — 커널이 유저 버퍼까지 채워놓고 알려준다), 최신 Linux는 io_uring(제출/완료 링 버퍼로 syscall 자체를 줄이는 완료 기반 모델).

이벤트 루프는 이 위에 유저 공간이 얹은 while문이다. Netty EventLoop를 의사코드로 펴면:

while (!shutdown) { int ready = selector.select(timeout); // ← epoll_wait. 여기서만 잠든다 for (SelectionKey k : selector.selectedKeys()) { if (k.isReadable()) channel.read(); // 커널 버퍼 → 유저 버퍼 (이미 데이터 있음, 안 막힘) // 파이프라인 핸들러 실행 = "콜백 호출" ← 커널이 아니라 이 스레드가 직접 부른다 } runAllTasks(); // 다른 스레드가 넣어둔 작업 큐 처리 }

그래서 “콜백이 오면 OS가 스레드를 깨워 실행한다”는 표현은 절반만 맞다. 커널이 깨우는 건 epoll_wait에서 자고 있던 이벤트 루프 스레드 하나뿐이고, 그 스레드가 자기 발로 애플리케이션 콜백(= CompletableFuture.complete() 포함)을 호출한다.

Java에서 실제로 epoll을 부르는 곳

계층무엇
java.nio.channels.SelectorJDK의 epoll/kqueue 래퍼. EPollSelectorImpl, KQueueSelectorImpl
java.net.http.HttpClient내부 SelectorManager 스레드가 select 루프를 돌고, 응답이 완성되면 CompletableFuturecomplete() 시킨다
Netty / WebFluxEventLoopGroup = (스레드 + Selector + 태스크 큐) N개. 기본 N은 Netty가 가용 코어 × 2, Spring WebFlux(Reactor Netty)는 max(4, 가용 코어) (📄 문서 기반)
가상 스레드 (JDK 21+)sun.nio.ch.Poller가 뒤에서 epoll/kqueue를 돌린다

그래서 CompletableFuture의 “논블로킹”은 이 표의 아래 계층이 만들어준 것이지, CompletableFuture 자신이 만든 게 아니다. CompletableFuture는 완료 신호를 옮기는 통로다.

가상 스레드: 블로킹 문법 + 논블로킹 기계

JDK 21의 가상 스레드는 이벤트 루프를 애플리케이션 코드에서 감춘다.

// 코드는 그냥 블로킹. 하지만 소켓 대기 시점에 캐리어 스레드에서 내려온다 String body = client.send(req, ofString()).body();

내부적으로는 소켓을 논블로킹 모드로 쓰고, 데이터가 없으면 Poller(epoll/kqueue)에 fd를 등록한 뒤 Continuation.yield()스택을 힙에 복사하고 캐리어를 놓아준다. 준비 알림이 오면 다시 캐리어에 올라가 이어서 실행된다. 커널 입장에서 잠든 스레드는 없다.

주의할 점:

  • JDK 21에서는 synchronized 블록 안에서 블로킹하면 캐리어 스레드가 pin 된다 (unmount 불가 → 캐리어 낭비, 최악엔 데드락). ReentrantLock으로 바꾸는 게 회피책이었고, JDK 24의 JEP 491에서 해소됐다.
  • 네이티브 프레임(JNI) 안에서의 블로킹은 여전히 pin 된다.
  • CPU 바운드 작업엔 이점이 없다. 가상 스레드가 해결하는 건 대기 비용이다.

실무로 옮기면

  • 이벤트 루프 스레드에서 블로킹 호출 금지. 루프 스레드는 코어 수만큼뿐이라 하나가 JDBC 쿼리로 100ms 막히면 그동안 그 스레드가 담당한 수천 커넥션이 전부 멈춘다. WebFlux에서 블로킹 코드를 섞으면 안 되는 이유가 이것.
  • 스레드 풀 크기는 “대기 시간 / 작업 시간” 비율로 정한다. CPU 바운드면 코어 수, I/O 대기가 길면 그보다 훨씬 크게. 다만 크게 잡을수록 위 표의 메모리·스위치 비용을 낸다.
  • 스레드 풀은 꽉 찼는데 CPU가 놀고 있다 → 전형적인 블로킹 I/O 병목. 풀을 키우기 전에 논블로킹 클라이언트나 가상 스레드를 먼저 검토한다.
Last updated on