Go勉強会(ゴルーチン・チャネル)のふりかえりと活用事例の紹介

はじめまして。フクロウラボでサーバサイドを担当しています渋谷と申します。よろしくお願いいたします。

はじめに

今回は、Go勉強会で学習した内容「ゴルーチン・チャネル」をふりかえりつつ、弊社での活用事例を紹介したいと思います。

フクロウラボでは @tenntenn さんの「Goハンズオン - ガチャを作ろう」を題材に、Go勉強会を毎週開催しています。2/17(木)と2/24(木)にSection 10: 並行処理」学びました。

@tenntenn さんありがとうございました🙇‍♂️

※注1:資料を使った講義等を行う場合は事前に@tenntennに許可を得る必要があります。

勉強会の様子は下記をご覧下さい。

Go勉強会のふりかえり

並行処理と並列処理について

曖昧になりやすいですが、並行処理と並列処理は別ものです。
これは Rob Pike 氏(Go言語の父)のBlogに記載されています。

In programming, concurrency is the composition of independently executing processes, while parallelism is the simultaneous execution of (possibly related) computations. Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.

引用:Concurrency is not parallelism

訳)
プログラミングでは、並行処理は独立して実行されるプロセスの合成であり、並列処理は(おそらく関連する)計算を同時に実行することである。並行処理とは、一度に多くのことを処理することです。並列処理とは、一度にたくさんのことを行うことです。

ふむふむ?!
「Go 言語による並行処理」本の一文を見てみましょう。

並行性はコードの性質を指し、並列性は動作しているプログラムの性質を指します。

引用:Go言語による並行処理

イメージ図がわかりやすいですね。

Let’s take a multi-threaded application as an example. The separation of the application into threads defines its concurrent model. The mapping of these threads on the available cores defines its level or parallelism. A concurrent system may run efficiently on a single processor, in which case it is not parallel.

引用(画像含む):Overview of Modern Concurrency and Parallelism Concepts

並行処理でコードを書いても、動作環境(左図:単一プロセッサ)によっては、並列に処理できないということですね。
今回は触れませんが、「並行処理の難しさ」についても別の機会で深掘りできればと思います。

ゴルーチンについて

A Tour of Go の説明と、言語仕様を見てみましょう。

A goroutine is a lightweight thread managed by the Go runtime.

引用:https://go.dev/tour/concurrency/1

訳)
ゴルーチンはGoランタイムによって管理される軽量なスレッドです。

A "go" statement starts the execution of a function call as an independent concurrent thread of control, or goroutine, within the same address space.

引用:https://golang.org/ref/spec#Go_statements

訳)
go文は、関数コールの実行を、同じアドレス空間内の独立した同時実行の制御スレッド(goroutine)として開始します。

ふむふむ。
A Tour of Goのコードを確認してみましょう。

注2:for文の実行回数を5から2に変更しています。

package main

import (
	"fmt"
	"time"
)

func say(s string) {
	for i := 0; i < 2; i++ {
		time.Sleep(100 * time.Millisecond)
		fmt.Println(s)
	}
}

func main() {
	go say("world")
	say("hello")
}

ソース:https://go.dev/tour/concurrency/1

[結果]

// 実行結果(1回目)
world
hello
world
hello

// 実行結果(2回目)
hello
world
world
hello

// 実行結果(3回目)
hello
world
hello

「hello」と「world」の出力順番が違います。また実行タイミングによって「world」が出力されないことがあります。

ゴルーチンには下記特徴があります。

  • 処理順序が保証されない

  • メインゴルーチン(main)が終わると他のゴルーチンの終了を待たずにプログラム全体が終了する

後者ですが「sync.WaitGroup」を使用することで、待ち合わせすることができます。

sync.WaitGroup構造体は、内部にカウンタを持っており、初期化時点でカウンタの値は0です。
ここでは以下のように設定しています。

1.  sync.WaitGroup構造体wgを用意する
2. wg.Add(1)で、wgの内部カウンタの値を+1する
3. defer wg.Done()で、ゴールーチンが終了したときにwgの内部カウンタの値を-1するように設定
4. wg.Wait()で、wgの内部カウンタが0になるまでメインゴールーチンをブロックして待つ

