[CloudNeta] Hands-On LLM Serving 2주차 part 1 - 워커 프로세스는 왜 '서 있는가' (OS level)

이 문서는 본편(2주차 part 1 - 코드 분석 (1))에서 갈라져 나온 심화 노트입니다. 질문은 하나였습니다.

워커 프로세스는 logger.debug("Waiting for batch from queue...")까지 찍고 왜 거기서 멈춰 있는가? 블로킹에 시간제한이 없으면 어딘가에서 루프를 돌아야 말이 되는 것 아닌가?

결론부터 말하면, 커널이 프로세스를 재워버립니다. 파이썬 코드가 아니라 커널의 스케줄러가 재우는거죠.

전체 그림

일단 ModelWorker() 의 루프에서 서있는 지점부터 다시 볼까요?

flowchart TD
    A["ModelWorker.run()
while True 루프"] --> B["task_queue.get()"] B --> C["Queue.get(block=True, timeout=None)
multiprocessing/queues.py"] C --> D["Connection._recv_bytes()
multiprocessing/connection.py"] D --> E["os.read(fd, ...)
read(2) 시스템콜"] E --> F{"파이프에
데이터가 있는가?"} F -- 있음 --> G["바이트 반환
pickle loads 후 배치 처리"] F -- 없음 --> H["커널: 프로세스를 런큐에서 제거
파이프 대기큐(wait queue)에 등록
상태 S (interruptible sleep)"] H --> I["CPU 스케줄링 대상에서 제외
CPU 사용률 0%"] I -- "부모가 put() = 파이프에 write" --> J["커널이 대기큐의 프로세스를 깨움
런큐 복귀"] J --> G G --> A

핵심은 F에서 "없음"으로 빠지는 분기입니다. 이때는 os.read()를 호출한 그 자리에서 시스템콜에 진입한 채로, 커널이 이 프로세스를 실행 가능한 프로세스 목록(런큐)에서 제외합니다.

1층: 파이썬 표준 라이브러리

Queue.get()이 하는 일

multiprocessing.Queue.get(block=True, timeout=None)의 기본 경로는 단순합니다[1].

if block and timeout is None:
    with self._rlock:
        res = self._recv_bytes()
    self._sem.release()

여기까지는 파이썬 코드입니다. 그런데 _recv_bytes()를 계속 파고들면 파이썬이 끝나는 지점이 나옵니다.

토끼굴: _rlock의 r은 Reentrant의 r인가

결론만 옮기면, ctx.Lock()으로 만드는 재진입 불가 일반 Lock이고 r은 reader의 r입니다(쓰기용 _wlock과 쌍을 이루는 작명).

변수명만 보고 "Reentrant Lock인 것 같은데"라고 유추했다가 틀렸던 추적기는 토끼굴: _rlock의 r은 Reentrant의 r인가 문서를 참고해주세요.

_recv_bytes()의 종점

multiprocessing/connection.py의 Connection._recv()가 실제 읽기를 수행합니다[2].

def _recv(self, size, read=_read):
    buf = io.BytesIO()
    handle = self._handle
    remaining = size
    while remaining > 0:
        chunk = read(handle, remaining)   # ← os.read. 파이썬의 끝.
        n = len(chunk)
        ...

read의 기본값 _read는 os.read입니다. 즉 종점은 파이프 파일 디스크립터에 대한 read(2) 시스템콜 호출입니다. 메시지는 길이 헤더(4바이트)를 먼저 읽고 그만큼 본문을 읽는 구조라서, 큐가 비어 있으면 첫 번째 os.read()에서 멈춥니다.

sequenceDiagram
    participant Q as Queue.get()
    participant C as Connection._recv_bytes()
    participant OS as os.read (read(2))
    participant K as 커널 (fs/pipe.c)

    Q->>C: _recv_bytes()
    C->>OS: read(fd, 4바이트 길이 헤더)
    OS->>K: 시스템콜 진입
    Note over K: 파이프 버퍼가 비어 있음
    K-->>K: pipe_read()에서
wait_event_interruptible 진입 Note over OS,K: 여기서 프로세스가 잠듦.
리턴하지 않음.

파이프와 Connection 계층

task_queue/result_queue의 실체인 파이프는 부모-자식 간 통신하는 통로입니다. 파이프의 정체, connection.py의 플랫폼별 구현, WSL이 윈도우 경로를 타지 않는 이유, 메시지 프레이밍까지의 추적은 토끼굴: 파이프와 Connection 계층을 참고해주세요.

2층: 시스템콜 계약

read(2)의 매뉴얼은 이 동작을 명시합니다[3].

파이프나 FIFO에서 읽을 때, 데이터가 없고 쓰기 쪽이 열려 있으면 read()는 데이터가 준비될 때까지 블록한다(O_NONBLOCK이 설정되지 않은 경우).

pipe(7) 매뉴얼도 같은 내용을 파이프 관점에서 서술합니다[4]. 요컨대 "빈 파이프를 읽으면 잠든다"는 것은 파이썬의 선택이 아니라 POSIX 계약입니다. 파이썬은 호출만 했고 이에 따를 뿐이죠.

여기서 처음 직관("블로킹이면 어딘가에서 돌아야 한다")이 왜 틀렸는지가 드러납니다. 폴링(polling)과 블로킹(blocking)은 다른 대기 전략입니다. 같은 프로젝트 안에 두 전략이 필요에 따라 있습니다. 한번 살펴보시죠:

폴링 블로킹
대기 주체 내 코드 (루프 + sleep) 커널 (wait queue)
대기 중 CPU 주기적으로 소모 0
깨어나는 계기 타이머가 깨워서 재확인 이벤트 발생 시 커널이 깨움
이 코드베이스의 예 부모의 requests_processing_loop
(time.sleep(0.1) 후 재확인)
워커의 task_queue.get()

부모의 루프는 요청이 없어도 초당 10번 깨어나 확인하고, 워커는 일이 올 때까지 완전히 잠듭니다. FastAPI 프로세스가 잠들어버리면 나머지 모든 프로세스가 뻗어버리니 이렇게 처리하면 안 되겠죠. 자세한 내용은 아래에서 후술하겠습니다.

3층: 커널 내부

시스템콜 진입부터 pipe_read까지

read()가 커널 안에서 어떤 경로로 pipe_read()에 도달하는지, 최신 torvalds/linux 저장소(fs/read_write.c, fs/pipe.c) 기준으로 따라갑니다.

1단계: 시스템콜 진입점 (fs/read_write.c)

유저 프로그램이 read(fd, buf, count)를 호출하면 커널 쪽에서 이렇게 받습니다[5].

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
    return ksys_read(fd, buf, count);
}

ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count)
{
    CLASS(fd_pos, f)(fd);
    ssize_t ret = -EBADF;

    if (!fd_empty(f)) {
        loff_t pos, *ppos = file_ppos(fd_file(f));
        if (ppos) {
            pos = *ppos;
            ppos = &pos;
        }
        ret = vfs_read(fd_file(f), buf, count, ppos);
        if (ret >= 0 && ppos)
            fd_file(f)->f_pos = pos;
    }
    return ret;
}

