💥

レースコンディションについて

2024/03/22に公開

はじめに

今回は、マルチスレッドプログラミングにおけるレースコンディション問題についてです。

プログラマやエンジニアならば一度はこの問題を見聞きしたことがあるのではないでしょうか。

説明のために簡略化されたレースコンディションのコードはよく見るし、その時は納得するのだけど、、

実際の業務アプリケーションを書くときに意識できるのか、、、

コードレビューを担当する際には問題を見つけることができるのか、、、、

サンプルアプリケーションコード

なるべく小さく、でも実際の業務アプリケーションに近いようなサンプルコードを用意しました。

以下は問題のあるサンプルコードです。どこに問題があるのでしょうか。

main.go
package main

import (
	// (省略)
	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google"
	"google.golang.org/api/calendar/v3"
	"google.golang.org/api/option"
)

// Retrieves a token from a local file.
func tokenFromFile(file string) (*oauth2.Token, error) {
	f, err := os.Open(file)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	tok := &oauth2.Token{}
	err = json.NewDecoder(f).Decode(tok)
	return tok, err
}

// Worker
// キューから予定を取り出してGoogleカレンダーに登録する
type Worker struct {
	srv *calendar.Service
}

func (w *Worker) setService(config *oauth2.Config, userName string) error {
	ctx := context.Background()
	tokFile := filepath.Join("internal", "app", fmt.Sprintf("%s-google-token.json", userName))
	tok, err := tokenFromFile(tokFile)
	if err != nil {
		return err
	}

	client := config.Client(context.Background(), tok)
	srv, err := calendar.NewService(ctx, option.WithHTTPClient(client))
	if err != nil {
		return err
	}

	w.srv = srv
	return nil
}

var config *oauth2.Config

func (w *Worker) Start() {
	for event := range queue {
		go func(evt *Event) {
			err := w.setService(config, evt.userId)
			if err != nil {
				log.Fatalf("Unable to retrieve calendar Client %v", err)
			}

			// なんらかの時間のかかる処理
			time.Sleep(1 * time.Second)

			calendarId := "primary"
			e, err := w.srv.Events.Insert(calendarId, evt.Event).Do()
			if err != nil {
				log.Fatalf("Unable to create event. %v\n", err)
			}

			fmt.Printf("Event created: %s\n", e.HtmlLink)
		}(event)
	}
}

type Event struct {
	Event  *calendar.Event
	userId string
}

var queue = make(chan *Event, 10)
var events = []*Event{
	{
		Event: &calendar.Event{
			Summary: "User1-くまさん",
			Start: &calendar.EventDateTime{
				DateTime: "2024-03-15T09:00:00+09:00",
				TimeZone: "Asia/Tokyo",
			},
			End: &calendar.EventDateTime{
				DateTime: "2024-03-15T10:00:00+09:00",
				TimeZone: "Asia/Tokyo",
			},
			ColorId: "1",
		},
		userId: "user1",
	},
	{
		Event: &calendar.Event{
			Summary: "User2-うさぎさん",
			Start: &calendar.EventDateTime{
				DateTime: "2024-03-15T09:00:00+09:00",
				TimeZone: "Asia/Tokyo",
			},
			End: &calendar.EventDateTime{
				DateTime: "2024-03-15T10:00:00+09:00",
				TimeZone: "Asia/Tokyo",
			},
			ColorId: "2",
		},
		userId: "user2",
	},
}

func main() {
	// OAuthクライアントの設定
	b, err := os.ReadFile(filepath.Join("configs", "credentials.json"))
	if err != nil {
		log.Fatalf("Unable to read client secret file: %v", err)
	}
	config, err = google.ConfigFromJSON(b,
		calendar.CalendarScope,
	)
	if err != nil {
		log.Fatalf("Unable to parse client secret file to config: %v", err)
	}

	// キューにイベントを登録
	for _, event := range events {
		queue <- event
	}
	close(queue)

	// Workerを作成
	workerCount := 1
	for i := 0; i < workerCount; i++ {
		w := &Worker{}
		go w.Start()
	}

	time.Sleep(10 * time.Second)
}

なにが問題なのか

お気づきでしょうか。

WorkerはフィールドにGoogleカレンダーサービスを持っています。

// Worker
// キューから予定を取り出してGoogleカレンダーに登録する
type Worker struct {
	srv *calendar.Service
}

Workerはキューからメッセージを取り出してメッセージごとにgoroutineを生成しています。