引用:ゴールーチンとチャネル - ゴールーチンの待ち合わせ
package main

import (
	"fmt"
	"sync"
	"time"
)

func say(s string) {
	for i := 0; i < 2; i++ {
		time.Sleep(100 * time.Millisecond)
		fmt.Println(s)
	}
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1)

	go func() {
		defer wg.Done()
	        say("world")
	}()

	say("hello")
	wg.Wait()
}

[結果]

// 実行結果(1回目)
hello
world
world
hello

// 実行結果(2回目)
hello
world
world
hello

// 実行結果(3回目)
world
hello
hello
world

「world」が必ず出力されるようになりました。

チャネルについて

言語仕様を見てみましょう。さき(H.Saki)さんの記事もわかりやすかったため、合わせて記載します。

A channel provides a mechanism for concurrently executing functions to communicate by sending and receiving values of a specified element type.

引用:https://go.dev/ref/spec#Channel_types

訳)
チャネルは、同時に実行される関数が、指定された要素型の値を送受信することによって通信するための機構を提供します。

チャネルは「異なるゴルーチン同士が、特定の型の値を送受信することでやりとりする機構」であるということです。

引用:ゴールーチンとチャネル(チャネル - 定義)

こちらも A Tour of Goのコードで確認してみましょう。

注3:デバッグのため「fmt.Println」を追加しています。

package main

import "fmt"

func sum(s []int, c chan int) {
	sum := 0
	for _, v := range s {
		sum += v
	}
	c <- sum // send sum to c
}

func main() {
	s := []int{7, 2, 8, -9, 4, 0}

	c := make(chan int)
	fmt.Println(len(s))
	fmt.Println(s[:len(s)/2])
	fmt.Println(s[len(s)/2:])

	go sum(s[:len(s)/2], c)
	go sum(s[len(s)/2:], c)
	x, y := <-c, <-c // receive from c

	fmt.Println(x, y, x+y)
}

ソース:https://go.dev/tour/concurrency/2

[結果]

// 配列の要素数
6
// 配列の中身(先頭〜中央)
[7 2 8]
// 配列の中身(中央〜後尾)
[-9 4 0]
// チャネルで受け取った値、合計値
-5 17 12

int 配列の数値を、別々のゴルーチン(先頭〜中央、中央〜後尾)で合算し、チャネルを使用して合算値をメインゴルーチンで受け取っています。異なるゴルーチン同士で、値の送受信ができていますね。

弊社での活用事例

お待たせしました!
それでは弊社での活用事例を紹介します。弊社では、Circuit Xの一斉メール送信機能で活用しています。

Circuit Xのプロダクトについては下記をご覧下さい。

概要図

説明

  • EC2(ダッシュボード)で一斉メール送信

  • メール情報(送信者、本文など)をRDSに実行待ちキューとして保存

  • EC2からECS(メール送信バッチ)へ起動要求

  • ECS(メール送信バッチ)がAmazon SES経由でメールを送信 ←★

ここ(★)でゴルーチン(sync.WaitGroup)・チャネルを使い、並行処理でメールを送信しています。

当時の課題

  • 移行前はダッシュボード(Rails)から直接メールを送信していた

  • メディア数の増加に伴いAppサーバ(Unicorn)のタイムアウトがたびたび発生

  • 一斉メール送信機能が使用できない状態へ

ECS(Fargate)+ Go の選定理由

アーキテクチャ検討会の様子

CTOの若杉さんと検討しました(2019年10月下旬)

最後に

一斉メール送信機能は、私が入社して3ヶ月目に担当しました。(関連するダッシュボードの改修はもう一人のメンバが担当してくれました。)
当時、ECSもGoもわからない私でしたが、先輩方がサポートして下さったおかげでなんとかリリースすることができました。
フクロウラボには、Goはもちろん、新しいことにチャレンジできる風土、切磋琢磨して成長できる環境があります。
まだまだ弱々Gopherですが、世界のGopherの皆さまと開発ができるよう、引き続き、自己研磨していきたいと思います。

Enjoy development !

この記事が気に入ったらサポートをしてみませんか?