フリーキーズ | 独学プログラミング

Go言語のGoroutineで並行処理を始める!初心者でもわかる基本を解説

リンドくん

リンドくん

たなべ先生、Go言語の「Goroutine」って何ですか?よく「軽量スレッド」って聞くんですけど...

たなべ

たなべ

Goroutineは、Go言語の最大の特徴の一つなんだ。
簡単に言うと、複数の処理を同時並行で実行できる仕組みのことなんだよ。従来のスレッドよりもはるかに軽量で使いやすいのが特徴なんだ。

Go言語を学び始めると、必ずと言っていいほど目にする「Goroutine(ゴルーチン)」という機能。
「並行処理ができる」「軽量スレッド」といった説明を聞いても、実際にどんなメリットがあるのか、どう使えばいいのかが分からないという方も多いのではないでしょうか?

Goroutineを理解することで、プログラムの実行速度を大幅に向上させることができ、現代のマルチコア環境を最大限に活用できるようになります。

この記事では、プログラミング初心者でも理解できるよう、Goroutineの基本概念から実践的な使い方まで、段階的に解説していきます。

プログラミング学習でお悩みの方へ

HackATAは、エンジニアを目指す方のためのプログラミング学習コーチングサービスです。 経験豊富な現役エンジニアがあなたの学習をサポートします。

✓ 質問し放題

✓ β版公開中(2025年内の特別割引)

HackATAの詳細を見る

Goroutineとは何か?並行処理の基本概念

リンドくん

リンドくん

でも先生、「並行処理」って普通のプログラムとどう違うんですか?

たなべ

たなべ

普通のプログラムは順番に一つずつ処理していくんだけど、並行処理では複数の処理を同時に進めることができるんだ。
例えば、料理で言うと、ご飯を炊きながら味噌汁を作るような感じかな。

並行処理とは何か

まず、並行処理の基本概念を理解しましょう。通常のプログラムは逐次処理と呼ばれる方式で動作します。つまり、一つの処理が完了してから次の処理に移るという流れです。

一方、並行処理では複数の処理を同時に実行できます。これにより、以下のようなメリットが生まれます。

  • 処理時間の短縮 → 複数の処理を並行して実行することで、全体の実行時間を短縮
  • レスポンシブな動作 → ユーザーインターフェースが固まることなく、スムーズに動作
  • リソースの有効活用 → マルチコアCPUの性能を最大限に活用

Goroutineの特徴

Go言語のGoroutineには、従来のスレッドと比べて以下のような優れた特徴があります。

軽量性

  • 1つのGoroutineは約2KBのメモリしか消費しません
  • 数万から数十万のGoroutineを同時に起動することも可能

簡単な起動方法

  • goキーワードを関数の前に付けるだけで並行処理が開始
  • 複雑な設定や初期化処理は不要

効率的なスケジューリング

  • Go言語のランタイムが自動的にGoroutineを管理
  • CPUコア数に応じて効率的に処理を分散

このように、Goroutineは「使いやすさ」と「高性能」を両立した画期的な仕組みなのです。

基本的なGoroutineの使い方

リンドくん

リンドくん

実際にはどうやって使うんですか?難しそうですけど...

たなべ

たなべ

実は驚くほど簡単なんだよ!
関数の前にgoと書くだけで、その関数が並行して実行されるんだ。まずは基本的な例を見てみよう。

最初のGoroutineプログラム

まず、最もシンプルなGoroutineの例を見てみましょう。

package main

import (
    "fmt"
    "time"
)

