Plenary's async system (plenary.async) is a coroutine-based async framework built on top of Neovim's libuv event loop. It converts traditional callback-style Node.js patterns into Lua coroutines, allowing you to write async code that looks synchronous.
The async.async module provides fundamental building blocks:
Converts a callback-style function into an async function that can be awaited.
local a = require("plenary.async")
-- Convert vim.defer (callback-style) to async
local sleep_async = a.wrap(vim.defer,2)
-- Now we can use it like a normal function
a.run(function()
sleep_async(100) -- Awaits 100ms
print("Done!")
end)How it works:
- When you call the wrapped function, it checks if all arguments are provided
- If yes: calls the original function directly (sync path)
- If no: yields the coroutine with the function and argument count
- Later, when the callback is invoked, it resumes the coroutine with the result
Executes an async function and calls a callback when complete.
a.run(function()
-- This is an async context
local result = some_async_operation()
return result
end, function(success, ...)
-- Callback runs when done
if success then
print("Result:", ...)
else
print("Error:", ...)
end
end)Creates a fire-and-forget async function. Cannot return values since it doesn't block.
local schedule_async = a.void(function()
vim.schedule(function()
print("This runs in main loop")
end)
end)
-- Returns immediately, runs in background
schedule_async()Channels provide a way to pass data between different async contexts. There are three types:
Send once, receive once.
local tx, rx = a.control.channel.oneshot()
-- Receiver (async function) - blocks until sender() is called
a.run(function()
local value = rx() -- Blocks here!
print("Got:", value)
end)
-- Sender (regular function) - not async
tx("hello") -- Unblocks the receiverHow oneshot recv works:
rx()is an async function created witha.wrap- When called, it yields its coroutine
- It stores its callback in a
saved_callbackvariable - When
tx()is called:- If callback exists → calls it immediately with the value
- If no callback yet → stores the value
- Later when
rx()is called → returns the stored value
What happens if you recv() with nothing?
- The coroutine yields/blocks until
tx()is called - Your async function pauses at that line
- When data arrives, the coroutine resumes and continues
Notification-only, no data passing.
local sender, receiver = a.control.channel.counter()
a.run(function()
-- Blocks until at least one send() has happened
receiver.recv()
print("Got notification!")
-- Another receiver
receiver.recv()
print("Another notification!")
end)
-- Multiple sends
sender.send() -- First recv unblocks
sender.send() -- Second recv unblocksHow counter recv works:
- Uses a Condvar (condition variable) internally
recv()checks ifcounter > 0- If yes: decrements counter and returns
- If no: calls
condvar:wait()to block - When
send()happens: it increments counter and callscondvar:notify_all() - This wakes up all waiting receivers
What happens if you recv() with nothing?
- If
counter == 0: blocks viacondvar:wait() - Coroutine yields until
send()is called
Multiple senders, single receiver, queue based.
local sender, receiver = a.control.channel.mpsc()
-- Producer 1
a.run(function()
for i = 1, 5 do
sender.send(i)
a.util.sleep(100)
end
end)
-- Producer 2
a.run(function()
for i = 6, 10 do
sender.send(i)
a.util.sleep(100)
end
end)
-- Consumer (blocks until data available)
while true do
local value = receiver.recv() -- Blocks if queue empty
print("Received:", value)
endHow mpsc recv works:
- Uses a Deque (double-ended queue) internally
recv()checks if deque is empty- If not empty: pops and returns the value
- If empty: calls
condvar:wait()to block - When
send()happens: pushes to deque and callscondvar:notify_all() - Wakes up waiting receiver(s)
What happens if you recv() with nothing?
- If deque is empty: blocks via
condvar:wait() - When any
send()adds data: unblocks and returns that data
This is pull model vs push model:
Pull model (channels):
- You call
recv()when you're ready to process data - The code structure makes this clear:
while true do
local data = receiver.recv() -- I'm ready for data now
process(data)
endPush model (callbacks):
- Data arrives whenever, you handle it in a callback
- Flow is inverted: callback controls when you handle data
The async system lets you write pull-style code that looks synchronous, but actually yields when no data is available.
Depends on context:
In async context (a.run):
a.run(function()
print("Before recv")
local data = receiver.recv() -- Yields here!
print("After recv") -- Won't run until data arrives
end)- Coroutine yields to the event loop
- Neovim stays responsive
- When data arrives → coroutine resumes → continues
In sync context (util.block_on):
-- DANGER: Blocks entire Neovim!
local data = util.block_on(function()
return receiver.recv()
end)- Blocks entire Neovim (not recommended!)
- Used only for testing or very special cases
Key point: The coroutine yields, but the event loop keeps running. Other async operations can continue.
All filesystem and network operations wrapped:
fs_read,fs_write,fs_mkdir, etc.tcp_connect,udp_send, etc.
local uv = require("plenary.async").uv
a.run(function()
local content = uv.fs_read("file.txt")
print(content)
end)local lsp = require("plenary.async").lsp
a.run(function()
local result = lsp.buf_request(0, "textDocument/hover", params)
print(result)
end)util.sleep(ms)- Async sleeputil.join(fns)- Wait for multiple async functionsutil.run_first(fns)- Race: first to complete winsutil.race(fns)- Race with regular functionsutil.scheduler()- Yield to vim.scheduleutil.apcall/protected- Safe error handling
- Condvar - Wait/notify pattern (like Go's sync.Cond)
- Semaphore - Limited concurrent access (like Go's sema)
- Channels - Oneshot, counter, MPSC (like Go's chan)
describe("async test", function()
a.it("should do something async", function()
a.util.sleep(100)
assert(true)
end)
end)Here's a complete example combining multiple concepts:
local a = require("plenary.async")
local channel = a.control.channel
local util = a.util
-- Worker pool using semaphores
local MAX_WORKERS = 3
local semaphore = a.control.Semaphore.new(MAX_WORKERS)
local task_sender, task_receiver = channel.mpsc()
-- Start consumer
a.run(function()
while true do
local task = task_receiver.recv() -- Pull next task
-- Acquire worker slot
local permit = semaphore.acquire()
-- Do work
print("Processing:", task.name)
a.util.sleep(task.duration)
print("Done:", task.name)
-- Release worker slot
permit:forget()
end
end)
-- Queue some tasks
for i = 1, 10 do
task_sender.send({name = "Task " .. i, duration = 100})
end
-- Sleep to see work happen
a.util.sleep(2000)What's happening:
- Worker pool limited to 3 concurrent tasks
- Consumer pulls tasks from channel (blocks when empty)
- Each worker acquires semaphore permit (blocks if full)
- When worker finishes, releases permit (unblocks waiting worker)
- All this looks synchronous but actually yields to event loop
a.wrap()is the bridge: callbacks → async coroutines- Channels let you coordinate between async contexts
recv()blocks but yields, keeping Neovim responsive- Use
a.run()to enter async context,void()for fire-and-forget - Everything in async/ uses this system: file I/O, LSP, timers, etc.
The async system lets you write concurrent, non-blocking Lua code that feels synchronous and readable, while leveraging Neovim's event loop under the hood.