QuickJS 위에 console.log, process.uptime(), 타이머, 파일 I/O, 이벤트 루프를 갖춘 작은 자바스크립트 런타임을 처음부터 만드는 과정을 살펴봅니다.
2026년 3월
자바스크립트 엔진(예: V8, JavaScriptCore)은 자바스크립트 코드를 실행합니다. 하지만 파일, HTTP 요청, 타이머 같은 것들은 알지 못합니다.
반면 자바스크립트 런타임(예: Node.js, Bun)은 자바스크립트가 실행되는 더 완전한 환경입니다. 여기에는 자바스크립트 엔진, 추가 API, 이벤트 루프와 태스크 큐, 그리고 플랫폼별 기능이 포함됩니다.
오늘 제가 이것으로 해킹해 보려는 것은 console.log, process.uptime(), setTimeout와 clearTimeout, fs.readFileSync와 fs.readFile, 그리고 파일 I/O를 위한 이벤트 루프와 워커 풀까지 갖춘 아주 작은 런타임입니다. QuickJS 위에 만들었습니다.
이 런타임이 실행할 수 있는 프로그램 예시는 다음과 같습니다:
const startedAt = process.uptime();
console.log("runtime booted");
setTimeout(async () => { // 타이머
console.log("uptime:", process.uptime() - startedAt);
// 동기 I/O
console.log("sync bytes:", fs.readFileSync("Makefile", "utf8").length);
// 비동기 I/O
console.log("async bytes:", (await fs.readFile("Makefile", "utf8")).length);
}, 100);
QuickJS는 실제로 qjs.c와 기본 표준 라이브러리를 중심으로 한 작은 셸/런타임을 함께 제공하지만, 저는 그 어느 것도 재사용하지 않을 생각입니다. 처음부터 시작하겠습니다.
그래서 우선 해야 할 일은 커스텀 실행 파일에서 QuickJS를 부팅하는 것입니다. 가장 작은 실용적인 임베더는 엔진 인스턴스(JSRuntime), 실행 환경(JSContext), 그리고 코드 파일을 읽어 평가하고 예외를 출력하는 방법으로 이루어집니다.
int main(int argc, char **argv)
{
JSRuntime *rt = JS_NewRuntime();
JSContext *ctx = JS_NewContext(rt);
int exit_code;
// ... 나중에 전역 객체 설치
exit_code = run_file(ctx, argv[1]);
// ... 그런 다음 타이머 / 비동기 작업이 끝날 때까지 이벤트 루프 실행
JS_FreeContext(ctx);
JS_FreeRuntime(rt);
return exit_code;
}
QuickJS는 자바스크립트를 실행하지만, 전역 환경의 모습이 어떨지, 입력이 무엇인지, 출력이 사용자에게 어떻게 반환될지는 호스트인 우리가 결정합니다.
static int run_file(JSContext *ctx, const char *path)
{
SourceFile source = {0};
JSValue result;
int exit_code = 1;
if (read_file(path, &source, NULL, 0) != 0) {
return 1;
}
// QuickJS가 프로그램을 실행하고 JSValue를 반환
result = JS_Eval(ctx,
(const char *)source.bytes,
source.len,
path,
JS_EVAL_TYPE_GLOBAL);
// 오류 보고
if (JS_IsException(result)) {
dump_exception(ctx);
} else {
exit_code = 0;
}
// 호스트가 만든 JSValue는 해제해야 함!
JS_FreeValue(ctx, result);
free_source_file(&source);
return exit_code;
}
벌써 무언가를 실행할 수 있습니다:
$ ./andjs example-uncaught-throw.js
Error: test exception
at <eval> (example-throw.js:1:16)
첫 번째 문제는 오류가 던져지지 않는 한 아무 일도 일어나지 않는 것처럼 보인다는 점입니다. 모두가 좋아하는 console.log를 추가해서 이를 해결해 봅시다.
호스트 함수는 자바스크립트 인수를 받아 QuickJS 함수(즉 JS_ToString)를 호출해 문자열로 바꿀 수 있습니다.
우리가 추가할 console.log 함수는 log 함수를 내부에 가진 console 객체를 전역 객체에 붙여 연결됩니다. 그 log 함수는 아래의 C 함수에 연결됩니다.
static JSValue js_console_log(JSContext *ctx, JSValueConst this_val,
int argc, JSValueConst *argv)
{
for (int i = 0; i < argc; i++) {
JSValue string_value;
const char *text;
if (i > 0) {
fputc(' ', stdout);
}
// 참고: Node.js와 정확히 같은 포매팅은 아님
string_value = JS_ToString(ctx, argv[i]);
text = JS_ToCString(ctx, string_value);
fputs(text, stdout);
JS_FreeCString(ctx, text);
JS_FreeValue(ctx, string_value);
}
fputc('\n', stdout);
return JS_UNDEFINED;
}
QuickJS가 자바스크립트 코드를 실행하기 전에, 먼저 전역 객체를 설정해야 합니다.
static int install_console(JSContext *ctx)
{
JSValue global_obj = JS_GetGlobalObject(ctx);
JSValue console_obj = JS_NewObject(ctx);
JSValue log_fn = JS_NewCFunction(ctx, js_console_log, "log", 1);
JS_SetPropertyStr(ctx, console_obj, "log", log_fn);
JS_SetPropertyStr(ctx, global_obj, "console", console_obj);
// ... 임시 핸들 해제
return 0;
}
이제 설정이 끝났으니, 런타임의 첫 번째 부수 효과를 살펴봅시다:
$ ./andjs example-log.js
sum: 3 ok
우리가 저장할 첫 번째 실제 런타임 상태는 프로세스 시작 시간입니다. 여기서 추가하기로 고른 것은 꽤 단순한 Node.js API인데, 직접 호출해 본 적이 한 번도 없는 것 같기도 한 process.uptime()입니다. 프로세스가 실행된 시간을 초 단위로 반환합니다.
이는 console.log처럼 엔진의 전역 객체에 순수 함수를 등록하는 것이 아니라, 엔진 바깥의 무언가를 추적한다는 뜻입니다.
typedef struct {
double start_time; // process.uptime()에 사용
int next_timer_id; // (타이머는 더 추가할 예정
Timer *timers; // 다음 섹션에서 이어집니다.)
// ...
} RuntimeState;
QuickJS는 JS_SetRuntimeOpaque(...)를 통해 호스트 상태를 저장할 수 있는 적절한 장소를 제공합니다. 이를 통해 우리 자신의 데이터를 QuickJS 런타임 추상화에 연결할 수 있습니다. 이 함수는 사용자 정의 포인터를 저장합니다(즉, 타입은 우리가 정의하고 수명도 우리가 관리합니다). 일부 QuickJS 콜백은 JSRuntime을 받기 때문에, 추가 조회 없이 이 상태에 직접 접근할 수 있습니다.
// 나중을 위해 커스텀 런타임 상태를 선언하고 저장
RuntimeState *state;
// ..
state = calloc(1, sizeof(*state));
now_monotonic(&state->start_time);
JS_SetRuntimeOpaque(rt, state);
여기서 흥미로운 곁가지 하나는 업타임을 어떻게 추적하느냐입니다(벽시계 시간이 아니라). 단조 증가 시계는 앞으로만 흐르며 NTP나 사용자의 시스템 시간 변경 같은 벽시계 조정의 영향을 받지 않습니다. 벽시계 시간에는 이런 성질이 없기 때문에, 만약 그것으로 process.uptime()를 구현했다면 경우에 따라 이 함수가 음수를 반환하는 것도 충분히 가능할 것입니다!
static JSValue js_process_uptime(JSContext *ctx, JSValueConst this_val,
int argc, JSValueConst *argv)
{
RuntimeState *state = JS_GetRuntimeOpaque(JS_GetRuntime(ctx));
double now;
// ... 인수 처리
now_monotonic(&now);
// 현재 시각과 시작 시각의 차이를 반환
return JS_NewFloat64(ctx, now - state->start_time);
}
주목할 점은 프로세스 업타임이 스크립트 실행 시작 시점은 아니라는 것입니다. 그래서 절대로 0은 보이지 않습니다.
$ ./andjs example-process-uptime.js
0.0003500021994113922
제가 이 프로젝트를 만들고 이 글을 쓰고 싶었던 이유 중 하나인 이벤트 루프를 처음부터 만드는 일에 점점 가까워지고 있습니다.
이벤트 루프는 자바스크립트 뒤에서 스케줄러 역할을 합니다. 먼저 동기 코드가 실행됩니다. 그런 다음 타이머, 이벤트, 프라미스 같은 비동기 작업이 콜백을 트리거하면서 더 많은 동기 코드를 실행합니다(그리고 이 순환은 계속됩니다). 이벤트 루프가 이 모든 작업을 스케줄링합니다.
아직 이벤트 루프 자체는 없지만, 그래도 그것이 처리할 작업을 미리 큐에 넣을 수는 있습니다. 본질적으로 타이머란 그런 큐에 들어간 작업일 뿐입니다.
typedef struct Timer Timer;
struct Timer {
int id; // 자바스크립트에서 쓰는 타임아웃 ID
double deadline; // 단조 증가 기준 만료 시각
JSValue callback; // 자바스크립트 콜백 함수에 대한 참조
Timer *next;
};
예약된 타이머들을 저장하기 위해 정렬된 연결 리스트를 골랐습니다. 충분히 빠르면서도(우선순위 큐 같은 대안과 비교하면) 구현 코드가 아주 적게 들기 때문입니다.
타이머가 정렬되어 있는 이유는, 하나를 실행할 시점이 되었고 마감 시각이 지난 타이머가 여러 개 있다면, 시작하기 위해 첫 번째 항목만 읽으면 되기 때문입니다.
static void insert_timer(RuntimeState *state, Timer *timer)
{
Timer **slot = &state->timers;
// 연결 리스트에서 들어갈 가장 이른 위치 찾기
while (*slot && (*slot)->deadline <= timer->deadline) {
slot = &(*slot)->next;
}
timer->next = *slot;
*slot = timer;
}
물론 단점은 삽입이 O(n) 시간이 걸린다는 점입니다 ... 하지만 코드가 정말 예쁘고 간결하지 않나요? CPU 캐시 친화적이지는 않지만요.
타이머를 설정하려면 다음이 필요합니다: 실행되어야 할 시간, 그리고 나중에 적절한 시점에 QuickJS가 콜백을 실행할 수 있도록 콜백에 대한 참조.
static JSValue js_set_timeout(JSContext *ctx, JSValueConst this_val,
int argc, JSValueConst *argv)
{
RuntimeState *state = JS_GetRuntimeOpaque(JS_GetRuntime(ctx));
Timer *timer = calloc(1, sizeof(*timer));
double now;
int64_t delay_ms = 0;
// ... fn과 ms 검증
now_monotonic(&now);
timer->id = state->next_timer_id++;
timer->deadline = now + (double)delay_ms / 1000.0;
timer->callback = JS_DupValue(ctx, argv[0]);
insert_timer(state, timer);
return JS_NewInt32(ctx, timer->id);
}
콜백의 수명은 JS_DupValue(...)와 JS_FreeValue(...)를 통해 호스트가 관리합니다. QuickJS에서 값(JSValue)은 참조 카운팅됩니다. 따라서 우리가 _dup_이나 _free_를 호출할 때는 “이 값에 대한 참조를 하나 더 갖고 있으니, 내가 끝났다고 알려줄 때까지 가비지 컬렉션하지 말아 달라”는 뜻이 됩니다.
API를 이해하고 나니 런타임과 엔진 사이의 공유 데이터를 관리하는 데 큰 어려움은 없었습니다(생각보다 훨씬 오래 걸리지 않았습니다). 이것만 봐도 QuickJS가 얼마나 세심하게 만들어졌는지 알 수 있습니다.
타이머를 해제하는 쪽은 더 단순합니다. 삭제하고, 메모리를 해제하고, undefined를 반환하면 됩니다.
static JSValue js_clear_timeout(JSContext *ctx, JSValueConst this_val,
int argc, JSValueConst *argv)
{
RuntimeState *state = JS_GetRuntimeOpaque(JS_GetRuntime(ctx));
int32_t id;
Timer **slot = &state->timers;
// ... argv[0]을 id로 변환
while (*slot) {
if ((*slot)->id == id) {
Timer *timer = *slot;
*slot = timer->next;
JS_FreeValue(ctx, timer->callback);
free(timer);
break;
}
slot = &(*slot)->next;
}
return JS_UNDEFINED;
}
이 두 함수 js_set_timeout과 js_clear_timeout은 console과 같은 방식으로 전역 객체에 설정됩니다. 런타임 내부의 더 복잡한 부분으로 들어가면서는 코드 조각을 너무 많이 쏟아내지 않기 위해 보여드릴 수 있는 양이 줄어들겠지만, 연결 방식은 대부분 같습니다.
이 시점에서 우리는 힙이 허용하는 한 원하는 만큼 타이머를 예약할 수 있습니다. 다만 이 타이머들은 다음 섹션까지 대기 상태로 남아 있습니다.
콜백(예: 타이머)이 언제 실행될지는 QuickJS가 아니라 호스트가 결정합니다.
setTimeout(() => {
console.log('A'); // 타이머 콜백 내부
// QuickJS 작업을 예약함(여기서는 프라미스 continuation / 마이크로태스크)
Promise.resolve().then(() => console.log('A+'));
}, 0);
setTimeout(() => {
console.log('B'); // 타이머 콜백 내부(나중에 실행됨)
}, 5);
console.log('sync'); // 어떤 타이머 콜백보다 먼저 실행됨
만료된 타이머를 실행하려면 그것들을 찾아내고, JS_Call(...)로 자바스크립트 실행을 트리거하면 됩니다.
중요한 점은, 콜백을 하나 트리거한 뒤에는 다음 타이머나 I/O 콜백보다 먼저 프라미스 continuation이 실행되도록, 대기 중인 QuickJS 작업을 모두 비워 줘야 한다는 것입니다. 이 경우 그 대기 작업은 프라미스 반응 작업이며, 흔히 마이크로태스크라고 생각하는 것들입니다.
static int run_expired_timers(JSContext *ctx)
{
RuntimeState *state = JS_GetRuntimeOpaque(JS_GetRuntime(ctx));
double now;
now_monotonic(&now);
while (state->timers && state->timers->deadline <= now) {
JSValue result;
Timer *timer = state->timers;
state->timers = timer->next;
result = JS_Call(ctx, timer->callback, JS_UNDEFINED, 0, NULL);
// ... 타이머 해제, 예외 보고
drain_pending_jobs(JS_GetRuntime(ctx));
now_monotonic(&now);
}
return 0;
}
저는 미리 계획을 세워 두었고, 앞으로 어디로 갈지 알고 있습니다(비동기 I/O!). 그래서 여기의 이벤트 루프 형태는 이미 서로 다른 종류의 콜백을 지원하는 방향으로 흘러가고 있습니다.
이벤트 루프 내부의 각 단계인 run_expired_timers와 run_completed_file_jobs는 모든 콜백 뒤에 대기 중인 QuickJS 작업을 비워 줍니다. 이렇게 하면 제가 원하는 순서를 얻을 수 있습니다. 즉, 어떤 콜백이 큐에 넣은 프라미스 continuation은 다음 타이머나 I/O 콜백보다 먼저 실행됩니다.
static int run_event_loop(JSContext *ctx)
{
JSRuntime *rt = JS_GetRuntime(ctx);
RuntimeState *state = JS_GetRuntimeOpaque(rt);
while (state->timers || runtime_has_async_work(state) || JS_IsJobPending(rt)) {
struct timeval timeout;
struct timeval *timeout_ptr;
run_expired_timers(ctx);
run_completed_file_jobs(ctx); // 지금은 no-op; 곧 다음 섹션에서 추가
drain_pending_jobs(rt);
if (!state->timers && !runtime_has_async_work(state) && !JS_IsJobPending(rt)) {
break;
}
// 가장 이른 타이머 찾기
compute_wait_timeout(state, &timeout, &timeout_ptr);
// I/O가 끝나거나 다음 타이머가 만료될 때까지 대기
wait_for_events(state, timeout_ptr);
}
return 0;
}
우리 런타임은 CPU를 태우지 않고 무언가가 일어날 때까지 잠들 수 있는 방법과, 즉시 깨어날 수 있는 방법이 필요합니다. 이런 깨우기 이벤트를 위해 저는 커널 파이프를 선택했습니다(정기 독자라면 지난주의 셸 만들기에서 이 파이프를 기억하실 겁니다).
별도 스레드에서 실행되는 워커들은 작업을 완료했을 때 이 웨이크업 파이프를 사용해 알립니다. 런타임의 메인 스레드에 있는 이벤트 루프는 select()를 이용해 이 웨이크업 파이프의 파일 디스크립터를 감시하다가, 읽을 준비가 되면 깨어납니다. 여기에 더해, 가장 이른 타이머를 select()의 타임아웃으로 설정해 두기 때문에 이벤트 루프는 웨이크업 파이프 대기를 중단하고 타이머 콜백을 처리하러 갈 수 있습니다.
static int wait_for_events(RuntimeState *state, struct timeval *timeout_ptr)
{
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(state->wakeup_pipe[0], &read_fds);
// 타임아웃까지 웨이크업 파이프에 데이터가 써지길 대기
select(state->wakeup_pipe[0] + 1, &read_fds, NULL, NULL, timeout_ptr);
if (FD_ISSET(state->wakeup_pipe[0], &read_fds)) {
clear_wakeup_pipe(state);
}
return 0;
}
웨이크업 파이프는 완료된 작업 자체, 예를 들어 읽은 파일의 내용을 전달하지는 않습니다. 단지 어떤 작업이 끝났음을 알리기 위해 한 바이트(정말로 1)만 전달합니다. 비동기 파일 작업은 읽어들인 바이트와 resolve, reject JSValue를 담은 연결 리스트에 저장됩니다. 하지만 그건 비동기 I/O로 갈 때 더 이야기하겠습니다.
그 전에 먼저 평범한 동기 I/O를 처리해야 합니다.
동기 파일 I/O는 메인 스레드가 그냥 블로킹되면 되기 때문에 구현이 쉽습니다. API는 Node.js와 비슷하지만 상당히 좁게 유지했습니다. path와 utf8만 받습니다.
호스트는 파일을 읽고 자바스크립트 문자열을 반환하거나 오류를 던집니다.
static JSValue js_fs_read_file_sync(JSContext *ctx, JSValueConst this_val,
int argc, JSValueConst *argv)
{
SourceFile source = {0};
const char *path;
const char *encoding;
char error_buf[512] = {0};
// ... 인수를 C 문자열로 변환
// ... "utf8" 외에는 모두 거부
// 단순한 읽기 유틸, 파일 바이트를 source에 읽어 넣음
if (read_file(path, &source, error_buf, sizeof(error_buf)) != 0) {
return JS_ThrowInternalError(ctx, "%s", error_buf);
}
return JS_NewStringLen(ctx, (const char *)source.bytes, source.len);
}
여기서는 QuickJS의 JS_NewStringLen을 사용해 QuickJS가 관리 메모리로 복사하고 결국 가비지 컬렉션할 새 JSValue를 만듭니다. null 종료된 C 문자열을 기대하는 JS_NewString과 달리, JS_NewStringLen은 길이를 알고 있는 버퍼를 다룰 때 더 적합하며 strlen 호출도 피할 수 있습니다.
fs.readFile이 호출되면, 생성된 프라미스의 해결(resolve) 또는 거부(reject)는 런타임의 책임이 됩니다.
프라미스 객체는 호스트에서 생성되어 엔진에 다시 전달됩니다. 호스트는 그 프라미스의 resolve와 reject 함수에 대한 참조를 보관하고, 작업이 끝나면 그중 하나를 호출한 다음 QuickJS가 이어지는 프라미스 작업을 실행하도록 둡니다.
병렬 I/O를 지원하기 위해 저는 스레드 기반 워커 풀을 선택했습니다. 이 워커들은 절대로 자바스크립트를 실행하지 않습니다. “$path에 있는 파일을 읽어라” 같은 작업을 받아 바이트를 가져오는 역할만 합니다. 대기 작업 큐에는 경로명이, 완료 작업 큐에는 파일 바이트나 오류가 들어 있습니다. 워커는 대기 큐에서 작업을 하나 꺼내 실행하고, 결과를 완료 큐에 넣습니다.
워커는 웨이크업 파이프에 1을 써서 메인 스레드(select()가 감시 중인 쪽)에 신호를 보냅니다. 그러면 메인 스레드는 결과와 함께 resolve 또는 reject를 호출할 수 있습니다.
비동기 파일 작업은 resolve와 reject 함수에 대한 참조를 담은 연결 리스트에 저장됩니다. 또 여기에 새로운 런타임 상태도 추가합니다: 뮤텍스(메인 스레드와 워커가 공유 메모리를 읽고 쓸 것이기 때문)와, 메인 스레드가 워커에게 대기 큐를 확인하라고 신호를 보내는 pthread_cond_t입니다.
typedef struct AsyncFileJob AsyncFileJob;
struct AsyncFileJob {
char *path;
JSValue resolve; // 자바스크립트 함수 참조
JSValue reject; // 자바스크립트 함수 참조
uint8_t *bytes; // 파일 내용일 수도 있음
size_t len;
char *error_message; // 오류일 수도 있음
AsyncFileJob *next;
};
typedef struct {
// ...
pthread_mutex_t mutex;
pthread_cond_t worker_cond; // 워커를 깨우기 위해 사용
int wakeup_pipe[2]; // 워커가 메인 스레드에 신호 보내는 데 사용
size_t active_file_jobs; // 개수 추적, 런타임 종료 판단용
AsyncFileJob *pending_jobs_head;
AsyncFileJob *pending_jobs_tail;
AsyncFileJob *completed_jobs_head;
AsyncFileJob *completed_jobs_tail;
pthread_t workers[WORKER_COUNT];
} RuntimeState;
이것이 글 처음에 나열한 기능들을 구현하기 위해 추가해야 하는 런타임 상태의 전부입니다. 나머지 작업은 이 큐들을 움직이는 일입니다. 먼저, 프로그램이 파일 내용을 비동기로 읽고 싶을 때 readFile이 호출하는 C 함수입니다.
프라미스를 생성해 반환하고, 대기 작업을 큐에 넣고, 워커에 신호를 보냅니다.
static JSValue js_fs_read_file(JSContext *ctx, JSValueConst this_val,
int argc, JSValueConst *argv)
{
RuntimeState *state = JS_GetRuntimeOpaque(JS_GetRuntime(ctx));
JSValue promise;
JSValue resolving_funcs[2];
AsyncFileJob *job;
// ... (path, "utf8") 검증
promise = JS_NewPromiseCapability(ctx, resolving_funcs);
job = calloc(1, sizeof(*job));
job->path = strdup(path);
job->resolve = resolving_funcs[0];
job->reject = resolving_funcs[1];
pthread_mutex_lock(&state->mutex);
state->active_file_jobs++; // 활성 작업 수 추적, 0이 되면 런타임 종료 가능
enqueue_async_file_job(&state->pending_jobs_head, &state->pending_jobs_tail, job);
pthread_cond_signal(&state->worker_cond); // 워커에 신호
pthread_mutex_unlock(&state->mutex);
return promise;
}
워커 쪽에서는 pthread_cond_wait로 “새 작업 신호”를 기다립니다(중간에 스스로 종료해야 하는지도 몇 가지 확인합니다). 그런 다음 대기 작업을 하나 꺼내 파일을 읽고, 결과를 완료 작업 큐에 씁니다.
앞서 말했듯, 워커는 자바스크립트에 대해 아무것도 모릅니다. 그냥 약간의 C 코드만 실행합니다.
static void *worker_main(void *opaque)
{
RuntimeState *state = opaque;
for (;;) {
AsyncFileJob *job;
SourceFile source = {0};
char error_buf[512] = {0};
pthread_mutex_lock(&state->mutex);
while (!state->pending_jobs_head && !state->stop_workers) {
// 신호 대기
pthread_cond_wait(&state->worker_cond, &state->mutex);
}
if (state->stop_workers && !state->pending_jobs_head) {
pthread_mutex_unlock(&state->mutex);
break;
}
job = dequeue_async_file_job(&state->pending_jobs_head, &state->pending_jobs_tail);
pthread_mutex_unlock(&state->mutex);
if (!job) {
continue;
}
// 워커는 C 데이터만 옮긴다.
if (read_file(job->path, &source, error_buf, sizeof(error_buf)) == 0) {
job->bytes = source.bytes;
job->len = source.len;
} else {
job->error_message = strdup(error_buf);
}
pthread_mutex_lock(&state->mutex);
enqueue_async_file_job(&state->completed_jobs_head, &state->completed_jobs_tail, job);
pthread_mutex_unlock(&state->mutex);
signal_event_loop(state); // 웨이크업 파이프에 1을 씀
}
}
런타임의 메인 스레드에서는 이벤트 루프가 이벤트를 받은 뒤 완료된 파일 작업을 처리할 차례가 됩니다. 먼저 락을 걸고 완료 작업들을 가져온 다음, 저장해 둔 resolve 또는 reject 함수를 호출해 프라미스를 fulfilled 또는 rejected 상태로 만듭니다. 이로써 이어지는 QuickJS 작업들이 예약되지만, 아직 바로 실행되지는 않습니다.
그 대기 중인 QuickJS 작업은 drain_pending_jobs가 각각에 대해 JS_ExecutePendingJob를 호출하면서 실행됩니다.
static int run_completed_file_jobs(JSContext *ctx)
{
RuntimeState *state = JS_GetRuntimeOpaque(JS_GetRuntime(ctx));
AsyncFileJob *jobs;
pthread_mutex_lock(&state->mutex);
jobs = state->completed_jobs_head;
state->completed_jobs_head = NULL;
state->completed_jobs_tail = NULL;
pthread_mutex_unlock(&state->mutex);
while (jobs) {
AsyncFileJob *job = jobs;
JSValue arg;
JSValue result;
jobs = jobs->next;
if (job->error_message) {
arg = new_error_value(ctx, job->error_message);
result = JS_Call(ctx, job->reject, JS_UNDEFINED, 1, (JSValueConst *)&arg);
} else {
arg = JS_NewStringLen(ctx, (const char *)job->bytes, job->len);
result = JS_Call(ctx, job->resolve, JS_UNDEFINED, 1, (JSValueConst *)&arg);
}
JS_FreeValue(ctx, arg);
JS_FreeValue(ctx, result);
// 중요: 이것이 있어야 await가 계속 진행된다.
drain_pending_jobs(JS_GetRuntime(ctx));
// ... 완료 레코드 해제
}
return 0;
}
모든 것은 결국 비동기 작업을 기다리는 꽤 간결한 run_event_loop로 향해 쌓여 왔습니다.
이벤트를 받으면, 이벤트 루프는 만료된 타이머를 실행하고, 파일 작업 결과를 엔진에 전달하고, 대기 중인 QuickJS 작업을 비우고, 그런 다음 종료하거나 더 많은 이벤트를 기다리기 시작합니다.
static int run_event_loop(JSContext *ctx)
{
JSRuntime *rt = JS_GetRuntime(ctx);
RuntimeState *state = JS_GetRuntimeOpaque(rt);
while (state->timers || runtime_has_async_work(state) || JS_IsJobPending(rt)) {
struct timeval timeout;
struct timeval *timeout_ptr;
run_expired_timers(ctx);
run_completed_file_jobs(ctx);
drain_pending_jobs(rt);
if (!state->timers && !runtime_has_async_work(state) && !JS_IsJobPending(rt)) {
break;
}
compute_wait_timeout(state, &timeout, &timeout_ptr);
wait_for_events(state, timeout_ptr);
}
return 0;
}
비동기 작업이 계속 생성되는 한, 이벤트 루프의 핵심은 계속 실행되고 반복됩니다!
참고: 제 런타임이 I/O와 타이머 우선순위 면에서 Node.js와 의미적으로 얼마나 가까운지는 검증하지 않았습니다.
재미 삼아, Apple M1 Pro에서 제 런타임과 Node.js v24.14.0를 비교하는 벤치마크를 돌려 봤습니다. 벤치마크는 Promise.all로 1MB 파일 열 개를 읽는 것입니다.
전체 시간(시작 포함):
node 27.7 ms ± 0.4 msandjs 7.2 ms ± 0.3 ms파일 읽기 구간:
node 3.828 msandjs 4.620 msQuickJS와 미니멀한 런타임이 더 빠른 시작 속도를 갖는 것은 놀라운 일이 아닙니다. 그것이 엔진으로서 QuickJS가 내세우는 가치 제안 중 하나니까요. 파일 읽기 구간에서도 비교적 근접한 수치를 낸 것은 꽤 만족스럽습니다.
물론, 측정되는 것이 아주 많은 것은 아닙니다. 작은 파일 읽기 작업 열 개 위에 얹을 수 있는 오버헤드는 사실 그리 크지 않습니다. 그래도 무언가를 측정하는 일은 역시 재미있습니다!
런타임 소스 코드는 healeycodes/andjs에서 확인할 수 있습니다.
새 글이 올라오면(조금은 불규칙하게) 알림을 받도록 구독하세요.