FastAPI documentation에 따르면,
1. ASGI 서버로 uvicorn을 권장하고 있고(링크)
2. uvicorn는 uvloop를 사용하며(링크)
3. 다시 uvloop는 libuv기반으로 돌아간다고(링크)
명시되어 있습니다. 따라서 libuv, uvloop, uvicorn, FastAPI 순으로 이야기를 써 내려가면 어떨까 합니다.
아래는 공식 문서를 참고해 정리한 내용이니
틀린 내용이 있다면 피드백 해주시면 감사하겠습니다!
unix system 기준으로 작성하였습니다.
libuv. Introduction
uvloop, Node.js의 핵심 이벤트 루프 라이브러리인 libuv는 이벤트 루프를 제공하고 I/O와 같은 활동의 callback based notification을 제공해줍니다. 파일을 쓰거나 소켓을 읽거나 타이머 기능을 사용하거나 하는 등의 일들은 모두 이벤트입니다. 위와 같은 이벤트 처리 과정에서 알려진 문제가 있는데, 이 모든 I/O 함수가 blocking이라는 겁니다.
blocking 함수를 유연하게 처리하는 방법은 여러가지 입니다. 그 중 하나가 스레드를 여러 개 둬서 한 스레드가 I/O를 처리하는 동안 다른 스레드가 CPU 잡을 실행하는 방법입니다. 하지만 libuv는 조금 다르게 접근합니다. 비동기, non-blocking 한 스타일입니다. (Asynchronous와 non-blocking의 구분은 일단 안하도록 하겠습니다.)
모던 OS는 이벤트 notification을 지원합니다. 예를 들어, socket read 동작이 sender가 실제로 무언가를 보내기 전까지 block하는 대신, 어플리케이션이 OS에 소켓을 보고 있다가 이벤트 notification을 queue에 넣어달라고 요청하는 식입니다. 이 동작은 어플리케이션이 관심사를 한 부분에 두고(I/O bound) 다른 부분에선 데이터를 가공하는 동작을 하기에(CPU bound) asynchronous합니다. 또한 이 동작을 하며 어플리케이션 프로세스는 다른 일을 할 수 있도록 열려 있기에 non-blocking 합니다.
libuv 에서 이벤트 루프는 싱글 스레드에서 동작하도록 설계되었습니다. 즉 한 스레드 내에서 어플리케이션의 모든 I/O 연산에 관여합니다.
위는 공식 문서에서 제시하는 싱글 스레드 내 비동기 I/O 처리 방식입니다.
Update loop time
시간 관련 system call을 위한 현재 시간을 캐싱하는 단계입니다.
loop alive?
루프가 살아있으면 반복되고, 아니면 종료합니다.
Run due timers
위에서 맞춘 현재 시간 이전에 수행이 끝나도록 schedule된 모든 시간 관련 callback을 수행합니다. 즉, 타이머가 지난 callback 들을 수행합니다.
Call pending callbacks
pending callback을 수행합니다. 대부분 I/O가 끝난 뒤 즉시 call이 되나, 다음 루프 반복을 위해 지연되는 callback이 있기도 합니다. 예로, TCP error와 같은 시스템 오퍼레이션 callback이 이 phase에서 수행됩니다.
Run idle handles
idle handles callback이 호출됩니다. 이름과는 다르게(?) 매 루프마다 수행되는 callback을 idle handles라고 합니다. node.js 공식 문서에는 내부적으로 사용된다고 합니다.
Run prepare handles
prepare handles callback이 호출됩니다. prepare handles callback은 루프가 I/O로 블락되기 직전에 호출됩니다. node.js 공식 문서에는 내부적으로 사용된다고 합니다.
Poll for I/O
폴링 시간이 계산됩니다. I/O 블락이 되기 전 루프가 얼마나 블록이 될 건지 계산합니다. timeout이 없을 경우 I/O 작업을 수행하기 위한 block 시간이 없다고 이해했습니다.
Run check handles
I/O 블락이 된 직후 call되는 callback들을 실행합니다. prepare handles에 대응합니다. node.js의 setImmediate()의 callback이 여기서 실행됩니다.
Call close callbacks
close callback을 실행합니다. 예로, 소켓이 갑자기 close 된 경우 그에 해당하는 callback이 이 phase에서 실행됩니다.
실제로, 위 단계들은 node.js에서 명시한 event loop phases와 동등하게 대응합니다.
이제 이 각각의 phase들이 실제 libuv의 코드 단에서 어떤 식으로 돌아가는지 파악해봅시다.(코드)
libuv. Inside
uv_run (src/unix/core.c #365)
event loop를 실행하는 api입니다.
uv__loop_alive()
가 true 이고 loop의 stop_flag
가 false로 유지될 경우 반복문이 계속 유지되고, UN_RUN_ONCE, UV_RUN_NOWAIT
flag가 있으면 반복문이 깨지는 구조로 짜여 있습니다. 해당 반복문 내에는 위에서 언급한 대로 아래 함수들이 조건에 맞다면 끊임없이 돌고 있음을 알 수 있습니다.
uv__update_time
uv__run_timers
uv__run_pending
uv__run_idle
uv__run_prepare
uv_backend_timeout
uv__io_poll
uv__run_check
uv__run_closing_handles
uv__update_time (src/unix/internal.h #295)
UV_UNUSED(static void uv__update_time(uv_loop_t* loop)) {
loop->time = uv__hrtime(UV_CLOCK_FAST) / 1000000;
}
UV_UNUSED
는 뭔가 했더니 __attribute__((unused))
였습니다. uv__hrtime
는 플랫폼에 따라 정의가 다르게 되어있는데, linux-core.c
를 참조하면, timespec
구조체의 tv_nsec
을 리턴함을 알 수 있습니다. loop 구조체 내 time을 해당 시간으로 맞추는 역할을 하는 함수로 현재 시간을 담습니다.
uv__run_timers (src/timer.c #163)
loop에서 timer를 구현하기 위해 min-heap을 구현합니다. libuv 내에서 timer는 heap구조로 짜여있습니다. 그 이유는 시간 복잡도 때문인데, 시간이 등록된 최소 값을 지났는지 여부만 판단하기 때문에 최소원을 삽입, 삭제까지 고려해 O(logN)에 뽑을 수 있도록 min-heap을 사용했다고 합니다. 아무튼, libuv는 등록된 timer 중 min 값을 뽑아내 시간이 지났는지를 체크합니다. 정해진 timeout이 지나면, uv_time_stop
에서 min-heap의 최소 시간을 제외시킨 뒤 timer_cb
에서 poll에 등록합니다.
container_of
는 여기를 참조해 어떤 struct의 멤버를 해당 struct로 캐스팅 하는 역할을 한다고 이해했습니다. 여기의 uv_fs_stat
을 자세히 살펴봅시다.
실질적으로 callback이 poll phase에 등록되는 작업은 uv_fs_stat
을 통합니다. 정확히는 POST
의 uv__work_submit
을 통해 등록된다고 보시면 될 거 같습니다. callback이 NULL이 아닌경우 poll에 등록되고, 아닌 경우 바로 실행이 됩니다.
uv__run_pending (src/unix/core.c #797)
pending callback을 수행합니다. loop의 pending_queue를 참조해 빌 때 까지 cb을 수행합니다. 여기서 수행 했는지 여부는 후에 timeout 여부를 계산할 때 다시 쓰입니다.
uv__run_##name(idle, check, prepare) (src/unix/loop-watcher.c #48)
uv__run_##name
친구들은 위 파일에 매크로 연산자로 정의되어 있습니다.
각 이름이 지정된 callback들을 큐에서 꺼내어 진행한다고 보시면 되겠습니다. 위 uv__run_pending
같은 경우와는 다르게 리턴이 없고 큐에 들어가는 타입이 살짝 다름을 알 수 있습니다.uv__run_idle
, uv__run_check
, uv__run_prepare
에 해당합니다.
보통 Node.js 와 libuv를 다루는 문서에서는 그저 node.js 내부에서 쓰이는 용도로 밖에 명시가 안되어있어 조금 더 파고들어 잡아낸 특징은 다음과 같습니다.
- idle: 매 루프에 한번씩 실행되는 콜백으로, 낮은 우선도를 가집니다. idle하게 등록되면 좋은 잡으로는, 어플리케이션 퍼포먼스를 위해 요약정보를 제공한다든지, tcp socket을 유지해 어플리케이션 다운로드 정보를 보여준다든지 하는 잡들이 있습니다.
- check: I/O 작업 바로 직후에 수행되어야 할 콜백들을 뜻합니다.
- prepare: I/O 작업 바로 직전에 수행되어야 할 콜백들을 뜻합니다.
uv_backend_timeout
I/O 시 블록될 시간을 계산합니다.
다양한 조건에서 I/O 블락이 일어나지 않음을 알 수 있습니다. 만일 조건에 해당하지 않는다면 uv__next_timeout
에서 다음 timeout을 계산합니다. pending_queue에 처리해야할 callback 이 있는 경우 non-block하게 진행됨을 알 수 있습니다.
uv__io_poll (core/unix/linux-core.c #191)
대망의 uv__io_poll
입니다. 코드는 너무 길어 일부만 첨부하도록 하겠습니다. linux os의 경우 epoll_wait
에서 실제 I/O 잡을 처리한다고 합니다.
timeout이 되기 전까지 무한 루프를 돌며 epoll_wait
를 수행하는 것을 보실 수 있습니다. 단계별로 설명하기 위해 주석에 A, B, C, D를 확인해주세요. (epoll 관련 커널 인터페이스가 리눅스 2.5.44부터 지원하기 때문에 그 이후 기준으로 코드가 짜여 있는 거 같습니다. macOS 에서는 kqueue, Windows에서는 IOCP가 그 기능을 대체한다고 합니다.)
- A :
watcher_queue
의 I/O file descriptor 들을 epoll list 에 추가/삭제 하는 등의 제어를 할 수 있도록epoll_ctl
을 수행하는 코드가 명시되어 있습니다. - B : timeout이 될 때 까지
epoll_wait
를 하여 epoll fd list의 I/O 동작을 기다립니다. 수행된 fd 개수를 nfds에 할당합니다. - C : nfds를 돌며 수행된 I/O 작업들의 callback을 수행합니다.
- D : 해당 루프를 돌며 걸린 시간 만큼 timeout에서 제외합니다.
uv__run_closing_handles (src/unix/core.c #308)
loop의 closing_handles를 돌며 uv__finish_close
를 수행합니다.
case UV_PREPARE:
case UV_CHECK:
case UV_IDLE:
case UV_ASYNC:
case UV_TIMER:
case UV_PROCESS:
case UV_FS_EVENT:
case UV_FS_POLL:
case UV_POLL:
case UV_SIGNAL:
case UV_NAMED_PIPE:
case UV_TCP:
case UV_TTY:
case UV_UDP:
uv__finish_close
에선 handle의 여러가지 type에 따라 상황에 맞게 처리하는 걸 볼 수 있으실 겁니다.
지금까지 루프의 각 phase에 맞는 코드들을 낮은 depth에서 살펴보았습니다. node.js든 uvicorn이든 내부에서 어떻게 이벤트 루프가 돌아가는지 알아볼 수 있는 좋은 시간이었던거 같습니다. 다음 uvloop를 살펴볼 때는 각 코드가 어떻게 implement 되었는지 중점으로 살펴보면 좋을 거 같습니다. 긴 글 읽어주셔서 감사합니다.
Reference
http://docs.libuv.org/en/v1.x/guide.html
https://www.kdata.or.kr/info/info_04_view.html?field=&keyword=&type=techreport&page=28&dbnum=180361&mode=detail&type=techreport
https://nodejs.org/ko/docs/guides/event-loop-timers-and-nexttick/