서론
ibuv는 Node.js와 독립적인 비동기 I/O 추상화 계층이지만, Node.js의 이벤트 루프와 비동기 작업 흐름을 담당하므로 함께 정리하였다.
libuv는 어떻게 비동기 작업과 그 콜백을 제어할까
비동기 작업 을 실행하면..
- fs.read(...) 를 호출하면 libuv의 uv_fs_read(이 연결 과정은 이 포스트에 정리해두었다) 가 아래 함수를 호출하여 스레드 풀과 공유되는 큐에 작업을 등록한다. 스레드풀이 큐에서 작업을 꺼내어 처리한 뒤 uv__async_send()를 호출하면 이벤트 루프가 완료된 작업을 pending 큐로 옮긴다.
uv_fs_read
|___ POST 매크로
|___ post

- server.listen(...) 을 실행하면 uv_listen 이 아래 함수를 호출하여 watcher 큐에 작업을 등록한다. 이후 동작은 uv_run에서 처리한다.

- setTimeout(...) 을 실행하면 uv_timer_start 함수가타이머의 만료 시각(timeout)을 계산해 이벤트 루프 힙에 등록한다.

비동기 작업이 완료되었는지 확인해서 콜백을 처리하기 위해..
libuv는 uv_run 함수로 이벤트루프를 구현했다. uv_run은 지속적인 I/O 요청을 처리하기 위해서(한개의 I/O 완료 여부를 감시하기 위해서가 아님) 다음의 작업들을 while 루프 내에서 반복한다.
- Timers: 타이머 큐에서 만료된 타이머 체크
- Pending Callbacks: 완료된 I/O 작업 큐 처리
- Poll (epoll_wait): 메인 I/O 감시 단계 (여기서 epoll_wait 호출)
- Check Phase: setImmediate 같은 후처리 핸들러 실행
- Idle/Close: 종료 처리

1. 파일 I/O 처리
uv__run_pending은 pending_queue 가 empty 할 때 까지 콜백함수를 꺼내 실행

2. 네트워크 I/O 등 처리
아래는 uv_io_poll의 핵심 로직만 남긴 것이다.
- watcher 큐가 empty할때까지 작업을 꺼내고, epoll_ctl을 통해 uv__io_start()로 등록된 fd를 커널에 추가/갱신한다.
- epoll_pwait() 을 호출하여 정해진 timeout 동안(계속 I/O만 확인할 수는 없다!) 블록되어 커널이 I/O 이벤트를 감지할 때까지 대기한다
- 커널에 의해 ready 상태가 되면 이벤트 발생한 fd에 대해 JS 콜백을 실행한다
void uv__io_poll(uv_loop_t* loop, int timeout) {
struct epoll_event events[1024];
struct epoll_event e;
struct uv__queue* q;
uv__io_t* w;
int epollfd = loop->backend_fd;
int nfds, fd, i;
struct epoll_event* pe;
// 1. watcher_queue 처리 (epoll_ctl 등록 단계)
while (!uv__queue_empty(&loop->watcher_queue)) {
q = uv__queue_head(&loop->watcher_queue);
w = uv__queue_data(q, uv__io_t, watcher_queue);
uv__queue_remove(q);
e.events = w->pevents;
e.data.fd = w->fd;
if (w->events == 0)
epoll_ctl(epollfd, EPOLL_CTL_ADD, w->fd, &e);
else
epoll_ctl(epollfd, EPOLL_CTL_MOD, w->fd, &e);
w->events = w->pevents;
}
// 2. epoll_wait: 커널에서 I/O 이벤트 대기
nfds = epoll_pwait(epollfd, events, ARRAY_SIZE(events), timeout, NULL);
// 시간 업데이트 등 내부 처리
SAVE_ERRNO(uv__update_time(loop));
// 3. 이벤트 처리 루프
for (i = 0; i < nfds; i++) {
pe = events + i;
fd = pe->data.fd;
// 해당 fd에 등록된 watcher 찾기
w = loop->watchers[fd];
if (w == NULL)
continue;
// 사용자가 감시 요청한 이벤트만 남김
pe->events &= w->pevents | POLLERR | POLLHUP;
if (pe->events != 0) {
// 실제 콜백 실행: 예를 들어 TCP 연결, 읽기, 쓰기 등
w->cb(loop, w, pe->events);
}
}
}
3. 타이머 처리
uv__run_timer 는 정해진 시간 동안(타이머만 계속 확인할 수는 없다!) 힙 루트(가장 시간 적게 남은 타이머) 확인하여 아직 시간이 안 됐으면 바로 종료하고, 만료된 타이머면 ready 큐로 옮긴다. ready 큐의 타이머를 하나씩 꺼내서 콜백을 호출한다.