func sayHello() {
    for i := 0; i < 3; i++ {
        fmt.Println("Hello from goroutine!")
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    // 通常の関数呼び出し
    fmt.Println("=== 順次処理の例 ===")
    sayHello()
    fmt.Println("Main function continues...")
    
    // Goroutineとして実行
    fmt.Println("\n=== 並行処理の例 ===")
    go sayHello() // ここがポイント!
    
    // メイン関数も処理を続ける
    for i := 0; i < 3; i++ {
        fmt.Println("Hello from main!")
        time.Sleep(150 * time.Millisecond)
    }
    
    // Goroutineの完了を待つ
    time.Sleep(500 * time.Millisecond)
}

このコードを実行すると、sayHello()関数がメイン関数と同時並行で実行されることが確認できます。

複数のGoroutineを起動する例

複数のGoroutineを同時に起動することも簡単です。

package main

import (
    "fmt"
    "time"
)

func worker(id int) {
    for i := 0; i < 3; i++ {
        fmt.Printf("Worker %d: task %d\n", id, i+1)
        time.Sleep(100 * time.Millisecond)
    }
    fmt.Printf("Worker %d: finished\n", id)
}

func main() {
    fmt.Println("Starting multiple goroutines...")
    
    // 5つのワーカーGoroutineを起動
    for i := 1; i <= 5; i++ {
        go worker(i)
    }
    
    // すべてのGoroutineが完了するまで待機
    time.Sleep(1 * time.Second)
    fmt.Println("All workers finished!")
}

この例では、5つのworker関数が同時並行で実行され、それぞれが独自のタスクを処理していることが分かります。

注意すべきポイント

Goroutineを使う際に注意すべき重要なポイントがあります。

メイン関数の終了

  • メイン関数が終了すると、すべてのGoroutineも強制終了されます
  • 適切な同期機構を使ってGoroutineの完了を待つ必要があります

実行順序の不確定性

  • 複数のGoroutineの実行順序は保証されません
  • 特定の順序に依存する処理は避けるべきです

これらの特性を理解することで、安全で効率的な並行プログラムを作成できるようになります。

チャネル(Channel)でGoroutine間の通信を行う

リンドくん

リンドくん

複数のGoroutineが動いてるとき、お互いにデータをやり取りしたいときはどうするんですか?

たなべ

たなべ

それにはGo言語のチャネル(Channel)という機能を使うんだ!
チャネルを使えば、Goroutine同士で安全にデータをやり取りできるんだよ。まさに「パイプ」のような働きをするんだ。

チャネルの基本概念

チャネル(Channel)は、Goroutine間でデータを安全に送受信するための仕組みです。Go言語では「メモリを共有するのではなく、通信によってメモリを共有する」という哲学があり、チャネルはその中核となる機能です。

チャネルには以下のような特徴があります。

  • 型安全 → 特定の型のデータのみを送受信可能
  • 同期機能 → データの送受信時に自動的に同期が取られる
  • ブロッキング動作 → データが準備できるまで処理を待機

基本的なチャネルの使い方

まず、シンプルなチャネルの例を見てみましょう。

package main

import (
    "fmt"
    "time"
)

func sender(ch chan string) {
    messages := []string{"Hello", "World", "from", "Goroutine"}
    
    for _, msg := range messages {
        fmt.Printf("Sending: %s\n", msg)
        ch <- msg // チャネルにデータを送信
        time.Sleep(500 * time.Millisecond)
    }
    
    close(ch) // チャネルを閉じる
}

func main() {
    // 文字列型のチャネルを作成
    ch := make(chan string)
    
    // 送信用のGoroutineを起動
    go sender(ch)
    
    // チャネルからデータを受信
    for msg := range ch {
        fmt.Printf("Received: %s\n", msg)
    }
    
    fmt.Println("Communication finished!")
}

この例では、sender関数がチャネルにデータを送信し、メイン関数がそれを受信しています。

バッファ付きチャネル

通常のチャネルは同期的に動作しますが、バッファ付きチャネルを使うことで非同期な通信も可能です。

package main

import (
    "fmt"
    "time"
)

func producer(ch chan int) {
    for i := 1; i <= 5; i++ 
    {
        fmt.Printf("Producing: %d\n", i)
        ch <- i
        time.Sleep(200 * time.Millisecond)
    }
    close(ch)
}

func consumer(ch chan int) {
    for num := range ch {
        fmt.Printf("Consuming: %d\n", num)
        time.Sleep(300 * time.Millisecond) // 消費が遅い
    }
}

func main() {
    // バッファサイズ3のチャネルを作成
    ch := make(chan int, 3)
    
    go producer(ch)
    go consumer(ch)
    
    // 処理完了まで待機
    time.Sleep(3 * time.Second)
}

バッファ付きチャネルを使うことで、送信側と受信側の処理速度の違いを吸収できます。

WaitGroupを使った同期制御

time.Sleepで待機する代わりに、sync.WaitGroupを使ってより確実な同期制御を行う方法もあります。

package main

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

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // 関数終了時にWaitGroupに完了を通知
    
    fmt.Printf("Worker %d starting\n", id)
    time.Sleep(time.Duration(id) * 100 * time.Millisecond)
    fmt.Printf("Worker %d finished\n", id)
}

func main() {
    var wg sync.WaitGroup
    
    // 5つのワーカーを起動
    for i := 1; i <= 5; i++ {
        wg.Add(1) // WaitGroupのカウンタを増やす
        go worker(i, &wg)
    }
    
    wg.Wait() // すべてのgoroutineが完了するまで待機
    fmt.Println("All workers completed!")
}

