1. plenary.nvim とは?
Neovim 界で一番メジャーなプラグインとは何でしょう? 異論はあるかも知れませんが、多くの人は telescope.nvim を挙げると思います。ファジーファインダーの決定版ですね。
telescope.nvim は非常に複雑で大規模なプラグインなのですが、その開発で使われている便利な関数群は別のプラグインに切り出されています。それが plenary.nvim です。
plenary.nvim 自体も様々な機能を持っているのですが、この中で本日紹介したいのは plenary.async
というライブラリー。名前の通り、Neovim Lua で非同期処理を実装するための関数群です。
このライブラリーには条件変数、セマフォ、チャンネルといった、本格的な非同期処理を書くために必要なパーツが含まれています。以下ではこのライブラリーの全機能について、実際に動作するコード例と共に紹介しています。
現在の plenary.nvim には plenary.async_lib
という、良く似た名前のライブラリーも含まれています。これはすでに deprecated なものとして、利用は推奨されていません。そのため、本記事ではこのモジュールについては言及しません。
2. Neovim + Lua で非同期処理を書く
Vim script のおさらいから、Lua で非同期処理を行うまでを見てみましょう。
2.1. Vim script での非同期処理
Vim script には元々、非同期処理を行うための仕組みがありませんでした。例えば、外部コマンドを実行する system()
はそのコマンドが終了するまで処理をブロックしてしまいます。
これを解決するため、Vim8 や Neovim ではジョブ機能、及び、ターミナル機能がサポートされました。これはコールバック型の非同期処理をサポートしており、外部コマンドの終了を待たずに別の処理を開始することが可能です。
" Neovim の場合
" ls コマンドの出力をバッファーの最後に追加します。
call jobstart('ls', {
\ 'on_stdout': { _, l -> append(line('$'), l) },
\ })
しかしこの方法ではいくつかの問題があります。
- 非同期に実行できるのは外部コマンドだけです。例えばファイルの読み書き(
readfile()
とか)中に他の操作を実行することはできません。 - 複雑な処理を書くと容易にコールバック地獄になり、コードが非常に読みにくくなります。
2.2. Lua のコルーチン
Neovim ではスクリプト言語として Lua5.1 / LuaJIT が利用可能です。Lua にはコルーチンがあります。
local co = coroutine.create(function(a, b)
print("co", a, b) -- co 2 5
local result = coroutine.yield(a + b, a - b)
print("co", result) -- co 4
return "finished"
end)
local ok, plus, minus = coroutine.resume(co, 2, 5)
assert(ok == true)
assert(plus == 7)
assert(minus == -3)
local ok, msg = coroutine.resume(co, plus + minus)
assert(ok == true)
assert(msg == "finished")
local ok, msg = coroutine.resume(co)
assert(ok == false)
assert(msg == "cannot resume dead coroutine")
コルーチンは coroutine.create
で作成でき、coroutine.resume
で開始します。coroutine.yield
はコルーチンの実行を一時停止し、呼び出し側に制御を返します。もう一度 coroutine.resume
を使うと一時停止した箇所から実行を再開できます。初見ではなかなか理解し辛い挙動をしますよね。
2.3. libuv
コルーチンを非同期 I/O ライブラリである libuv と組み合わせることで非同期処理が可能となります。
libuv はイベントループによるスケジューリングと非同期 I/O を実現するため、Node.js の開発チームによって作られました。Neovim にはその Lua インターフェイスである luv が組み込まれています。例えば、指定したパスの情報を得る fs_stat()
は非同期に呼び出すことができ、Neovim 自体の動作や他のコルーチンをブロックせずに処理を続行できます。
luv の関数には vim.loop
というテーブルからアクセスできます。詳しいドキュメントはこちらにあります。
リンク先のドキュメントでは vim.uv
と表記されています。これは開発版の v0.10.x では vim.loop
が vim.uv
に改名される予定だからです。
-- 同期バージョン
-- 完了するまで処理をブロックします。
local stat = vim.loop.fs_stat(vim.env.HOME)
print(stat.type) -- "directory" と表示します。
print "It has finished getting stat"
-- 非同期バージョン
-- 処理をブロックしません。完了するとコールバックを実行します。
vim.loop.fs_stat(vim.env.HOME, function(_, stat)
print(stat.type) -- "directory" と表示します。
print("It has finished getting stat")
end)
print "It has not finished yet here"
2.4. libuv の関数をコルーチンで実行する
しかし、これだと相変わらずコールバック地獄になりそうですね。これをコルーチンと組み合わせてみましょう。
-- コルーチンをコールバックに変換して実行します。
local function executor(thread)
local step
step = function(err, stat)
local _, returned_function, path = coroutine.resume(thread, err, stat)
if coroutine.status(thread) == "dead" then
return
else
returned_function(path, step)
end
end
return step
end
-- 実行したいコルーチン
local thread = coroutine.create(function()
local _, stat = coroutine.yield(vim.loop.fs_stat, vim.env.HOME)
print(stat.type) -- "directory" と表示します。
print("It has finished getting stat")
end)
-- 実行機でコルーチンを実行します。
executor(thread)()
-- 1 秒待つ。これが必要な理由は後ほど。
vim.wait(1000)
上記のコードを適当なファイルに保存した後、以下のコマンドで動作を確認できます。
$ nvim -l /path/to/hoge.lua
directory
It has finished getting stat
-l
オプションを付けると Neovim を Lua の実行環境として使えます。指定したファイルを Lua スクリプトとして解釈し、エディタは起動せずにスクリプトの完了と共に終了します。
「実行したいコルーチン」のコードを見てみると、非同期に実行される関数(vim.loop.fs_stat
)が、コールバックを使わずに同期的に実行されているように見えます。
-- 実行したいコルーチン
local thread = coroutine.create(function()
local _, stat = coroutine.yield(vim.loop.fs_stat, vim.env.HOME)
print(stat.type) -- "directory" と表示します。
print("It has finished getting stat")
end)
でも executor()
を含めた全体のコードは、いきなり複雑過ぎて訳分かりませんよね? はい。これは分からなくて結構です。全部 plenary.nvim がやってくれます。
3. plenary.async の基本
plenary.async
の効果は一目瞭然です。README にある例を見てみましょう。
-- README のコードを抜粋して修正
local function read_file(path, callback)
vim.loop.fs_open(path, "r", tonumber("0666", 8), function(err, fd)
assert(not err, err)
vim.loop.fs_fstat(fd, function(err, stat)
assert(not err, err)
vim.loop.fs_read(fd, stat.size, 0, function(err, data)
assert(not err, err)
vim.loop.fs_close(fd, function(err)
assert(not err, err)
callback(data)
end)
end)
end)
end)
end
read_file("/path/to/file", function(data)
print(data)
end)
うんざりするようなコールバック地獄のコードが、
local async = require "plenary.async"
local function read_file(path)
local err, fd = async.uv.fs_open(path, "r", tonumber("0666", 8))
assert(not err, err)
local err, stat = async.uv.fs_fstat(fd)
assert(not err, err)
local err, data = async.uv.fs_read(fd, stat.size, 0)
assert(not err, err)
local err = async.uv.fs_close(fd)
return data
end
print(read_file("/path/to/file"))
plenary.async
を使うとこんなに読み易くなります。
Neovim は Lua5.1 と LuaJIT の双方を組み込み言語としてサポートしています。しかし、plenary.nvim は LuaJIT 専用です。公式で配布している Neovim のビルドは LuaJIT が組み込まれていますが、コンパイルした環境によっては Lua5.1 がリンクされているかも知れません。
LuaJIT は Lua5.1 をベースとして開発されており、大きな特徴として “Fully Resumable VM” を実装していることが挙げられています。plenary.async
(とそれを使う telescope.nvim)はこれに依存しているため、Lua5.1 では動かないのです。
次節からは plenary.async
を使うに当たって最低限知っておかないといけない async.wrap
, async.void
, async.run
を紹介します。
3.1. async.wrap
非同期処理を行う関数がコールバック型だった場合、同期的な書き方ができるように async.wrap
を使って変換することができます。例として、vim.system
を変換してみましょう。
vim.system
は外部コマンドを実行するための比較的新しい API です。以下のように使います。
-- 同期的に実行します。完了するまでブロックします。
local obj = vim.system({ "ls" }, { text = true }):wait()
print(obj.code) -- 終了ステータス
print(obj.stdout) -- 標準出力
-- 非同期に実行します。完了後にコールバック関数を実行します。
vim.system({ "ls" }, { text = true }, function(obj)
print(obj.code) -- 終了ステータス
print(obj.stdout) -- 標準出力
end)
この、非同期に実行するバージョンを async.wrap
で変換してみます。
-- 変換したい関数と、呼び出すのに必要な引数の数を指定します。
local async_system = async.wrap(vim.system, 3)
この関数を叩くために以下のようなファイルを作って実行してみましょう。
以降のスクリプトは実行するために plenary.nvim が必要です。予めローカルにクローンした上で、パスを $PLENARY_PATH
という環境変数に設定しておいてください。
$ git clone https://github.com/nvim-lua/plenary.nvim /tmp/plenary.nvim
$ export PLENARY_PATH=/tmp/plenary.nvim
vim.opt.runtimepath:prepend(vim.env.PLENARY_PATH)
local async = require "plenary.async"
local async_system = async.wrap(vim.system, 3)
local obj = async_system({ "ls" }, { text = true })
print(obj.code)
print(obj.stdout)
/tmp/hoge.lua
に保存した上で、nvim -l
コマンドで直接実行してみると……
$ nvim -l /tmp/hoge.lua
E5113: Error while calling lua chunk: /tmp/hoge.lua:4: attempt to yield across C-call boundary
stack traceback:
[C]: in function 'async_system'
/tmp/hoge.lua:4: in main chunk
あれ? エラーになりますね。なぜでしょう。
3.2. async.void
attempt to yield across C-call boundary
エラーとは、端的に言えば「coroutine.yield
をコルーチンの外で使っている」という意味です。plenary.async
モジュールは内部で coroutine.yield
を利用しており、そのまま呼ぶとエラーになるのです。async.void
を使うとこのエラーを避けることができます。
vim.opt.runtimepath:prepend(vim.env.PLENARY_PATH)
local async = require "plenary.async"
local async_system = async.wrap(vim.system, 3)
async.void(function()
local obj = async_system({ "ls" }, { text = true })
print("status: " .. obj.code)
print("stdout: " .. obj.stdout)
end)()
-- 1 秒待つ
vim.wait(1000)
$ nvim -l /tmp/hoge.lua
status: 0
stdout: Applications
Desktop
Documents
……
それっぽい表示が現れましたね。async.void
は通常のコードから非同期関数を呼ぶ時に、安全に呼べるようにラッピングしてくれます。async.void
の返り値は関数ですので、実行する際は async.void(function() …… end)()
のように、最後の ()
を忘れないようにしましょう。
async.void
でラップする関数に引数を与えて実行する場合は、最後の ()
にそれを記述します。
local function some_async_fn(foo, bar)
async_hoge(foo, bar)
end
async.void(some_async_fn)("ほげ", "ふが")
この例の場合は、foo = "ほげ"
, bar = "ふが"
となります。
3.3. vim.wait が必要な訳
前節のコードでは最後に vim.wait
を呼び出しています。なぜこれが必要なのかというと、コルーチンは文字通り「非同期に」実行されるため、同期的なコード実行が終了するとそのまま Neovim が終了してしまうのです。
# vim.wait を省くと何も表示せずに終了してしまいます。
$ nvim -l /tmp/hoge.lua
$
上記のコードではこれを防ぐために、単純に 1 秒待つコード(vim.wait(1000)
)を追加しています。そのため、コルーチンがもっと早く完了しても 1 秒経つまで待つコードになっています。
これをもっとスマートに書くために、async.util.block_on
というユーティリティ関数が用意されています。
vim.opt.runtimepath:prepend(vim.env.PLENARY_PATH)
local async = require "plenary.async"
local async_system = async.wrap(vim.system, 3)
async.util.block_on(function()
local obj = async_system({ "ls" }, { text = true })
print("status: " .. obj.code)
print("stdout: " .. obj.stdout)
end)
async.util.block_on
は実行中 Neovim の動作を止めてしまう危険な関数です。このエントリー内で例示するスクリプトは CLI から nvim -l
で実行し易いようにこの関数を使っています。ですが実際に書くプラグインでは必ず async.void
を使ってください。plenary.nvim の中でも、この関数はテスト用途にしか使われていません。
3.4. async.run
async.void
と基本的には同じですが、こちらは第 2 引数にコールバックを指定することができ、非同期関数の後に続けて別の処理を実行することができます。
vim.opt.runtimepath:prepend(vim.env.PLENARY_PATH)
local async = require "plenary.async"
local async_system = async.wrap(vim.system, 3)
local url_count = 0
local done = 0
-- URL を fetch してレスポンスのバイト数を計算します。
local function calculate_size(url)
local result = async_system { "curl", "-s", url }
done = done + 1
return url, #result.stdout
end
-- URL とサイズを与えると、それを画面に表示します。
local function print_size(url, size)
print(("%d: url => %s, size => %d"):format(done, url, size))
end
async.util.block_on(function()
local result = async_system { "curl", "-sL", "https://yahoo.co.jp" }
for url in result.stdout:gmatch 'href="([^"]+)"' do
url_count = url_count + 1
-- calculate_size() を実行し、その返り値を print_size() に与えて呼び出します。
async.run(function()
return calculate_size(url)
end, print_size)
end
print(("fetching up to %d URLs"):format(url_count))
end)
-- 最大で 10 秒待つ
vim.wait(10000, function()
return url_count == done
end)
ちょっと例が複雑になりましたね。このスクリプトでは Yahoo! Japan のトップページを読み込み、その HTML にある <a href="……">
タグに含まれる URL にアクセスし、それぞれのバイト数を表示しています。実際に叩くと以下のような結果が得られます。
$ nvim -l /tmp/hoge.lua
fetching up to 62 URLs
1: url => https://restaurant.ikyu.com/rsCosite.asp?CosNo=10000175&CosUrl=, size => 1072
2: url => https://www.ikyu.com/ikCo.ashx?cosid=ik010002&surl=%2F&sc_e=ytc_pc_ikyu, size => 194
3: url => https://travel.yahoo.co.jp/ikCo.ashx?cosid=y_010002&cosuid=ytsl&surl=%2F&sc_e=ytsl, size => 213
4: url => https://map.yahoo.co.jp/, size => 4926
5: url => https://search.yahoo.co.jp/image, size => 2138
……
async.util.block_on
関数の中では、抽出した URL それぞれについてコルーチンを作成し、並行して複数の curl
コマンドを叩いています。そこに使われているのが async.run
関数です。
async.run(function()
calculate_size(url)
end, print_size)
この部分を単に、
local url, size = calculate_size(url)
print_size(url, size)
と書き換えてみましょう。途端に実行が遅くなると思います。この場合は複数の URL に並行してアクセスすることをやめ、一つずつ直列にアクセスしているのです。
3.5. 再び vim.wait
async.util.block_on
を使っているのにも関わらず、この例では再び vim.wait
が登場しています。async.util.block_on
は指定した関数が完了するまで処理をブロックしますが、その関数の中で新たに作られたコルーチンの完了までは検知できないからです。そのため、calculate_size
関数の中で処理を終えた数をカウントし(done = done + 1
)、それが事前に計算した URL の数と一致するまで待っています。
vim.wait(10000, function()
return url_count == done
end)
このように、終了がちゃんと検知できないままでは実用できません。これは後程説明します、async.util.join
や async.control
サブモジュールを使って解決できます。
他の言語で非同期処理を書いたことがある人は、コルーチンから平気で外部の変数(done
とか)をイジっているのをみてギョッとしたかも知れませんね。Lua において、これは全く問題の無い操作です。Lua は(特殊な操作をしない限り)シングルスレッドで動作します。つまり一つの変数にアクセスするコルーチンは同時に一つまでであることが保証されているのです。
これは逆に言えば、複数のコルーチンを並列に実行することはできないということです。それらは必ず並行に実行されますので、一つのコルーチンが CPU を占有したまま返って来ない場合、Neovim 自体の動作が止まってしまいます。
4. plenary.async のサブモジュール達
今まで紹介した async.wrap
, async.void
, async.run
は、実は async.async
というサブモジュール内に実装されています。plenary.async には他にも様々なサブモジュールがあり、以下ではそれらについて解説しています。
これらのサブモジュール群については plenary.async
内のテーブルからアクセスしてください。
local async = require "plenary.async"
async.util.sleep -- plenary.async.util.sleep
async.lsp.buf_request -- plenary.async.lsp.buf_request
4.1. async.util
他のサブモジュールに含まれない、雑多な関数が含まれています。
4.1.1. async.util.block_on
これはすでに説明済みですね。今までは第 1 引数だけを指定して書いていましたが、第 2 引数にタイムアウトとなるミリ秒を指定することもできます。この値はデフォルトで 2000 ミリ秒となっていますので、処理に時間が掛かる場合は延ばした方が良いです。
-- 実行に 4 秒掛かる関数を実行するのでタイムアウトを 10 秒にする。
async.util.block_on(function()
async_function_takes_over_4000_ms()
end, 10000)
print "done"
4.1.2. async.util.will_block
非同期関数を与えると、処理をブロックする関数にして返します。
local block_function = async.util.will_block(function()
some_async_function()
end, 10000)
-- 非同期関数が完了するまで処理をブロックします。
block_function()
4.1.3. async.util.join
複数のコルーチンを引数に取り、全てのコルーチンが終了するまで待って結果を返します。
httpbin.org はテスト用の様々なデータを提供してくれる便利なサイトです。このサイトから、ランダムなバイト列を 10 回取得して表示してみましょう。
vim.opt.runtimepath:prepend(vim.env.PLENARY_PATH)
local async = require "plenary.async"
local async_system = async.wrap(vim.system, 3)
async.util.block_on(function()
local funcs = {}
for _ = 1, 10 do
table.insert(funcs, function()
local result = async_system { "curl", "-sL", "https://httpbin.org/bytes/15" }
return vim.base64.encode(result.stdout)
end)
end
local results = async.util.join(funcs)
for i, result in ipairs(results) do
print(i, result[1])
end
end)
$ nvim -l ~/git/dotfiles/hoge.lua
1 KrvERDluTFxl5ikTTAlu
2 l9ZVGYVEEyl+DT5bmet6
3 peMHClipBQ4wYmU8+sAG
4 sQ91FtU5eeO6qpugp/QA
5 hvx6PrA/sxavsnpB4FVk
6 1Fx9REOwk7Zrv7T5tkb9
7 No0wOYzkStpsryClbxbu
8 Xskw2wJeUc0O92EZNY89
9 BH09cRU54zuqqCqOyeRb
10 TkD/yogueDVzQbJwawCh
async.util.join
は指定した関数をそれぞれ別のコルーチンで実行し、結果をテーブルにして返してくれます。async.run
の説明をした時は done
変数を使うなどしてコルーチンの完了を検知していましたが、async.util.join
を使うとシンプルに記述できますね。
4.1.4. async.util.race
async.util.join
と同様複数のコルーチンを実行できますが、こちらは最初に完了した結果のみを返してくれます。
vim.opt.runtimepath:prepend(vim.env.PLENARY_PATH)
local async = require "plenary.async"
local async_system = async.wrap(vim.system, 3)
local urls = {
"https://yahoo.co.jp",
"https://google.com",
"https://microsoft.com",
"https://apple.com",
}
async.util.block_on(function()
local url, size = async.util.race(vim.tbl_map(function(url)
return function()
local result = async_system { "curl", "-sL", url }
return url, #result.stdout
end
end, urls))
print(url, size)
end)
$ nvim -l ~/git/dotfiles/hoge.lua
https://apple.com 242731
Yahoo! Japan, Google, Microsoft, Apple の 4 つのサイトに同時にアクセスし、最初に結果の返って来たサイトのバイト数を表示しています。おそらく試す度に結果は異なるはずです。async.util.join
では必ず全ての結果が返るまで待っていましたのでそこが違います。
async.util.race
には、完了しなかった他のコルーチンをキャンセルしてくれる機能はありません。今回の例の場合、最初に結果の返ってきたコマンド以外は kill
して終了するようにするのが作法としては良いでしょう。
4.1.5. async.util.apcall
通常の pcall
関数は引数に指定した関数を実行し、例外を吐いた場合でも安全に結果を返してくれます。
local function i_like_even(num)
if num % 2 == 0 then
return "good"
else
error "this is not an even number"
end
end
local ok, msg = pcall(i_like_even, 2)
print(ok, msg)
local ok, msg = pcall(i_like_even, 1)
print(ok, msg)
$ nvim -l /tmp/hoge.lua
true good
false /tmp/hoge.lua:39: this is not an even number
async.util.apcall
はこれの非同期関数版です。
vim.opt.runtimepath:prepend(vim.env.PLENARY_PATH)
local async = require "plenary.async"
local async_system = async.wrap(vim.system, 3)
async.util.block_on(function()
local function fetch(url)
local result = async_system { "curl", "-sf", url }
if result.code == 0 then
return #result.stdout
else
error("failed to fetch: " .. url)
end
end
local ok, code
ok, code = async.util.apcall(fetch, "https://example.com")
print(ok, code)
ok, code = async.util.apcall(fetch, "https://example.com/should/be/404")
print(ok, code)
finished = true
end)
nvim -l /tmp/hoge.lua
true 1256
false /tmp/hoge.lua:12: failed to fetch: https://example.com/should/be/404⏎
2 回目のリクエストでは、あり得ない URL を指定した際にきちんとエラーが返っていますね。
4.1.6. async.util.sleep
名前の通り、コルーチンの動作を指定したミリ秒だけ停止します。
-- 1 秒停止します。
async.util.sleep(1000)
「停止」するのは async.util.sleep
を実行したコルーチンだけで、他のコルーチンや Neovim 自体の動作は止まりません(Neovim の動作が止まってしまう :sleep
とはそこが違います)。また、引数に 0
を指定するのも意味があり、別のコルーチンに制御を移したり、ユーザーの入力動作を妨げないようにする用途に使ったりします。この場合の動作は後述の async.util.scheduler
とほぼ同じです。
4.1.7. async.util.scheduler
ソースを見ると分かるとですが、たった一行だけの関数です。
---An async function that when called will yield to the neovim scheduler to be able to call the api.
M.scheduler = a.wrap(vim.schedule, 1)
実際に使う時はこんな感じ。
async.run(function()
-- some async logic
async.util.scheduler()
-- another async logic
end)
vim.schedule
は引数に与えた関数を、次回のイベントループまで遅延して実行します。上記のコードの場合は、-- some async logic
と -- another async logic
は別々のループで実行される訳です。
これがなぜ必要になるのかというと、テキストロックを避けるためです。テキストロックとは何か、というのはリンク先のヘルプに詳しく説明があります。Neovim 上の Lua はあくまでシングルスレッドでのイベントループモデルで動作し、coroutine.yield
などで処理を明け渡さない限り、CPU を専有し続けてしまいます。つまりこれは「協調的(cooperative)スケジューリング」です。このため、Lua のコルーチンの中から Neovim の機能を呼び出すには制限を受けることがあります。
協調的スケジューリング、又はそれの対となる非協調的スケジューリングについては以下の記事が詳しいです。
また、コルーチンの中で受ける「制限」については async.api の節で詳しく解説しています。
この関数は複数のコルーチン間を協調的に動かす場合に重要になってきます。具体的な例は次項の async.control
を説明する時に挙げましょう。
4.2. async.control
plenary.async の中で一番重要なサブモジュールです。他の言語で「非同期処理」と聞いた時に思い付くような操作は全てこのサブモジュールに実装されています。条件変数、セマフォ、チャンネル、といった面々です。このサブモジュールを使うと初めて「非同期処理」っぽいコードが書けるようになります。
4.2.1. async.control.Condvar
所謂条件変数(condvar)です。複数のコルーチンを、タイミングを揃えて動作させる時に使います。
vim.opt.runtimepath:prepend(vim.env.PLENARY_PATH)
local async = require "plenary.async"
local async_system = async.wrap(vim.system, 3)
local urls = {
"https://yahoo.co.jp",
"https://google.com",
"https://twitter.com",
"https://apple.com",
}
local finished
local lengths = {}
-- 条件変数を用意する
local cv = async.control.Condvar.new()
-- URL に並行にアクセスし、バイト数を得るコルーチン
async.run(function()
-- async.util.join を使っているので、全ての URL にアクセスするまで待ちます。
async.util.join(vim.tbl_map(function(url)
return function()
local result = async_system { "curl", "-sL", url }
lengths[url] = #result.stdout
end
end, urls))
-- 準備ができたことを通知します。
cv:notify_all()
end)
-- 準備ができたら結果を表示するコルーチン
async.run(function()
print "accessing URLs……"
-- ここで動作をストップ。準備ができるまで待ちます。
cv:wait()
for url, length in pairs(lengths) do
print(url, length)
end
finished = true
end)
-- 終わるまで最大 10 秒待つ
vim.wait(10000, function()
return finished
end)
$ nvim -l ~/git/dotfiles/hoge.lua
accessing URLs……
https://google.com 19728
https://twitter.com 171106
https://apple.com 243605
https://yahoo.co.jp 34132
4 つの URL に並行にアクセスし、そのバイト数を表示しています。accessing URL……
と表示した後動作が止まり、全ての URL にアクセスが終わるまで待っています。全部の結果が揃ったら URL とバイト数が表示されるはずです。実際にはそんな書き方をしないと思うのですが、今回は例示のため、URL にアクセスするコルーチンと結果を表示するコルーチンをわざわざ分けて実行しています。
用意ができるまで待つ cv:wait()
と、準備ができたことを通知する cv:notify_all()
という 2 つのメソッドを使っています。今回は待っているコルーチンが一つだけなので余り意味がありませんが、複数のコルーチンが cv:wait()
で待っている場合に、どれか一つにだけ通知する cv:notify_one()
というメソッドも用意されています。
4.2.2. async.control.Semaphore
セマフォです。複数のコルーチンを動作させる際に、同時に実行する数を制限するために使います。
vim.opt.runtimepath:prepend(vim.env.PLENARY_PATH)
local async = require "plenary.async"
local async_system = async.wrap(vim.system, 3)
async.util.block_on(function()
-- 1. 条件変数。全てのコルーチンが完了したことを検知します。
local cv = async.control.Condvar.new()
-- 2. セマフォ。同時に動作するコルーチンを 5 個に制限します。
local sm = async.control.Semaphore.new(5)
local done = 0
-- 3. 50 個のコルーチンを開始します。
for _ = 1, 50 do
async.run(function()
-- 5. 許可を得ます。もし許可が得られない、つまり、すでに 5 個のコルーチン
-- が動作しているならここでブロックします。
local permit = sm:acquire()
-- 6. httpbin.org にアクセスしてランダムな 15 バイトを得ます。
local result = async_system { "curl", "-sL", "https://httpbin.org/bytes/15" }
done = done + 1
print(done, vim.base64.encode(result.stdout))
-- 7. 一つの処理が終わったら、許可を返却します。これで他のコルーチンが動作
-- できるようになります。
permit:forget()
-- 8. 全部終わったら条件変数を使って通知します。
if done == 50 then
cv:notify_all()
end
end)
end
-- 4. コルーチンを開始後、ここでブロックします。
cv:wait()
-- 9. 全部終わったらここに来ます。
print "done!"
end, 50000)
さあ、だいぶ非同期処理プログラミングっぽくなってきましたね。このスクリプトでは、https://httpbin.org に 50 回アクセスし、その返り値を表示しています。その際、以下のテクニックを使っています。
- 条件変数を使って、処理が終わったことを通知している。
- Web サイトに同時にアクセスするコルーチンをセマフォで 5 個に制限している。
スクリプト内のコメントにも番号を付けてますが、これは以下のように動作します。
- 条件変数。全てのコルーチンが完了したことを検知します。
- 前節で説明した
async.control.Condvar
を使います。
- 前節で説明した
- セマフォ。同時に動作するコルーチンを 5 個に制限します。
- ここで今回キモとなる
async.control.Semaphore
を使います。
- ここで今回キモとなる
- 50 個のコルーチンを開始します。
-
async.run
を使ってコルーチン(以後、子コルーチンと呼びます)を開始しています。この時点では元のコルーチン(同じく、親コルーチン)の動作が継続しますので、作成した子コルーチンは一切動作していません。
-
- コルーチンを開始後、ここでブロックします。
-
cw:wait()
により、親コルーチンはyield
し、処理を別のコルーチンに渡します。
-
- 許可を得ます。もし許可が得られない、つまり、すでに 5 個のコルーチンが動作しているならここでブロックします。
- ここからが子コルーチンの処理です。
- httpbin.org にアクセスしてランダムな 15 バイトを得ます。
- 一つの処理が終わったら、許可を返却します。これで他のコルーチンが動作できるようになります。
- 全部終わったら条件変数を使って通知します。
- 全部終わったらここに来ます。
前述したように、Neovim 上の Lua、つまり、libuv でのスケジューリングは「協調的(cooperative)」です。親コルーチンは for ループで子コルーチンを沢山作りますが、そのまま CPU を明け渡さずにループを抜けます。その後、cw:wait()
で初めて親コルーチンはストップし、CPU を明け渡します。つまり、この cw:wait()
が無ければそのままスクリプトは終了してしまうのです。
このような、条件変数とセマフォの組み合わせは plenary.async
に限らずあらゆるライブラリの非同期処理で利用されます。他の言語でも似たような仕組みを見た読者も多いと思います。
4.2.3. async.control.channel
条件変数とセマフォはコルーチンの動作を制御するものですが、チャンネルはコルーチン間で通信する際に使います。概念としては前節までより少し複雑ですので、最初は理解するのが難しいかも知れません。
4.2.3.1. async.control.channel.oneshot
2 つのコルーチンで、一度だけ値を受け渡しするために使います。
vim.opt.runtimepath:prepend(vim.env.PLENARY_PATH)
local async = require "plenary.async"
local async_system = async.wrap(vim.system, 3)
local function fetch_result(tx)
local result = async_system { "curl", "-sL", "https://httpbin.org/bytes/15" }
tx(vim.base64.encode(result.stdout))
end
async.util.block_on(function()
-- Tx(送信機)と Rx(受信機)を作る
local tx, rx = async.control.channel.oneshot()
-- 関数に Tx を渡して、非同期に実行する
async.void(fetch_result)(tx)
-- Rx が結果を返すのを待つ
local result = rx()
print(result)
end, 5000)
$ nvim -l /tmp/hoge.lua
fU5xoGwq4t4NHxMv1u4J
httpbin.org にアクセスしてランダムなバイト列を得るだけの、簡単なスクリプトです。httpbin.org にアクセスする関数(fetch_result
)を async.void
に与えて子コルーチンとして実行しています。fetch_result
は curl
コマンドの結果を Tx に与えて返します。
親コルーチンは Rx が結果を返すのを待ちます。この時結果が返ってくるまで実行がブロックされます。
Tx, Rx といった用語は電気通信で使われるものに由来しています。それぞれ Transmitter, Receiver を略したものです。
vim.opt.runtimepath:prepend(vim.env.PLENARY_PATH)
local async = require "plenary.async"
local async_system = async.wrap(vim.system, 3)
local function calculate_size(url)
local result = async_system { "curl", "-s", url }
return url, #result.stdout
end
local function print_size(done, url, size)
print(("%d: url => %s, size => %d"):format(done, url, size))
end
async.util.block_on(function()
-- 1. Tx, Rx を準備します。
local tx, rx = async.control.channel.oneshot()
-- 2. Apple のトップページを取得して、中のリンクを全て取得します。
local top = "https://apple.com"
local result = async_system { "curl", "-sL", top }
local urls = {}
for url in result.stdout:gmatch 'href="([^"]+)"' do
if url:match "^/" then
table.insert(urls, top .. url)
elseif not url:match "^#" then
table.insert(urls, url)
end
end
-- 3. 子コルーチンを作成して各々のリンクにアクセスします。
local done = 0
for _, url in ipairs(urls) do
-- 6. async.run は第 1 引数の関数が完了後、第 2 引数の関数を実行します。
async.run(function()
return calculate_size(url)
end, function(url, size)
done = done + 1
print_size(done, url, size)
-- 7. 完了した数が URL の数と等しければ、Tx で完了を通知します。
if done == #urls then
tx()
end
end)
end
-- 4. 準備ができたらメッセージを表示します。
print(("fetching up to %d URLs"):format(#urls))
-- 5. 全ての子コルーチンが完了するまでここでブロックします。
rx()
print "done!"
end, 50000)
$ nvim -l ~/git/dotfiles/hoge.lua
fetching up to 265 URLs
1: url => https://apple.com/today/, size => 310
2: url => https://apple.com/airpods/, size => 312
3: url => https://support.apple.com/kb/HT209218, size => 0
……
263: url => https://apps.apple.com/us/app/apple-store/id375380948, size => 1195784
264: url => https://www.goldmansachs.com/terms-and-conditions/Apple-Card-Customer-Agreement.pdf, size => 1494627
265: url => https://www.apple.com/dk/, size => 146808
done!
- Tx, Rx を準備します。
- Apple のトップページを取得して、中のリンクを全て取得します。
- 子コルーチンを作成して各々のリンクにアクセスします。
- 準備ができたらメッセージを表示します。
- 全ての子コルーチンが完了するまでここでブロックします。
- Rx が受け取った値はそのまま捨てています。
-
async.run
は第 1 引数の関数が完了後、第 2 引数の関数を実行します。 - 完了した数が URL の数と等しければ、Tx で完了を通知します。
- Tx は単に終了したことを通知したいだけなので、値を何も送っていません。
前節までの例では vim.wait
で終了を検知していましたが、チャンネルを使うとそれが綺麗に書けます。この例ではチャンネルを条件変数のように使っています。tx()
, rx()
の 2 つの関数に分けているので条件変数より役割が明確化されて読み易いかも知れません。
4.2.3.2. async.control.channel.counter
前節の async.control.channel.oneshot
は名前の通り 1 回しか送受信できません。複数回通知したい場合は async.control.channel.counter
を使います。
vim.opt.runtimepath:prepend(vim.env.PLENARY_PATH)
local async = require "plenary.async"
-- スクリプトが起動してからの経過時間を得ます。
local start = vim.loop.clock_gettime "realtime"
local function elapsed()
local now = vim.loop.clock_gettime "realtime"
local nsec = now.nsec - start.nsec
local sec = now.sec - start.sec
return ("elapsed: %.2fs"):format(sec + nsec / 1000000000)
end
-- 0.5 秒毎に 5 回通知します。
local function sender(tx)
async.util.sleep(500)
tx:send()
async.util.sleep(500)
tx:send()
async.util.sleep(500)
tx:send()
async.util.sleep(500)
tx:send()
async.util.sleep(500)
tx:send()
end
-- 3 回通知を受け取ります。
local function receiver(rx)
for i = 1, 3 do
rx:recv()
print(("%s Received!: %d"):format(elapsed(), i))
end
end
async.util.block_on(function()
print(elapsed() .. " start!")
local tx, rx = async.control.channel.counter()
-- 送信、受診それぞれのための子コルーチンを起動します。
async.void(sender)(tx)
async.void(receiver)(rx)
print(elapsed() .. " waiting……")
async.util.sleep(5000)
-- 5 秒待って、受信できていない通知を全て受け取って終了します。
rx:last()
print(elapsed() .. " Received all!")
end, 10000)
$ nvim -l /tmp/hoge.lua
elapsed: 0.00s start!
elapsed: 0.00s waiting……
elapsed: 0.50s Received!: 1
elapsed: 1.00s Received!: 2
elapsed: 1.50s Received!: 3
elapsed: 5.00s Received all!
上記のスクリプトでは送信機(sender
)と受信機(receiver
)、それぞれの子コルーチンを起動し、sender
から receiver
に通知を送っています。
-- 通知を送る
tx:send()
-- 通知が届くまでブロックする
rx:recv()
これ以外に、残りの通知を全て受け取って空っぽにしてしまうメソッドもあります。
-- 全ての通知を受け取って空にする
rx:last()
一方通行、かつ、値を送れず通知するだけの機能なので使い所は難しいですが、その分シンプルで動作も分かり易いです。
4.2.3.3. async.control.channel.mpsc
mpsc
って変な名前ですが、これは “Multi Producer, Single Consumer” の略です。所謂、Producer Consumer パターンを実装することができます。
以下の例は ~/Documents
と ~/Desktop
の中身を列挙するスクリプトです。ファイルやディレクトリの列挙には vim.fs.dir
という Neovim 組み込みの関数を使っています。この関数は指定したディレクトリの中身を再帰的に列挙しますが、ここでは depth = 2
というオプションを付けて列挙する階層を制限しています。
vim.opt.runtimepath:prepend(vim.env.PLENARY_PATH)
local async = require "plenary.async"
-- Producer
-- 指定したパスからファイルやディレクトリを列挙します。
local function producer(tx, finish, path)
local count = 0
for entry, type in vim.fs.dir(vim.fs.normalize(path), { depth = 2 }) do
-- 見付けたファイルなどを Consumer へ送ります。
tx.send(path, entry, type)
-- 3 個送ったら、別のコルーチンに制御を渡します。
count = count + 1
if count % 3 == 0 then
async.util.scheduler()
end
end
-- 全部列挙したら完了したことを通知します。
finish:send()
end
-- Consumer
-- 送られてきたファイルやディレクトリを表示します。
local function consumer(rx)
while true do
-- データを受け取るまでここでブロックします。
local path, entry, type = rx.recv()
print(("type = %s, fullpath = %s/%s"):format(type, path, entry))
end
end
async.util.block_on(function()
local finish, wait = async.control.channel.counter()
local tx, rx = async.control.channel.mpsc()
-- ~/Documents の中身を列挙します。
async.void(producer)(tx, finish, "~/Documents")
-- ~/Desktop の中身を列挙します。
async.void(producer)(tx, finish, "~/Desktop")
-- Producer から送られてきたエントリーを表示します。
async.void(consumer)(rx)
-- Producer が完了するのを待ちます。2 つ起動しているので 2 回待っています。
wait:recv()
wait:recv()
end, 10000)
だいぶ複雑になりましたね。ここでは上記のように 3 つのコルーチンを起動し、それぞれが通信し合って結果を出力しています。
vim.fs.dir
のソースを読むと分かるのですが、実は vim.fs.dir
の中でもエントリーの列挙のためにコルーチンを起動しています。つまり、このスクリプトでは親コルーチンを含め、合計 5 つのコルーチンが起動している計算になります。
このスクリプトでは 2 種類のチャンネルを使っています。
local finish, wait = async.control.channel.counter()
- スクリプト全体の終了を検知するために使います。送信機(
finish
)は Producer に渡され、エントリーの列挙が終わったらそれを通知(finish:send()
)します。
受信機(wait
)は親コルーチンで待機(wait:recv()
)し、通知があるまでブロックします。 local tx, rx = async.control.channel.mpsc()
- Producer から Consumer にデータを受け渡すために使います。送信機(
tx
)は Producer に渡され、エントリーを見付けるたびにそれを Consumer に送信(tx:send()
)します。
受信機(rx
)は Consumer に渡され、値を受け取るたびにそれを整形して画面に表示します。
上記のスクリプトを実行すると次のように表示されます。
type = file, fullpath = /Users/jinnouchi.yasushi/Documents/file01.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Documents/file02.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Documents/file03.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Desktop/file01.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Desktop/file02.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Desktop/file03.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Documents/file04.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Documents/file05.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Documents/file06.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Desktop/file04.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Desktop/file05.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Desktop/file06.txt
……
3 個ずつ、~/Documents
と ~/Desktop
の中身が交互に現れますね。これはスクリプト中の 12 行目から 16 行目のコードが原因です。
-- 3 個送ったら、別のコルーチンに制御を渡します。
count = count + 1
if count % 3 == 0 then
async.util.scheduler()
end
試しにこれを消してみましょう。すると ~/Documents
の内容を全て列挙してから、~/Desktop
の列挙が始まるはずです。
type = file, fullpath = /Users/jinnouchi.yasushi/Documents/file01.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Documents/file02.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Documents/file03.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Documents/file04.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Documents/file05.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Documents/file06.txt
……
# 以下、~/Documents の中身が延々と列挙される。
# 終わると、~/Desktop の列挙が始まる。
type = file, fullpath = /Users/jinnouchi.yasushi/Desktop/file01.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Desktop/file02.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Desktop/file03.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Desktop/file04.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Desktop/file05.txt
type = file, fullpath = /Users/jinnouchi.yasushi/Desktop/file06.txt
……
何度か紹介した通り、Neovim の Lua による非同期処理は「協調的」に行われます。コルーチンが明示的に CPU を明け渡さない限り、一つのコルーチンの処理が続行します。これを避けるために、ここでは以前紹介した async.util.scheduler
を使っています。
今回の例では Producer の中でやる処理が非常に単純なためにこのようなロジックが必要でした。実際のアプリケーションでは Producer でも様々な I/O 処理(ファイルシステムやインターネットへのアクセス)が行われるはずです。その場合は I/O 処理のたびにコルーチンが切り替わるような動作をしますから、わざわざこのようなロジックを書く機会は少ないはずです。
これはもちろん、「I/O 処理」に非同期処理関数を使った場合の話です。例えば、Lua 標準の io.open
は必ず同期的にファイルを開きます。次節の async.uv.fs_open
(あるいはコールバック版の vim.loop.fs_open
)を使いましょう。
4.3. async.uv
vim.loop.*
の関数群をコールバックを使わずに利用可能にしたものです。例えば、vim.loop.fs_stat
なら、async.uv.fs_stat
が対応しています。
local path = vim.env.HOME
local err, stat = async.uv.fs_stat(path)
assert(not err, err)
-- ホームディレクトリの後に、"directory" と表示します。
print(path, stat.type)
どのような関数が実装されているかは実際に async.uv のソースを見てください。
4.4. async.api
async.util.scheduler
の節でも少し触れましたが、Neovim の API(vim.api.*
の関数群)には利用する上で重要な制限があります。それは「非同期関数のコールバックの中では(ほとんど)使えない」というものです(:h E5560
)。例えば、以下のコードをファイルに保存して実行するとエラーになってしまいます。
vim.loop.fs_stat(vim.env.HOME, function(_, stat)
-- "directory" と表示する。
print(stat.type)
-- ここでエラー。
print(vim.api.nvim_get_current_buf())
end)
$ nvim -l /tmp/hoge.lua
directory
Error executing luv callback:
/tmp/hoge.lua:13: E5560: nvim_get_current_buf must not be called in a lua loop callback
stack traceback:
[C]: in function 'nvim_get_current_buf'
/tmp/hoge.lua:13: in function </tmp/hoge.lua:9>
これは plenary.async
の関数群についても同じです。async.wrap
の節で説明した通り、async.uv.*
の関数群は内部的には vim.loop.*
の関数をコールバック型で動かしています。そのため以下のように同じエラーが発生します。
vim.opt.runtimepath:prepend(vim.env.PLENARY_PATH)
local async = require "plenary.async"
async.util.block_on(function()
local _, st = async.uv.fs_stat(vim.env.HOME)
-- "directory" と表示する。
print(st.type)
-- ここでエラー。
print(vim.api.nvim_get_current_buf())
end)
nvim -l /tmp/hoge.lua
E5113: Error while calling lua chunk: .../share/nvim/lazy/plenary.nvim/lua/plenary/async/util.lua:38: Blocking on future timed out or was interrupted.
/tmp/hoge.lua:9: E5560: nvim_get_current_buf must not be called in a lua loop callback
stack traceback:
[C]: in function 'error'
.../share/nvim/lazy/plenary.nvim/lua/plenary/async/util.lua:38: in function 'block_on'
/tmp/hoge.lua:4: in main chunk
directory
これを避ける方法はいくつかありますが、良く使われるのは、vim.schedule
を使って実行を次のイベントループにまで遅延させることです。
vim.loop.fs_stat(vim.env.HOME, function(_, stat)
-- "directory" と表示する。
print(stat.type)
-- 今度は大丈夫。
vim.schedule(function()
print(vim.api.nvim_get_current_buf())
end)
end)
$ nvim -l /tmp/hoge.lua
directory
1
plenary.nvim の async.api.*
を使うとこれを自動的に行ってくれます。
vim.opt.runtimepath:prepend(vim.env.PLENARY_PATH)
local async = require "plenary.async"
async.util.block_on(function()
local _, st = async.uv.fs_stat(vim.env.HOME)
-- "directory" と表示する。
print(st.type)
-- 今度は大丈夫。
print(async.api.nvim_get_current_buf())
end)
$ nvim -l /tmp/hoge.lua
directory
1
Neovim の API には api-fast という概念があります。これは上記のようなコールバック中でも実行を許されているということで、例えば vim.api.nvim_get_mode()
が該当します。このような関数は async.api
経由だと次のイベントループまで遅延して実行されてしまいます。直接呼び出した方がいいでしょう。
4.5. async.lsp
実質、async.lsp.buf_request_all
のみが定義されています。これは vim.lsp.buf_request_all
の非同期バージョンです。
async.lsp.buf_request
という関数も定義されていますが、同期バージョンの vim.lsp.buf_request
は将来的に削除される見込みです。
4.6. async.tests
plenary.nvim はテスト用のモジュールを内蔵していますが、それを非同期関数と共に使えるようにしたものです。
local async = require "plenary.async"
async.tests.add_to_env()
a.describe("async.uv.fs_stat", function()
a.describe("when given a path", function()
a.it("should return no error", function()
local err = async.uv.fs_stat "./"
assert.is_nil(err)
end)
a.it("should return a table", function()
local _, stat = async.uv.fs_stat "./"
assert.is_table(stat)
end)
end)
end)
$ nvim --headless --noplugin \
-c 'set rtp^=$PLENARY_PATH' \
-c 'lua require"plenary.test_harness".test_file("/tmp/hoge.lua")'
Scheduling: /tmp/hoge.lua
========================================
Testing: /tmp/hoge.lua
Success || async.uv.fs_stat when given a path should return no error
Success || async.uv.fs_stat when given a path should return a table
Success: 2
Failed : 0
Errors : 0
========================================
async.tests.add_to_env()
を呼び出すとグローバルで使える関数、a.describe
と a.it
が利用可能になります。plenary.nvim を使ったテスト手法についてはここに割く紙面がありませんので、別稿に譲りたいと思います。
5. 終わりに
この記事では plenary.async
モジュールの全てを解説しました。Neovim 標準のコールバックを使った方法を避け、コルーチンを利用することで、スマートに非同期処理を記述できるようになります。
そもそもこの記事を作ろうと思った動機は、telescope.nvim で複雑な Picker / Finder を作る手法について纏めようとしたことでした。その説明の際にどうしても避けて通れない、非同期プログラミングについて書いているとそれがどんどん膨らみ、今回のような長文になってしまいました。
元の目的であった、「telescope.nvim での Picker / Finder の全て」はまた別の記事に纏めようと思います。
今回学んだ plenary.async
モジュールを使って、telescope-frecency.nvim を作っています。plenary.async
をフルに使うことで、ユーザーの操作をブロックせずに完全に非同期に動作するプラグインになっています。telescope.nvim ユーザーの方は是非使ってみてください。