Go言語は並行処理に特化した設計を持っており、その中核をなすのが「goroutine」と「channel」です。標準ライブラリだけで軽量スレッド(goroutine)の起動やデータの安全な受け渡し(channel)が行えるため、他言語に比べて非常にシンプルかつ強力な並行プログラミングが可能です。本記事では、goroutineとchannelを使った基本的な非同期処理の書き方や、そのポイントについて解説します。
goroutineとは?
goroutineとは、Goランタイム上で動作する軽量な並行処理単位です。OSスレッドよりも軽量で、数万~数十万単位のgoroutineを生成してもパフォーマンスに優れた並行処理を行えます。goroutineはgoキーワードを付けて関数を呼び出すことで簡単に作成できます。
goroutineの基本例
package main
import (
"fmt"
"time"
)
func main() {
// 通常の関数呼び出し
hello()
// goキーワードを付けると、hello()関数が別のgoroutineで実行
go hello()
// メイン関数はメインgoroutineとして動作する
// 他のgoroutineが処理する前にメインgoroutineが終了してしまうと何も起きないため、
// ここではわざと少し待機する
time.Sleep(1 * time.Second)
}
func hello() {
fmt.Println("Hello, goroutine!")
}
上記のコードでは、go hello()によってhello()関数が独立した並行処理として実行されます。ただし、メインgoroutineはメイン関数終了とともにプログラムを終了してしまうため、time.Sleepで少し待機して他のgoroutineが動く機会を与えています。この例では、Hello, goroutine!というメッセージが出力されます。
channelとは?
複数のgoroutineが存在する場合、処理結果やデータをやり取りする必要が出てきます。Goでは、そのためにchannel(チャネル)という仕組みが用意されています。channelは、goroutine間で安全にデータを受け渡すためのキューのようなものです。
channelの基本的な使い方
make(chan 型)でchannelを生成します。
<-演算子でデータの送受信を行います。
package main
import "fmt"
func main() {
// int型のデータを受け渡すchannelを生成
ch := make(chan int)
// 別goroutineで値を送信する
go func() {
ch <- 42 // channelに42を送信
}()
// 受信側はchannelから値を受け取るまでブロック(待機)
value := <-ch
fmt.Println(value) // 42が表示される
}
上記例では、go func()内でch <- 42とすることで、メインgoroutineに42という整数を渡しています。受信側はvalue := <-chでchannelから値を受け取り、受信が完了するまでブロックします。これにより、データ受け渡しのタイミングが自然と同期され、安全な並行処理が可能になります。
「非同期処理」とは、プログラムが何らかの処理を依頼した後、その結果が出るのを待たずに、次の処理を進めることを指します。
たとえば、あなたが「ラーメン屋さんでラーメンを注文する場面」を想像してみてください。
同期処理の場合:
店員さんにラーメンを注文します。その後、店員さんがラーメンを作ってくれる間、あなたはその場でじっと待って、ラーメンが出来上がってからようやく次の行動(食べる)に移れます。このように、「処理(ラーメンが出来上がる)」を待っている間、ほかの行動に移れない状態が「同期的」な流れです。
非同期処理の場合:
店員さんにラーメンを注文しますが、出来上がるまで待たずに、先にスマホでニュースを読んだり、水を飲んだり、ほかのことをして過ごすことができます。ラーメンが出来上がったら店員さんが「出来ましたよ」と呼んでくれて、あなたはその時点でラーメンを受け取って食べ始めれば良いのです。これが「非同期的」な流れです。
要するに、「待ち時間に他のことができる」のが非同期処理のポイントです。
プログラムで言えば、ファイルからデータを読み込む処理や、外部のサーバーに問い合わせをする処理は、完了まで時間がかかることがあります。同期的にやると、結果が返ってくるまでプログラムは止まってしまいます。しかし、非同期処理を利用すれば、結果待ちの間にも他の仕事をどんどん進めることができます。結果が用意できたタイミングで通知を受け取り、その後に結果を使う処理に進めればよいのです。
このように非同期処理を使うことで、プログラムは無駄な待機時間を減らし、より効率的に並行して作業を進めることができます。
goroutineとchannelを組み合わせた非同期処理例
以下は、複数のgoroutineで並行計算を実行し、結果をchannelで集約する例です。
複数のタスクを並行実行し、結果を集める
package main
import (
"fmt"
"math/rand"
"time"
)
// 重い計算を模した関数
func heavyCalc(id int) int {
time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
return id * id
}
func main() {
rand.Seed(time.Now().UnixNano())
ch := make(chan int)
const numTasks = 5
// 複数のgoroutineで計算を並行実行
for i := 1; i <= numTasks; i++ {
go func(n int) {
result := heavyCalc(n)
ch <- result
}(i)
}
// 結果を受け取って合計を求める
total := 0
for i := 0; i < numTasks; i++ {
result := <-ch
total += result
}
fmt.Printf("合計: %d\n", total)
}
上記コードでは、numTasks個のgoroutineが同時にheavyCalcを実行し、計算結果をchannelへ送信しています。メインgoroutineはforループでnumTasks回channelから値を受信し、合計値を求めます。このコード例では、時間のかかる処理を並行して実行することで、単純な逐次実行よりも効率的なタスク処理が可能になります。
channelのバッファリング
channelは非同期なデータ受け渡しをするためにブロッキングな挙動を取ります。通常、送信側は受信されるまでブロックしますが、バッファリングされたchannelを使うと、一定数までは送信側がブロックせずに送信できます。
ch := make(chan int, 3) // バッファサイズ3
バッファサイズ3のchannelの場合、受信が行われなくても3つまで値を送信しておけます。これにより、送受信のタイミングを微妙にずらしたり、スループットを改善することが可能です。
contextとの併用
実際の業務コードでは、非同期処理を行う際に「いつ中断するか」を決める必要がある場合が多くあります。Goではcontextパッケージを使ってgoroutine間にキャンセルやタイムアウトを伝達できます。contextはgoroutine・channelと組み合わせて非同期処理フロー全体を制御するために重要なツールです。
// 簡略例: contextを用いたキャンセル可能なgoroutine処理
ctx, cancel := context.WithCancel(context.Background())
go func() {
for {
select {
case <-ctx.Done():
fmt.Println("キャンセルされました")
return
default:
// 処理続行
}
}
}()
// 何らかの条件でcancel関数を呼ぶとgoroutineが停止する
cancel()
まとめ
goroutineはgoキーワード一つで並行処理を始められる軽量なスレッドライクな実行単位です。
channelはgoroutine間で安全かつ同期的なデータ通信を可能にする仕組みで、<-演算子でデータの送受信を行います。
これらを組み合わせることで、Goでは非常に簡潔かつ強力な並行プログラミングが可能になります。