Slint 1.17이 Linux와 macOS에서 Node.js 바인딩의 16ms 틱 문제를 libuv 통합으로 해결한 방법과, Windows·Deno·Bun이 아직 과제로 남아 있는 이유를 설명합니다.
Slint는 크로스 플랫폼 UI를 구축하기 위한 툴킷입니다. 핵심은 Rust로 작성되었지만, 같은 .slint 마크업이 Rust, C++, JavaScript, TypeScript, Python을 위한 각 언어다운 API로 컴파일되므로 Slint는 애플리케이션에 맞는 어떤 언어 안에서도 사용할 수 있습니다.
JavaScript와 TypeScript는 특정 종류의 데스크톱 앱에 잘 맞습니다. 숫자 계산을 집중적으로 수행하기보다 입력, 네트워크 호출, 파일, 데이터베이스를 조율하는 코드입니다. 우리는 Slint가 그런 앱을 위한 진지한 선택지가 되기를 바랍니다. Electron보다 더 가벼운 대안으로서, 브라우저 없이 직접 GPU에 접근할 수 있습니다.
Node.js 바인딩은 한동안 존재해 왔지만, 최근까지도 뚜렷한 문제점이 있었습니다. UI 스레드는 할 일이 있든 없든 16밀리초마다 깨어났고, 그 결과 유휴 상태에서도 CPU와 배터리를 소모했으며, UI 이벤트는 최대 16ms까지 늦게 도착할 수 있었습니다. Slint 1.17은 Linux와 macOS에서 이 문제를 해결합니다. 이 글에서는 우리가 이를 어떻게 해결했는지, 그리고 왜 Windows, Deno, Bun이 아직도 목록에 남아 있는지를 설명합니다.
slint-ui는 일반적인 npm 패키지입니다. 다음과 같이 작성합니다:
import * as slint from "slint-ui";
const ui = slint.loadFile(new URL("app.slint", import.meta.url));
const window = new ui.MainWindow();
window.show();
await slint.runEventLoop();
runEventLoop는 사용자가 마지막 창을 닫거나 앱이 quit()를 호출할 때 해결되는 Promise를 반환합니다. 내부적으로는 napi-rs를 통해 Rust로 넘어가며, 문제가 시작되는 지점도 바로 여기입니다. Rust가 스레드를 넘겨받는 순간, Node의 타이머와 I/O는 스레드가 다시 반환될 때까지 멈춥니다. 그 이유를 이해하려면 먼저 이벤트 루프에 대해 이야기해야 합니다.
이벤트 루프는 처음부터 끝까지 계속 실행되기보다, 무언가가 일어나기를 기다리며 생애 대부분을 보내는 프로그램을 구동합니다. 클릭, 네트워크 패킷, 타이머 만료 같은 것들입니다. 본질적으로 이는 프로그램의 전체 수명 동안 호출 스택의 맨 아래에 머무르다가 종료할 때만 반환하는 무한 루프입니다. 매 순회마다 다음 이벤트를 기다리고, 핸들러를 디스패치한 뒤, 다시 기다리기로 돌아갑니다. 의사 코드는 다음과 같습니다:
loop {
let timeout = time_until_next_timer();
// Block until an event arrives or until a timer expires.
let events = wait_for_events(timeout);
for event in events {
dispatch(event); // deliver input, resize, ... to the UI
}
fire_due_timers(); // run expired timer callbacks
run_posted_callbacks(); // callbacks posted from other threads or async tasks
render_frame(); // repaint windows whose contents changed
}
블로킹 wait_for_events는 운영체제의 한 가지 기본 기능에 대응합니다. Linux에서는 epoll, BSD와 macOS에서는 kqueue, Windows에서는 I/O completion ports입니다.
Node 바인딩을 사용하는 Slint 애플리케이션에는 하나의 스레드를 공유하는 두 개의 이벤트 루프가 있습니다. Node는 libuv를 구동하며, JavaScript가 예약한 타이머와 I/O, 즉 네트워크, 파일, DNS, 자식 프로세스를 처리합니다. Slint는 윈도잉 백엔드인 winit을 구동하며, 플랫폼의 윈도 시스템(X11, Wayland, AppKit, Win32)과 통신하고 키보드, 포인터, 리사이즈, expose 이벤트를 표면화합니다.
이 둘은 같은 스레드를 공유해야 합니다. Slint 프로퍼티는 렌더링 중에도 접근되고, GUI 이벤트가 발생시키는 콜백에서도 접근되기 때문입니다. 그리고 그 콜백은 JavaScript를 호출하는데, JavaScript는 반드시 Node의 메인 스레드에서 실행되어야 합니다. 또한 macOS에서는 GUI를 오직 메인 스레드에서만 구동할 수 있는데, 그 메인 스레드는 이미 Node가 차지하고 있습니다. 따라서 "그냥 Slint의 루프를 워커에 넣자"라는 뻔한 우회책은 사용할 수 없습니다.
어떤 런타임에서든 동작하는 가장 단순한 방법은 Rust를 호출하는 setInterval(16)입니다. Rust는 Slint 루프를 블로킹 없이 한 번 순회하고 반환합니다. 그러면 libuv가 틱 사이에서 실행되므로 JavaScript 타이머와 I/O가 다시 동작합니다. 하지만 유휴 CPU가 낭비되고, 프로세스는 절대 잠들지 못하며, 모든 JavaScript 타이머는 최대 16ms까지 늦어집니다.
Slint 1.17에서는 Linux와 macOS에서 이 틱을 실제 libuv 통합으로 대체했습니다. 핵심은 각 libuv 순회의 올바른 시점에 Slint의 루프를 비우는 것입니다:
┌── one libuv iteration ───────────────────────────┐
│ 1. update cached clock │
│ 2. run due timers │
│ 3. run pending callbacks │
│ 4. run prepare hooks ◄── we install ours here │
│ 5. poll for I/O, sleeping up to │
│ uv_backend_timeout() ms (normally blocks) │
│ 6. run check hooks │
│ 7. run close callbacks │
└──────────────────────────────────────────────────┘
libuv는 uv_prepare_t를 제공합니다. 이는 타이머가 실행된 뒤, 그러나 I/O 폴링 전에 각 순회마다 콜백이 실행되는 핸들입니다. 우리가 개입하고 싶은 위치가 정확히 여기입니다. JavaScript 타이머 콜백이 이미 실행되었을 만큼 충분히 늦고, 동시에 폴링의 절전 예산이 우리가 방금 한 작업을 반영할 만큼 충분히 이른 시점입니다.
// Slint's libuv prepare callback -- edited for brevity.
fn prepare_callback() {
// How long libuv may sleep before its next timer or I/O is due.
let timeout = uv_backend_timeout(uv_loop);
// Run Slint's event loop: handle a pending windowing event (input, resize)
// or other Slint event (timer, posted callback), else block up to `timeout`
// waiting for one.
process_slint_events_with_timeout(timeout);
// We already did the waiting here. The trick: signal libuv so its own
// I/O poll (step 5) returns at once instead of blocking again.
}
uv_backend_timeout()은 "다른 무언가가 주의를 필요로 하기 전에 얼마나 오래 자도 안전한가"에 대한 libuv의 답이므로, Slint는 UI 이벤트가 더 빨리 깨우지 않는 한 정확히 그만큼 잠듭니다.
prepare hook은 한 방향을 다루지만, 반대 방향도 중요합니다. libuv에 I/O 준비가 완료되면 Slint는 이를 빠르게 알아차리고 제어를 다시 넘겨야 합니다. 그래서 우리는 Slint 자체 루프의 future에서 libuv의 백엔드 fd(uv_backend_fd())를 감시합니다. spawn_local은 그 future에 Waker를 제공하는데, 이 wake()는 Slint의 이벤트 루프를 깨웁니다. 그리고 async-io는 fd가 읽기 가능해지는 즉시 그것을 호출하므로, prepare hook 안에서 블로킹 중이던 process_slint_events_with_timeout이 반환되어 libuv에 제어를 다시 넘깁니다:
// The single epoll/kqueue fd behind the libuv loop; readable when any I/O is ready.
let backend_fd = uv_backend_fd(uv_loop);
// Wrap it so async-io's reactor watches it and wakes our future on readability.
let async_fd = async_io::Async::new_nonblocking(backend_fd)?;
slint::spawn_local(async move {
// We don't read the fd; being woken is the point.
while async_fd.readable().await.is_ok() {}
})?;
이렇게 제어가 prepare 콜백으로 돌아오면, 그 콜백은 uv_async_send를 통해 libuv에 신호를 보내 다음 순회에서 우리의 콜백이 실행되게 합니다.
Windows는 하나의 대기 가능한 fd 대신 I/O completion port를 사용하므로, 유닉스용 prepare-hook 방식은 직접적으로 옮겨지지 않습니다. 필요한 배관은 libuv 내부에 존재하지만 공개 API의 일부는 아니며, Node도 이를 다시 노출하지 않습니다.
Electron은 completion-port 핸들을 노출하기 위해 libuv에 패치를 적용하지만, 그 패치는 아직 업스트림에 반영되지 않았습니다. libuv 2.x가 이 문제를 제대로 해결하겠지만, 그 시점은 가깝지 않고 Node.js가 이를 채택하는 시점은 더 늦을 것입니다.
그래서 Windows는 여전히 16ms 틱을 사용합니다. 가장 유망한 해결책은 Electron이 하는 방식과 비슷하게 자체 패치된 libuv를 포함하는 전용 node-slint 러너입니다.
Deno는 libuv를 전혀 사용하지 않으며(Rust 위의 tokio), Bun도 자체 런타임입니다. 둘 다 우리가 Node에서 사용하는 hook을 제공하지 않으므로, 계획은 소유권을 뒤집는 것입니다. 즉 Slint의 루프를 주 루프로 두고, 런타임의 future가 slint::spawn_local을 통해 그 위에 스케줄되도록 하는 러너를 만드는 것입니다.
그때까지는 둘 다 Node와 같은 .node 바이너리를 그대로 실행합니다. 우리는 libloading을 통해 런타임에 libuv 심볼을 해석하므로, 이를 노출하지 않는 호스트에서는 로드 실패 대신 16ms 틱으로 폴백합니다.
npm install slint-ui를 실행하고 Linux 또는 macOS에서 실행하면 libuv 통합을 사용할 수 있습니다. UI 입력은 즉시 디스패치되고, 유휴 앱은 잠들며, 아무 일도 일어나지 않을 때 CPU 사용량은 0으로 떨어집니다. Windows, Deno, Bun에서는 바인딩이 16ms 틱으로 폴백합니다. 여전히 동작은 하지만, 덜 만족스럽습니다.
우리는 여전히 이 세 가지를 작업 중입니다. 브라우저를 번들링하지 않고 JavaScript로 데스크톱 UI를 만들고 싶었다면, npm 패키지는 오늘 바로 사용할 준비가 되어 있습니다. 전체 구현은 api/node/rust/uv_event_loop.rs에 있으며, Slint Node.js guide에는 나머지 API 표면이 정리되어 있습니다.