🧵

Neovim Lua でマルチスレッド

2024/02/02に公開

はじめに

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 が便利です。

https://luajit.org/ext_buffer.html

これを使うと高速な文字列へのエンコード、デコードが可能になります。
私が試したところ 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.mpackstring.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.* にある便利関数が使えるのでかなり楽です。

https://github.com/neovim/neovim/pull/17386

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 で辞書の読み込みに使っています。

https://github.com/uga-rosa/cmp-dictionary

非常にニッチな機能ですが、一部のプラグイン作者には便利だと思います!

GitHubで編集を提案

Discussion