LoginSignup
40
6

[Go] ゴルーチンの実例探し

Last updated at Posted at 2023-12-21

はじめに

本記事は 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 さんの記事になります。お楽しみに!!

参考

40
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
40
6