[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()
_rlock- 같은 파이프 끝을 여러 리더가 동시에 읽으면 한 메시지의 바이트가 섞이므로, 읽기를 한 번에 하나로 직렬화하는 잠금입니다.
- 이름의 r은 Reentrant가 아니라 reader이며, 실체는
ctx.Lock()으로 만드는 일반 Lock입니다(쓰기용_wlock과 쌍).
_recv_bytes()- 파이프에서 pickle 직렬화된 바이트를 읽습니다.
_sem.release()- 큐에 든 항목 수를 세는 카운터 반납입니다.
maxsize제한을 구현하는 장치이며, 동시접근 제어용이 아닙니다.
- 큐에 든 항목 수를 세는 카운터 반납입니다.
여기까지는 파이썬 코드입니다. 그런데 _recv_bytes()를 계속 파고들면 파이썬이 끝나는 지점이 나옵니다.
Queue.get() 소스와 주석
# multiprocessing.queues
class Queue(object):
...
# 기본적으로 블로킹에, 시간 제한이 없다
def get(self, block=True, timeout=None):
# 큐가 닫혔나 확인하고
if self._closed:
raise ValueError(f"Queue {self!r} is closed")
# 블로킹이면서 타임아웃이 없으면 (기본 경로. 워커가 타는 길)
if block and timeout is None:
# rlock을 획득하고 바이트를 가져옴.
# rlock은 "같은 파이프 끝을 읽는 건 한 번에 하나"를 보장하는 잠금.
# 여러 리더가 동시에 읽으면 한 메시지의 바이트가 섞이기 때문.
# 파이프가 비어있으면 _recv_bytes() 안의 os.read에서 무기한 잠든다. 여기가 그 지점.
with self._rlock:
res = self._recv_bytes()
# 세마포어 릴리즈.
# 동시접근 제어용이 아니라 큐에 든 항목 수를 세는 카운터다.
# put()이 acquire(가득 차면 대기), get()이 release(자리 반납). maxsize 구현 장치.
self._sem.release()
# 타임아웃이 지정된 경로. monotonic 타임 + timeout으로 데드라인을 잡는다
else:
if block:
deadline = time.monotonic() + timeout
# 잠근거 꺼내려면 acquire 해야되고
if not self._rlock.acquire(block, timeout):
# Empty 는 익셉션이다. 큐에서 쓰는 익셉션임
raise Empty
try:
if block:
timeout = deadline - time.monotonic()
if not self._poll(timeout):
raise Empty
# _poll()은 peek이 맞다. 데이터를 꺼내지 않고 "읽을 게 있는지"만 확인.
# 내부는 select/poll 시스템콜이라 타임아웃까지는 이것도 커널 잠자기.
# "최대 N초만 자다가 깨워줘"를 구현하는 수단이라 타임아웃 경로에서만 쓰인다.
elif not self._poll():
raise Empty
# 이 바이트는 파이프로 넘어온 pickle 직렬화 결과물이다.
# 길이 헤더(4바이트)를 먼저 읽고 그만큼 본문을 읽는다.
res = self._recv_bytes()
# 위와 같은 항목 수 카운터 반납
self._sem.release()
finally:
# acquire의 짝. 잡았던 읽기 잠금을 반납하는 것뿐이다.
# 참고로 rlock의 r은 Reentrant가 아니라 reader다.
# Queue.__init__에서 self._rlock = ctx.Lock() 으로 만드는 일반 Lock이고,
# 쓰기용 self._wlock(writer lock)과 쌍을 이루는 이름이다.
self._rlock.release()
# unserialize the data after having released the lock
# context.reduction.ForkingPickler 는
# https://github.com/python/cpython/blob/main/Lib/multiprocessing/reduction.py 에 있고
# https://github.com/python/cpython/blob/a59c5bb023fd74a3c482fc29bd6cefc32c56b531/Lib/pickle.py#L1878 로 직렬화된 객체를 로드.
# 결과적으로 파이프에서 가져온 바이트를 역직렬화해서 원래 객체로 돌려준다
return _ForkingPickler.loads(res)
토끼굴: _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(대기큐, 조건)은:
조건(pipe_readable(pipe), 즉 "파이프에 읽을 데이터가 있는가")이 거짓이면 현재 프로세스를 sleep 상태로 전환해서 CPU를 다른 프로세스에게 양보하고, 대기 큐(pipe->rd_wait)에 등록합니다.- 나중에 누군가 파이프에 데이터를 쓰면(
write()쪽 코드가wake_up_interruptible_sync_poll(&pipe->rd_wait, ...)를 호출) 커널이 이 프로세스를 깨웁니다. - 깨어나면 다시 조건을 체크하고, 참이면 그제서야 실제로 데이터를 버퍼에 복사해서 리턴합니다.
참고로 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]. 버퍼가 비어 있으면 다음 흐름을 탑니다.
- 읽을 데이터가 없음을 확인
wait_event_interruptible_exclusive()계열 매크로로 진입- 프로세스 상태를
TASK_INTERRUPTIBLE로 바꾸고, 파이프의 wait queue에 자신을 등록 schedule()을 호출하여 CPU를 다른 프로세스에 넘김
이 시점부터 프로세스는 스케줄러의 런큐에 없습니다. "무한루프를 도는 것"과 "잠드는 것"의 차이가 여기서 물리적으로 갈립니다. 도는 루프는 런큐에 남아 CPU 시간을 소모하지만, 잠든 프로세스는 깨울 이벤트가 오기 전까지 스케줄러가 쳐다보지도 않습니다.
wait queue와 TASK_INTERRUPTIBLE의 일반론은 LWN의 스케줄러/대기 메커니즘 문서와 커널 문서에서 확인할 수 있습니다[7].
깨우는 쪽: pipe_write와 wake_up
부모 프로세스가 task_queue.put()을 호출하면 다음이 일어납니다.
- Queue 내부의 feeder 스레드가 객체를 pickle로 직렬화하여 파이프에
write(2)합니다[1:1]. (put()은 버퍼에 넣고 바로 리턴하며, 실제 파이프 쓰기는 feeder 스레드가 합니다) - 커널의
pipe_write()가 데이터를 파이프 버퍼에 넣고, 그 파이프의 wait queue에 등록된 프로세스들을wake_up계열 함수로 깨웁니다[6:2]. - 깨어난 워커는 런큐에 복귀하고, 자기 차례가 오면
pipe_read()가 데이터를 채워read(2)가 드디어 리턴합니다. - 제어가 파이썬으로 돌아와
_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
38074: uvicorn 본체(부모).LLMEngine과requests_processing_loop스레드가 여기 삽니다.- 자식 둘은 fork로 생성되어 cmdline이 부모와 똑같이 보입니다.
pgrep -f에 세 개가 걸린 이유입니다.
어느 쪽이 워커인가
$ 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
38124가 모델 워커입니다.wchan=pipe_read는 정확히 위에서 추적한 그 지점, 즉task_queue.get()안의read(2)에서 잠들어 있다는 뜻입니다. RSS 약 950MB는 opt-125m 모델과 torch 런타임의 무게입니다.STAT=Sl의S는 interruptible sleep,l은 멀티스레드라는 표시입니다[8].wchan(wait channel)은 프로세스가 잠들어 있는 커널 함수의 이름입니다. 커널 소스의 함수명이 그대로 노출되므로, 유저스페이스에서 커널의 수면 지점을 관측하는 창구가 됩니다.
추가로 확인하고 싶다면:
# 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
...처리...
- 워커의 루프:
get()에서 무기한 잠듭니다. 요청이 없으면 다음 바퀴로 못 넘어가니 유휴 상태에서 사실상 0바퀴/초입니다. 깨우는 것은 이벤트(부모의put())입니다. - 부모의 루프: 한 바퀴에 최대 0.1초만 잡니다. 요청이 없어도 초당 약 10번 깨어나 확인합니다. 깨우는 것은 타이머입니다.
그리고 부모의 폴링 스레드도 일하는 동안에는 블로킹됩니다. 배치를 워커에 보낸 직후 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 전송 |
정리
task_queue.get()의 대기는 파이썬 코드의 루프가 아니라,read(2)시스템콜 안에서 커널이 프로세스를TASK_INTERRUPTIBLE상태로 재우는 것으로 구현됩니다.- 깨우는 것도 커널입니다. 쓰는 쪽이 파이프에 write하면
pipe_write()가 wait queue의 프로세스를 wake_up으로 깨웁니다. - 따라서 워커는 요청이 없는 동안 CPU를 전혀 쓰지 않습니다. 이는
wchan=pipe_read,STAT=S, CPU 0%로 살펴봤죠. - 같은 코드베이스 안에서 부모의
requests_processing_loop는 반대 전략(폴링)을 씁니다. 위에서 살펴보았듯 FastAPI 이벤트 루프는 별도 스레드를 만들어 유지시키고, 워커는 작업이 들어올 때 처리하면 되기 때문이죠. - 블로킹의 단위는 프로세스가 아니라 스레드입니다. 부모의 처리 루프 스레드도 배치를 보낸 뒤에는
result_queue.get()에서 같은 방식으로 블로킹되지만, 0.1초만 중지하는 셈입니다. 다만 FastAPI 이벤트 루프는 다른 스레드라서 계속 돕니다.
CPython Lib/multiprocessing/queues.py.
Queue.get()의 블로킹 경로와 feeder 스레드(_start_thread,_feed) 구현. ↩︎ ↩︎CPython Lib/multiprocessing/connection.py.
Connection._recv()가os.read로 내려가는 지점. ↩︎read(2) man page. 빈 파이프/FIFO 읽기 시의 블로킹 계약. ↩︎
pipe(7) man page. 파이프 I/O의 블로킹 동작과 버퍼 의미론. ↩︎
linux/fs/read_write.c.
SYSCALL_DEFINE3(read)→ksys_read()→vfs_read()의 시스템콜 진입 경로와file->f_op분기. ↩︎linux/fs/pipe.c.
pipe_read()의 수면 진입과pipe_write()의 wake_up 호출. ↩︎ ↩︎ ↩︎LWN: Wait queue와 스케줄링 관련 문서 및 커널 스케줄러 문서.
TASK_INTERRUPTIBLE, wait queue,schedule()의 일반론. ↩︎ps(1) man page. PROCESS STATE CODES 절(
S,l등)과wchan필드 설명. ↩︎