
前回のおさらい
前回は、構造体とインターフェースについて学びました。今回は、第1回でも少し触れた、Goの大きな特徴の1つ「同時にたくさんの作業を進める仕組み」について学びます。この仕組みのことを**並行処理(へいこうしょり)**と呼びます。
「同時に処理する」ってどういうこと?
これまで書いてきたプログラムは、すべて「上から順番に、1つずつ」処理が進んでいました。たとえば、次のようなプログラムです。
task1()
task2()
task3()
これは、task1が終わってからtask2が始まり、task2が終わってからtask3が始まる、という流れです。1人の店員さんが、お客さんを1人ずつ順番に対応しているようなイメージです。
しかし、世の中のプログラムには、「複数のお客さんからのリクエストを、同時に処理したい」というものがたくさんあります。たとえば、あるWebサービスに、100人が同時にアクセスしてきたとします。1人ずつ順番に対応していたら、最後の人はとても長い時間待たされてしまいますよね。
そこで、「複数のレジを開けて、複数の店員さんで同時にお客さんに対応する」ように、プログラムでも複数の処理を同時に進める仕組みが必要になります。これが並行処理です。
Goは、この並行処理をとても書きやすく設計されている言語です。その中心となるのが、これから紹介するゴルーチンとチャネルという2つの仕組みです。
ゴルーチンってなに?
ゴルーチン(Goroutine) とは、ひとことで言うと 「Goにおける、軽量な処理の実行単位」 です。「新しく店員さんを1人増やして、その処理を任せる」ようなイメージだと考えてください。
普通に関数を呼び出すと、その処理が終わるまで、次の行には進みません。
func main() {
task1() // task1が終わるまで、ここで待つ
task2() // その後にtask2が実行される
}
一方、関数を呼び出すときに go というキーワードを頭につけると、その処理は「別の店員さん(ゴルーチン)」に任されて、main関数の処理はそれを待たずに次の行へ進みます。
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("こんにちは!")
}
func main() {
go sayHello()
fmt.Println("メイン処理です")
time.Sleep(1 * time.Second)
}
実行結果は、次のようになることが多いです(実行するたびに、順番が変わることもあります)。
メイン処理です
こんにちは!
go sayHello() の部分がポイントです。「sayHello関数を、新しいゴルーチン(別の店員さん)に任せて、実行を開始してください」という意味です。main関数は、sayHelloの完了を待たずに、すぐ次の行(fmt.Println("メイン処理です"))に進みます。
そのため、実行結果を見ると、「こんにちは!」よりも先に「メイン処理です」が表示されることが多くなります。sayHelloとmainの残りの処理が、同時並行で進んでいる、というわけです。
time.Sleep はなぜ必要?
コード中に出てきた time.Sleep(1 * time.Second) は、「1秒間、処理を止める」という意味です。これがないと、main関数の処理がすべて終わった時点で、プログラム全体が終了してしまいます。
Goのプログラムは、main関数の処理が終わった瞬間に、たとえ他のゴルーチンがまだ処理中であっても、プログラム全体を強制終了してしまうという性質があります。お店にたとえるなら、「店長(mainゴルーチン)が帰ってしまったら、他の店員さん(ゴルーチン)がまだ接客中でも、お店ごと閉店してしまう」ようなイメージです。
今回はsayHelloの処理を確実に表示させたいので、main関数側で少しだけ待ってあげる必要がある、というわけです(ただし、これは説明のための簡易的な方法です。本格的なプログラムでは、後述するsync.WaitGroupという、より確実な仕組みが使われることが多いです)。
複数のゴルーチンを同時に動かす
ゴルーチンは、いくつでも同時に立ち上げることができます。
package main
import (
"fmt"
"time"
)
func worker(id int) {
fmt.Printf("ワーカー%dが仕事を開始しました\n", id)
}
func main() {
for i := 1; i <= 3; i++ {
go worker(i)
}
time.Sleep(1 * time.Second)
fmt.Println("すべての処理が終わりました")
}
実行結果は、たとえば次のようになります(順番は実行するたびに変わります)。
ワーカー3が仕事を開始しました
ワーカー1が仕事を開始しました
ワーカー2が仕事を開始しました
すべての処理が終わりました
for文の中でgo worker(i)を3回実行することで、3人の店員さん(ゴルーチン)が同時に働き始めます。表示される順番が実行するたびに変わるのは、3つのゴルーチンが「本当に同時に」動いているためです。「どのゴルーチンが先に終わるかは、実行してみるまでわからない」という点は、並行処理を理解する上でとても大切なポイントです。
チャネルってなに?
複数のゴルーチンが同時に動くと、次のような課題が出てきます。「ゴルーチンAで計算した結果を、ゴルーチンBに安全に渡したい」という場面です。
これを解決するための仕組みが、チャネル(Channel) です。チャネルは、ひとことで言うと 「ゴルーチン同士が、安全にデータをやり取りするための、パイプのようなもの」 です。
ベルトコンベアをイメージしてください。ある人(ゴルーチンA)がベルトコンベアに商品を乗せて、別の人(ゴルーチンB)がベルトコンベアの反対側でその商品を受け取る、というイメージです。このベルトコンベアの役割をするのがチャネルです。
package main
import "fmt"
func calculate(ch chan int) {
result := 10 + 20
ch <- result // チャネルに結果を送る
}
func main() {
ch := make(chan int)
go calculate(ch)
result := <-ch // チャネルから結果を受け取る
fmt.Println("計算結果:", result)
}
実行結果は次のようになります。
計算結果: 30
1つずつ確認しましょう。
ch := make(chan int)
make(chan int) で、「int型のデータをやり取りできる、チャネル」を1つ作成しています。chanはchannelの略です。
func calculate(ch chan int) {
result := 10 + 20
ch <- result
}
ch <- result の部分が、「チャネルchに、resultの値を送る」という意味です。矢印がチャネルに向かって流し込まれているようなイメージで覚えると覚えやすいです。
result := <-ch
こちらは逆に、「チャネルchから、値を受け取って、resultに入れる」という意味です。矢印がチャネルから出てくるようなイメージです。
ここで重要な性質があります。result := <-ch の行は、チャネルに値が送られてくるまで、処理をそこで待ち続ける という性質を持っています。そのため、go calculate(ch)によって開始された計算が終わり、ch <- resultでチャネルに値が送られてくるまで、main関数はきちんと待ってくれます。
これはとても重要な点です。先ほどのゴルーチンの例では、処理を待つためにtime.Sleepという、いわば「とりあえず1秒待つ」という不確実な方法を使っていました。もし処理に1秒以上かかったら、結果が表示される前にプログラムが終了してしまいます。一方でチャネルを使うと、「相手の処理が終わって、値が送られてくるまで、確実に待つ」ということができるようになります。並行処理を安全に書く上で、チャネルはとても大切な役割を果たしています。
まとめ
今回は、以下のことを学びました。
- 並行処理とは、複数の処理を同時に進める仕組み
- ゴルーチンは、Goにおける軽量な処理の実行単位で、
go 関数名()で開始できる main関数の処理が終わると、他のゴルーチンが処理中でもプログラム全体が終了してしまう- チャネルは、ゴルーチン同士が安全にデータをやり取りするための仕組みで、
ch <- 値で送信、<-chで受信する - チャネルの受信は、値が送られてくるまで待ってくれるので、
time.Sleepよりも確実に処理を待ち合わせられる
ゴルーチンとチャネルは、最初は少しとっつきにくく感じるかもしれません。ですが、「複数の店員さんが同時に働いて、ベルトコンベアで結果をやり取りする」というイメージを持っておくと、少しずつ理解しやすくなるはずです。
次回はいよいよ最終回です。プログラムがうまくいかなかったときの対処法である「エラーハンドリング」と、書いたプログラムが正しく動くことを確認するための「テスト」の書き方について学んでいきます。
次回:「Go言語入門⑥エラーハンドリング・テスト編」お楽しみに。


