goroutineで自作ランタイム上にsetTimeoutを再現する(実装の手順とcallback-hellもついてくるよ!!)

こんばんは、id:Kroutonです。RustのFutureについて調べていたはずが*1いつの間にかcallbackでsetTimeoutを再現するコードをGoで書いていたのでその実装手順について簡単に書きたいと思います。実用性はほぼないです。

注意書き

筆者がプログラミングを始めた頃にはもうES6(というかBluebirdを使ったPromise)が広まりかけた頃なので、体験したことありませんし間違ってるかもしれません。また、本エントリを読んだことによるPTSDやフラッシュバックなどの症例については一切の責任を負いません。

要件

var rt = NewRuntime()

func main() {
	rt.run(func() {
		println("これからsetTimeoutをやっていきます")
		setTimeout(1500, func() {
			println(3)
		})
		setTimeout(2000, func() {
			println(4)
		})
		setTimeout(2, func() {
			println(1)
			setTimeout(3000, func() {
				println("こんな風にネストもできるよ、はいおしまい!")
			})
		})
		setTimeout(1000, func() {
			println(2)
		})
		println("1->2->3->4の順番で出るはず")
	})
}

標準出力

これからsetTimeoutをやっていきます
1->2->3->4の順番で出るはず
1
2
3
4
こんな風にネストもできるよ!はいおしまい!

Playground

play.golang.org

初手

package main

import (
	"time"
)

// TODO: そのうちフィールドを用意する
type Runtime struct{}

func NewRuntime() *Runtime {
	return &Runtime{}
}

func (rt *Runtime) run(program func()) {
	program()
}

var runtime = NewRuntime()

// とりあえず今はsleepして実行するだけ
func setTimeout(ms int, callback func()) {
	time.Sleep(time.Duration(ms) * time.Millisecond)
	callback()
}

func main() {
	runtime.run(func() {
		println("開始")
		setTimeout(100, func() {
			println(2)
		})
		// 待つ時間が少ないこっちが先に実行されたい
		setTimeout(20, func() {
			println(1)
		})
                 setTimeout(1000, func() {
		        println("終わり")
                 })
	})
}

このコードは

開始
2
1
終わり

の順に出力されるので改良が必要ですね(といっても並行処理も何もしてないの当たり前ですが)

setTimeoutでgoroutineを呼んでみる

setTimeoutの実装を次のように変えてみましょう

func setTimeout(ms int, callback func()) {
	go func() {
		time.Sleep(time.Duration(ms) * time.Millisecond)
		callback()
	}()
}

そうすると、実行結果が

開始

になりますね。goroutine自体は作れているのですが、その中でprintlnを呼ぶ前にmainの処理が終わってしまうのでこのようになってしまいます。
なので、次はmain関数が終わらないようにしてみましょう。

メインスレッドを無限ループさせる

Runtime#runを次のようにしましょう

func (rt *Runtime) run(program func()) {
	program()
	for {
	}
}

こうすると結果は

開始
まずはこっち
OK
終わり
...(ただし実行は終わらない)

と正しい順番になりつつも無限ループが終わらないといった感じになりますね。ここまで来たら、channelを用意してgoroutine内で値を上手く渡せば無限ループをbreakできそうな感じがしませんか?というわけでそろそろRuntimeのフィールドを追加しましょう。

Runtimeにフィールドを追加してsetTimeoutを実装しきる

type Runtime struct {
	nextID    int
	callbacks map[int]func()
	recv      chan int
}

func NewRuntime() *Runtime {
	recv := make(chan int)
	return &Runtime{recv: recv, callbacks: map[int]func(){}}
}

func (rt *Runtime) register(callback func()) int {
	registeredID := rt.nextID
	rt.callbacks[registeredID] = callback
	rt.nextID++
	return registeredID
}

func setTimeout(ms int, callback func()) {
	callbackID := runtime.register(callback)
	go func() {
		time.Sleep(time.Duration(ms) * time.Millisecond)
		runtime.recv <-callbackID
	}()
}

setTimeoutが呼ばれると、Runtimeのcallbacksにユニークなid(この場合はint)をkey、渡されたcallbackをvalueとしてmapに追加します。そして、goroutine内で指定された時間だけ待った後、 用意したchannel(この場合はruntime.recv)にmapに追加したkeyを送信します。

完成

func (rt *Runtime) run(program func()) {
	program()
	for {
		callbackID := <-rt.recv
		callback := rt.callbacks[callbackID]
		delete(rt.callbacks, callbackID)
		callback()
		if len(rt.callbacks) == 0 {
			break
		}
	}
}

最後はRuntime#runを弄って完成です。 setTimeoutで送信されたkeyを受信して、そのcallbackを引いてきてmapから削除し、実行します。登録されたcallbackがすべて空になったら無限ループが終わる。といった感じです。

あとがき

本当はRustを使ったmspcで書きたかったけどGoの方需要がある気がしたので書きなおしました、まあチャンネルがある言語なら大体同じだと思うので好きな言語で書いてみてください、全然わからんとか間違えてるとかご意見あったら
https://twitter.com/Krout0nまで

追記 2020/07/06

package main
<200b>
import (
	"sync"
	"time"
)
<200b>
func run(program func()) {
	program()
	wg.Wait()
	println("Done")
}
<200b>
var wg = &sync.WaitGroup{}
<200b>
func setTimeout(ms int, callback func()) {
	wg.Add(1)
	go func() {
		time.Sleep(time.Duration(ms) * time.Millisecond)
		callback()
		wg.Done()
	}()
}
<200b>
func main() {
	run(func() {
		println("開始")
		setTimeout(100, func() {
			println("OK")
		})
		// 待つ秒数が少ないこっちが先に実行されたい
		setTimeout(20, func() {
			println("まずはこっち")
		})
		setTimeout(1000, func() {
			println("終わり")
		})
	})
}

これだけでいいらしい、goroutine周りの道具がしっかりあってGoいい感じですね

*1: Introduction - Futures Explained in 200 Lines of Rust このRuntimeのコードもほぼここに乗ってる実装と同じです。 cssamsonさんに圧倒的感謝