この方法を使うことで、すべてのGoroutineが確実に完了してからプログラムを終了できます。

注意すべきポイント

実践的にGoroutineを使う際の重要なポイントをまとめておきます。

適切なGoroutine数の制御

// ワーカープール
func workerPool(jobs <-chan int, results chan<- int, numWorkers int) {
    var wg sync.WaitGroup
    
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                // 処理を実行
                results <- job * 2
            }
        }()
    }
    
    wg.Wait()
    close(results)
}

エラーハンドリングの重要性

  • Goroutine内で発生したエラーは、チャネルを通じて適切に伝達する
  • パニックが発生した場合の回復処理も考慮する

リソースリークの防止

  • チャネルの適切なクローズ
  • Goroutineの確実な終了
  • メモリ使用量の監視

これらのパターンを理解することで、実際のプロジェクトでGoroutineを効果的に活用できるようになります。

パフォーマンス向上の実例とベンチマーク

リンドくん

リンドくん

Goroutineを使うと、実際にどのくらい速くなるんですか?

たなべ

たなべ

それは気になるよね!
実際にベンチマークを取ってみると、処理によっては数倍から数十倍も高速化できることがあるんだ。具体例で見てみよう!

CPU集約的処理の並行化

まず、CPU集約的な処理(計算処理)でのパフォーマンス改善例を見てみましょう。

package main

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

// 素数判定の関数(計算集約的な処理)
func isPrime(n int) bool {
    if n < 2 {
        return false
    }
    for i := 2; i*i <= n; i++ {
        if n%i == 0 {
            return false
        }
    }
    return true
}

// 逐次処理バージョン
func findPrimesSequential(start, end int) []int {
    var primes []int
    for i := start; i <= end; i++ {
        if isPrime(i) {
            primes = append(primes, i)
        }
    }
    return primes
}

// 並行処理バージョン
func findPrimesConcurrent(start, end int, numWorkers int) []int {
    jobs := make(chan int, 100)
    results := make(chan int, 100)
    var wg sync.WaitGroup
    
    // ワーカーGoroutineを起動
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for num := range jobs {
                if isPrime(num) {
                    results <- num
                }
            }
        }()
    }
    
    // ジョブを送信するGoroutine
    go func() {
        for i := start; i <= end; i++ {
            jobs <- i
        }
        close(jobs)
    }()
    
    // 結果を収集するGoroutine
    go func() {
        wg.Wait()
        close(results)
    }()
    
    var primes []int
    for prime := range results {
        primes = append(primes, prime)
    }
    
    return primes
}

func benchmark() {
    const (
        start = 1
        end   = 50000
    )
    
    fmt.Printf("Finding primes from %d to %d\n", start, end)
    fmt.Printf("Number of CPU cores: %d\n\n", runtime.NumCPU())
    
    // 逐次処理の測定
    startTime := time.Now()
    primesSeq := findPrimesSequential(start, end)
    seqDuration := time.Since(startTime)
    
    fmt.Printf("Sequential processing:\n")
    fmt.Printf("  Time: %v\n", seqDuration)
    fmt.Printf("  Primes found: %d\n\n", len(primesSeq))
    
    // 並行処理の測定(CPU数と同じワーカー数)
    numWorkers := runtime.NumCPU()
    startTime = time.Now()
    primesCon := findPrimesConcurrent(start, end, numWorkers)
    conDuration := time.Since(startTime)
    
    fmt.Printf("Concurrent processing (%d workers):\n", numWorkers)
    fmt.Printf("  Time: %v\n", conDuration)
    fmt.Printf("  Primes found: %d\n", len(primesCon))
    fmt.Printf("  Speedup: %.2fx\n", float64(seqDuration)/float64(conDuration))
}

func main() {
    benchmark()
}

I/O集約的処理の並行化

次に、I/O集約的な処理(ネットワーク通信)での改善例を見てみましょう。

package main

import (
    "fmt"
    "io"
    "net/http"
    "sync"
    "time"
)

// 逐次処理でのHTTPリクエスト
func fetchURLsSequential(urls []string) map[string]int {
    results := make(map[string]int)
    
    for _, url := range urls {
        resp, err := http.Get(url)
        if err != nil {
            results[url] = -1
            continue
        }
        
        body, err := io.ReadAll(resp.Body)
        resp.Body.Close()
        
        if err != nil {
            results[url] = -1
        } else {
            results[url] = len(body)
        }
    }
    
    return results
}

