Neovim does not provide a native, high-level mutex or atomic locking mechanism to guarantee state consistency across concurrent RPC or vim.fn.job callbacks. While the callbacks themselves are executed on the main event loop—meaning they do not run in parallel threads—the logical order of state mutations is not guaranteed when multiple asynchronous processes return results in rapid succession.
The Execution Model
It is important to distinguish between thread safety and logical race conditions. Because Neovim's Lua state and the main event loop are single-threaded, two callbacks cannot mutate a table at the exact same CPU cycle. However, they can interleave in ways that create non-deterministic behavior:
- Serialization: Neovim serializes job output through internal channel buffers. Callbacks are queued and executed sequentially on the main thread.
- Logical Races: If Job A and Job B both read a global variable, perform an async operation, and then write back to that variable in their callbacks, the final state depends entirely on which job finishes first, not which was started first.
- UI Flickering: Rapid-fire updates to the same buffer from different callbacks can cause visible jitter or overwrite partial data if the plugins do not track the sequence of requests.
OS Scheduling and Stability
The underlying OS process scheduler determines when the background worker process completes and writes to the pipe. This introduces environment-specific variance:
- Execution Order: On a heavily loaded system or across different OS kernels (Linux vs. macOS), the latency between a process finishing and Neovim triggering the callback varies. You cannot rely on the start order of jobs to dictate the callback order.
- Stability: While the OS scheduler won't crash the Neovim process, it can lead to "stale state" bugs where a slower, older request completes after a newer request, overwriting the most recent data.
Recommended Implementation Patterns
To ensure consistency, implement serialization at the Lua level rather than relying on the core engine:
- Sequence Numbering: Assign a unique incrementing ID to every job. In the callback, ignore any result with an ID lower than the last processed ID.
- State Queuing: Instead of mutating global state directly in
on_stdout, push the result into a Lua table (queue) and use a timer or vim.schedule to process the queue sequentially.
- Scoped Buffers: Avoid modifying the active buffer directly. Write results to a temporary buffer or a Lua table, then perform a single atomic update to the UI.
Verification Command: To observe callback interleaving, you can run multiple jobs that print their ID and a timestamp to a log file via :redir and compare the sequence of on_stdout triggers against the job start order.