株式会社ナレッジワークさんのEnablement Internship for Gophersに参加しました!
株式会社ナレッジワークさんのGo言語を学ぶインターンに参加しました!
概要
株式会社ナレッジワークさん主催のtenntennさんによるGophersのための3日間のインターンに参加しました!
1日目にモブプロによるチームでの開発、2日目3日目は個人でライブラリの開発・発表といった形です。
しかしながら採用担当の方より長期的な視点を見据えてのイネーブルメント活動の一環としてご縁をいただき、参加する運びとなりました。
講義ではGo Conferenceやtenntenn Conferenceの主催をされているtenntennさんが講師となり、またチーム内にもナレッジワークのエンジニアの方がメンターとして付いてくれました。
私のチームでは異彩の経歴を放つnotogawaさんがメンターさんとして担当してくださり、Goに関する話だけでなくエンジニアキャリアの話など興味深い内容を聞くことができました。
また完全に私事ですが、ナレッジワークのCTOである川中 真耶(mayah)さんは私がエンジニアを志した大きな要因の1つである「王様達のヴァイキング」の技術監修をされている方です。
他にもGDEに選出された方がいらっしゃるなど、ナレッジワークさんはかなり強いエンジニア集団というイメージがあります。
チーム開発・個人開発では定期的にメンターさんへの相談時間があり、また任意のタイミングでtenntennさんの相談ができる形でした。
1日目 チーム開発
1日目は2種類のテーマの中から選んでのチーム開発でした!
私のグループではsync.Onceの指定回数実行版の作成及び、Go1.21で導入されるsync.OnceFuncの指定回数実行版の作成を行いました。
複数回呼ばれた時、指定回数を超えて実行しないようなものであり、スレッドセーフである必要もあります。
tenntennさんからの講義でGoのソースの読み方についてご教授いただきました。
- Goのソースで一番読みやすいのはhttps://cs.opensource.google/go/go
- sync.Onceは最近のバージョンだと最適化のために複雑になっているので、ちょっと前のバージョンを読むといい。
このあたり→https://cs.opensource.google/go/go/+/refs/tags/go1.12.16:src/sync/once.go - sync.OnceFuncのGo1.21での導入経緯はコードのblameからIssuesを辿るといい
https://cs.opensource.google/go/go/+/refs/tags/go1.21rc1:src/sync/oncefunc.go
↓
https://go-review.googlesource.com/c/go/+/451356
↓
https://github.com/golang/go/issues/56102
といった形です!
グループ内でモブプロ形式で行いながら実装をしていきました。
まずはsync.Onceのソースを読みながら、指定回数実行版であるsync.Mulitを作成しました。
sync.Onceの実装を見ると、コードの行数としては短いものです
一方でMutexでのLockに加えて2度に渡るdone変数の値のチェックなど工夫の詰まった味わい深いコードでした。
その後テストを書きスレッドセーフであるかなどを確認してsync.OnceFuncの指定回数実行版であるsync.MultiFuncも作成しました。
sync.OnceFuncに関しては次のメジャーバージョンであるGo1.21で導入されるもののため、実装の起点となったIssuesやRC版のソースコードを読みながら理解する必要があります。
最初はsync.Onceとsync.OnceFuncの違いや意味すら曖昧だった中、なんとか理解していきました。
sync.MultiFuncを作るためにsync.OnceFuncのコードをチームの人達と読んでいく中で、validとrecover()で冗長にpanicをハンドリングしているように感じました。
g := func() {
defer func() {
p = recover()
if !valid {
// Re-panic immediately so on the first call the user gets a
// complete stack trace into f.
panic(p)
}
}()
f()
valid = true // Set only if f does not panic
}
recover()でpanicした結果取れる上、panciでなければnilが返るからf()の後にvalid変数を変えて多重にチェックする必要ないのでは……?とチームでまとまっていました。
ですがtenntennさんの意見を伺うとこの冗長な処理にも意味があることを教えていただきました。
panicが生じない時、recover()の返り値はnilになりますが、Go1.20までではpanic(nil)でもrecover()の返り値がnilになります。
つまりrecover()の返り値だけではpanicが無かったのかpanic(nil)だったのか判定できません。
そのためpanicによって以後の処理が行われなくなることを利用して、指定の関数の後にvaild変数を書き換えることで確実にpanicをハンドリングできるという仕組みのようです。
またGo1.21からはpanic(nil)でrecover()の返り値がnilにならなくなります。
ここで私が普通に考えれば、このsync.OnceFuncも同様にGo1.21から入るので、やっぱり冗長にvalid変数なんか使わずにrecover()の結果だけ見れば良くないか……?となります。
ですがGoでは後方互換性が崩れる変更においてはgo:debug panicnil=1のように無効化できるため、標準パッケージではそれも前提とした挙動の保証が必要なためGo1.20以前のpanic(nil)のrecover()の挙動も踏まえた上での冗長さだということでした。深いです。
同じコードを読んでいるのに、得ている情報が違うということを痛感しました。
そしてたった数行にもかなり複雑な意味を含意していると知りました。
さらに話は終わりではありません。
特定の処理によって以後の処理がスキップされるのはpanicだけでなくruntime.Goexit()も同様で、この場合はpanicでないのにsync.OnceFuncではpanicとなる問題があります。
tenntennさんとsync.OnceFuncの相談をしている時にこのことに気付かれて、これはバグでは!?と盛り上がりました。
リリース前のバグはこうやって見つかるのだと感動しました。
最終的には意図した挙動だったようです。
panicとruntime.Goexit、os.exitの挙動の違いなどGo言語の仕様を隅々まで理解することの大切さをとても感じた瞬間でした。知らないと気付きようがない挙動です。
またsync.MultiFuncを実装する上で他にもいただいた助言として、singleflightに似た実装があるから参考にするといいという助言もいただきました。
巨人の肩に乗るというのはまさにこのことだと思います。
私も何か実装する時、参考になる引き出しをたくさん持っていたいと感じました。
スレッドセーフでかつ任意の関数が入るライブラリ作るの難しい!!と思いつつ、なんとか動くものを作りました。
1日目の最後に振り返りをして終了です。
2,3日目 個人開発
2日目、3日目の朝はGood & Newから始まります。
Good & Newは24時間以内にあったよかったことを話すもので、これにより強制的によかったことを日々探すようになるのでお得な感じがして個人的には好きです。
インターン2日目はちょうどGo1.21RC2のリリースがあって、午前の講義パートではGo1.21RC2がリリースされたことを踏まえての共有や、前日見つかったsync.OnceFuncの挙動の解説、個人でのライブラリ開発のテーマに関する解説などが行われました。
テーマを決めて午後からは個人でのライブラリ開発です。tenntennさんから3種類の方向性を提示していただき、その中から方向性を選んで独自に作っていく形です。
チーム部屋自体はそのままで、チームの部屋でカメラをONにして悩み顔をさらけ出しながら開発を進めていました。
私は「可視化ツールの作成」をテーマに選びました。
選んだ理由としては1日目にチーム開発でテストを書いている時、goroutineを適切に待っていなかったことにより意図しない挙動に遭遇した経験がありました。
例えば下記のようなコードは
func main() {
var sum int64
for i := 1; i <= 5; i++ {
go func(num int) {
time.Sleep(1 * time.Second)
atomic.AddInt64(&sum, int64(num))
}(i)
}
fmt.Println("Sum:", sum)
}
https://go.dev/play/p/Y0OrvD2gq5_x
Sum:15を期待したはずがSum:0として出力されます。
これはmain関数内でgoroutineを動かしても、動かしたgoroutineより前にmain関数が終了してしまうことにより生じます。
ちなみに上記のコードはwaitGroupなどを適切に使って下記のように記述することで意図した結果になります。
func main() {
var sum int64
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(num int) {
time.Sleep(1 * time.Second)
atomic.AddInt64(&sum, int64(num))
wg.Done()
}(i)
}
wg.Wait()
fmt.Println("Sum:", sum)
}
https://go.dev/play/p/6k-WA7MIORN
go tool traceなどすれば気付くことができるミスですが、コンパイルエラーにはならず初学者が迷走しやすいと考えました。少なくとも私は迷走しそうです。
だからこそmain関数終了時に残っているgoroutineを可視化できればよいのでは?という方針でライブラリを個人で作っていきました。
Go1.21からの仕様変更でruntime.Stackに作成された親のgoroutineを取得できるようになりました。これによりgoroutineが作ったgoroutineのような入れ子関係のあるgoroutineについても情報を可視化できるようになります。
実装においてはruntime.Stackの結果をどうパースするか、そしてそれをどう表示するかについて悩みました。
runtime.Stackは文字列でスタックトレースやgoroutineのID、生成した行数などが得られるため、それを構造体に落とし込む必要がありました。
パースは正規表現で行いましたが、処理ごとに関数で切り出すことで複雑さを軽減しました。
これによってunitテストも書けるようになりました!
またパースした結果を表示する部分はbayashiさんのgo-proptreeで表示する形にしました。
2日目の午後から3日目の夕方までという短い時間でしたが、なんとか藻掻きながら3日目の発表30分前に完成して、30分で発表資料を作ってギリギリで発表できました。
ライブラリ名はnogoliviです。
残り火のように残っているgoroutineを検知してno go livingにするということで、nogolivi(残り火)と名付けました!
最終発表で他の学生の方の発表も拝見しましたが、1日ちょっとで作ったとは思えないものや発想が素晴らしいものなど、全体的に興味深い最終発表でした。
またできていない部分も発表したことに対して、ナレッジワークさんの行動指針である「Be True」を体現しているかのような反応も伺うことができました。
最終発表後は懇親会といった形で、エンジニアの方々によるパネルディスカッションや懇談が行われ、今後のエンジニアとしてのキャリアを考える上で大変勉強になりました。
感想
Go言語に対して
Go Conferenceの際にも感じましたが、Go界隈では並行処理が他の言語と比べると圧倒的に当たり前に使われています。
チャネルやsyncパッケージ、contextパッケージなど標準で並行処理の仕組みが備わっており、さらにスレッドセーフであることに気をつけながら実装するなど多言語から来た人間には感動するものがありました。
一方で逐次処理で育った人間にとってはdeferやgoroutineなど、ソースコードを上から下に見ていけば良いだけではないという難しさも感じました。
またスレッドセーフなコードを書くことも難しく、テストを書くことの重要性をひしひしと感じました。
エンジニアとして
Goの標準パッケージのソースコードを読む経験は恥ずかしながら今回が初めてでした。
特にsync.Onceやsync.OnceFuncでは任意の関数を扱うためpanicなどの考慮も必要であると同時にスレッドセーフである必要もあります。
結果として短いながらもとても濃い内容のソースリーディングとなりました。
私の見てきた中で、ソースリーディングを行っている人は技術力が高い傾向にあると感じていました。
そういった意味でも今回のインターンでソースリーディングの読み方、またそれによってインセンティブがあることを身をもって体感し、エンジニアとしての階段を1つ登った感覚がありました。
ナレッジワークさんでの言い方をすると「できる喜び」を感じた部分になります。
謝辞
株式会社ナレッジワークの皆様、特にtenntennさんとnotogawaさんには大変お世話になりました。ありがとうございました!