// 並行処理でのHTTPリクエスト
func fetchURLsConcurrent(urls []string) map[string]int {
    type result struct {
        url  string
        size int
    }
    
    results := make(map[string]int)
    resultChan := make(chan result, len(urls))
    var wg sync.WaitGroup
    
    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            
            resp, err := http.Get(u)
            if err != nil {
                resultChan <- result{u, -1}
                return
            }
            
            body, err := io.ReadAll(resp.Body)
            resp.Body.Close()
            
            if err != nil {
                resultChan <- result{u, -1}
            } else {
                resultChan <- result{u, len(body)}
            }
        }(url)
    }
    
    go func() {
        wg.Wait()
        close(resultChan)
    }()
    
    for res := range resultChan {
        results[res.url] = res.size
    }
    
    return results
}

func networkBenchmark() {
    urls := []string{
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/1",
    }
    
    fmt.Printf("Fetching %d URLs with 1-second delay each\n\n", len(urls))
    
    // 逐次処理
    start := time.Now()
    seqResults := fetchURLsSequential(urls)
    seqDuration := time.Since(start)
    
    fmt.Printf("Sequential fetching:\n")
    fmt.Printf("  Time: %v\n", seqDuration)
    fmt.Printf("  Successful fetches: %d\n\n", countSuccessful(seqResults))
    
    // 並行処理
    start = time.Now()
    conResults := fetchURLsConcurrent(urls)
    conDuration := time.Since(start)
    
    fmt.Printf("Concurrent fetching:\n")
    fmt.Printf("  Time: %v\n", conDuration)
    fmt.Printf("  Successful fetches: %d\n", countSuccessful(conResults))
    fmt.Printf("  Speedup: %.2fx\n", float64(seqDuration)/float64(conDuration))
}

func countSuccessful(results map[string]int) int {
    count := 0
    for _, size := range results {
        if size > 0 {
            count++
        }
    }
    return count
}

func main() {
    networkBenchmark()
}

パフォーマンス改善のガイドライン

実際のパフォーマンス測定結果から、以下のようなガイドラインが得られます。

CPU集約的処理

  • マルチコア環境ではCPU数と同じかそれ以下のGoroutine数が効果的
  • 計算量が多い処理ほど並行化の効果が高い
  • 2-4倍程度の速度向上が期待できる

I/O集約的処理

  • 数十から数百のGoroutineを同時実行しても効果的
  • ネットワーク遅延が大きいほど並行化の効果が高い
  • 10倍以上の速度向上も可能

メモリ使用量への配慮

  • Goroutineの数が増えすぎるとメモリ使用量も増加
  • ワーカープールでGoroutine数を制限
  • チャネルのバッファサイズも適切に設定

これらの知見を活用することで、実際のプロジェクトでも大幅なパフォーマンス改善を実現できます。

よくある間違いとトラブルシューティング

リンドくん

リンドくん

Goroutineを使ってて、たまにプログラムが固まったり、予期しない結果になったりするんですが...

たなべ

たなべ

それは初心者がよく遭遇する問題だね!
Goroutineにはいくつかの典型的な落とし穴があるんだ。これらを知っておくことで、トラブルを未然に防げるよ。

よくある間違い1 レースコンディション

複数のGoroutineが同じ変数に同時にアクセスすることで発生する問題です。

問題のあるコード例:

package main

import (
    "fmt"
    "sync"
)

var counter int // 共有変数

func increment(wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 1000; i++ {
        counter++ // レースコンディションが発生!
    }
}

func badExample() {
    var wg sync.WaitGroup
    
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go increment(&wg)
    }
    
    wg.Wait()
    fmt.Printf("Counter: %d (expected: 10000)\n", counter)
    // 実際の結果は10000より小さくなることが多い
}

修正版(Mutexを使用)

package main

import (
    "fmt"
    "sync"
)

var (
    counter int
    mutex   sync.Mutex // Mutexを追加
)

func safeIncrement(wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 1000; i++ {
        mutex.Lock()   // ロックを取得
        counter++
        mutex.Unlock() // ロックを解放
    }
}

func goodExample() {
    var wg sync.WaitGroup
    
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go safeIncrement(&wg)
    }
    
    wg.Wait()
    fmt.Printf("Counter: %d (expected: 10000)\n", counter)
    // 正確に10000になる
}

よくある間違い2 デッドロック

Goroutine同士が互いの処理完了を待ち続けて、プログラムが停止してしまう問題です。

