-
-
Notifications
You must be signed in to change notification settings - Fork 5.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Lua: structured concurrency, Promises, task pipelines #19624
Comments
I believe the gold standard is https://github.com/nvim-lua/plenary.nvim#plenaryasync, but @lewis6991 rolled his own for GitSigns. Either could conceivably be upstreamed if deemed useful enough. |
For context here's the complete implementation Gitsigns uses: local co = coroutine
local async_thread = {
threads = {},
}
local function threadtostring(x)
if jit then
return string.format('%p', x)
else
return tostring(x):match('thread: (.*)')
end
end
function async_thread.running()
local thread = co.running()
local id = threadtostring(thread)
return async_thread.threads[id]
end
function async_thread.create(fn)
local thread = co.create(fn)
local id = threadtostring(thread)
async_thread.threads[id] = true
return thread
end
function async_thread.finished(x)
if co.status(x) == 'dead' then
local id = threadtostring(x)
async_thread.threads[id] = nil
return true
end
return false
end
local function execute(async_fn, ...)
local thread = async_thread.create(async_fn)
local function step(...)
local ret = { co.resume(thread, ...) }
local stat, err_or_fn, nargs = unpack(ret)
if not stat then
error(string.format("The coroutine failed with this message: %s\n%s",
err_or_fn, debug.traceback(thread)))
end
if async_thread.finished(thread) then
return
end
assert(type(err_or_fn) == "function", "type error :: expected func")
local ret_fn = err_or_fn
local args = { select(4, unpack(ret)) }
args[nargs] = step
ret_fn(unpack(args, 1, nargs))
end
step(...)
end
local M = {}
function M.wrap(func, argc)
return function(...)
if not async_thread.running() then
return func(...)
end
return co.yield(func, argc, ...)
end
end
function M.void(func)
return function(...)
if async_thread.running() then
return func(...)
end
execute(func, ...)
end
end
M.scheduler = M.wrap(vim.schedule, 1)
return M I basically just copy and paste this for all my plugins now. |
That's a good sign. Does it support cancellation? How does error handling look? How does it look to aggregate results? |
It doesn't support issuing out several coroutines with any kind of join, so there is nothing to cancel or aggregate, the 95% usecase is for use with sequential system commands (via
Here's an example of injecting
It just issues out both stacktraces: one from the application code, and one from the async lib. I've found it useful enough that I don't feel the need to improve it. |
I think this would be a great addition to the stdlib. I've been playing around with async implementations as well, and I'd like to bring forward some topics that I think are worth consideration:
|
A closure could be created that can be called only once.
Maybe instead it could be
Maybe I'm missing something, but it should always resume the caller? edit: Okay, I get what you mean now, you're asking about suspending a nested coroutine. I think both behaviors should be supported, the standard way of using |
In some of my plugins I started using the pattern norcalli mentioned in #11312 (comment):
and found that it's both simple and effective in avoiding callback nesting. I think if we were to add some async/await or promise abstraction it would help to formulate the problem first, to help ensure we're solving the right thing. |
Perhaps ideas and implementation from https://github.com/kevinhwang91/promise-async can be useful. |
I've read something similar about Javascript: A Study on Solving Callbacks with JavaScript Generators, hope it might help. |
I am also implementing async-await for my plugin development. (I plan to use it primarily for sequencing code related to key mapping.) Currently, I have not written the Promise.all equivalent, but will be able to add it easily. https://github.com/hrsh7th/nvim-kit/blob/main/lua/___plugin_name___/Async/init.spec.lua#L27 |
@mfussenegger Absolutely. If there are patterns we can document without adding sugar, that is definitely preferred. So to close this, we can document those patterns. Or both: maybe we only need sugar for "race two or more operations, cancellation or waiting for multiple operations". |
Using @mfussenegger suggestion in #19624 (comment) I've come up with a very simple and lean implementation with some necessary safety check boilerplate: local M = {}
function M.wrap(func, argc)
return function(...)
local co = coroutine.running()
if not co then
error('wrapped functions must be called in a coroutine')
end
local args = {...}
args[argc] = function(...)
if co == coroutine.running() then
error('callback called in same coroutine as calling function')
end
coroutine.resume(co, ...)
end
func(unpack(args, 1, argc))
return coroutine.yield()
end
end
function M.void(func)
return function(...)
if coroutine.running() then
return func(...)
else
return coroutine.wrap(func)(...)
end
end
end
M.scheduler = M.wrap(vim.schedule, 1)
return M Example usage: local function foo(x, cb)
vim.schedule(function()
cb(x + 1)
end)
end
local afoo = wrap(foo, 2)
local bar = void(function(x)
local a = afoo(x)
print('Hello', a)
end)
bar(3) Outputs |
Just chiming in: |
I have a lot of experience with this because I implemented the async library in plenary. The first iteration was basically a direct copy of https://github.com/ms-jpq/lua-async-await. It was not the best design because there is no point for async await with thunks. It felt like the extra thunks were just there to mirror the syntax from function await(f)
f(nil)
end It was also much harder to pass async functions to higher order functions. The new version did not need -- now iterators just work like normal
for entry in readdir() do
end
-- instead of
local iter = await(readdir())
while true do
local entry = await(iter())
if entry == nil then break end
end Back then I didn't realize that this was basically delimited continuations. Now, I think we should just implement algebraic effects using one-shot delimited continuations which is described in this paper and implemented in eff.lua. The issue with just using coroutines for converting callback based functions is that you don't get to use coroutines for anything else. With algebraic effects, you can have other effects in addition to async effects. Algebraic effects can implement local handle = handler {
val = function(v) return v end,
[Log] = function(k, arg)
print(arg)
return k()
end,
[ReadFile] = function(k, path)
return k(fs.read_file(path))
end,
[Read] = function(k)
return k(config)
end
}
handle(my_program) If you wanted to test your functions, you can change the interpretation to not use IO. local vfs = {my_file = "asdfasdf", another_file = "adfadsfdf"}
local logs = {}
-- handle without IO!
local handle = handler {
val = function(v) return v end,
[Log] = function(k, arg)
table.insert(logs, arg)
return k()
end,
[ReadFile] = function(k, path)
return k(vfs[path])
end,
[Read] = function(k)
return k(config)
end
} Async could be implemented like this: local handle = handler {
val = callback,
[Async] = function(k, fn, ...)
fn(k, ...)
end
} Exceptions: local handle = handler {
val = function(v) return v end,
[Exception] = function(k, e)
return e
end,
} |
I implemented If neovim core supports async-await, I'm wondering if neovim should introduce promsie like async primitives or not.
|
I found the interesting PR. |
@oberblastmeister I've taken a look at eff.lua, and it appears to be a completely generalized version of the current async library we're using. If you take eff.lua and the async implementation you mentioned and refactor enough, you get to the same async lib I implemented in gitsigns. It's quite interesting, but I'm not sure if we need something as generalized as that. The main need is for an async lib to better utilize libuv and all of it's callback based functions.
eff.lua uses coroutines in the exact same way the current async lib does, so not sure this is true.
It looks like this implementation creates a new coroutine for every async function. This is a big drawback compared to the plenary/gitsigns implementations that uses a single coroutine for each context. It also requires I've created https://github.com/lewis6991/async.nvim which I'm using as a reference for all the plugins I use async in. The library is small enough that (for now) it's ok to just vendor directly in plugins without needing it as a depedency. |
Yes. My implementation creates a coroutine for each asynchronous operation. I also wonder that "it is the drawback that requires the call to await". Because I believe the difference is where and when to yield asynchronous primitives. For example, look at the following example -- Return just AsyncTask
local function timeout_async(ms)
return AsyncTask.new(function(resolve))
vim.defer_fn(resolve, ms)
end)
end
-- Return AsyncTask if it is running in the main coroutine, otherwise yield.
local function timeout(ms)
local task = timeout_async(ms)
if vim.async.in_context() then
return task:await()
end
return task
end
vim.async.run(function())
local s = uv.now()
timeout(200) -- does not need to `await` call.
assert.is_true(math.abs(uv.now() - s - 200) < 10)
end) In this example, I think it is a difficult question as to whether we should choose Promise, thunk, or another means as an asynchronous primitive. To me, the Promise style seem like a good choice, since there are many JavaScript users and they are easy to understand. Translated with www.DeepL.com/Translator (free version) |
Comforting javascript users is not a goal, and should not be factored into any of the designs here. As a typescript/javascript user myself, I'm very skeptical that async/await is a good interface. We should focus on what feels right for a Lua + Nvim.
Since the introduce of Sounds like https://github.com/lewis6991/async.nvim is informed by a lot of previous work, which is a good sign! Does it provide a way to cancel tasks, handle errors/cancellations, and aggregate results? |
I've been using plenary's async module with neotest for a while now and have found the lack of an async/await syntax to be much more fitting with Lua's simplistic style of programming. IMO adding async/await would be clunky.
I wanted to introduce the async usage to nvim-dap-ui which is similar to LSP, where it's currently using a lot of callbacks. I worked off of @lewis6991's implementation and added the ability to cancel tasks and handle errors rather than them just being raised to the user. His implementation already covers aggregating results. It's very similar to Python's asyncio Task class just because I didn't want to have to design the API much. Also added some flow control primitives. Implementation here https://github.com/rcarriga/nvim-dap-ui/blob/feat/new-api/lua/dapui/async/base.lua and some usage in tests https://github.com/rcarriga/nvim-dap-ui/blob/feat/new-api/tests/unit/async/base_spec.lua. Excuse the long namespaced names, it's just for generating documentation. |
Thought I'd update as I've been having a bit of fun with this. I've done a fair bit of refactoring and adding of features in the last few days. The main features are currently:
Not sure if it's the direction core would want to go with an async library, (e.g. the LSP client but I was doing something similar for the DAP specification and LSP was mentioned as a key reason for the async usage so thought I'd slap something together for it). I think it does demonstrate the advantages of a single library by implementing broadly useful features and providing a stable, tested solution for async that can be used by anybody. Here is some sample usage of the LSP client. Open the script in the nvim-dap-ui branch to see the type checking/documentation with Lua LSP local async = require("dapui").async
async
.run(function()
local client = async.lsp.client(2)
-- Notifications usage
client.notify.textDocument_willSave({
reason = "3",
textDocument = {
uri = vim.uri_from_bufnr(0),
},
})
-- Request usage
local symbols = client.request.textDocument_documentSymbol(0, {
textDocument = { uri = vim.uri_from_bufnr(0) },
}, { timeout = 1000 })
return symbols
end)
.add_callback(function(task)
if task.error() then
vim.schedule_wrap(error)(task.error())
else
print(vim.inspect(task.result()))
end
end) |
What is the purpose of |
The |
Some work was started on structured concurrency in lua (similar to Python's trio) here in case that approach might also be considered here |
I was inspired enough by the article I linked to above that I created a plugin to provide structured concurrency in neovim lua: https://github.com/svermeulen/nvim-lusc |
Should we support |
This comment was marked as off-topic.
This comment was marked as off-topic.
I have added Promise like error handling for https://github.com/ms-jpq/lua-async-await to handle LSP response type where the first value is the error. So no more assert checks on the response is needed. It's done in the https://github.com/nvim-java/lua-async-await local function lsp_request(callback)
local timer = vim.loop.new_timer()
assert(timer)
timer:start(2000, 0, function()
-- First parameter is the error
callback('something went wrong', nil)
end)
end
local M = require('sync')
local main = M.sync(function()
local response = M.wait_handle_error(M.wrap(lsp_request)())
end)
.catch(function(err)
print('error occurred ', err)
end)
.run() Result:
|
This is what I am currently using. It is very similar to some of the samples posted above in this thread, possibly because I also derived it from /ms-jpq/lua-async-await by breaking down all the calls and simplifying. Thought I would just share it here as another example. It's very lean and probably does not cover everything. Currently I handle errors outside of this, either via |
Hi, I would like to present my design for structured concurrency in Neovim: Coop.nvim. I believe it addresses all requirements you have listed out for such a framework + it’s as straightforward as such a framework could be (How it works doc). I believe it provides an elegant approach to the subject. Here’s how Coop relates to the listed requirements:
See examples for examples of handling errors, running tasks in parallel, and complex awaiting conditions.
Yes,
True. In Coop you can freely nest task functions (a task function is a function that may call
Coop indeed “promisifies” local stdin = vim.uv.new_pipe()
local stdout = vim.uv.new_pipe()
local stderr = vim.uv.new_pipe()
local handle, pid, cat_future = uv.spawn("cat", {
stdio = { stdin, stdout, stderr },
})
local read_future = coop.Future.new()
vim.uv.read_start(stdout, function(err, data)
assert(not err, err)
if data ~= nil and not read_future.done then
read_future:complete(data)
end
end)
vim.uv.write(stdin, "Hello World")
-- Here we wrap the APIs into a task to use the synchronous looking interface.
local task = coop.spawn(function()
-- Wait for stdout to produce the data.
local read_data = read_future:await()
assert.are.same("Hello World", read_data)
local err_stdin_shutdown = uv.shutdown(stdin)
assert.is.Nil(err_stdin_shutdown)
-- Wait for the cat process to finish.
return cat_future:await()
end)
-- Wait for 200ms for the task finish.
local exit_code, exit_sign = task:await(200, 2)
Coop provides a generic converter callback to coroutine converter: The generic conversion from a coroutine (a task) into a callback is done through an await overload that accepts a callback.
All possible. For example, Coop provides an implementation of Tentative implementations of
Coop doesn’t natively provide the task tree abstraction to keep with the Lua’s spirit of coroutines being standalone threads. Task trees can be implemented on top of Coop, because the programmer can intercept the |
@gregorias Thanks for sharing, that looks very promising. |
related: #11312
Problem
There's no builtin abstraction for:
vim.system()
returns its own ad-hoc "task" that can be "awaited" via:wait()
.vim.fs.files():filter(..):map(function(f) vim.fs.delete(f) end)
vim.iter
.Expected behavior
await
everywhere, that is a horrible consequence of the JS async/await model. Instead consider Go'sgo
for opt-in concurrency.vim.system()
be "promisified" without its knowledge? Or could it handle differently when it detects that it's in a coroutine? Sovim.system()
would be synchronous normally, butvim.async(vim.system, ...):wait()
would be asynchronous.cb_to_co()
from this article.await_all
,await_any
(pseudo-names). See JS Promise.all().jobwait()
!)Implementations
Related
notes from reddit discussion
The text was updated successfully, but these errors were encountered: