はじめまして!Schoo Advent Calendar 2024の22日目の記事を担当します、ブイと申します。
Schooでは、大学や専門学校向けにオンライン授業を提供するための学習管理プラットフォーム「Schoo Swing」を提供しています。私はSwingチームのエンジニアとして、主にGo言語を用いたシステム開発を担当しています。
本記事では、Goに関する技術的なトピックについて、Swingの開発経験を踏まえながらご紹介します。
はじめに
Go言語は、そのシンプルで読みやすいコードと高いパフォーマンスにより、特にウェブサーバー向けの開発で広く利用されています。その中でも、Goの大きな特徴であるGoroutineは、並行処理を簡単かつ効率的に実現できる仕組みとして注目されています。Goroutineを活用することで、軽量な並列処理や非同期処理を手軽に実装できます。
しかし、Goroutineの設計を誤ると、メモリリークやサーバークラッシュといった深刻な問題を招く恐れがあります。また、こうした問題はデバッグを困難にし、開発の負担を増大させる要因にもなり得ます。Goプログラムの開発では、Goroutineを使う場面を避けることはほぼ不可能ですが、その適切な利用には十分な経験と注意が必要です。
本記事では、Goの開発を通じて学んできた経験を基に、Goroutineを採用する際に知っておきたいポイントをご紹介します。これからGoを本格的に使う方や、既にGoでの開発を行っている方にとって参考になれば幸いです。
Goroutineの基本
Goroutineは、Goプログラムにおける基本的な並行処理の単位です。すべてのGoプログラムは、少なくとも1つのGoroutineを持っています。それが、プログラムの起動時に自動的に作成されるmain Goroutineです。
Goroutineは通常のOSスレッドに比べて軽量で、大量に生成してもシステムへの負担が少ないのが特徴です。また、使い方もシンプルで、関数の前にgoキーワードを付けるだけで新しいGoroutineを生成できます。
package main
import (
"fmt"
"time"
)
func say(message string) {
fmt.Println(message)
}
func main() {
// メインGoroutineでsay関数を実行
say("Hello from Main")
// Goroutineを使用してsay関数を並行実行
go say("Hello from Goroutine")
time.Sleep(1*time.Second)
}
$ go run main.go
Hello from Main
Hello from Goroutine
say関数を編集せずに、別のGoroutineで実行させることができますね。
では、この例で、time.Sleep(1*time.Second)はなぜ必要なのでしょうか?
Goroutineのライフサイクル
Goでは、fork-join model に基づいた並行処理を採用しています。
- Fork: 親の処理から分岐し、子の処理を並行して実行します。
- Join: 並行処理が終了し、再び親の処理と合流する点を指します。この合流地点はjoin pointと呼ばれます。
Goroutineのライフサイクルは以下の通り
- 生成:
go
キーワードを使用して新しいGoroutineを生成します。 - 実行: GoランタイムがGoroutineをスケジューリングして処理を実行します。
- 終了: Goroutineが関数の終了やreturn文に到達した時点でライフサイクルが終了します。
注意すべき点は、親のmain Goroutineが終了すると、その時点で未完了のGoroutineも強制終了されることです。
join pointの重要性
上記のコードでは、Hello from Goroutine
を出力するために time.Sleep(1 * time.Second)
を使用して、main Goroutineの終了を意図的に遅らせています。main Goroutineが終了すると、未完了のGoroutineもその時点で強制終了されるからです。
func main() {
// メインGoroutineでsay関数を実行
say("Hello from Main")
// Goroutineを使用してsay関数を並行実行
go say("Hello from Goroutine")
-- time.Sleep(1*time.Second)
}
Hello from Goroutine
が出力されなくなりましたね。
$ go run main.go
Hello from Main
syncパッケージでのJoin Pointの実装
未完了のGoroutineを確実に終了させるには、sync.WaitGroup
を使用してjoin pointを作成するのが一般的です。
修正版
func main() {
// メインGoroutineでsay関数を実行
say("Hello from Main")
-- go say("Hello from Goroutine")
++ var wg sync.WaitGroup
++ wg.Add(1)
++ go func() {
++ defer wg.Done()
++ say("Hello from Goroutine")
++ }()
++ wg.Wait() // join pointを作成
}
$ go run main.go
Hello from Main
Hello from Goroutine
Goroutineを安全に取り扱う
Goroutineはgo
キーワードで簡単に作成しますが、同時に危険もたくさん潜んでいます。
以下は、実際の例を用いながら、安全に設計するためのポイントを解説します。
以下のようなAPIサーバでのユーザー登録フローを考えます:
- APIサーバがユーザー登録リクエストを受け取る。
- ユーザー情報をデータベースに登録する。
- 登録後にアクティベーションメールを送信する
最低限のAPIサーバのコードです。
package main
import (
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
func main() {
engine := gin.New()
engine.POST("/users", postUsers)
log.Fatal(engine.Run(":8080"))
}
func postUsers(c *gin.Context) {
var param UserPayload
if err := c.ShouldBind(¶m); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
createdUser, err := doCreateUser(¶m)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, gin.H{"user": createdUser})
}
func doCreateUser(param *UserPayload) (*User, error) {
createdUser, err := insertUserToDB(param)
if err != nil {
return nil, err
}
if err := sendActivationMail(createdUser.Email); err != nil {
return nil, err
}
return createdUser, nil
}
func insertUserToDB(param *UserPayload) (*User, error) {
// insert user to db
return &User{Email: param.Email}, nil
}
func sendActivationMail(email string) error {
// send activation mail
time.Sleep(3 * time.Second)
return nil
}
type UserPayload struct{ Email string }
type User struct{ Email string }
$ curl -X POST -H "content-type:application/json" -d '{"Email":"nobody@test.com"}' localhost:8080/users
# 3秒後
{"user":{"Email":"nobody@test.com"}}
メール送信処理に時間がかかるため、APIのレスポンスが遅くなり、ユーザー体験が悪化します。
メール送信を非同期に処理することで、レスポンスタイムを短縮します。
func doCreateUser(param UserPayload) (*User, error) {
createdUser, err := insertUserToDB(param)
if err != nil {
return nil, err
}
++ go sendActivationMail(createdUser.Email)
return createdUser, nil
}
$ curl -X POST -H "content-type:application/json" -d '{"Email":"nobody@test.com"}' localhost:8080/users
{"user":{"Email":"nobody@test.com"}}
APIのレスポンスが即時に返されるようになりますね。
一見すると便利なのですが、問題点を見ていきましょう。
メモリリーク:Context活用し、キャンセルやタイムアウト実装
リークに関する説明はこのサンプルコードを参照してみてください。
例として、 sendActivationMail
は以下の実装であるとします。
func sendActivationMail(email string) error {
sentResult := make(chan error)
go doSendMail(sentResult)
return <-sentResult
}
-
<-sentResult
でチャネルから結果を受け取るまで待機します。 -
doSendMail
が何らかの理由で結果を送信しなかった場合、Goroutineが無限に待機状態となり、リソース(メモリやCPU)を消費し続けます。
このような状況を避けるために、一定期間で処理が完了しない場合はタイムアウトを設け、Goroutineを強制的に終了させるようにしましょう
func doCreateUser(param *UserPayload) (*User, error) {
createdUser, err := insertUserToDB(param)
if err != nil {
return nil, err
}
++ go func(email string) {
++ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
++ defer cancel()
++
++ if err := sendActivationMail(ctx, email); err != nil {
++ log.Printf("メール送信エラー: %v", err)
++ return
++ }
++ log.Printf("メール送信完了: %s", email)
++ }(createdUser.Email)
return createdUser, nil
}
func sendActivationMail(ctx context.Context, email string) error {
steps := []string{
"SMTPサーバーに接続しています...",
"メールデータを送信しています...",
"SMTPサーバーからの応答を待っています...",
}
for i, step := range steps {
select {
case <-ctx.Done():
fmt.Printf("キャンセルされました: %s (ステップ %d)", step, i+1)
return ctx.Err()
case <-time.After(2 * time.Second): // 各ステップに遅延をシミュレート
fmt.Printf("完了: %s", step)
}
}
return nil
}
$ curl -X POST -H "content-type:application/json" -d '{"Email":"nobody@test.com"}' localhost:8080/users
{"user":{"Email":"nobody@test.com"}}
設定した期限(5秒のタイムアウト)内に処理が完了しなかった場合や、異常な状態が発生した場合でも、Goroutineが確実に終了することを保証できます。
2024/12/16 14:50:27 完了: SMTPサーバーに接続しています...
2024/12/16 14:50:29 完了: メールデータを送信しています...
2024/12/16 14:50:30 キャンセルされました: SMTPサーバーからの応答を待っています... (ステップ 3)
2024/12/16 14:50:30 メール送信エラー: context deadline exceeded
Contextの活用方法については、弊社の佐藤が詳しく解説した記事がありますので、ぜひご覧ください。
サーバクラッシュ:panicの管理、Goroutine内でのリカバリの実装
Goでは、panicが発生すると、発生したGoroutineが異常終了します。panicがキャッチされない場合、最終的にはプログラム全体がクラッシュしてしまいます。
recoverを使用することで、クラッシュを回避しプログラムを維持することが可能です。
package main
import (
"fmt"
"time"
)
func riskyTask() {
fmt.Println("Starting risky task...")
time.Sleep(1 * time.Second)
panic("Something went wrong!")
}
func main() {
fmt.Println("Starting program...")
// safeGoroutineを使用してriskyTaskを実行
riskyTask()
// メインのGoroutineが終了しないように少し待機
time.Sleep(2 * time.Second)
fmt.Println("Program finished without crashing.")
}
Starting program...
Starting risky task...
panic: Something went wrong!
goroutine 1 [running]:
main.riskyTask()
/Users/chungtbui/Desktop/godemo/main.go:11 +0x70
main.main()
/Users/chungtbui/Desktop/godemo/main.go:18 +0x54
exit status 2
以下のようにrecoverをいれておけば、プログラムのクラッシュを防げます。
func main() {
fmt.Println("Starting program...")
++ defer func() {
++ if r := recover(); r != nil {
++ fmt.Printf("Recovered from panic: %v\n", r)
++ }
++ }()
Starting program...
Starting risky task...
Recovered from panic: Something went wrong!
先ほどのGinの例に戻り、以下のエンドポイントを追加してみましょう。
engine.GET("/panic", func(c *gin.Context) {
panic("panic!")
})
$ curl localhost:8080/panic -v
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> GET /panic HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
* Empty reply from server
* Closing connection
curl: (52) Empty reply from server
サーバはクラッシュし、レスポンスが返されません。
GinはRecoveryミドルウェアを提供しており、これを適用することで、エンドポイント内のpanicを自動的にキャッチし、サーバ全体のクラッシュを防げます。
engine.Use(gin.Recovery())
$ curl localhost:8080/panic -v
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> GET /panic HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 500 Internal Server Error
< Date: Mon, 16 Dec 2024 04:47:40 GMT
< Content-Length: 0
<
* Connection #0 to host localhost left intact
サーバは500 Internal Server Errorを返し、クラッシュすることなく次のリクエストを受け付けます。
では、アクティベーションメール送信のgoroutineでpanicが発生したらどうなるでしょうか?
func sendActivationMail(ctx context.Context, email string) error {
panic("unexpected error")
}
$ curl -X POST -H "content-type:application/json" -d '{"Email":"nobody@test.com"}' localhost:8080/users -v
Note: Unnecessary use of -X or --request, POST is already inferred.
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:8080...
* connect to ::1 port 8080 from ::1 port 52395 failed: Connection refused
* Trying 127.0.0.1:8080...
* connect to 127.0.0.1 port 8080 from 127.0.0.1 port 52396 failed: Connection refused
* Failed to connect to localhost port 8080 after 0 ms: Couldn't connect to server
* Closing connection
curl: (7) Failed to connect to localhost port 8080 after 0 ms: Couldn't connect to server
あれ、サーバ全体がクラッシュしてしまいました。以降のリクエストは一切受け付けられません。Recovery middlewareが適用されているはずなのに!
実はGoroutine内で発生したpanicは、そのGoroutine内でのみ影響を与えます。GinのRecoveryミドルウェアは、その適用範囲がGinのエンドポイントに限られるため、Goroutine内のpanicには効果がありません。
Goroutineを起動するたびにrecoverのテンプレートコードを毎回記述するのは面倒ですね。
その場合、以下のようなユーティリティ関数を用意しておくと便利でしょう!
func SafeGo(fn func) {
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("Recovered from panic: %v\n", r)
}
}()
f() // 実行する関数
}()
}
-- go func() {
-- sendActivationMail(ctx, "example@test.com")
-- }()
++ SafeGo(func() {
++ sendActivationMail(ctx, "example@test.com")
++ })
バッチ処理でメールが送信されない?Contextの重要性を再確認!
この時点でContextやPanic処理の経験を活かして、現時点のプログラムコードです。
func postUsers(c *gin.Context) {
var param UserPayload
if err := c.ShouldBind(¶m); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
createdUser, err := doCreateUser(c.Request.Context(), ¶m)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, gin.H{"user": createdUser})
}
func doCreateUser(ctx context.Context, param *UserPayload) (*User, error) {
createdUser, err := insertUserToDB(ctx, param)
if err != nil {
return nil, err
}
log.Printf("ユーザー登録完了: %v", createdUser.Email)
go func(email string) {
defer func() {
if r := recover(); r != nil {
fmt.Printf("Recovered from panic: %v\n", r)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := sendActivationMail(ctx, email); err != nil {
log.Printf("メール送信エラー: %v", err)
return
}
log.Printf("メール送信完了: %s", email)
}(createdUser.Email)
return createdUser, nil
}
func insertUserToDB(ctx context.Context, param *UserPayload) (*User, error) {
// insert user to db
return &User{Email: param.Email}, nil
}
func sendActivationMail(ctx context.Context, email string) error {
steps := []string{
"SMTPサーバーに接続しています...",
"メールデータを送信しています...",
"SMTPサーバーからの応答を待っています...",
}
for i, step := range steps {
select {
case <-ctx.Done():
log.Printf("キャンセルされました: %s (ステップ %d)", step, i+1)
return ctx.Err()
case <-time.After(1 * time.Second): // 各ステップに遅延をシミュレート
log.Printf("完了: %s", step)
}
}
return nil
}
他のシステム連携のために定期的に一括でユーザー登録を実施する必要が出てくることがあります。このようなケースでは、既存のユーザー登録フローを再利用したいところですね。
以下のプログラムを作ります。
func init() {
++ cmd := &cobra.Command{
++ Use: "runBatchCreateUsers",
++ RunE: runBatchCreateUsers,
++ }
++ rootCmd.AddCommand(cmd)
}
func runBatchCreateUsers(cmd *cobra.Command, args []string) error {
++ ctx := cmd.Context()
++ params, err := loadData(ctx) // どこかからユーザーデータをロードする
++ if err != nil {
++ return err
++ }
++
++ for _, param := range params {
++ _, err := doCreateUser(ctx, param)
++ if err != nil {
++ return err
++ }
++ }
++ return nil
}
このプログラム実行してみます
$ go run main.go runBatchCreateUsers
2024/12/16 15:24:03 ユーザー登録完了: 1@example.com
2024/12/16 15:24:03 ユーザー登録完了: 2@example.com
2024/12/16 15:24:03 ユーザー登録完了: 3@example.com
あれ?メール送信がされず、完了ログも出力されません。
ここで、先ほどのjoin pointの話を思い出してみましょう。Goroutineを起動しているにもかかわらず、main Goroutineに対してjoin pointを作成していないため、main Goroutineが終了すると同時に子Goroutineも終了してしまう、という説明をしましたね。
今回も同じ問題が発生しています。このプログラムでは、forループが終了するとmain Goroutineが終了し、それに伴いメール送信を担当する子Goroutineが途中で終了してしまいます。そのため、メールが送信されず、ログも出力されない状態となっています。
対策としてはさまざまな方法が考えられますが、今回のようにバッチ処理であれば、リアルタイム性(レスポンスタイム)を意識する必要がないため、メール送信を同期的に実行するのがシンプルとなります。
func doCreateUser(ctx context.Context, param *UserPayload) (*User, error) {
createdUser, err := insertUserToDB(ctx, param)
if err != nil {
return nil, err
}
log.Printf("ユーザー登録完了: %v", createdUser.Email)
email := createdUser.Email
if err := sendActivationMail(ctx, email); err != nil {
log.Printf("メール送信エラー: %v", err)
return nil, err
}
log.Printf("メール送信完了: %s", email)
return createdUser, nil
}
$ go run main.go runBatchCreateUsers
2024/12/16 15:33:58 ユーザー登録完了: 1@example.com
2024/12/16 15:34:01 メール送信完了: 1@example.com
2024/12/16 15:34:01 ユーザー登録完了: 2@example.com
2024/12/16 15:34:04 メール送信完了: 2@example.com
2024/12/16 15:34:04 ユーザー登録完了: 3@example.com
2024/12/16 15:34:07 メール送信完了: 3@example.com
このプログラムは正しく動作していますが、処理速度が遅いです。ユーザー1人の登録に約3秒かかり、10人分を登録すると30秒もかかってしまいます。処理速度を改善してみましょう。
func runBatchCreateUsers(cmd *cobra.Command, args []string) error {
...
++ var wg sync.WaitGroup
for _, param := range params {
++ wg.Add(1)
++ go func(param *UserPayload) {
++ defer wg.Done()
++ _, err := doCreateUser(ctx, param)
++ if err != nil {
++ log.Printf("ユーザー登録エラー: %v", err)
++ }
++ }(param)
}
++ wg.Wait()
return nil
}
$ go run main.go runBatchCreateUsers
2024/12/16 15:44:09 ユーザー登録完了: 10@example.com
2024/12/16 15:44:09 ユーザー登録完了: 2@example.com
2024/12/16 15:44:09 ユーザー登録完了: 8@example.com
2024/12/16 15:44:09 ユーザー登録完了: 4@example.com
2024/12/16 15:44:09 ユーザー登録完了: 6@example.com
2024/12/16 15:44:09 ユーザー登録完了: 5@example.com
2024/12/16 15:44:09 ユーザー登録完了: 3@example.com
2024/12/16 15:44:09 ユーザー登録完了: 7@example.com
2024/12/16 15:44:09 ユーザー登録完了: 9@example.com
2024/12/16 15:44:09 ユーザー登録完了: 1@example.com
2024/12/16 15:44:12 メール送信完了: 6@example.com
2024/12/16 15:44:12 メール送信完了: 3@example.com
2024/12/16 15:44:12 メール送信完了: 5@example.com
2024/12/16 15:44:12 メール送信完了: 2@example.com
2024/12/16 15:44:12 メール送信完了: 1@example.com
2024/12/16 15:44:12 メール送信完了: 4@example.com
2024/12/16 15:44:12 メール送信完了: 7@example.com
2024/12/16 15:44:12 メール送信完了: 10@example.com
2024/12/16 15:44:12 メール送信完了: 8@example.com
2024/12/16 15:44:12 メール送信完了: 9@example.com
一斉に全員分の処理を行うことで、処理時間が30秒(10人 × 3秒/人)から3秒に短縮されましたね。
しかし、この実装ではGoroutineを無制限に立ち上げてしまうため、メール送信やデータベースへの頻繁な読み書きが発生し、サーバーリソースが圧迫される可能性があります。
- 接続拒否やTooManyConnectionsエラーの発生
- サーバーのCPUやメモリの過剰使用によるリソース枯渇
- 等
Goroutineの数を適切に制御し、サーバーへの負荷を軽減する設計が必要です。
起動可能なGoroutine数を制限する
var wg sync.WaitGroup
++ const poolSize = 3
++ sem := make(chan struct{}, poolSize)
for _, param := range params {
++ sem <- struct{}{}
wg.Add(1)
go func(param *UserPayload) {
defer func() {
wg.Done()
++ <-sem
}()
sem
という補助チャンネルを用いて同時に起動可能なGoroutineの数をpoolSize(ここでは3)に制限しています。
その結果、全ての処理が完了するまでのリソース使用量を抑えることができます。
エラーの伝播
ユーザー登録の過程でエラーが発生しても、プログラムが終了コード 0 で終了してしまうと、全ての処理が完了したかどうかが分かりません。エラーを検知して適切に対処することができません。
Goroutineを扱う際には、エラーを正しく伝播させる仕組みを導入することが重要です。そうすることによって、プログラム全体の状態を把握し、必要な対策を講じることが可能になります。
func runBatchCreateUsers(cmd *cobra.Command, args []string) error {
...
++ type createResult struct {
++ user *User
++ err error
++ }
++ var mu sync.Mutex
++ results := make([]createResult, 0)
sem := make(chan struct{}, poolSize)
for _, param := range params {
sem <- struct{}{}
wg.Add(1)
go func(param *UserPayload) {
defer func() {
wg.Done()
<-sem
}()
user, err := doCreateUser(ctx, param)
if err != nil {
log.Printf("ユーザー登録エラー: %v", err)
}
++ mu.Lock()
++ results = append(results, createResult{user, err})
++ mu.Unlock()
}(param)
}
wg.Wait()
++ numErr := 0
++ numCreated := 0
++ for _, res := range results {
++ if res.err != nil {
++ numErr++
++ } else {
++ numCreated++
++ }
++ }
++ log.Printf("numCreated: %d, numErr: %d", numCreated, numErr)
++ if numErr > 0 {
++ return errors.New("登録に失敗したユーザーが存在します")
++ }
return nil
}
この実装のポイント
- エラーの集約: エラーと成功した処理をresultsスライスに記録し、後から集計。
- 並行処理の安全性: sync.Mutexを用いてresultsへのアクセスを排他制御。
- 最終的な結果のログ出力とエラーハンドリング
errgroupを使用する
sync.WaitGroupとsync.Mutexを手動で管理する代わりに、Go標準ライブラリのerrgroupを使用すると、より簡潔で安全な実装が可能です。errgroupは並行処理のエラー管理を簡単に行うためのライブラリで、エラーの伝播やGoroutineの終了待機が組み込まれています。
func runBatchCreateUsers(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
params, err := loadData() // load input data from somewhere
if err != nil {
return err
}
const poolSize = 10
g, ctx := errgroup.WithContext(ctx)
// 起動可能なGoroutine数を制限
g.SetLimit(poolSize)
for _, param := range params {
param := param
g.Go(func() error {
_, err := doCreateUser(ctx, param)
if err != nil {
return err
}
return nil
})
}
// 全てのGoroutineが終了するのを待機
if err := g.Wait(); err != nil {
return fmt.Errorf("登録に失敗したユーザーが存在します: %v", err)
}
return nil
}
おわりに
Goroutineを安全に利用するポイントをご紹介してきましたが、まだまだ書ききれないところがたくさんあります。ぜひ、参考にしていただき、実際のお仕事に役に立てていただけると嬉しいです。
- contextの活用:goroutineを使っても使わなくても必ずcontextを第一引数に、タイムアウトやキャンセルを備えておこう。
- panicのリカバリ:Goroutine内でpanicが起きたら、recoverで対応を忘れずに。
- 同時実行数の制御:Goroutineは無制限に起動しないようにしよう。
- errgroupの活用:並行処理のエラー管理やキャンセル処理を楽にする。
Schooでは一緒に働く仲間を募集しています!