(참고) I/O 멀티플렉싱이란
I/O 작업은 유저 스페이스에서 할 수 없고 커널에 위임해야 한다.
Sync/Blocking 모델의 경우 커널에서 해당 작업이 완료될 때까지 프로세스가 Blocked 상태로 전환되므로, 다른 작업을 할 수 없다. 만약 이 작업이 끝나기 전에 다른 I/O 작업을 시작하고 싶다면 새 프로세스 또는 스레드를 생성해야 해서, 블록 상태인 프로세스가 여러 개 유지되는 비효율이 있다.
Sync/Non-Blocking 모델은 프로세스에서 [fd를 감시 + 슬립] 를 반복한다. 이 프로세스가 슬립인 동안만 다른 프로세스와 커널이 CPU 점유할 수 있다. 커널이 CPU를 점유하고 있을 때 I/O 가 처리될 수 있다. 이 모델은 fd를 감시하는데 CPU를 사용하느라 다른 프로세스와 커널이 대기해야 하는 비효율이 있다.
Async/Blocking 모델은 두 모델의 단점을 보완한다. 한 프로세스에서 여러 개의 fd를 처리할 수 있고, (fd가 완료되었을 때 다음 작업을 실행하는건 맞지만) fd 완료 여부를 감시하는데 CPU를 점유하지 않는 모델이다. 한 프로세스에서 여러 개의 fd를 처리할 수 있다는 점에서 I/O 멀티플렉싱이라고도 한다(멀티플렉싱: 여러 개의 신호, 데이터 스트림, 또는 자원을 하나의 공유된 채널이나 시스템 상에서 동시에 처리하거나 전달하는 기술을 의미함).
I/O 멀티 플렉싱의 기법에는 select, poll, epoll 등이 있다. 공통 동작은 프로세스에서 이 시스템 콜을 호출 시 커널은 해당 프로세스를 (설정한 timeout 동안) 수면 상태로 대기시키고, I/O 이벤트 발생 시 커널의 인터럽트 핸들러가 이벤트를 등록한 fd를 준비 큐에 올리고 대기 중인 프로세스를 READY상태로 바꿔준다는 점이다. 차이점도 있는데, 앞의 두 방식(select, poll)은 유저스페이스에서 완료된 fd 를 알기위해 전체 fd (max 또는 fd 개수만큼) 리스트를 선형 탐색이 필요한데 epoll은 이와 달리 완료된 fd리스트를 바로 알 수 있다는 점이다.
epoll의 주요 함수는 다음과 같다. 우선 epoll_create와 epoll_ctl을 이용하여 epoll 관련 셋팅 및 fd와 작업 타입을 커널에 등록한다. epoll_wait을 호출하면 커널은 해당 프로세스를 (설정한 timeout 동안) 수면 상태로 대기시키고, I/O 이벤트 발생 시 커널의 인터럽트 핸들러가 이벤트를 등록한 fd를 준비 큐에 올리고 대기 중인 프로세스를 READY상태로 바꾼다. CPU스케줄러에 의해 다시 실행된 프로세스는 epoll_wait의 나머지 로직을 실행하고 이 리턴값으로부터 어떤 fd에서 이벤트가 발생했는지 알 수 있다.
Reference
'기술 조사 > Node.js' 카테고리의 다른 글
| AsyncHooks 를 이용하여 비동기작업과 콜백을 연결하기 (0) | 2025.11.12 |
|---|---|
| Node.js C++ Codebase 영한 번역 (0) | 2025.11.09 |
| inspect 옵션으로 CPU 프로파일 얻고 분석하기 (0) | 2025.10.09 |
| Node.js에서 멀티코어를 활용하기 (8) | 2025.01.01 |
| Node.js 딥다이브 (1) - JavaScript 를 "비동기"로 "실행"하기 위해 해야 할 일 (3) | 2024.11.08 |