6장. 논블로킹은 플래그 하나다 — O_NONBLOCK과 EAGAIN

만화로 보는 요약 — 먼저 읽어보세요
논블로킹은 대단한 장치가 아니라 플래그 한 개입니다. 문제는 그다음이에요 — 거절당한 뒤 순진하게 다시 물어보면 CPU를 40배 씁니다.
면접 실전 질문: ①
O_NONBLOCK을 켜면read()는 무엇을 돌려주나요? ② 논블로킹 I/O만으로 서버를 만들면 왜 안 되나요? ③write()에도EAGAIN이 나올 수 있나요?
배경 — “나를 재우지 마라”
5장이 남긴 질문입니다.
“데이터가 있을 때
read()가 잠들지 않는다면, ‘잠들지 마라’고 커널에게 미리 말해두는 방법도 있지 않을까요?”
있습니다. 그리고 놀랄 만큼 소박해요. 파일 디스크립터에 플래그 하나를 켜면 됩니다.
fcntl(fd, F_SETFL, O_NONBLOCK); // 이 fd 는 이제 나를 재우지 마라이게 전부입니다.
실무에서는
int fl = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, fl | O_NONBLOCK);로 씁니다.F_SETFL은 기존 플래그를 통째로 덮어쓰거든요 — 위 한 줄은 갓 만든 소켓이라 안전한 겁니다. 리눅스라면socket(..., SOCK_NONBLOCK)·accept4()로 시스템 콜 한 번을 아낍니다.
O_NONBLOCK. 이름 그대로 “블로킹하지 마라”예요.
그런데 5장의 두 번째 질문이 곧바로 따라옵니다. 재우지 않는다면, 데이터가 없을 때는 뭘 돌려줘야 할까요? 커널이 낼 수 있는 답은 하나뿐입니다. “지금은 없어.”
이 장은 그 대답 하나가 만드는 새로운 문제에 대한 이야기입니다.
스토리 — 거절이라는 대답
논블로킹 read()가 빈 소켓에서 하는 일은 이렇습니다. -1을 돌려주고 errno에 EAGAIN을 세웁니다.
EAGAIN. “again”, 다시 해보라는 뜻이에요. 에러 이름인데 실은 에러가 아닙니다. **“실패했다”가 아니라 “지금은 아니다”**예요.
이 이름, 이 책에서 세 번째입니다. 2장에서 “엣지 트리거는 EAGAIN이 날 때까지 읽어라”라고 했죠 — 그 EAGAIN이 지금 이겁니다. 1장에서 스레드가 더 안 만들어질 때 나온 EAGAIN과는 이름만 같아요. 그쪽은 “자원이 없다”, 이쪽은 “읽을 게 없다”입니다.
이름이 두 개인 것도 역사의 흔적입니다. BSD 계열은 EWOULDBLOCK(“블로킹됐을 텐데”)을, System V 계열은 EAGAIN(“다시 해봐”)을 썼어요. 표준은 둘을 합치는 대신 둘 다 남겨두고 “같은 값이어도 된다”고만 정했습니다. 그래서 리눅스·macOS에서는 실제로 같은 값이고, 잠시 뒤 측정에서 확인합니다. 다만 이식성이 필요한 코드는 여전히 둘 다 봅니다 — errno == EAGAIN || errno == EWOULDBLOCK.
여기서 아주 중요한 구분을 하나 해둡시다.
논블로킹
read()는 “기다리지 않는 read”이지, “결과를 나중에 주는 read”가 아닙니다.
비동기라고 하면 흔히 “일단 요청해두면 나중에 결과가 온다”를 떠올립니다. O_NONBLOCK은 그런 게 아니에요. 요청을 접수하지도 않습니다. 그냥 “지금은 없다”고 즉시 거절할 뿐이고, 나중에 알려주겠다는 약속도 하지 않습니다.
그러니 데이터를 얻으려면 방법은 하나입니다. 다시 물어보는 것.
핵심 — 거절당하는 비용
① 논블로킹 read는 정말 즉시 돌아온다
빈 소켓에 O_NONBLOCK을 켜고 read()를 걸었습니다.
측정 환경: Apple M1(8코어) / 램 16GB / macOS 26.5, cc -O2. 전체 소스는 docs/book/code/async/ch06-nonblocking.c에 있습니다.
A. 빈 소켓 + O_NONBLOCK → 반환값 -1, errno=35(Resource temporarily unavailable), 1회 평균 232 ns 만에 즉시 복귀
(EAGAIN=35, EWOULDBLOCK=35 — macOS/리눅스에서 둘은 같은 값입니다)✅ 실측 (같은 환경, 2026-08. 워밍업 500회 후 1,000회 평균을 3회, 중앙값 232 ns / 범위 224~526 ns)
약속대로입니다. 잠들지 않고 232 ns 만에 돌아왔어요. 5장에서 잰 “데이터가 있을 때의 read()”가 873 ns였으니 같은 자릿수입니다. 데이터가 있든 없든 시스템 콜 한 번 값만 내는 거죠.
그리고 EAGAIN과 EWOULDBLOCK이 둘 다 35로 찍혔습니다.
② 그런데 다시 물어보는 값이 비싸다
문제는 여기서 시작됩니다. EAGAIN을 받았으면 뭘 해야 할까요? 데이터가 필요하니, 다시 물어보는 수밖에 없습니다.
while (read(fd, &c, 1) < 0 && errno == EAGAIN)
; // 될 때까지 계속 다시 부른다이걸 **바쁜 대기(busy polling)**라고 부릅니다. 그리고 이게 얼마나 비싼지 5장과 똑같은 조건 — 0.5ms 뒤에 데이터가 도착하는 상황 — 으로 재봤습니다.
| 같은 0.5ms를 기다리는 두 방법 | 소요 | 소비 CPU | read() 호출 | 자발적 컨텍스트 스위치 |
|---|---|---|---|---|
바쁜 대기 (O_NONBLOCK + 재시도) | 639 μs | 638 μs | EAGAIN 2,587회 + 성공 1회 | 0.00회 |
블로킹 (read() 한 번) | 641 μs | 15 μs | 1회 | 1.00회 |
✅ 실측 (같은 환경, 2026-08. 각 200회 평균을 3회. 소요 — 바쁜 대기 634656 μs / 블로킹 640644 μs. CPU — 630662 μs / 1516 μs. EAGAIN 횟수 2,587~2,645회.)
네 열을 차례로 보세요.
소요 시간은 같습니다. 639μs vs 641μs — 회차 편차 안이라 순서가 뒤집히기도 합니다. 바쁜 대기가 더 빠르지 않아요. 당연합니다, 데이터가 0.5ms 뒤에 오는데 아무리 빨리 물어봐도 그전에 나올 리 없죠.
CPU 소비는 40배 차이입니다. 638μs vs 15μs. 그리고 638μs는 전체 소요 639μs와 거의 같아요. 기다리는 내내 코어 하나를 통째로 태우고 있었다는 뜻입니다.
컨텍스트 스위치는 정반대입니다. 0.00회 vs 1.00회. 바쁜 대기는 한 번도 안 잤어요 — 그게 CPU를 태운 이유고요.
호출 횟수가 그 이유를 숫자로 보여줍니다. 0.5ms 동안 EAGAIN을 2,587번 받고 2,588번째에 성공했습니다. 앞의 2,587번은 전부 헛수고예요.
여기서 이 장의 결론이 나옵니다.
O_NONBLOCK만으로는 아무것도 해결되지 않습니다. 잠드는 비용(컨텍스트 스위치 1회)을 없앤 대신, CPU를 통째로 태우는 비용으로 바꿔 치웠을 뿐이에요. 거래를 한 거지 이득을 본 게 아닙니다.
다만 이 결론에는 조건이 붙습니다. 대기가 0.5ms였기 때문이에요. 5장에서 깨우는 데만 31μs가 든다고 쟀죠 — 기대 대기가 31μs보다 짧으면 잠들었다 깨는 쪽이 더 비쌉니다. 그래서 초저지연 시스템은 지금도 일부러 바쁜 대기를 씁니다. 코어를 태우는 값을 알고 내는 거예요.
③ 그래서 2장의 그 물건이 필요하다
이제 2장에서 본 epoll·kqueue가 왜 필요한지가 정확히 보입니다.
- 블로킹은 fd 하나당 스레드 하나를 재웁니다. 1장의 4,068개 천장.
- 바쁜 대기는 CPU를 태웁니다. 방금 본 96%.
세 번째 길이 필요했어요. “지금 읽을 게 있는 fd만 알려줘” 하고 커널에 물어본 뒤, 알려준 fd에만 논블로킹 read()를 거는 것. 그러면 EAGAIN을 받을 일이 거의 없습니다. 헛수고 2,587번이 사라져요.
정리하면 논블로킹 I/O는 이 조합입니다.
O_NONBLOCK (재우지 마라) + epoll/kqueue (준비된 것만 알려줘)둘 중 하나만으로는 안 됩니다. epoll 없이 O_NONBLOCK만 쓰면 방금 본 것처럼 코어를 통째로 태웁니다. 반대로 O_NONBLOCK 없이 epoll만 쓰면 통보를 받고 건 read()가 잠듭니다. 2장의 엣지 트리거를 떠올려보세요 — 통보 한 번에 도착분을 전부 읽으려면 EAGAIN이 날 때까지 read()를 돌려야 하는데, 블로킹 fd라면 바로 그 마지막 read()가 잠들어버려요. 스레드가 하나든 백 개든 걸립니다. 레벨 트리거에서도 안전하지 않습니다. 커널은 가짜 준비 통보를 보낼 수 있고(📄 epoll(7)), 스레드가 여럿이면 그새 옆 스레드가 데이터를 가져가 버립니다.
write()의 EAGAIN — 커널이 이미 하고 있던 백프레셔
EAGAIN은 읽기 전용이 아닙니다. 송신 버퍼가 가득 차면 write()도 EAGAIN을 돌려줍니다.
상대가 느려서 데이터를 안 가져가면 커널의 송신 버퍼가 차오르고, 결국 커널이 “지금은 더 못 받는다”고 거절해요. 블로킹 모드였다면 이 지점에서 쓰는 쪽이 잠듭니다.
얼마나 받아주는지도 재봤습니다. 아무도 읽지 않는 소켓에 계속 써봤어요.
C. write() 가 EAGAIN 을 낼 때까지 쓴 바이트: 8192 (errno=35 Resource temporarily unavailable)✅ 실측 (같은 환경, 2026-08)
8KB. 커널이 대신 받아주는 양은 딱 여기까지고, 그다음부터는 거절입니다.
이게 뭔지 아시겠나요? 백프레셔의 가장 원시적인 형태입니다. 4장에서 리액티브의 핵심이라고 했던 그 개념이, 사실은 커널이 소켓 레벨에서 이미 하고 있던 일이에요. “생산자가 소비자보다 빠르면 생산자를 멈춘다.”
리액티브가 한 일은 이 메커니즘을 애플리케이션 레벨로 끌어올린 것입니다. 소켓 하나가 아니라 파이프라인 전체에서 같은 규칙을 적용하도록요. 16장에서 다시 다룹니다.
정리
- 논블로킹은 fd에 붙는 플래그 하나입니다. 데이터가 없으면
-1+EAGAIN으로 즉시 돌아옵니다(232 ns, 컨텍스트 스위치 0회). EAGAIN은 에러가 아니라 “지금은 아니다”입니다. 리눅스·macOS에서는EWOULDBLOCK과 같은 값이지만, 표준은 달라도 된다고 허용하니 이식성이 필요하면 둘 다 보세요.- 논블로킹
read()는 “기다리지 않는 read”이지 “나중에 주는 read”가 아닙니다. 요청을 접수해두지도, 나중에 알려주지도 않습니다. - 그래서 혼자서는 쓸모가 없습니다. 다시 물어보면 바쁜 대기가 되고, 0.5ms 기다리는 데
EAGAIN2,587번과 CPU 638μs(블로킹의 40배, 소요 시간과 거의 같은 값)를 씁니다. 단 기대 대기가 31μs보다 짧으면 얘기가 뒤집힙니다. - 논블로킹 I/O =
O_NONBLOCK+ 준비 통보(epoll/kqueue). 둘 중 하나만으로는 성립하지 않습니다. write()의EAGAIN은 커널이 하는 백프레셔입니다. 실측으로 8KB까지 받아주고 거절했어요. 리액티브가 이 메커니즘을 애플리케이션 레벨로 올린 겁니다.
생각해볼 질문
epoll이 “준비됐다”고 알려준 fd에read()를 걸었는데도EAGAIN이 나올 수 있을까요? 어떤 상황일까요?- 커널이 “준비됐다”를 알려준다고 해서, 커널이 우리 콜백 함수를 실행해주는 건 아닙니다. 그럼 통보를 받고 콜백을 부르는 그 코드는 어디에 있을까요?
- 이 측정(0.5ms 대기)에서는 바쁜 대기가 얻은 게 없어 보입니다. 대기가 5μs였다면 표의 어느 숫자가 뒤집혔을까요? 5장의 어떤 값이 그 손익분기점을 알려주나요?
2번이 다음 장입니다. 지금까지 “이벤트 루프”라는 말을 계속 써왔는데, 정작 그게 어디서 도는 무엇인지는 말한 적이 없어요. 직접 만들어보면 알게 됩니다 — 그건 커널에 없습니다.