はじめに
本記事は RetailAI Adventurers Advent Calendar 2023 の22日目の記事です。
昨日は、絶賛キーボード沼にハマっている?(詳しくは昨日の記事を参照) satoshihiraishi さんの Quarkus+CloudRunで作る爆速アプリケーション という記事でした。まだ見ていない方は是非ご覧ください!♡もよろしくお願いします!!
キーボード、自分にとってベストなものを探すのはとても難しいですよね。。
ちなみに私は少し前に分割キーボードに切り替えまして、現在は BAROCCO MD770 を使っています。皆さんのおすすめがあれば是非教えて下さい!
何の記事?
この記事では、Go のゴルーチンについて書いていきます。
私は Retail AI という会社でバックエンドエンジニアをしています。弊社ではバックエンドの言語として Go を採用することが比較的多いので、自然と Go に触れる機会は多いです。しかしこれまで、この言語の大きな特徴の一つであるゴルーチンを、実務で使う機会は決して多くはなかったような気がします。もちろんゼロというわけではないですが、これは Go を利用する開発者としていかがなものかと、ふと思いました。そこで、今回は標準ライブラリやオープンソースのコードを読んで、実例を探していくことにしました。そうすることで、今後自分がゴルーチンを使うときの参考になりますし、使った方が良い場面に気づきやすくなるかもしれません。
それでは早速やっていきます。
※ 以下の内容は Go 言語の基礎を知っていることを前提としています。
環境
- macOS: Sonoma 14.2(M1)
- Go: 1.21.5
その前にゴルーチンとは?
念の為、ゴルーチンについて振り返ります。
ゴルーチンとは、Go のランタイムが管理する軽量のスレッドのようなものです。OS が管理するスレッドより軽量で、生成にかかるコストが小さく、さほどリソースを気にせずに扱えることが特徴です。ゴルーチンは関数を独立して実行するような書き方をして生成し、並行処理(Concurrency)を実現します。実行環境がマルチコアであれば、Go のランタイムが必要に応じてゴルーチンを別のコアに割り当てるため、ある種、並列(Parallel)であるとも言えます。
ちなみに、Go での並行と並列に関しては、一昔前のものになりますが、Rob Pike 氏が2012年の講演で説明しています。興味のある方はこちらもご参照ください。
さて、まずはゴルーチン自体の確認として、簡単なプログラムを書いてみます。
ただ並行に Hello
と書き出すだけです。分かりやすさのために番号も振ってみます。
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(workerNum int, wg *sync.WaitGroup) {
fmt.Printf("[goroutine %d] Hello\n", workerNum)
wg.Done()
}(i, &wg)
}
wg.Wait()
}
結果
❯ go run .
[goroutine 3] Hello
[goroutine 2] Hello
[goroutine 6] Hello
[goroutine 4] Hello
[goroutine 5] Hello
[goroutine 7] Hello
[goroutine 8] Hello
[goroutine 9] Hello
[goroutine 1] Hello
[goroutine 0] Hello
順番がバラバラになっており、それっぽい結果になりました。想定通りに動作していそうです。
では、ゴルーチンの復習はこれくらいにして、実例探しに移ります。
実例1: http パッケージ
ゴルーチンの実例を探す際に真っ先に浮かんだのは、標準の http パッケージです。
Go を学んだ全員が利用経験があると言っても過言ではないでしょう。
Go でサーバーを立てると、裏では多くのゴルーチンを生成してリクエストを処理していると聞きますね。
まずは Go で HTTP サーバーを起動する簡易的なコードを書いてみます。Hello, world!
と返すだけのサーバーです。入門用の学習教材で出てきそうですね。
package main
import "net/http"
func helloHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, world!"))
}
func main() {
http.HandleFunc("/hello", helloHandler)
if err := http.ListenAndServe(":8080", nil); err != nil {
panic(err)
}
}
このコードの http.ListenAndServe
関数から見ていきます。
この関数の中では、http.Server
構造体のポインタ型のメソッドである ListenAndServe
メソッドを呼び、さらにその中で Serve
メソッドを呼んでいます。
func (srv *Server) ListenAndServe() error {
if srv.shuttingDown() {
return ErrServerClosed
}
addr := srv.Addr
if addr == "" {
addr = ":http"
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return srv.Serve(ln) // <--- ここ
}
そのメソッドの中身を見ていくと...ありました!!go
キーワードです!!
コネクションごとにゴルーチンを作っているようです。
func (srv *Server) Serve(l net.Listener) error {
...
for {
rw, err := l.Accept()
...
tempDelay = 0
c := srv.newConn(rw)
c.setState(c.rwc, StateNew, runHooks)
go c.serve(connCtx) // <--- ここ
}
}
この serve
メソッドの中で色々やっているのですが、その中には、慣れ親しんだ ServeHTTP
メソッドの呼び出しがあります。そこでは、マルチプレクサを自分で用意していればそれを使い、していなければ DefaultServeMux
を使ってリクエストに対応するハンドラでリクエストを処理する、という流れです。
下記は実際の ServeHTTP
メソッドの中身です。
func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
handler := sh.srv.Handler
if handler == nil {
handler = DefaultServeMux
}
if !sh.srv.DisableGeneralOptionsHandler && req.RequestURI == "*" && req.Method == "OPTIONS" {
handler = globalOptionsHandler{}
}
handler.ServeHTTP(rw, req)
}
ここまでで、Go の HTTP サーバーの裏ではコネクションごとにゴルーチンを生成し、ハンドラでリクエストを処理していることを実際に確認できました。当然と言えば当然ですが、サーバーとしてはある一つのリクエストに対して対応している間、他のリクエストを受け付けられない状態になんてできないので、ここはゴルーチンが使われるべき場面、と言えそうですね。
実例2: uber-go/zap
次に浮かんだのはロギングライブラリです。ロギングでは効率化のため、都度ログを書き込むのではなく、一定以上溜めてからまとめて書く、バッチのような処理をすることも多いと思います。そのため、そこでゴルーチンを使っているのでは? と考えられます。今回実際に見てみるのは uber-go/zap
とします。こちらは見た目から分かる通り、uber さんが作ったオープンソースのロギングモジュールです。
この zap の低レベルのインターフェースを提供するモジュールである zapcore のドキュメントを眺めていると、BufferedWriteSyncer というそれっぽい型を見つけました。使い方を見つつ、サンプルコードを書いてみます。
package main
import (
"os"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func main() {
// ログファイルのセットアップ
file, err := os.Create("sample.log")
if err != nil {
panic(err)
}
defer file.Close()
// BufferedWriteSyncer の設定
bws := &zapcore.BufferedWriteSyncer{
WS: zapcore.AddSync(file),
Size: 512 * 1024,
FlushInterval: time.Minute,
}
defer bws.Stop()
// エンコーダーの設定
encoderConfig := zap.NewProductionEncoderConfig()
encoder := zapcore.NewJSONEncoder(encoderConfig)
// ロガーの作成
core := zapcore.NewCore(encoder, bws, zap.InfoLevel)
logger := zap.New(core)
// ログを書く
logger.Info("This is an info message")
logger.Error("This is an error message")
// 同期する
logger.Sync()
}
このコードを実行すると、指定したログが追加された sample.log
というファイルが出力されます。
❯ cat sample.log
{"level":"info","ts":1702981473.913003,"msg":"This is an info message"}
{"level":"error","ts":1702981473.9131038,"msg":"This is an error message"}
では、このコードをもう少し掘り下げてみます。最初にログを書き込んでいる部分に着目します。
logger.Info("This is an info message")
そうです、この部分です。この中で呼んでいる zapcore.CheckedEntry
型のポインタの Write
メソッドを見ると、下記のコードがあります。
for i := range ce.cores {
err = multierr.Append(err, ce.cores[i].Write(ce.Entry, fields))
}
なにやら今度は zapcore.ioCore
型のポインタの Write
メソッドを繰り返しコールし、エラーが出ないか確認しているようです。さらにこのメソッドの中でようやく BufferedWriteSyncer
型が登場します。下記はそのポインタ型の Write
メソッドです。
func (s *BufferedWriteSyncer) Write(bs []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if !s.initialized {
s.initialize()
}
...
}
initialize
というメソッドが呼ばれており、怪しいです。
そしてこのメソッドの中身はというと...
func (s *BufferedWriteSyncer) initialize() {
...
s.initialized = true
go s.flushLoop()
}
ありました!! go
キーワードです!(長かったですね)
そのゴルーチン上で実行されている flushLoop
メソッドは以下になります。
func (s *BufferedWriteSyncer) flushLoop() {
defer close(s.done)
for {
select {
case <-s.ticker.C:
// we just simply ignore error here
// because the underlying bufio writer stores any errors
// and we return any error from Sync() as part of the close
_ = s.Sync()
case <-s.stop:
return
}
}
}
time.Ticker
型を使い、定期的に同期処理を実行しています。
ここで使われる間隔の値は、最初に書いたコードの初期化部分で指定しています。
今回は1分を設定していました。
// BufferedWriteSyncer の設定
bws := &zapcore.BufferedWriteSyncer{
WS: zapcore.AddSync(file),
Size: 512 * 1024,
FlushInterval: time.Minute, // <--- ここ
}
念の為、少しコードをいじって試したところ、ちゃんと設定通り、1分間隔で同期されました。
このようにロギングのような、一定量のデータを集めてまとめて一度に処理したい、といったケースでゴルーチンは役立ちそうです。ロギング以外にも用途はたくさんありそうですね。
さいごに
今回はゴルーチンへの理解を深めるべく、実例を追ってみました。Go を使っているが、ゴルーチンには正直あまり馴染みがない、といった方に、少しでも参考になれば嬉しいです。もちろん、ゴルーチンの利用例は他にもたくさんあるはずなので、気になる方はご自身でも探してみてください。
明日は takudooon さんの記事になります。お楽しみに!!