Neovim Lua でマルチスレッド
はじめに
Lua はシングルスレッドで、Neovim のメインループ内で実行されます。
つまり Neovim Lua で何か重い処理を実行すると、本体の操作までブロックされます。
今回は別のスレッドを作ることで、ブロッキングを回避する方法について紹介します。
手法
luv (vim.uv or vim.loop)
Neovim では vim.loop
(nightly では非推奨になり vim.uv
に移動) として luv
(libuv の Lua bindings) が公開されています。
今回は vim.uv
を使って書きます。
Thread pool の作り方
luv では thread pool が利用できます。
これを使うと異なるスレッドで処理を実行することができます。
local function work_callback(a, b)
return a + b
end
local function after_work_callback(c)
print("The result is " .. c)
end
local work = vim.uv.new_work(work_callback, after_work_callback)
work:queue(1, 2)
work:queue(3, 4)
-- output: "The result is 3"
-- output: "The result is 7"
vim.uv.new_work
で初期化し、work:queue()
でタスクをキューに積みます。
このとき queue()
はスレッドの実行を待たないことに注意してください。
new_work
は 2 つのコールバック関数を受け取ることができます。
1 つ目は新しいスレッドで実行される処理、2 つ目は元のメインスレッドで実行される処理です。
after_work_callback
はメインスレッドで実行されるので、ここに重い処理を書いてしまうと本体はブロックされます。
work_callback
をメインにし、after_work_callback
は必要最小限にしましょう。
スレッド間通信
1 つ目のコールバック関数には queue()
に渡した引数が渡されます。
1 つ目のコールバック関数の戻り値が 2 つ目のコールバック関数に渡されます。
このときスレッド間を通信するため、送ることのできる値には制限があります。
数値や文字列は送信できますが、テーブルや関数は不可能です。
関数を送る方法は私も分かりませんが(必要だと思ったこともない)、テーブルを送れないのは不便ですね。
そこで LuaJIT の String Buffer Library が便利です。
これを使うと高速な文字列へのエンコード、デコードが可能になります。
私が試したところ vim.json
(cjson) より倍くらい早かったです。
local function work_callback()
return require("string.buffer").encode({ "foo", "bar" })
end
local function after_work_callback(encoded)
local result = require("string.buffer").decode(encoded)
print(vim.inspect(result)) -- 何故か vim.print() がただの print() になるので。。。
end
local work = vim.uv.new_work(work_callback, after_work_callback)
work:queue()
--- output: { "foo", "bar" }
メタテーブルはそのままだと消えます。
消さずに送ることもできるそうですが、一手間かかるので付け直せるならそれでいいと思います。
string.buffer
を用いたシリアライズは、深くネストされたテーブルでエラーが発生することに注意してください。
私は木構造 (Trie木) を使っていたときに遭遇しました。
100回まではセーフですので、使用するデータ構造と相談してください。
nest.lua
これでギリギリエラーになります。
local obj = {}
for _ = 1, 100 do
obj = { obj }
end
require("string.buffer").encode(obj)
vim.json
(cjson) は 1000回までいけますが、こちらも無制限ではありません。
vim.mpack
は string.buffer
よりも制限が厳しいです(32回)。
スレッドにおける制限
異なるスレッドを起動するため、多少制限はあります。
元のスレッドで定義されたローカル変数にはアクセスできません。
実行結果を保存したい場合は after_work_callback
に送信してから行いましょう。
local buffer = require("string.buffer")
vim.uv.new_work(function()
-- buffer にはアクセスできない
-- return buffer.encode(...)
return require("string.buffer").encode(...)
end, function(encoded)
-- こっちはメインスレッドなので大丈夫
local obj = buffer.decode(encoded)
end)
とはいえ、今はもう vim.*
にある便利関数が使えるのでかなり楽です。
vim.split()
などは変わらず使えます。
vim.fn.*
と vim.api.*
にはアクセスできないので気を付けてください。
vim.uv
も使えるのでファイルを読み込み、加工してその結果を返すような処理も問題なく書けます。
local function work_callback(path)
local fd = assert(vim.uv.fs_open(path, "r", 438))
local stat = assert(vim.uv.fs_fstat(fd))
local data = assert(vim.uv.fs_read(fd, stat.size, 0))
assert(vim.uv.fs_close(fd))
local lines = vim.split(data, "\r?\n")
return path, require("string.buffer").encode(lines)
end
local function after_work_callback(path, encoded_lines)
local lines = require("string.buffer").decode(encoded_lines)
print(("The line number of '%s' is %d"):format(path, #lines))
end
local work = vim.uv.new_work(work_callback, after_work_callback)
work:queue("/usr/share/dict/words")
--- output: The line number of '/usr/share/dict/words' is 104335
終わりに
私は cmp-dictionary で辞書の読み込みに使っています。
非常にニッチな機能ですが、一部のプラグイン作者には便利だと思います!
Discussion