5장. read() 한 번의 전 여정 — 잠드는 순간과 깨는 순간

만화로 보는 요약 — 먼저 읽어보세요
같은 read()인데 데이터가 있으면 컨텍스트 스위치가 0회, 없으면 정확히 1회입니다. 그 1회가 이 책이 말하는 “잠든다”입니다.
면접 실전 질문: ① 블로킹
read()를 부른 스레드는 CPU를 쓰고 있나요? ② 자발적 컨텍스트 스위치와 비자발적 컨텍스트 스위치는 무엇이 다른가요? ③ 잠든 스레드를 깨우는 데 얼마나 걸리나요?
배경 — “재운다”를 계속 말했지만
여기서부터는 커널 안입니다. 1부가 “왜 이렇게 됐나”였다면, 2부는 “실제로 무슨 일이 일어나나”예요.
1부 내내 같은 표현을 썼습니다. 커널이 스레드를 재운다. 1장은 그 재운 스레드가 22.5배의 세금을 물린다고 했고, 2장은 그걸 피하려고 커널이 통보 창구를 바꿨다고 했어요.
그런데 재운다는 게 정확히 무슨 일일까요?
비유로는 다들 압니다. “기다린다”, “블록된다”, “멈춘다”. 그래서 read() 한 번을 현미경으로 봅니다.
먼저 결론부터 말해두죠. read()는 하나의 함수가 아니라 두 개의 서로 다른 사건입니다. 데이터가 이미 도착해 있으면 잠들지 않고, 없으면 잠듭니다. 같은 코드 한 줄인데요.
스토리 — 커널이 스레드를 재우는 절차
데이터가 아직 안 온 소켓에 read(fd, buf, n)을 부르면 이런 일이 순서대로 일어납니다.
[유저] read(fd) 호출
└─ syscall 진입: CPU가 커널 모드로 전환
[커널] 소켓 수신 큐 확인 → 비어 있음
├─ 현재 태스크 상태를 "인터럽트 가능한 대기"로 변경
│ (리눅스에서는 TASK_INTERRUPTIBLE)
├─ 소켓의 대기 큐에 이 태스크를 매달아 둠
└─ 스케줄러 호출 → 실행 큐에서 빠짐 → 다른 스레드가 CPU 획득
⋯ 이 스레드는 CPU를 쓰지 않는다. 대신 커널 태스크 구조체와
스택 메모리는 계속 점유한다 ⋯
[상대] 누군가 이 소켓에 데이터를 쓴다
[커널] 수신 큐에 데이터 적재
└─ "데이터 도착" 콜백 → 대기 큐의 태스크를 깨움 → 실행 큐에 넣음
[스케줄러] 차례가 오면 이 스레드 재개
[커널] 수신 버퍼 → 유저 버퍼로 복사 → read() 반환📄 문서 기반 — 리눅스 커널 구조입니다. 세 가지를 덧붙여둡니다. 첫째, 소켓 종류에 따라 다릅니다. TCP라면 “누군가 쓴다” 자리에 NIC 인터럽트와 소프트IRQ, TCP 스택 처리가 들어가고, 잠시 뒤 측정에 쓸 유닉스 도메인 소켓이라면 그 과정 없이 커널 안에서 바로 전달됩니다. 둘째, 아래 측정은 macOS(XNU)에서 했습니다. 이름과 자료구조는 다르지만(XNU는 TASK_INTERRUPTIBLE 대신 tsleep 계열을 씁니다) “대기 큐에 매달고 → 실행 큐에서 빼고 → 깨우면 되돌린다”는 골격은 같습니다. 셋째, 실제 순서는 위 그림보다 조심스럽습니다 — 커널은 대기 큐에 먼저 매단 다음 상태를 바꾸고, 한 번 더 확인한 뒤 재웁니다. 매달기 직전에 도착한 데이터의 깨움 신호를 놓치지 않으려고요.
여기서 두 가지만 기억하면 됩니다.
첫째, 스레드는 “기다리는” 게 아니라 “치워지는” 겁니다. 실행 큐에서 빠지면 스케줄러의 후보 목록에서 사라져요. 그래서 CPU를 안 씁니다. 반대로 말하면, 다시 후보가 되려면 누군가 명시적으로 깨워야 합니다.
둘째, 이 모든 게 데이터가 없을 때만 일어납니다. 이미 와 있으면 커널은 그냥 복사하고 돌아갑니다. 잠드는 절차 자체가 생략돼요.
이 두 번째 문장이 이 장의 측정 대상입니다.
핵심 — 잠드는 것을 숫자로 세기
잠드는 건 셀 수 있습니다. 커널이 이미 세고 있거든요.
리눅스와 macOS는 프로세스마다 컨텍스트 스위치 횟수를 세어 알려줍니다. getrusage()로 읽을 수 있어요.
- 자발적(voluntary) 컨텍스트 스위치 — 스스로 CPU를 내려놓은 횟수. 잠든 횟수와 같습니다.
- 비자발적(involuntary) 컨텍스트 스위치 — 시간 할당량을 다 써서 스케줄러에게 뺏긴 횟수.
우리가 세고 싶은 건 앞의 것입니다.
static long vcsw(void) {
struct rusage ru;
getrusage(RUSAGE_SELF, &ru);
return ru.ru_nvcsw; // 자발적 컨텍스트 스위치 누적 횟수
}측정 환경: Apple M1(8코어) / 램 16GB / macOS 26.5, cc -O2. 유닉스 도메인 소켓쌍을 씁니다. 전체 소스는 docs/book/code/async/ch05-blocking-read.c에 있습니다.
① 데이터가 있으면 — 잠들지 않는다
먼저 데이터를 미리 넣어두고 read()를 2,000번 부릅니다. write()를 시간 측정 밖으로 빼는 게 중요합니다 — 섞어 재면 시스템 콜 두 개 값이 나오거든요.
for (int i = 0; i < 500; i++) { write(sv[1],&c,1); read(sv[0],&c,1); } // 워밍업
for (int i = 0; i < N; i++) write(sv[1], &c, 1); // 시간 밖에서 미리 채운다
long v0 = vcsw(), t0 = now_ns();
for (int i = 0; i < N; i++) read(sv[0], &c, 1); // ← 순수 read 만 잰다A. 데이터 있음 — read() 1회 873 ns | 자발적 컨텍스트 스위치 0회 (2,000회 중)✅ 실측 (같은 환경, 2026-08. 워밍업 500회 후 5회 실행, 중앙값 873 ns / 범위 298~1,207 ns. M1의 성능·효율 코어 배치에 따라 회차 편차가 큽니다.)
처음엔
write()와read()를 한 루프에서 번갈아 재고 그걸 “read()값”이라고 썼습니다. 그건 시스템 콜 두 개 값이에요. 미리 채워두고read()만 분리해 다시 쟀더니 절반 이하로 떨어졌습니다.
2,000번을 불렀는데 자발적 컨텍스트 스위치가 0회입니다. 한 번도 안 잤어요. 이때의 read()는 그냥 시스템 콜 한 번입니다. 유저 모드에서 커널 모드로 갔다가, 버퍼를 복사하고, 돌아옵니다.
이게 중요한 이유가 있습니다. 흔히 “블로킹 I/O는 느리다”고 말하지만, 블로킹 함수를 부른다고 항상 느린 게 아닙니다. 데이터가 준비돼 있으면 논블로킹만큼 빨라요. 비싼 건 함수가 아니라 기다림입니다.
② 데이터가 없으면 — 정확히 1회 잠든다
이번엔 비어 있는 소켓에 read()를 겁니다. 별도 스레드가 0.5ms 뒤에 데이터를 넣어줍니다.
B. 데이터 없음 — read() 1회 674,705 ns | 자발적 컨텍스트 스위치 1.00회 (호출당)✅ 실측 (같은 환경, 2026-08. 매 회 소켓쌍과 writer 스레드를 새로 만들어 200회 반복하고, 그 실행을 5회. 중앙값 674,705 ns / 범위 649,870~727,275 ns)
호출당 정확히 1.00회. 반올림해서 1이 아니라, 200번 재서 정확히 200회입니다. 잠들었다 깨어난 게 정확히 한 번씩이라는 뜻이에요.
앞 절의 0회와 나란히 놓으면 그림이 완성됩니다.
read() | 자발적 컨텍스트 스위치 | 소요 |
|---|---|---|
| 데이터 있음 (2,000회) | 0회 | 873 ns |
| 데이터 없음 (200회) | 1.00회 | 674,705 ns = 대기 0.5ms + 깨우기 31μs + 잔여 약 143μs |
같은 함수, 같은 한 줄인데 한쪽은 0회, 한쪽은 정확히 1.00회입니다. 그 1회가 이 장이 말하는 “잠든다”예요.
소요 시간 쪽은 비율로 읽지 마세요. 674μs의 대부분은 제가
usleep(500)으로 만든 대기 시간이라, 딜레이를 5ms로 바꾸면 같은 코드가 8,800배를 찍습니다. 손잡이로 돌아가는 비율은 증거가 아닙니다. 이 표의 증거는 왼쪽 열입니다. 설명되지 않은 잔여 143μs는 스레드 생성과usleep오버슈트로 보입니다(추정).
카운트에 관해 한 가지. 이건 프로세스 전체 합계입니다. macOS는 타이머 슬립을 자발적 컨텍스트 스위치로 세지 않아 여기서는 딱 1.00이 나오지만, 리눅스에서는 데이터를 넣는 쪽 스레드의 대기까지 함께 잡혀 2.00이 나올 수 있습니다(미검증).
③ 깨우는 신호부터 반환까지, 23~41μs
그럼 대기 시간을 빼고, 깨우는 신호가 온 순간부터 read()가 돌아오기까지만 재면 얼마일까요? 쓰는 쪽이 write()를 부른 시각을 기록해두고 빼봤습니다.
C. 깨우는 신호 → read() 반환까지 평균 31,225 ns✅ 실측 (같은 환경, 2026-08. 200회 평균을 5회, 중앙값 31,225 ns / 범위 23,395~40,615 ns — 회차 편차가 ①②보다 큽니다)
중앙값 31μs. 데이터가 있을 때의 read()가 873 ns였으니, 깨어나는 것만으로 시스템 콜 36번 값을 치릅니다.
이 숫자를 서버로 옮겨봅시다. 요청 하나가 외부 API를 세 번 부르는 평범한 서버라면, 요청당 세 번 자고 세 번 깹니다. 깨어나는 데만 약 93μs가 응답 시간에 붙어요. 100ms짜리 외부 호출이라면 0.1%도 안 되니 무시할 만합니다. 그런데 1ms짜리 내부 호출이라면 **10%**입니다.
두 가지 단서를 붙여둡니다. 첫째, 이건 지연이지 CPU 소비가 아닙니다. 그 31μs 동안 코어는 다른 스레드에게 열려 있어요(실제 CPU 소비는 6장에서 따로 잽니다). 둘째, 이 값은 커널 안에서만 오가는 유닉스 도메인 소켓의 바닥값입니다. 진짜 네트워크였다면 NIC 인터럽트와 TCP 스택 처리가 더 붙습니다.
그래도 방향은 분명해요. 대기를 스레드로 표현하면, 대기 횟수만큼 이 비용을 냅니다. 그리고 응답이 빠를수록 이 비용의 비중이 커집니다.
1장의 22.5배와 이어 붙이면
1장에서 잰 것도 결국 깨우기 비용이었습니다. 배경에 자는 스레드가 없을 때 인계 1회가 2.3μs였고, 4,000개가 자고 있으면 51.7μs였죠.
거기선 park/unpark, 여기선 소켓 — 방식이 다르니 나란히 놓고 뺄 수 있는 숫자는 아닙니다. 다만 두 측정이 같은 것을 가리킵니다. 스레드를 재우고 깨우는 일에는 값이 붙고, 그 값은 시스템에 자는 스레드가 많을수록 커집니다.
어느 요인이 그 증가분을 만드는지는 8장에서 분해합니다. 1장에서 미뤄둔 빚이에요.
정리
read()는 두 개의 서로 다른 사건입니다. 데이터가 있으면 시스템 콜 한 번(873 ns, 컨텍스트 스위치 0회), 없으면 잠들기 한 번(1.00회).- “잠든다”는 비유가 아니라 셀 수 있는 사건입니다.
getrusage()의 자발적 컨텍스트 스위치가 정확히 그 횟수예요. - 잠든 스레드는 CPU를 쓰지 않습니다. 실행 큐에서 아예 빠지니까요. 대신 태스크 구조체와 스택은 계속 점유합니다 — 1장에서 본 자리값이 이겁니다.
- 깨우는 신호부터 반환까지 중앙값 31μs(유닉스 소켓 기준, 바닥값). 데이터가 있을 때의
read()36번 값입니다. 요청마다 세 번 자고 깨면 응답 시간에 약 93μs가 붙어요 — 100ms 호출엔 무시할 만하지만 1ms 호출엔 10%입니다. - 비싼 건 블로킹 함수가 아니라 기다림입니다. 데이터가 준비돼 있으면 블로킹 API도 빠릅니다.
생각해볼 질문
- 데이터가 있을 때
read()가 잠들지 않는다면, “잠들지 마라”고 커널에게 미리 말해두는 방법도 있지 않을까요? 그러면 데이터가 없을 때는 무엇을 돌려줘야 할까요? - 잠들지 않는
read()를 얻었다고 칩시다. 데이터가 올 때까지 계속 다시 부르면 어떻게 될까요? 이번 장의 어떤 숫자가 그 방식의 비용을 알려줄까요? - 자발적 컨텍스트 스위치가 0회인데도 느린 코드가 있다면, 무엇을 의심해야 할까요?
1번이 다음 장의 주제입니다. 커널에게 “나를 재우지 마라”고 말하는 방법은 실제로 있습니다. 플래그 하나예요. 그런데 그 플래그를 켜는 순간, 2번 질문이 곧바로 따라옵니다.