.LAB
RETURN 에서 AWAIT 까지·RETURN 에서 AWAIT 까지 #2

블로킹이라는 기본값

·4min read
  • Blocking
  • I/O
  • Synchronous
  • Threading

기다리는 것 말고는 선택지가 없다

지난 글에서 콜 스택의 계약을 말했다. 호출하면 기다리고, 반환되면 다음으로 넘어간다. 이 "기다린다"가 곧 동기 실행이다.

C#
byte[] data = File.ReadAllBytes("image.raw");  // 여기서 멈춘다
Process(data);  // ReadAllBytes가 끝나야 실행된다
Render();       // Process가 끝나야 실행된다

이 코드에 비동기를 "선택"한 흔적은 없다. 그냥 함수를 호출했을 뿐이다. 그런데 실행은 동기다. 동기는 기본값이다. 아무것도 하지 않으면 자동으로 적용되는 실행 모델이고, 콜 스택이 설계된 대로 작동한 결과다. 블로킹을 선택한 게 아니라, 블로킹에서 벗어나는 쪽이 추가 작업을 요구한다.

CPU 관점에서 본 대기의 비용

File.ReadAllBytes가 디스크에서 데이터를 읽는 동안 CPU는 무엇을 하는가. 아무것도 안 한다.

sequenceDiagram
    participant CPU
    participant 디스크
    CPU->>디스크: ReadAllBytes 요청
    Note over CPU: 유휴 (300,000 사이클)
    Note over 디스크: 읽는 중...
    디스크-->>CPU: 데이터 반환
    Note over CPU: Process 실행
    Note over CPU: Render 실행

구체적인 숫자를 보자. SSD의 4KB 랜덤 읽기 지연은 약 100us다. 3GHz CPU 기준으로 100us는 300,000 사이클이다. 300,000 사이클이면 정수 덧셈을 300,000번 실행할 수 있다. 그 시간 동안 CPU는 디스크 컨트롤러가 응답하기를 기다리며 유휴 상태에 놓인다.

블로킹의 실체

블로킹의 비용은 "느리다"가 아니다. "그 동안 아무 일도 일어나지 않는다"가 비용이다. 스레드는 점유되어 있지만 일을 하지 않는다. 서버라면 그 스레드가 처리할 수 있었던 다른 요청이 큐에서 대기한다. UI 스레드라면 사용자 입력에 반응하지 못한다. 자원을 잡고 있으면서 자원을 쓰지 않는 상태, 이것이 블로킹의 실체다.

탈출 경로 두 가지

블로킹이 수용 불가능한 상황이 되면 벗어날 방법은 두 가지다.

경로 1: 다른 스레드를 만든다

C#
Thread t = new Thread(() => {
    byte[] data = File.ReadAllBytes("image.raw");  // 이 스레드가 블로킹
    Process(data);
});
t.Start();

Render();  // 메인 스레드는 멈추지 않는다
sequenceDiagram
    participant 메인 스레드
    participant 새 스레드
    participant 디스크
    메인 스레드->>새 스레드: t.Start()
    새 스레드->>디스크: ReadAllBytes 요청
    Note over 메인 스레드: Render() 실행
    Note over 새 스레드: 대기 (블로킹)
    Note over 디스크: 읽는 중...
    디스크-->>새 스레드: 데이터 반환
    Note over 새 스레드: Process 실행

블로킹은 여전히 일어난다. 다른 스레드에서 일어날 뿐이다. 문제가 해결된 게 아니라 이동한 것이다. 그리고 새로운 문제가 따라온다. 스레드 생성 비용이 있다. Windows 기준 스레드 하나의 기본 스택 크기는 1MB이고, 생성과 컨텍스트 스위칭에 수천 사이클이 소모된다. 두 스레드가 같은 데이터에 접근하면 동기화가 필요하고, 동기화는 또 다른 블로킹을 만든다.

경로 2: 대기 방식 자체를 바꾼다

C#
// 대기 방식을 바꾼다
var request = BeginRead("image.raw");
// return하되, 끝났을 때 알려달라고 등록
request.OnComplete += (data) => Process(data);
Render();  // 기다리지 않고 바로 실행

블로킹하지 않는다. 관심을 등록하고 다음으로 넘어간다. 그런데 코드의 구조가 근본적으로 달라졌다. 위에서 아래로 흐르던 선형 흐름이 콜백 등록으로 바뀌었다. 실행 순서를 코드의 배치만으로 파악할 수 없게 됐다. 에러가 발생하면 어디로 전파되는지도 달라진다. 이것이 다음 편에서 다룰 주제다.

단순함에는 무게가 있다

두 방식을 나란히 놓고 보자.

C#
// 블로킹: 가장 단순한 코드
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개의 동시 연결을 처리하는 데는 필요하다. 블로킹은 나쁜 코드가 아니다. 문제는 머무르는 비용이 떠나는 비용을 넘어서는 시점이 언제인지다.