블로킹 I/O와 이벤트 루프
블로킹은 스레드가 커널의 대기 큐로 옮겨지고 스케줄러가 CPU를 회수하는 상태를 말한다. 이벤트 루프는 그걸 피하려고 유저 공간 스레드 하나가 epoll_wait를 무한 반복하며 준비된 소켓만 처리하는 구조다.
언제 찾게 되는가 — “논블로킹인데 왜 스레드가 필요하지?”, “커널이 콜백을 불러주는 건가?”가 궁금할 때. 또는 스레드 풀은 꽉 찼는데 CPU 사용률은 3%일 때.
핵심 답 (먼저 읽을 3줄)
read()가 막히는 건 스레드가 “기다리는” 게 아니라 커널이 그 스레드를 재우는 것이다. 태스크 상태가TASK_INTERRUPTIBLE로 바뀌고 소켓의 대기 큐에 매달린 뒤 컨텍스트 스위치가 일어난다. 데이터가 도착해야 다시 실행 큐로 돌아온다.- OS에는 “이벤트 루프”라는 기능이 없다. 커널이 주는 건
epoll/kqueue라는 준비 알림 창구뿐이다. 루프를 도는 것도, 콜백을 호출하는 것도 전부 유저 공간 코드(Netty의EventLoop스레드 등)다. - 그래서 논블로킹의 본질은 “스레드 수를 연결 수에서 떼어내는 것”이다. 연결 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,000 | 97ms | +125.7 MB | ~62 KB | 2,007 |
| 플랫폼 스레드 | 4,000 | 250ms | +208.1 MB | ~52 KB | 4,007 |
| 플랫폼 스레드 | 10,000 | — | — | — | 생성 실패 (아래) |
| 가상 스레드 | 2,000 | 15ms | +7.6 MB | ~3 KB | 16 |
| 가상 스레드 | 10,000 | 56ms | +40.6 MB | ~4 KB | 16 |
✅ 실측 (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 reachedRAM이 부족해서가 아니라 프로세스당 스레드 수 제한에 걸린 것이다 (sysctl kern.num_taskthreads = 4096). 이름은 OutOfMemoryError지만 원인은 리소스 한도다. 리눅스라면 /proc/sys/kernel/threads-max와 RLIMIT_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/BSDkqueue, WindowsIOCP(준비 통보가 아니라 완료 통보 모델 — 커널이 유저 버퍼까지 채워놓고 알려준다), 최신 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.Selector | JDK의 epoll/kqueue 래퍼. EPollSelectorImpl, KQueueSelectorImpl |
java.net.http.HttpClient | 내부 SelectorManager 스레드가 select 루프를 돌고, 응답이 완성되면 CompletableFuture를 complete() 시킨다 |
| Netty / WebFlux | EventLoopGroup = (스레드 + 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 병목. 풀을 키우기 전에 논블로킹 클라이언트나 가상 스레드를 먼저 검토한다.
Related
- Future.get() vs CompletableFuture — JVM 쪽에서 이 구조가 어떻게 API로 드러나는지, 실측 비교
- 프로세스·스레드와 동기화 — 스레드·컨텍스트 스위치·동기화 기본기
- TCP vs UDP — 위 여정에서 패킷이 소켓 수신 큐에 닿기까지의 전송 계층
- CountDownLatch — 블로킹 대기가 만드는 데드락과 가상 스레드 해법