여기서 fd(정수)를 실제 struct file로 바꾸고, 파일 오프셋을 챙긴 뒤 vfs_read()로 넘깁니다. (fd_pos, CLASS 매크로 등은 최신 커널에서 락 관리를 자동화하는 문법입니다.)

2단계: VFS 계층 (vfs_read)

ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
    ssize_t ret;

    if (!(file->f_mode & FMODE_READ))
        return -EBADF;
    ...
    if (file->f_op->read)
        ret = file->f_op->read(file, buf, count, pos);
    else if (file->f_op->read_iter)
        ret = new_sync_read(file, buf, count, pos);
    else
        ret = -EINVAL;
    ...
    return ret;
}

여기가 핵심 분기점입니다. read()는 파일 종류마다 실제 구현이 다릅니다. file->f_op(file operations 구조체)에 등록된 함수를 호출하는데, 일반 디스크 파일이냐, 파이프냐, 소켓이냐, 터미널이냐에 따라 완전히 다른 코드가 실행됩니다. "read는 만능 인터페이스고 실체는 fd 뒤의 객체가 정한다"는 유닉스 철학이 코드로 드러나는 지점입니다.

3단계: 실제로 "기다리는" 코드 (fs/pipe.c)

일반 디스크 파일은 데이터가 이미 캐시/디스크에 있어서 블로킹이 체감되지 않지만, 파이프(예: ls | grep foo)나 터미널 입력을 읽을 때는 진짜로 "잠들어서 기다리는" 코드가 보입니다[6].

