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