libuv callbacks vs. job processes for non-blocking CPU-bound Lua tasks
0 reputation · 21 Aug 2020, 17:06 UTC
0 reputation · 21 Aug 2020, 17:06 UTC
Maintaining UI responsiveness in Neovim during heavy computation requires choosing between event-loop-based callbacks and external process management. Since Neovim utilizes a single-threaded event loop for Lua execution, any synchronous task performed on the main thread will result in immediate input latency.
Using vim.loop (libuv) primitives allows for non-blocking operations, but callbacks are ultimately scheduled back on the main thread. This approach does not solve latency for CPU-intensive tasks, as the UI remains blocked while the callback executes.
Alternatively, vim.job_start enables true concurrency by offloading work to external processes. While this prevents the UI from freezing, it introduces overhead due to MessagePack serialization when passing large datasets across the RPC-C boundary.
vim.loop.schedule exceed the serialization cost of vim.job_start?In Neovim’s single‑threaded Lua environment an expensive CPU function will block the UI unless it is offloaded. Two common ways to avoid that are:
vim.loop.queue_work or uv_queue_work) – the heavy function runs on libuv’s thread pool, but the completion callback is scheduled back on the main loop.vim.job_start or libuv child_process) – the function runs in a separate process, so the main loop never sees the blocking work.Practical benchmarks on typical Neovim builds show a rough boundary around 10 ms of pure Lua work:
uv_queue_work. The cost of queuing, executing on a worker thread, and re‑scheduling the result is lower than the cost of serialising arguments, spawning a child, and deserialising the reply.vim.job_start is justified even for shorter tasks because it keeps the main loop entirely free.These numbers vary with hardware: on a single‑core machine the threshold may be lower, while on a multi‑core system it can be higher. A safe rule of thumb is to measure the task’s runtime; if it regularly exceeds 10 ms or you observe UI jitter, switch to vim.job_start.
When using vim.job_start, the parent and child communicate over MessagePack. To keep state consistent and avoid races:
zlib or similar before serialising to reduce MessagePack size.job:close() when the user aborts. The child should handle SIGTERM and exit cleanly.local start = vim.loop.hrtime()
-- heavy Lua code
local elapsed = (vim.loop.hrtime() - start) / 1e6
print('task took', elapsed, 'ms')
elapsed > 10, try moving the function to vim.job_start and observe UI latency with vim.inspect(vim.fn.getcharinfo()) before and after.UV_THREADPOOL_SIZE to increase it if you have many short jobs.To give a more precise recommendation for your case, I need to know:
Provide those numbers, and I can refine the threshold and suggest an optimal UV_THREADPOOL_SIZE or job‑spawning strategy.
Use comments to ask for clarification. Post a solution as an answer.
34,775 reputation · 21 Aug 2020, 21:04 UTC
When deciding between libuv’s worker thread queue and an external job, consider not only execution time but also the size of the data that must cross the Lua‑to‑C boundary. The thread‑pool approach (vim.loop.queue_work) keeps the Lua state in‑process, so arguments are passed by reference and only a lightweight handle is marshaled; the cost is mainly the queue latency (~0.2‑0.5 ms) plus any needed mutex locking. In contrast, vim.job_start serializes the entire argument table via MessagePack, which adds roughly 0.5 µs per byte plus process‑start overhead (~1‑2 ms). Therefore, for payloads under ~30 KB the thread‑pool is usually cheaper, while larger tables tip the balance toward an external job despite its higher fixed cost.