if (wait_event_interruptible_exclusive(pipe->rd_wait, pipe_readable(pipe)) < 0)
    return -ERESTARTSYS;

이 한 줄이 사실상 "OS가 값을 줄 때까지 기다린다"를 코드로 구현한 부분입니다. wait_event_interruptible_exclusive(대기큐, 조건)은:

참고로 O_NONBLOCK 플래그가 걸려 있으면 이 wait_event_interruptible_exclusive 호출 자체를 건너뛰고 즉시 -EAGAIN을 반환하도록 앞단에서 분기됩니다.

정리: 코드로 본 blocking 흐름

flowchart TD
    U["유저 프로그램: read(fd, buf, 100)"] --> S["SYSCALL_DEFINE3(read)
시스템콜 진입"] S --> K["ksys_read()
fd를 struct file로 변환"] K --> V["vfs_read()
file->f_op->read / read_iter 로 분기"] V --> P["(파이프의 경우) pipe_read()"] P --> W["wait_event_interruptible_exclusive(
rd_wait, pipe_readable())"] W -- "데이터 없음" --> SL["프로세스 sleep, CPU 양보
★ 기다림이 실제 벌어지는 지점"] W -- "데이터 있음" --> CP["데이터를 버퍼에 복사 후 리턴"] SL -- "write 쪽이 wake_up으로 깨움" --> W

즉 "read()는 기본적으로 blocking"이 개념적인 설명이었다면, 커널 안에서는 그것이 정확히 wait_event_* 계열 매크로가 프로세스를 스케줄러 큐에서 빼서 재우는 방식으로 구현되어 있다는 것을 확인한 것입니다.

잠드는 쪽: pipe_read와 wait queue

리눅스 커널에서 파이프 읽기는 fs/pipe.c의 pipe_read()가 처리합니다[6:1]. 버퍼가 비어 있으면 다음 흐름을 탑니다.

  1. 읽을 데이터가 없음을 확인
  2. wait_event_interruptible_exclusive() 계열 매크로로 진입
  3. 프로세스 상태를 TASK_INTERRUPTIBLE로 바꾸고, 파이프의 wait queue에 자신을 등록
  4. schedule()을 호출하여 CPU를 다른 프로세스에 넘김

이 시점부터 프로세스는 스케줄러의 런큐에 없습니다. "무한루프를 도는 것"과 "잠드는 것"의 차이가 여기서 물리적으로 갈립니다. 도는 루프는 런큐에 남아 CPU 시간을 소모하지만, 잠든 프로세스는 깨울 이벤트가 오기 전까지 스케줄러가 쳐다보지도 않습니다.

wait queue와 TASK_INTERRUPTIBLE의 일반론은 LWN의 스케줄러/대기 메커니즘 문서와 커널 문서에서 확인할 수 있습니다[7].

깨우는 쪽: pipe_write와 wake_up

부모 프로세스가 task_queue.put()을 호출하면 다음이 일어납니다.

  1. Queue 내부의 feeder 스레드가 객체를 pickle로 직렬화하여 파이프에 write(2) 합니다[1:1]. (put()은 버퍼에 넣고 바로 리턴하며, 실제 파이프 쓰기는 feeder 스레드가 합니다)
  2. 커널의 pipe_write()가 데이터를 파이프 버퍼에 넣고, 그 파이프의 wait queue에 등록된 프로세스들을 wake_up 계열 함수로 깨웁니다[6:2].
  3. 깨어난 워커는 런큐에 복귀하고, 자기 차례가 오면 pipe_read()가 데이터를 채워 read(2)가 드디어 리턴합니다.
  4. 제어가 파이썬으로 돌아와 _ForkingPickler.loads(res)가 실행되고, Queue.get()이 배치 데이터를 반환합니다.
sequenceDiagram
    participant PT as 부모: requests_processing_loop 스레드
    participant FD as 부모: Queue feeder 스레드
    participant K as 커널
    participant W as 워커 프로세스 (잠든 상태)

    Note over W: read(2) 안에서 TASK_INTERRUPTIBLE로 수면 중
wchan=pipe_read, CPU 0% PT->>FD: task_queue.put((prompts, True)) FD->>K: pickle 바이트를 파이프에 write(2) K->>K: pipe_write: 버퍼에 데이터 적재 K->>W: wake_up: wait queue에서 꺼내 런큐로 Note over W: TASK_RUNNING으로 전환 W->>K: (스케줄링되면) pipe_read 재개 K-->>W: read(2) 리턴 (바이트 전달) W->>W: _ForkingPickler.loads(res) W->>W: generate_forward_batch(batch) 실행 W->>K: result_queue에 결과 write Note over PT: 같은 메커니즘으로
result_queue.get()에서 자던
부모 스레드가 깨어남 PT->>PT: 결과 수신, 클라이언트 큐로 분배

부모 스레드가 result_queue.get()에서 기다리는 것도 완전히 같은 메커니즘입니다. 방향만 반대일 뿐, 양쪽 다 "빈 파이프를 읽으면 잠들고, 상대가 쓰면 깨어나는" 구조입니다.

워커 프로세스의 상태 전이

stateDiagram-v2
    [*] --> Running_초기화: fork 후 run() 진입
    Running_초기화 --> Running_초기화: 모델 로드
(CPU 소모, RSS 증가) Running_초기화 --> Sleeping: task_queue.get()
파이프 비어 있음 Sleeping --> Running_추론: 부모가 put()
커널이 wake_up Running_추론 --> Sleeping: 결과 put 후
다시 get()에서 수면 note right of Sleeping STAT=S (interruptible sleep) wchan=pipe_read CPU 0% end note

직접 확인해봅시다

이론은 위와 같고, 실제로 확인한 기록입니다. 서버 구동 후 요청을 보내지 않은 상태였습니다.

프로세스 트리

$ pgrep -f "uvicorn main:app"
38074
38124
38211

$ ps -ef --forest
l4in  38074   7131  0 ... python -m uvicorn main:app
l4in  38124  38074  0 ...  \_ python -m uvicorn main:app
l4in  38211  38074  3 ...  \_ python -m uvicorn main:app

어느 쪽이 워커인가

$ ps -o pid,stat,wchan:25,rss -p 38124,38211
    PID STAT WCHAN                       RSS
  38124 Sl   pipe_read                 975152
  38211 Sl   futex_wait_queue          1419124

추가로 확인하고 싶다면:

# read(2)에서 멈춰 있는 것을 실시간으로 보기. 요청을 보내면 read가 리턴하는 순간이 보임
strace -p 38124

# 커널 스택 직접 확인 (root 필요)
sudo cat /proc/38124/stack

열린 질문: 38211은 무엇인가

38211은 RSS가 1.4GB로 워커보다 크고, wchan=futex_wait_queue(스레드 동기화 대기)이며, 지속적으로 CPU를 약간 사용합니다. 코드상 명시적 fork는 워커 하나뿐이므로 하나가 더 있는 셈입니다.

유력한 가설은 vLLM의 엔진 프로세스입니다. llm/llm.py에서 torch.cuda.is_available()이 참이면 VLLM(model=...)을 초기화하는데, 최근 vLLM은 엔진 코어를 별도 자식 프로세스로 띄웁니다. cat /proc/38211/comm은 python으로만 나와서 이름으로는 확정하지 못했습니다. 추가 확인 방법:

# 스레드 이름 나열. vLLM이면 특유의 스레드 이름이 보일 수 있음
ls /proc/38211/task | while read t; do cat /proc/38211/task/$t/comm; done | sort | uniq -c

# 로드된 라이브러리에서 단서 찾기
grep -c vllm /proc/38211/maps

서버 시작 로그에 vLLM 초기화 배너가 있었는지 확인하는 것이 가장 빠른 방법입니다.

FastAPI는 돌고, 워커는 멈추고

여기까지 오면 "자식 프로세스는 블로킹이라 서 있고, 부모 프로세스는 스레드 하나를 더 만들어 0.1초마다 돈다"는 그림이 잡히는데, 두 가지를 더 정확히 해두면 완성됩니다.

첫째, 모든 프로세스는 최소 한 개의 스레드를 가집니다. "프로세스가 코드를 실행한다"는 말은 항상 "그 프로세스 안의 어떤 스레드가 실행한다"는 뜻입니다. 워커 프로세스에서 run()의 while True를 실행하는 것도 그 프로세스의 메인 스레드입니다. 그러니 "워커 프로세스가 블로킹됐다"와 "워커의 메인 스레드가 블로킹됐다"는 같은 말입니다. 결과를 살펴보면 워커의 STAT이 Sl이었는데 l은 멀티스레드 표시입니다. result_queue.put() 시 multiprocessing.Queue가 만드는 feeder 스레드, torch의 내부 스레드 등이 있어서입니다. 그래도 run() 루프를 도는 것은 메인 스레드 하나이고, 그 스레드가 wchan=pipe_read에서 자고 있던 것입니다.

둘째, 워커와 부모 루프는 둘 다 무한루프입니다. 차이는 루프 유무가 아니라 한 바퀴 안에서 기다리는 방식입니다.

# 워커 (자식 프로세스, run)               # 부모 (requests_processing_loop 스레드)
while True:                              while True:
    batch_data = task_queue.get()  # ★       seqs = get_next_batch(is_streaming=True)
    ...처리...                                if not seqs:
                                                 time.sleep(0.1)      # ★
                                                 continue
                                             ...처리...

그리고 부모의 폴링 스레드도 일하는 동안에는 블로킹됩니다. 배치를 워커에 보낸 직후 execute_forward_batch() 안의 result_queue.get()에서, 자식과 완전히 같은 메커니즘(빈 파이프 read → 커널 수면)으로 결과를 기다립니다. 즉 "0.1초 폴링"은 일이 없을 때의 모습이고, 일하는 중의 모습은 블로킹 대기입니다. 그래도 부모 "프로세스"는 멈추지 않습니다. 블로킹은 호출한 스레드만 홀드하므로, FastAPI 이벤트 루프는 다른 스레드에서 계속 새 요청을 받습니다.

세 실행 흐름을 한 장으로 정리하면 이렇습니다.

flowchart LR
    subgraph P["부모 프로세스 (uvicorn, 38074)"]
        EL["FastAPI 이벤트 루프
항상 동작, 블로킹 없음"] RT["requests_processing_loop 스레드
유휴: 0.1초 폴링
배치 중: result_queue.get()에서 블로킹"] end subgraph C["자식 프로세스 (워커, 38124)"] WM["메인 스레드 (run)
유휴: task_queue.get()에서 무기한 블로킹
배치 수신 시: 추론 실행"] end RT -- "task_queue.put()" --> WM WM -- "result_queue.put()" --> RT EL -. "run_coroutine_threadsafe로
토큰 전달받음" .- RT
실행 흐름 유휴 상태 일하는 중
워커 메인 스레드 task_queue.get()에서 무기한 수면 추론 실행
부모의 처리 루프 스레드 0.1초 폴링 result_queue.get()에서 수면
FastAPI 이벤트 루프 대기 (블로킹 없음) 요청 수신, SSE 전송

정리


  1. CPython Lib/multiprocessing/queues.py. Queue.get()의 블로킹 경로와 feeder 스레드(_start_thread, _feed) 구현. ↩︎ ↩︎

  2. CPython Lib/multiprocessing/connection.py. Connection._recv()가 os.read로 내려가는 지점. ↩︎

  3. read(2) man page. 빈 파이프/FIFO 읽기 시의 블로킹 계약. ↩︎

  4. pipe(7) man page. 파이프 I/O의 블로킹 동작과 버퍼 의미론. ↩︎

  5. linux/fs/read_write.c. SYSCALL_DEFINE3(read) → ksys_read() → vfs_read()의 시스템콜 진입 경로와 file->f_op 분기. ↩︎

  6. linux/fs/pipe.c. pipe_read()의 수면 진입과 pipe_write()의 wake_up 호출. ↩︎ ↩︎ ↩︎

  7. LWN: Wait queue와 스케줄링 관련 문서 및 커널 스케줄러 문서. TASK_INTERRUPTIBLE, wait queue, schedule()의 일반론. ↩︎

  8. ps(1) man page. PROCESS STATE CODES 절(S, l 등)과 wchan 필드 설명. ↩︎