デッドロックが発生するコード

package main

import "fmt"

func deadlockExample() {
    ch := make(chan int) // バッファなしチャネル
    
    ch <- 42 // ここで無期限に待機(受信者がいない)
    
    fmt.Println(<-ch) // ここには到達しない
}

修正版

package main

import "fmt"

func fixedExample() {
    ch := make(chan int, 1) // バッファ付きチャネル
    
    ch <- 42              // バッファがあるので送信できる
    fmt.Println(<-ch)     // 42を受信して表示
}

// または、Goroutineを使った修正版
func anotherFix() {
    ch := make(chan int)
    
    go func() {
        ch <- 42 // Goroutineで送信
    }()
    
    fmt.Println(<-ch) // メインGoroutineで受信
}

よくある間違い3 Goroutineリーク

Goroutineが終了せずにメモリに残り続ける問題です。

リークが発生するコード

package main

import (
    "fmt"
    "time"
)

func leakyExample() {
    ch := make(chan int)
    
    // このGoroutineは永続的に待機し続ける
    go func() {
        for {
            select {
            case data := <-ch:
                fmt.Println("Received:", data)
            }
        }
    }()
    
    // チャネルにデータを送信せずに関数終了
    time.Sleep(100 * time.Millisecond)
    // Goroutineが残り続ける(リーク)
}

修正版(コンテキストを使用)

package main

import (
    "context"
    "fmt"
    "time"
)

func fixedLeakExample() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // 関数終了時にキャンセル
    
    ch := make(chan int)
    
    go func() {
        for {
            select {
            case data := <-ch:
                fmt.Println("Received:", data)
            case <-ctx.Done():
                fmt.Println("Goroutine terminated")
                return // Goroutineを適切に終了
            }
        }
    }()
    
    time.Sleep(100 * time.Millisecond)
    // cancelが呼ばれてGoroutineが終了する
}

デバッグとプロファイリングのテクニック

Goroutineの問題をデバッグするための便利なテクニックをご紹介します。

レースコンディションの検出

go run -race main.go

Goroutineの数を監視

package main

import (
    "fmt"
    "runtime"
    "time"
)

func monitorGoroutines() {
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()
    
    for i := 0; i < 5; i++ {
        <-ticker.C
        fmt.Printf("Goroutines: %d\n", runtime.NumGoroutine())
    }
}

チャネルの状態確認

select {
case data := <-ch:
    // データを受信した場合の処理
    fmt.Println("Received:", data)
case <-time.After(5 * time.Second):
    // タイムアウトした場合の処理
    fmt.Println("Timeout: no data received")
}

これらのパターンを理解することで、安全で効率的なGoroutineプログラムを作成できるようになります。

まとめ

リンドくん

リンドくん

Goroutineってすごく強力な機能なんですね!でも、使いこなすにはかなり練習が必要そうです...

たなべ

たなべ

そうだね!でも心配しないで。基本をしっかり理解して、小さなプログラムから始めれば必ずマスターできるよ。
現代のソフトウェア開発では並行処理は必須スキルだから、今のうちに身につけておくと将来大きなアドバンテージになるはずだ!

この記事では、Go言語のGoroutineについて、基本概念から実践的な活用法まで幅広く解説してきました。最後に、重要なポイントをまとめておきましょう。

  • 圧倒的な軽量性 → 数万のGoroutineも同時実行可能
  • シンプルな構文goキーワード一つで並行処理を開始
  • 優れたパフォーマンス → CPU集約・I/O集約処理の両方で大幅な高速化
  • 安全な通信機構 → チャネルによる型安全なデータ交換

現代のソフトウェア開発では、以下のような場面でGoroutineが威力を発揮します。

  • Webサーバの開発 → 複数のリクエストを同時処理
  • データ処理システム → 大量のデータを並行して処理
  • マイクロサービス → 複数のAPIを並行して呼び出し
  • リアルタイムアプリケーション → チャットやゲームサーバーの開発

並行処理やシステム設計の理解は、効率的なソフトウェアを構築するためにも欠かせない基礎知識です。

Goroutineのようなコンピュータサイエンスの基盤技術をしっかりと理解することで、現代においても価値の高いエンジニアとして活躍できるでしょう。

まずは今回紹介したサンプルコードを実際に動かしてみて、Goroutineの動作を体感してください。
そして、自分のプロジェクトでも並行処理が活用できる場面を見つけて、実践してみることが大切です。

この記事をシェア

関連するコンテンツ