
블로킹이라는 기본값
- BlockingI/OSynchronousThreading
기다리는 것 말고는 선택지가 없다
지난 글에서 콜 스택의 계약을 말했다. 호출하면 기다리고, 반환되면 다음으로 넘어간다. 이 "기다린다"가 곧 동기 실행이다.
아래 코드는 파일을 읽고, 처리하고, 렌더링하는 세 단계를 순서대로 실행한다.
#include <stdio.h>
int main(void) {
FILE* f = fopen("image.raw", "rb");
unsigned char buf[4096];
size_t n = fread(buf, 1, sizeof(buf), f); // 여기서 멈춘다
fclose(f);
process(buf, n); // fread가 끝나야 실행된다
render(); // process가 끝나야 실행된다
return 0;
}이 코드 어디에도 비동기를 "선택"한 흔적은 없다. 그냥 함수를 호출했을 뿐인데, 실행은 동기다. 동기 실행은 따로 선택한 적이 없는데도 적용되어 있었다. 콜 스택이 설계된 대로 작동한 결과이고, 블로킹에서 벗어나는 쪽이 오히려 추가 작업을 요구한다.
스레드 관점에서 본 대기의 실체
fread가 디스크에서 데이터를 읽는 동안 CPU는 무엇을 하는가. 이 스레드에 한해서는 아무것도 안 한다. 그런데 "아무것도 안 한다"의 실체는 언어 수준에서는 보이지 않는다. 실제로 일어나는 일은 운영체제 안에 있다.
fread는 C 표준 라이브러리 함수지만, 내부적으로 운영체제의 시스템 콜을 호출한다. Linux에서는 read(), Windows에서는 NtReadFile이다. 이 시스템 콜이 실행되면 커널은 디스크 드라이버에 I/O 작업을 요청하고, 해당 스레드를 대기(SLEEP) 상태로 전이시킨다. 대기 상태에 들어간 스레드는 OS 스케줄러의 실행 큐에서 빠진다. CPU를 점유하면서 기다리는 것이 아니라, 스케줄러가 아예 이 스레드를 실행 대상에서 제외하는 것이다.
stateDiagram-v2
[*] --> 실행중: 스케줄러가 CPU 할당
실행중 --> 대기: fread → 시스템 콜 → 커널이 스레드를 SLEEP으로 전이
대기 --> 준비: I/O 완료 인터럽트 → 커널이 스레드를 깨움
준비 --> 실행중: 스케줄러가 CPU 재할당
Note right of 대기: CPU 사용 안 함\n스케줄러 실행 큐에서 제외디스크 드라이버가 읽기를 끝내면 하드웨어 인터럽트가 발생한다. 커널은 이 인터럽트를 받아서 대기 중이던 스레드를 깨우고, 준비(READY) 상태로 전이시킨다. 스케줄러가 다시 이 스레드에 CPU를 할당하면, 스레드 컨텍스트에 저장되어 있던 레지스터 상태와 콜 스택 정보가 복원되면서 fread 호출 이후 지점부터 실행이 재개된다.
구체적인 숫자를 보면 대기의 규모가 선명해진다. SSD의 4KB 랜덤 읽기 지연은 약 100us이고, 3GHz CPU 기준으로 이는 300,000 사이클에 해당한다. 정수 덧셈을 300,000번 실행할 수 있는 시간인데, 그동안 이 스레드는 스케줄러에서 빠져서 어떤 코어에서도 실행되지 않는다.
블로킹의 비용은 "느리다"가 아니다. "그동안 아무 일도 일어나지 않는다"가 비용이다. 스레드는 대기 상태에 놓여 CPU를 쓰지 않는다. 서버라면 그 스레드가 처리할 수 있었던 다른 요청이 큐에서 대기하고, UI 스레드라면 사용자 입력에 반응하지 못한다. 스레드라는 자원을 잡고 있으면서 유용한 일을 하지 않는 상태, 이것이 블로킹의 실체다.
블로킹 함수라는 것
그러면 "블로킹 함수"란 무엇인가. fread, WriteFile, recv 같은 함수들을 블로킹 함수라고 부르는데, 이 함수들의 공통점은 내부에서 커널 시스템 콜을 호출하고 그 시스템 콜이 스레드를 대기 상태로 전이시킨다는 것이다. 블로킹은 이 함수들이 가진 속성이라기보다, 그 안에서 호출되는 OS 계층의 동작이다.
이 점은 중요한 함의가 있다. 순수한 유저모드 코드, 그러니까 시스템 콜을 타지 않는 C 코드만으로는 스레드를 블로킹 상태로 전이시킬 수 없다. while(1) {} 같은 무한 루프는 CPU를 100% 점유하면서 다른 일을 못 하게 만들지만, 이것은 OS가 스레드를 대기 상태로 전이시킨 것이 아니라 CPU를 소비하면서 도는 것이라 블로킹과는 다른 현상이다. 블로킹은 CPU를 소비하지 않는 대기이고, busy-wait은 CPU를 소비하는 대기다.
탈출 경로 두 가지
블로킹이 수용 불가능한 상황이 되면 벗어날 방법은 두 가지다.
경로 1: 다른 스레드를 만든다
블로킹하는 작업을 별도의 스레드로 옮기면, 메인 스레드는 그 사이에 다른 일을 할 수 있다.
#include <pthread.h>
void* worker(void* arg) {
FILE* f = fopen("image.raw", "rb");
unsigned char buf[4096];
fread(buf, 1, sizeof(buf), f); // 이 스레드가 블로킹
fclose(f);
process(buf, sizeof(buf));
return NULL;
}
int main(void) {
pthread_t t;
pthread_create(&t, NULL, worker, NULL);
render(); // 메인 스레드는 멈추지 않는다
pthread_join(t, NULL);
return 0;
}sequenceDiagram
participant 메인 스레드
participant 새 스레드
participant 디스크
메인 스레드->>새 스레드: pthread_create
새 스레드->>디스크: fread 요청
Note over 메인 스레드: render() 실행
Note over 새 스레드: 대기 (블로킹)
Note over 디스크: 읽는 중...
디스크-->>새 스레드: 데이터 반환
Note over 새 스레드: process 실행블로킹은 여전히 일어나는데, 다른 스레드로 옮겨갔을 뿐이다. 해결이라기보다 이동에 가깝다. 그리고 이동에는 비용이 따라온다. 스레드 하나를 생성하면 스택 메모리가 할당되고 컨텍스트 스위칭에 수천 사이클이 소모된다. 두 스레드가 같은 데이터에 접근하면 동기화가 필요한데, 동기화는 그 자체로 또 다른 블로킹을 만든다.
경로 2: 대기 방식 자체를 바꾼다
스레드를 늘리는 대신, 기다리는 방식 자체를 바꾸는 접근도 있다. 호출자가 자신의 함수를 피호출자에게 넘겨두고, 작업이 끝나면 피호출자가 그 함수를 호출하는 구조다. 1편에서 말한 콜백, 즉 호출 방향의 역전이 여기서 실제로 쓰인다.
typedef void (*on_complete_fn)(unsigned char* data, size_t len);
void begin_read(const char* path, on_complete_fn callback) {
// 비동기 I/O 시작, 완료 시 callback 호출
}
int main(void) {
begin_read("image.raw", process); // 관심을 등록하고 넘어간다
render(); // 기다리지 않고 바로 실행
return 0;
}블로킹하지 않는다. 호출자는 자신의 함수 포인터를 넘기고 다음으로 넘어간다. 그런데 코드의 구조가 근본적으로 달라졌다. 위에서 아래로 흐르던 선형 흐름이 함수 포인터 등록으로 바뀌었고, 실행 순서를 코드의 배치만으로 파악할 수 없게 됐다. 에러가 발생했을 때 전파 경로도 달라진다. 이것이 다음 편에서 다룰 주제다.
단순함에는 무게가 있다
두 방식을 나란히 놓고 보자. 여기서 계층의 구분이 중요하다. 블로킹은 OS 스레드 레벨에서 일어나는 현상이고, 비동기 전환은 그 위에 놓인 언어 레벨의 선택이다. C에서는 async/await 같은 문법이 없어서 콜백이나 이벤트 루프로 직접 구현해야 했다. C#의 async/await는 이 구조를 문법 수준에서 감싸준다. 어느 쪽이든 OS가 스레드를 대기시키는 동작 자체가 사라지는 것은 아니고, 그 대기를 어떤 스레드가 감당할 것인지를 재배치하는 것에 가깝다.
// 블로킹: 가장 단순한 코드
var data = LoadData();
var result = Process(data);
Save(result);
// 비동기로 바꾸면
var data = await LoadDataAsync();
var result = await ProcessAsync(data);
await SaveAsync(result);
// 줄 수는 같아 보이지만
// 상태 머신, 스케줄링, 에러 전파 경로가 전부 달라진다줄 수는 세 줄로 동일하다. 하지만 아래쪽 코드가 컴파일되면 상태 머신이 생성된다. await마다 실행이 쪼개지고, 각 조각의 스케줄링을 런타임이 관리한다. 예외가 발생했을 때 스택 트레이스의 모양도 달라지고, 디버거에서 한 줄씩 따라가는 동작도 달라진다.
블로킹 코드는 콜 스택이 보장하는 가장 단순한 실행 모델이다. 호출하면 기다리고, 반환되면 다음 줄이 실행된다. 예측 가능하고 디버깅이 쉬우며 콜 스택의 의미가 명확하다. 비동기로 전환하는 순간 이 단순함 위에 기계 장치가 올라간다. 상태 머신, 스케줄러, 컨텍스트 캡처. 이 장치들은 블로킹의 비용이 도입하는 비용보다 클 때만 정당화된다.
CLI 도구가 파일 하나를 읽는 데 비동기가 필요하지 않다. 서버가 10,000개의 동시 연결을 처리하는 데는 필요하다. 블로킹은 나쁜 코드가 아니다. 머무르는 비용이 떠나는 비용을 넘어서는 시점이 언제인지가 판단의 핵심이다.