Ccmmutty logo
Commutty IT
11 min read

Luaのasync/await実装について

https://cdn.magicode.io/media/notebox/59e71df0-f4b9-4bea-a1ae-dbf8ae2dffd9.jpeg
ms-jpg/lua-async-awaitで どのようにasync/awaitが実現されているかについてのまとめになります.

目次

  • 各モジュールの説明
  • 実際に使ってみる
  • 内部実装の説明

各モジュールの説明

ms-jpg/lua-async-awaitがどのような振る舞いをするのかはそれが提供するmethodが互いにどう影響しているかを調べることで理解できます.

a.sync

a.syncはa.waitで設定されたco.yieldを一つずつ実行しながらコルーチンを展開します.

a.wait

co.yieldを設定します.

a.wrap

引数なしのコールバック関数を作ります.

pong

コルーチンを作成します.

実際に使ってみる

Neovim上で使ってみます.
関数async-fNeovim Autocmdを便利に書くFennelマクロで紹介したasync-do!マクロにコールバック関数を加えたものです.
(local uv vim.loop)
(local a (require :async))

(fn async-f [f]
  (λ [...]
    (local args [...])
    (λ [callback]
      (var async nil)
      (set async
           (uv.new_async
             (vim.schedule_wrap
               (λ []
                 (if (= args nil)
                   (f)
                   (f (unpack args)))
                 (callback)
                 (async:close)))))
      (async:send))))

(fn sleep [sec]
  (print :sleep sec :sec :start)
  (local t (.. "sleep " (tostring sec)))
  (vim.cmd t)
  (print :sleep sec :sec :end))

(local task2 (λ []
               (a.sync (λ []
                         (local async-sleep (async-f sleep))
                         ((async-sleep 3) (lambda [] nil))
                         (a.wait (a.sync (lambda [] (print :v-start))))
                         (a.wait ((a.wrap (async-sleep 8))))
                         (a.wait ((a.wrap (async-sleep 6))))
                         (a.wait_all [((a.wrap (async-sleep 4))) 
                                      ((a.wrap (async-sleep 2))) 
                                      ((a.wrap (async-sleep 1)))])
                         (print :v-end)))))


((task2))
(print :auter 2)

実行結果

v-start                                                                                                   
auter 2
sleep 3 sec start
sleep 8 sec start
sleep 8 sec end
sleep 6 sec start
sleep 6 sec end
sleep 4 sec start
sleep 2 sec start
sleep 1 sec start
sleep 1 sec end
sleep 2 sec end
sleep 4 sec end
v-end
sleep 3 sec end

実際に使ってみる2

(fn pomodoro-timer []
          (local a (require :async))
          (local uv vim.loop)
          (local timeout (lambda [ms callback]
                           (local timer (uv.new_timer))
                           (local callback (vim.schedule_wrap (lambda []
                                                                (uv.timer_stop timer)
                                                                (uv.close timer)
                                                                (callback))))
                           (uv.timer_start timer
                                           ms
                                           0
                                           callback)))

          (local timer (lambda [ms msg  callback]
                        (timeout ms (lambda [] (callback msg)))))
          (local t (a.wrap timer))
          (local s (lambda [x] (* x 1000)))
          (local m (lambda [x] (* (s x) 60)))
          (local task (lambda []
                        (a.sync (lambda []
                                  (var cnt 0)
                                  (while (< cnt 20)
                                    (print (.. "task start: " cnt))
                                    (var cnt (+ cnt 1))
                                    (local r (a.wait (t (m 25) "start to rest")))
                                    (vim.notify r)
                                    (local r (a.wait (t (m 5) "start to work")))
                                    (vim.notify r))))))
          ((task))
)

内部実装の説明

  • ms-jpq/lua-async-awaitの内部実装の説明です.
  • ほぼ ms-jpq/lua-async-await の翻訳になります.

coroutine to async, await

コルーチンについて

一応, 簡単な例を示します.
-- コルーチンの本体
local function task(...)
    coroutine.yield("first") -- 処理を中断する
    return "second"
end

local task1 = coroutine.create(task) -- コルーチンの作成
local sucess, result = coroutine.resume(task1, ...) -- コルーチンの実行
print(result) -- `first`
local sucess, result = coroutine.resume(task1, ...) -- コルーチンの再開
print(result) -- `second`

ms-jpq/lua-async-awaitの実装のミソ

async, awaitと coroutineの違いは, RHSが準備できたらLHSに値を送ることができるかいなかです.

