はじめに
こちらの記事は個人学習でGo言語の並行処理について学習をした際のアウトプットになります。
勉強する際に使用させていただいた、主な教材はこちらになります。
- 書籍
- 記事
-
Goでの並行処理を徹底解剖!
- こちらの記事は初心者の方でも、すごく読みやすいと思いますので是非一度ご覧いただきたいです。
-
Goでの並行処理を徹底解剖!
並行処理について
並行処理を行うことのメリットとデメリットについて
- メリット
- 複数のCPUに処理が渡されることで、実行時間が速くなる可能性がある。
- デメリット
- プログラムの実行される順番が分からない。
※以下の例のように処理の流れが決まっている場合は、実行時間が速くなる訳ではない。
例)
func1を実行
func1を実行した結果を使ってfunc2を実行
func2を実行した結果を使ってfunc3を実行
並行処理と並列処理の違いについて
- 並行処理
- ある時間の 範囲 において、複数のタスクを扱う
- 一度に多くの事を 扱う
- 並行性は ソースコード の性質
- 並列処理
- ある時間の 点 において、複数のタスクを扱う
- 一度に多くの事を 行う
- 並列性は 動作しているプログラム の性質
ProcessとThreadの違いについて
- Process(プロセス)
- 実行中のプログラムのこと
- 仕事の単位を表す概念
- Thread(スレッド)
- プロセス内で命令を逐次実行する部分、CPUコアを利用する単位のこと
- Processより小さい単位であり一つのプロセスにより所有、共通のメモリを参照
Goでの並行処理の話
Goの並行処理はライブラリを使用せず、言語自体に文法として組み込まれているものを使用するのが一般的。
代表的なものは以下の3つ
- Goroutine(ゴルーチン)
- channel(チャネル)
- select
Goroutineについて
関数やメソッドを呼び出す際に、「go」と前に記述することでゴルーチンが作成される
- 以下の書き方ではゴルーチンを実行させるためにtime.Sleepを使用しており、本来このような使い方はしない。
func main() {
fmt.Println("A")
go func() {
fmt.Println("ゴルーチンを実行")
}()
fmt.Println("B")
time.Sleep(time.Second) // ここでゴルーチンが実行される
fmt.Println("C")
}
実行結果
A
B
ゴルーチンを実行
C
- 以下の書き方では「ゴルーチンを実行」というメッセージは表示されない。
- ゴルーチンが作成されて実行される前に、main関数を抜けてプログラムを終了してしまうため
func main() {
go func() {
fmt.Println("ゴルーチンを実行")
}()
fmt.Println("A")
}
実行結果
A
- また以下の書き方ではバグが発生してしまう。
- ループごとに新しい変数が作られる訳ではなく、メモリ上にある変数の値が書き変えられて使いまわされるため。
- ゴルーチンが起動している間にループが終わってしまうので、変数を参照した時点では最後に実行されたループの変数しか受け取れない。(起動コストがかかってしまう。)
items := []int{1,2,3,4}
for _, i := range items {
go func() {
fmt.Println(i)
}()
}
time.Sleep(time.Second)
実行結果
4
4
4
4
対処法
- ブロックスコープで変数を代入し直す。
- Goではローカル変数の定義したときに、それが外部の関数やreturnで返された場合は、その変数が関数の寿命よりも長く生存するとみなされ、スタックではなくヒープにメモリを確保する。
- ゴルーチンとして定義されている無名関数は 個別の関数 として扱われるため、ループ処理ごとにメモリの確保が行われる。
items := []int{1, 2, 3, 4}
for _, i := range items {
i2 := i
go func() {
fmt.Printf("i2 = %d, address = %p\n", i2, &i2)
}()
}
time.Sleep(time.Second)
実行結果
i = 3, address = 0xc00001c038
i = 2, address = 0xc000180000
i = 4, address = 0xc00001c030
i = 1, address = 0xc000094000
- ゴルーチンの関数に引数をつける
- ループ変数を引数として渡し、この引数はゴルーチンのスタックメモリに積まれる。
- ヒープを使わない分、こちらの方がパフォーマンスは高い。
items := []int{1, 2, 3, 4}
for _, i := range items {
go func(i int) {
fmt.Printf("i = %d, address = %p\n", i, &i)
}(i)
}
time.Sleep(time.Second)
実行結果
i = 3, address = 0xc00001c038
i = 2, address = 0xc000180000
i = 4, address = 0xc00001c030
i = 1, address = 0xc000094000
スタックとヒープについて
- スタック(メモリ)
- 通常の変数の領域
- 関数実行の履歴、関数内部でのみ使われるデータを保持する確保が高速なメモリ領域のこと
- ヒープ
- アプリケーションが動的に確保する領域
- 確保されたメモリは任意のタイミングで破棄される
- Goでは関数の内と外で共有されるメモリの場合は、自動的にヒープに割り付けられる。
channelについて
- 異なるゴルーチン同士が特定の型の値を送受信することでやりとりする機構のこと
- チャネルは、チャネルオペレータの「<-」を用いて値の送受信ができる。
- チャネル作成について
- チャネルを通じて情報伝達したいデータの型とバッファサイズを設定
- バッファの指定を省略するとバッファなしになる。
チャネルの作成とクローズ
ch := make(chan int) // バッファなしのintチャネル
ch := make(chan int、 5) // バッファ量5のintチャネル
-
そもそもバッファとは
- 入出力の一部を一時的に保存する領域のこと
- Golangにおけるバッファは主にbyte型のスライス([]byte)で表現される。
-
チャネルの送受信の書き方
- 送信は1通りの書き方しかないが受信は複数の書き方がある。
// チャネルに送信
ch <- v // vをチャネルchへ送信する
// チャネルから受信
<-ch // 受信したことだけを検知する
r := <-ch // 受信したことを検知してデータを変数に入れる
r, ok := <-ch // 受信と共にチャネルの状態も変数に格納する
- 演算子を使わない方法でforループとの組み合わせでチャネルから受信する方法もある。
- スライスの場合は1つ目がインデックスで2つ目が実データであるが、チャネルの場合はインデックスは存在せず実データのみ。
- チャネルがクローズされるとループは終了される。
- クローズされるまでブロックし続ける。
ch := make(chan int)
go func() {
ch <- 1
ch <- 2
ch <- 3
close(ch) // クローズするとループ解除
}()
// チャネルに対してループ
for v := range ch {
fmt.Println(v)
}
実行結果
1
2
3
selectについて
- select文を使うことで、ゴルーチンは複数の操作を待機することができる。
- チャネルに値が入っていない場合は受信をブロックするため、ブロックせずに処理を行いたい場合に使う。
c1 := make(chan string)
c2 := make(chan string)
// どちらのチャネルも一定の時間が経てば値を受信する。
go func() {
time.Sleep(1 * time.Second)
c1 <- "A"
}()
go func() {
time.Sleep(1 * time.Second)
c2 <- "B"
}()
// c1,c2の値をどちらも非同期で受信し、届いた方から表示する。
for i := 0; i < 2; i++ {
select {
case msg1 := <-c1:
fmt.Println(msg1)
case msg2 := <-c2:
fmt.Println(msg2)
}
}
- 実行結果はAが先に表示されることもBが先に表示されることもある。
- c1またはc2のどちらが先に値が入るかは明確ではないため。
また似たようなものでsync.WaitGroupというのもある。
sync.WaitGroupについて
- 複数のゴルーチンが終了するのを待つもの。
- メインゴルーチンでAddメソッドを呼び、終了するために必要なゴルーチンの数を設定する。Waitメソッドで全てのゴルーチンが終了するまでブロックすることができる。
sync.WaitGroupの各メソッド
- Add
- 終了するまで待ちたいゴルーチンの数を指定
- ゴルーチンを起動する前に呼ぶ
- ゴルーチン起動までにWait()に到達すると、Add()呼ばれないまま終了する恐れがあるため。
- Done
- それぞれのゴルーチンを終了するときに呼ぶ
- Wait
- 全てのゴルーチンが終了するまで待つもの。(ブロック)
var wg sync.WaitGroup
wg.Add(3)
go func() {
fmt.Println("A")
wg.Done()
}()
go func() {
fmt.Println("B")
wg.Done()
}()
go func() {
fmt.Println("C")
wg.Done()
}()
wg.Wait()
fmt.Println("全部Doneされました")
実行結果
B
C
A
全部Done
終わり
ここまで、読んでいただきありがとうございました!
間違っている点などがございましたらコメントなどでご指摘ただいけると嬉しいです。