Using coroutines in Neovim Lua
In this blog post I describe the use of Lua coroutines in the context of Lua programming for Neovim and provide converters for callback-based code for easy interaction with existing, non-coroutine codebases. The big pay-off of using coroutines is making your asynchronous code significantly more readable at little cost once you understand them.
Motivation
Neovim has adopted Lua as its de-facto config and plugin language. Neovim provides a standard library that is, unfortunately, callback-based (e.g., uv.fsopen). This is unfortunate, because callbacks lead to significantly poorer readability. Even if you avoid the immediate problem of deeply-nested callback hells, some constructs still end up way more complex.
Consider the use-case of grepping files in a directory. We first get a directory listing, and then grep through each file. In a synchronous setup, it’s a simple for-loop:
1
2
3
4
5
6
7
8
9
10
11
-- `ls_dir_sync` and `match_sync` are simplified API for listing a directory
-- and finding a match in a file. For example, `ls_dir_sync` could implemented
-- with `vim.uv.fs_scandir`.
function grep_dir_sync(dir, needle, cb) do
for file in ls_dir_sync(dir) do
if match_sync(file, needle) then
return file
end
end
end
This is readable, but has the potential drawback of blocking the editor. A popular path completion plugin, cmp-path, suffers from this. When we address this flaw with callbacks, our code becomes egregious:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- `ls_dir_cb` and `match_cb` use callbacks.
function grep_dir_cb(dir, needle, cb)
ls_dir_cb(dir, function(entries)
if is_empty(entries) then
cb(nil)
end
local file = table.remove(entries, 1)
match_cb(file, needle, function(match)
grep_file_cb(match, file, entries, needle, cb)
end)
end)
d
function grep_file_cb(match, file, entries, needle, cb)
if match then
cb(file)
end
if is_empty(entries) then
cb(nil)
end
file = table.remove(entries, 1)
match_cb(file, needle, function(match)
grep_file_cb(match, file, entries, needle, cb)
end)
end
Modern languages solve this problem through the addition of async-await syntax and an event loop (JavaScript, Python). Lua, somewhat uniquely, doesn’t have that kind of specialized syntax nor a built-in event loop system, but it has coroutines that are designed in such a way that you can use them to cleanly express your asynchronous code.
Lua coroutines to the rescue
If you haven’t encountered Lua coroutines yet, then take a look at their brief documentation. I’m going to assume you are familiar with it.
Before diving into coroutine use, let’s agree on nomenclature:
- A coroutine function is a Lua function that may yield with
coroutine.yield
. - A coroutine (AKA thread) is the result of passing a coroutine function to
coroutine.create
.
This distinction is important. For example:
coroutine.resume
operates on threads and not on coroutine functions.- Coroutine functions yield up to the level of their thread delimited by
coroutine.resume
. coroutine.yield
can only happen within a thread.
Let’s now assume that we have coroutine versions of the filesystem API, ls_dir_co
and match_co
(I’ll show how to construct them shortly). grep_dir_co
looks as follows:
1
2
3
4
5
6
7
8
9
10
--- Greps files in `dir` for `needle`.
---
--- This is a fire-and-forget coroutine function.
function grep_dir_co(dir, needle)
for file in ls_dir_co(dir) do
if match_co(file) then
return file
end
end
end
This looks exactly like the synchronous version but is nonblocking, which is a big win.
Lua’s coroutines are transparent. All functions can be coroutine functions with no special syntax required. They are also “contagious.” Using a coroutine function inside a function makes the function into a coroutine function, so it’s good to document that. It’s a good practice to indicate that a function may yield by, for example, adding a _co
suffix.
We can use the coroutine functions almost like a regular function by wrapping it with a thread:
1
2
3
4
5
6
7
8
9
10
function find_and_print_co()
local file = grep_dir_co("foo_dir", "needle")
if file then
print("Found the file: " .. file .. ".")
else
print("Could not find the file.")
end
end
coroutine.resume(coroutine.create(find_and_print_co))
This code will asynchronously print a message with the result of the grep.
You might notice that we only call coroutine.resume
once instead of resuming the coroutine till it finishes. This brings us to the topic of fire-and-forget coroutine functions.
Fire-and-forget coroutine functions
Lua introduces coroutines as functions that can yield (return) and resume multiple times. You can use Lua coroutines like Python generators, but that’s not how we’ll be using coroutines most of the time, because our concern here is to use asynchronicity to deal with blocking I/O calls.
In our context, calls like ls_dir_co
and match_co
yield until the corresponding I/O call is ready. The Neovim’s event loop will resume the corresponding thread when that is the case. This pattern of control flow is so common for I/O operations that I call such coroutines fire-and-forget coroutine functions. You only resume such coroutines once from your Lua code, and the event loop will resume them till the end.
1
2
3
function fire_and_forget(co)
coroutine.resume(coroutine.create(co))
end
fire-and-forget coroutine functions are also contagious and should be documented.
Callback–coroutine conversion
So I promised to show you how to get ls_dir_co
and match_co
, and I’ll do that by adapting ls_dir_cb
and match_cb
. In fact, I can do so generically:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
--- Converts a callback-based function to a coroutine function.
---
---@tparam function f The function to convert. The callback needs to be its
--- first argument.
---@treturn function A coroutine function. Accepts the same arguments as f
--- without the callback. Returns what f has passed to the
--- callback.
M.cb_to_co = function(f)
local f_co = function(...)
local this = coroutine.running()
assert(this ~= nil, "The result of cb_to_co must be called within a coroutine.")
local f_status = "running"
local f_ret = nil
-- f needs to have the callback as its first argument, because varargs
-- passing doesn’t work otherwise.
f(function(ret)
f_status = "done"
f_ret = ret
if coroutine.status(this) == "suspended" then
-- If we are suspended, then f_co has yielded control after calling f.
-- Use the caller of this callback to resume computation until the next yield.
local cb_ret = table.pack(coroutine.resume(this))
if not cb_ret[1] then
error(cb_ret[2])
end
return cb_ret[]
end
-- If we are here, then the coroutine is still running, so `f` must have
-- worked synchronously. There’s nothing for us to resume.
end, ...)
if f_status == "running" then
-- If we are here, then `f` must not have called the callback yet, so it
-- will do so asynchronously.
-- Yield control and wait for the callback to resume it.
coroutine.yield()
end
return f_ret
end
return f_co
end
cb_to_co
is a mouthful. I simplified it slightly and omitted handling of multiple returns, but you can see the full implementation in coerce.nvim.
With cb_to
, we can adapt any callback-based function. We only need to ensure that the callback becomes the first argument:
1
2
ls_dir_co = cb_to_co(function(cb, dir) ls_dir_cb(dir, cb) end)
match_co = cb_to_co(function(cb, dir, needle) match_cb(dir, needle, cb) end)
In my codebases, I wrap existing callback-based APIs into such coroutine functions and only use coroutines avoiding callbacks altogether.
Coroutine to callback conversion
The last thing to discuss is the conversion from coroutines to callback-based functions. This is useful, because we don’t always want to just fire-and-forget like we did with find_and_print_co
. Sometimes we can’t use coroutines, because our code needs to work with an existing framework, where a non-coroutine function is expected (AKA the function-color problem). In such cases, it is possible to turn a coroutine function into a callback-based function like so:
1
2
3
4
5
6
7
8
--- Calls `cb` once the file has been found and printed.
function find_and_print_cb(cb)
local co = function()
fire_and_print_co()
cb()
end
fire_and_forget(co)
end
Conclusion
I hope that this post will make it more common for Lua code writers to use coroutines. Coroutines are significantly easier to work with than callbacks and it’s easy to adapt existing asynchronous APIs to coroutines. Ubiquitous use of asynchronous programming should also make Neovim plugins less likely to block.
Addendum: What’s wrong with Plenary Async?
You might be aware of an attempt by Plenary to make asynchronous easy: async.run
. I don’t think it’s a good library for a few reasons:
async
uses a complicated machinery that is hard to understand. It’s unnecessary, because native coroutines work just fine.- The machinery is fragile. You can’t even nest two coroutine functions, which is a pretty basic thing you’d want to do.
I’d say just stick to native coroutines.