同期的なコルーチン

まずRHSがすでにできている同期的な例を用いて考えてきます.
これがコルーチンに値を渡す方法です.
co.resume(thread, x, y, z)
コアとなる考え方はcorutineをすべて展開されるまで繰り返すことです.
-- 再帰関数nxtを使ってコルーチンを展開する.
local pong = function (thread)
    local nxt = nil
    nxt = function (cont, ...)
        if not cont
        then return ...
        else return nxt(co.resume(thread, ...))
        end
    end
    return nxt(co.resume(thread))
end
pongにコルーチンを渡すと, 完全に展開されるまで再帰的にコルーチンが実行されます.
local thread = co.create(function ()
    local x = co.yield(1)
    print(x)
    local y, z = co.yield(2, 3)
    print(y)
end)
pong(thread)
実行すると以下のような結果が得られるでしょう.
$ lua pong.lua
1
2       3

Thunk

pongの非同期的なバージョンを考えるために, 一つ簡単な 概念を紹介させてください.
Thunkはコールバック関数を引き呼び起こすことを目的とした関数です.
Thunkにより関数の型は(arg, callback) -> void から arg -> (callback -> void) -> void
に変形されます.
-- Thankより,ファイルの読み込みが遅延される
local read_fs = function (file)
    local thunk = function (callback)
        -- callbackはfileが読み込まれたあと, 呼ばれる関数
        fs.read(file, callback)
    end
    return thunk
end

-- Thunkを使って変形するまえ
-- local read_fs = function (file)
--     fs.read(file, callback)
-- end
このThunkによる関数の型変形は自動化することができます.
local wrap = function (func)
    local factory = function(...)
        local params = {...}
        local thunk = function (step)
            table.insert(params, step)
            return func(unpack(params))
        end
        return thunk
    end
    return factory
end

local thunk = wrap(fs.read)
では,なぜこれが必要なのでしょうか?
Async Await
答えは簡単です.私達のRHSにThunkを使うからです.

上の答えを言うとともに,
step関数というトリックを説明する必要があります.
step関数の唯一の仕事ははすべてのthunkにコールバック関数を置くことです.
重要なことは, それぞれのコールバックにおいて coroutineの処理を一つ進めることです.
-- func: コルーチンの本体
-- callback: 最後に呼びたい関数
local pong = function (func, callback)
    assert(type(func) == "function", "type error :: expected func")
    local thread = co.create(func)
    local step = nil
    step = function (...)
        local stat, ret = co.resume(thread, ...)
        assert(stat, ret)
        if co.status(thread) == "dead" then
            -- これ以上resume()できなければ, callback関数を呼ぶ
            -- このときretはcallback関数に渡される引数`co.resume`の第2引数になる.
            (callback or function () end)(ret)
        else
            assert(type(ret) == "function", "type error :: expected func")
            ret(step)
        end
    end
    step()
end
pongは自身のコルーチンの展開処理が終わった後にcallback関数を呼ぶことに気づくでしょう.

このことは以下のアクションで見ることができます.
local echo = function (...)
    local args = {...}
    local thunk = function (step)
        step(unpack(args))
    end
    reutrn thunk
end

local thread = co.create(function ()
    local x, y, z = co.yield(echo(1, 2, 3))
    print(x, y, z)
    local x, f, c = co.yield(echo(4, 2, 6))
    print(k, f, c)
    pong(thread)
end)
注意:ここでは説明のために同期エコーを使用しています。コールバックがいつ呼び出されるかは問題ではありません.この機構はタイミングに依存しません.
非同期は同期をより一般化したものと考えることができます.
最後のセクションで非同期バージョンを実行することができます.

Await All

thunkのさらなる利点は任意の手続きをthunkに注入することができることです.
例えば,たくさんのthunkをつなげることなどです.
local join = function (thunks)
    local len = table.getn(thunks)
    local done = 0
    local acc = {}
    local thunk = function (step)
        if len == 0 then
            return step()
        end
        for i, tk in ipairs(thunks) do
            local callback = function (...)
                acc[i] = {...}
                done = done + 1
                if done == len then
                    step(unpack(acc))
                end
            end
            tk(callback)
        end
    end
end

感想

  • co.yieldが非同期処理(luv)が終了するまで待つことができるのがすごいなと思いました.
  • thunk, (join)はパターンとしていつでも使えるように覚えておきたいなと思いました.

Discussion

コメントにはログインが必要です。