func (w *Worker) Start() {
	for event := range queue {
		go func(evt *Event) {
			err := w.setService(config, evt.userId) // 🌟この部分!!
			if err != nil {
				log.Fatalf("Unable to retrieve calendar Client %v", err)
			}

			// なんらかの時間のかかる処理
			time.Sleep(1 * time.Second)

...

Worker : Googleカレンダーサービス : goroutine = 1 : 1 : N という状態です。

Workerがメッセージを処理するためにgoroutine1を生成します。(goroutine1はuser1のメッセージを処理する、とする)

Workerはgoroutine1が処理中に、goroutine2を生成します。(goroutine2はuser2のメッセージを処理する、とする)

まだ、goroutine1はGoogleカレンダーAPIへのリクエストを完了させていません。

そして、goroutine2はgoroutine1が処理しているuser1とは別のuser2用にサービスをセット(Token)してまったら、、、

user1の予定が見ず知らずのuser2のカレンダーに登録されてしまいました...!!😰


user2のGoogleカレンダー

問題を修正する

修正方法はいくつかあると思いますが、
1goroutineに1Googleカレンダーサービスを専属であてがうように修正しました。

これによりgoroutine間でGoogleカレンダーサービスを使いまわすことがなくなり競合は解消されました。

main.go
...
 // Worker
 // キューから予定を取り出してGoogleカレンダーに登録する
 type Worker struct {
-       srv *calendar.Service
 }

-func (w *Worker) setService(config *oauth2.Config, userName string) error {
+func (w *Worker) getService(config *oauth2.Config, userName string) (*calendar.Service, error) {
        ctx := context.Background()
        tokFile := filepath.Join("internal", "app", fmt.Sprintf("%s-google-token.json", userName))
        tok, err := tokenFromFile(tokFile)
        if err != nil {
-               return err
+               return nil, err
        }

-       client := config.Client(context.Background(), tok)
+       client := config.Client(ctx, tok)
        srv, err := calendar.NewService(ctx, option.WithHTTPClient(client))
        if err != nil {
-               return err
+               return nil, err
        }

-       w.srv = srv
-       return nil
+       return srv, nil
 }

 var config *oauth2.Config

 func (w *Worker) Start() {
        for event := range queue {
                go func(evt *Event) {
-                       err := w.setService(config, evt.userId)
+                       srv, err := w.getService(config, evt.userId)
                        if err != nil {
	                        log.Fatalf("Unable to retrieve calendar Client %v", err)
                        }

						// なんらかの時間のかかる処理
                        time.Sleep(1 * time.Second)

                        calendarId := "primary"
-                       e, err := w.srv.Events.Insert(calendarId, evt.Event).Do()
+                       e, err := srv.Events.Insert(calendarId, evt.Event).Do()
                        if err != nil {
                                log.Fatalf("Unable to create event. %v\n", err)
                        }

問題に気づくには・・

じつはGo言語にはデータ競合を発見する機能 (Race Detector) があります。
go buildgo run 実行時に -race オプションをつけるだけで警告してくれます。
ぜひ、CIに組み込んでおきたいですね!

修正前の問題のコードを -race オプションつきで実行してみます。

go run -race ./cmd/insert-google-calendar-events/main.go
==================
WARNING: DATA RACE
Write at 0x00c00011a870 by goroutine 9:
  main.(*Worker).setService()
      /home/rshmz/go/src/github.com/rshmz/google-calendar-app/cmd/insert-google-calendar-events/main.go:50 +0x178
  main.(*Worker).Start.func1()
      /home/rshmz/go/src/github.com/rshmz/google-calendar-app/cmd/insert-google-calendar-events/main.go:59 +0x8e
  main.(*Worker).Start.func2()
      /home/rshmz/go/src/github.com/rshmz/google-calendar-app/cmd/insert-google-calendar-events/main.go:72 +0x41

Previous write at 0x00c00011a870 by goroutine 10:
  main.(*Worker).setService()
      /home/rshmz/go/src/github.com/rshmz/google-calendar-app/cmd/insert-google-calendar-events/main.go:50 +0x178
  main.(*Worker).Start.func1()
      /home/rshmz/go/src/github.com/rshmz/google-calendar-app/cmd/insert-google-calendar-events/main.go:59 +0x8e
  main.(*Worker).Start.func2()
      /home/rshmz/go/src/github.com/rshmz/google-calendar-app/cmd/insert-google-calendar-events/main.go:72 +0x41

Goroutine 9 (running) created at:
  main.(*Worker).Start()
      /home/rshmz/go/src/github.com/rshmz/google-calendar-app/cmd/insert-google-calendar-events/main.go:58 +0x50
  main.main.func1()
      /home/rshmz/go/src/github.com/rshmz/google-calendar-app/cmd/insert-google-calendar-events/main.go:138 +0x33

Goroutine 10 (running) created at:
  main.(*Worker).Start()
      /home/rshmz/go/src/github.com/rshmz/google-calendar-app/cmd/insert-google-calendar-events/main.go:58 +0x50
  main.main.func1()
      /home/rshmz/go/src/github.com/rshmz/google-calendar-app/cmd/insert-google-calendar-events/main.go:138 +0x33
==================

...

競合の発生箇所、競合の書き込みをしたgoroutineとその生成箇所を警告してくれます。
素敵ですね😊

参考

今回は以下のGoogleカレンダーAPI Go クイックスタートを参考にサンプルアプリを作成しました。

https://developers.google.com/calendar/api/quickstart/go?hl=ja

簡易的ではありましたが、なるべく実際の業務アプリケーションに近い形にすることで注意を促せたらと思っています。
もし仮に本番にリリースされてしまったら・・・考えるだけでおそろしいですね😰

サンプルアプリ

https://github.com/rshmz/google-calendar-app

サンプルアプリケーション構成

.
|-- cmd
|   |-- insert-google-calendar-events
|   |   `-- main.go
|   `-- save-google-account-token
|       `-- main.go
|-- configs
|   `-- credentials.json
|-- go.mod
|-- go.sum
`-- internal
    `-- app
        |-- user1-google-token.json
        `-- user2-google-token.json
  • cmd/insert-google-calendar-events/main.go

    Googleカレンダーに予定を登録するワーカーです.

    ※ main.go 内のダミーEvent.userIdはトークン保存時に指定したユーザ名で書き換えます.

    $ go run ./cmd/insert-google-calendar-events/main.go
    
  • cmd/save-google-account-token/main.go

    Googleカレンダーへ予定を登録するためのtokenを取得し保存します.

    実行するとユーザへGoogleカレンダーの書き込み権限を求める認可画面URLがコンソールに表示されます.

    手順に従い、得た認可コードをコンソールに入力しEnter押下でtokenが保存されます.

    $ go run ./cmd/save-google-account-token/main.go -user=user1
    
  • configs/credentials.json

    OAuthクライアントアプリのclient_idとclient_secretです.

  • internal/app/user_name-google-token.json

    リソースオーナー(user)のトークンです.

ソーシャルデータバンク テックブログ

Discussion