Slint 与 Node.js 事件循环

Lobsters Hottest 工具

摘要

Slint 与 Node.js 集成之前会导致 16 毫秒的时钟周期,浪费 CPU 并延迟事件;1.17 版本在 Linux 和 macOS 上修复了此问题,允许 Rust UI 循环与 libuv 协作而无需轮询,提高了效率和响应能力。

<p><a href="https://lobste.rs/s/mml4wf/slint_node_js_event_loop">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/06/30 15:39

# Slint 与 Node.js 事件循环 来源:https://slint.dev/blog/slint-and-the-nodejs-event-loop Slint 是一个用于构建跨平台 UI 的工具包。其核心用 Rust 编写,但同样的 `.slint` 标记语言可编译成 Rust、C++、JavaScript、TypeScript 和 Python 的惯用 API,因此 Slint 可以嵌入到适合应用程序的任何语言中。 JavaScript 和 TypeScript 特别适合某类桌面应用:协调输入、网络调用、文件和数据库的代码,而非进行密集计算。我们希望 Slint 成为这类应用的严肃选项,一个比 Electron 更轻量的替代方案:直接访问 GPU,无需浏览器。 Node.js 绑定已经存在一段时间,但直到最近它仍有一个尖锐的问题:UI 线程每 16 毫秒唤醒一次,即使没有工作要做,也会在空闲时消耗 CPU 和电池电量,并且 UI 事件可能延迟最多 16 毫秒。Slint 1.17(https://slint.dev/blog/slint-1.17-released)在 Linux 和 macOS 上修复了这个问题。本文介绍我们是如何实现的,以及为何 Windows、Deno 和 Bun 仍在我们待办列表中。 ## 从 Node.js 使用 Slint `slint-ui` 是一个常规的 npm 包。你可以编写类似这样的代码: ```javascript 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` 返回一个 Promise,当用户关闭最后一个窗口或应用程序调用 `quit()` 时该 Promise 解析。在底层,它通过 napi-rs(https://napi.rs/)进入 Rust,而问题正始于此处:一旦 Rust 接管线程,Node 的定时器和 I/O 就会停止,直到它将线程交还。要理解原因,我们需要回溯并讨论事件循环。 ## 什么是事件循环? 事件循环驱动任何在生命周期中等待事件发生而非从头到尾运行的程序:点击、网络数据包、定时器触发。本质上,它是一个在程序整个生命周期中位于调用栈底部的无限循环,仅在退出时返回。每次循环,它等待下一个事件,调度一个处理程序,然后继续等待。伪代码如下: ```javascript loop { let timeout = time_until_next_timer(); // 阻塞直到事件到达或定时器过期。 let events = wait_for_events(timeout); for event in events { dispatch(event); // 将输入、调整大小等传递到 UI } fire_due_timers(); // 执行到期的定时器回调 run_posted_callbacks(); // 从其他线程或异步任务发布过来的回调 render_frame(); // 重新绘制内容发生变化的窗口 } ``` 阻塞的 `wait_for_events` 映射到操作系统原语:Linux 上的 epoll(https://en.wikipedia.org/wiki/Epoll),BSD 和 macOS 上的 kqueue(https://en.wikipedia.org/wiki/Kqueue),Windows 上的 I/O 完成端口(https://en.wikipedia.org/wiki/Input/output_completion_port)。 ## 两个循环,一个线程 使用 Node 绑定的 Slint 应用程序有两个事件循环共享一个线程。Node 驱动 libuv(https://libuv.org/),它运行 JavaScript 调度的定时器和 I/O:网络、文件、DNS、子进程。Slint 驱动其窗口后端 winit(https://github.com/rust-windowing/winit),后者与平台的窗口系统(X11、Wayland、AppKit、Win32)通信,并暴露键盘、指针、调整大小和 expose 事件。 它们必须共享同一个线程,因为 Slint 属性既在渲染期间访问,也在 GUI 事件触发的回调中访问,而这些回调会调用 JavaScript,而 JavaScript 必须在 Node 的主线程上运行。此外,在 macOS 上,GUI 只能从主线程驱动,而 Node 已经占据主线程。这排除了明显的“将 Slint 循环放在 worker 上”的变通方法。 ## 16 毫秒 tick 在任何运行时上都能工作的最简单方法是 `setInterval(16)`,它调用到 Rust,运行 Slint 循环的一个非阻塞迭代,然后返回。libuv 在 tick 之间运行,因此 JavaScript 定时器和 I/O 再次工作。但空闲的 CPU 被浪费,进程从不休眠,并且每个 JavaScript 定时器都会延迟最多 16 毫秒。 ## Prepare 钩子 在 Slint 1.17 中,在 Linux 和 macOS 上,我们用真实的 libuv 集成替换了 tick。关键是在每个 libuv 迭代的适当时机耗尽 Slint 的循环: ``` ┌── 一个 libuv 迭代 ───────────────────────────┐ │ 1. 更新缓存的时钟 │ │ 2. 执行到期的定时器 │ │ 3. 执行待处理的回调 │ │ 4. 执行 prepare 钩子 ◄── 我们在此处安装钩子 │ │ 5. 轮询 I/O,休眠最长 │ │ uv_backend_timeout() 毫秒(通常阻塞) │ │ 6. 执行 check 钩子 │ │ 7. 执行 close 回调 │ └──────────────────────────────────────────────┘ ``` libuv 暴露了 `uv_prepare_t`(https://docs.libuv.org/en/v1.x/prepare.html),一个句柄,其回调在每次迭代中触发,在定时器执行之后、I/O 轮询之前。这正是我们想要的位置:足够晚以使 JavaScript 定时器回调已经触发,足够早以使轮询的休眠预算反映我们刚刚执行的操作。 ```rust // Slint 的 libuv prepare 回调——为简洁起见已编辑。 fn prepare_callback() { // libuv 在下一次定时器或 I/O 到期前可以休眠的时长。 let timeout = uv_backend_timeout(uv_loop); // 运行 Slint 的事件循环:处理一个待处理的窗口事件(输入、调整大小) // 或其他 Slint 事件(定时器、发布回调),否则最多阻塞 `timeout` 毫秒 // 等待事件。 process_slint_events_with_timeout(timeout); // 我们已经在这里完成了等待。技巧:通知 libuv,使其自身的 // I/O 轮询(步骤 5)立即返回,而不是再次阻塞。 } ``` `uv_backend_timeout()`(https://docs.libuv.org/en/v1.x/loop.html#c.uv_backend_timeout)是 libuv 回答“在需要处理其他事情之前安全休眠多长时间”的方式,因此 Slint 恰好休眠那么久,除非 UI 事件提前唤醒它。 ## 当 libuv 有 I/O 时唤醒 Slint prepare 钩子涵盖了一个方向,另一个方向也很重要。当 libuv 有 I/O 就绪时,Slint 需要快速察觉并交还控制权。因此,我们从 Slint 自身循环上的一个 future 中监视 libuv 的后端文件描述符(`uv_backend_fd()`(https://docs.libuv.org/en/v1.x/loop.html#c.uv_backend_fd))。`spawn_local` 为该 future 提供一个 `Waker`(https://doc.rust-lang.org/std/task/struct.Waker.html),其 `wake()` 唤醒 Slint 的事件循环,并且 async-io 一旦文件描述符可读就立即调用它,因此阻塞在 prepare 钩子中的 `process_slint_events_with_timeout` 返回,并将控制权交还给 libuv: ```rust // libuv 循环背后的单个 epoll/kqueue fd;当任何 I/O 就绪时可读。 let backend_fd = uv_backend_fd(uv_loop); // 将其包装,以便 async-io 的反应器监视它,并在可读时唤醒我们的 future。 let async_fd = async_io::Async::new_nonblocking(backend_fd)?; slint::spawn_local(async move { // 我们不读取 fd;被唤醒就是目的。 while async_fd.readable().await.is_ok() {} })?; ``` 一旦控制返回到 prepare 回调,它会通过 `uv_async_send`(https://docs.libuv.org/en/v1.x/async.html#c.uv_async_send)通知 libuv,使其下一次迭代触发我们的回调。 ## Windows Windows 使用 I/O 完成端口而不是单个可等待的文件描述符,因此 Unix 的 prepare 钩子方法不能直接转换过来。libuv 内部存在相应管道,但不是其公共 API 的一部分,并且 Node 没有重新暴露它。 Electron 修补了 libuv(https://github.com/electron/electron/blob/main/patches/node/feat_add_uv_loop_interrupt_on_io_change_option_to_uv_loop_configure.patch)以暴露完成端口句柄,但该补丁未上游。libuv 2.x 将正确修复此问题,但不会很快到来,Node.js 将更晚才采用。 因此 Windows 仍然处于 16 毫秒 tick 状态。最有希望的修复方法是提供一个专用的 `node-slint` 运行器,它自带修补后的 libuv,类似 Electron 的做法。 ## Deno 和 Bun Deno 根本不使用 libuv(它基于 Rust 和 tokio),而 Bun 是其自己的运行时。两者都不提供我们用于 Node 的钩子,因此计划是反转所有权:一个运行器,其中 Slint 循环是主要的,运行时的 future 通过 `slint::spawn_local` 调度到它上面。 在此之前,两者都运行与 Node 相同的 `.node` 二进制文件:我们通过 `libloading` 在运行时解析 libuv 符号,因此不暴露它们的主机只会退回到 16 毫秒 tick,而不是加载失败。 ## 今天你能获得什么 如果你 `npm install slint-ui` 并在 Linux 或 macOS 上运行,你会获得 libuv 集成:UI 输入立即分发,空闲应用程序休眠,CPU 使用率在没有事情发生时降至零。在 Windows、Deno 和 Bun 上,绑定会退回到 16 毫秒 tick。它仍然工作,只是不那么优美。 我们仍在处理这三个平台。如果你想从 JavaScript 构建桌面 UI 而无需捆绑浏览器,npm 包今天就可使用。完整实现在 api/node/rust/uv_event_loop.rs(https://github.com/slint-ui/slint/blob/master/api/node/rust/uv_event_loop.rs),而 Slint Node.js 指南(https://docs.slint.dev/latest/docs/node/)提供了其余 API 接口。

相似文章

Slint 1.17 发布

Lobsters Hottest

Slint 1.17 引入了拖放、系统托盘图标、工具提示以及模型行上的双向绑定,进一步推动其成为桌面级 UI 工具包的目标。

Linux延迟测量与合成器调优

Lobsters Hottest

一项详细调查,使用基于Teensy的LDAT工具测量游戏中的Linux延迟,在KDE Wayland下的Nvidia GPU上使用各种设置测量点击到光子延迟,并与Windows进行比较。