Skip to Content

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을 돌려주고 errnoEAGAIN을 세웁니다.

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였으니 같은 자릿수입니다. 데이터가 있든 없든 시스템 콜 한 번 값만 내는 거죠.

그리고 EAGAINEWOULDBLOCK이 둘 다 35로 찍혔습니다.

② 그런데 다시 물어보는 값이 비싸다

문제는 여기서 시작됩니다. EAGAIN을 받았으면 뭘 해야 할까요? 데이터가 필요하니, 다시 물어보는 수밖에 없습니다.

while (read(fd, &c, 1) < 0 && errno == EAGAIN) ; // 될 때까지 계속 다시 부른다

이걸 **바쁜 대기(busy polling)**라고 부릅니다. 그리고 이게 얼마나 비싼지 5장과 똑같은 조건 — 0.5ms 뒤에 데이터가 도착하는 상황 — 으로 재봤습니다.

같은 0.5ms를 기다리는 두 방법소요소비 CPUread() 호출자발적 컨텍스트 스위치
바쁜 대기 (O_NONBLOCK + 재시도)639 μs638 μsEAGAIN 2,587회 + 성공 1회0.00회
블로킹 (read() 한 번)641 μs15 μs1회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 동안 EAGAIN2,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 기다리는 데 EAGAIN 2,587번과 CPU 638μs(블로킹의 40배, 소요 시간과 거의 같은 값)를 씁니다. 단 기대 대기가 31μs보다 짧으면 얘기가 뒤집힙니다.
  • 논블로킹 I/O = O_NONBLOCK + 준비 통보(epoll/kqueue). 둘 중 하나만으로는 성립하지 않습니다.
  • write()EAGAIN은 커널이 하는 백프레셔입니다. 실측으로 8KB까지 받아주고 거절했어요. 리액티브가 이 메커니즘을 애플리케이션 레벨로 올린 겁니다.

생각해볼 질문

  1. epoll이 “준비됐다”고 알려준 fd에 read()를 걸었는데도 EAGAIN이 나올 수 있을까요? 어떤 상황일까요?
  2. 커널이 “준비됐다”를 알려준다고 해서, 커널이 우리 콜백 함수를 실행해주는 건 아닙니다. 그럼 통보를 받고 콜백을 부르는 그 코드는 어디에 있을까요?
  3. 이 측정(0.5ms 대기)에서는 바쁜 대기가 얻은 게 없어 보입니다. 대기가 5μs였다면 표의 어느 숫자가 뒤집혔을까요? 5장의 어떤 값이 그 손익분기점을 알려주나요?

2번이 다음 장입니다. 지금까지 “이벤트 루프”라는 말을 계속 써왔는데, 정작 그게 어디서 도는 무엇인지는 말한 적이 없어요. 직접 만들어보면 알게 됩니다 — 그건 커널에 없습니다.

7장. 이벤트 루프는 OS에 없다

Last updated on