コンテンツにスキップ

並行・非同期処理

はじめに

本サイトにつきまして、【IT技術の知見】はじめに - 俺の技術ノート をご認識のほど宜しくお願いいたします。


01. 処理の種類

単一処理

▼ 単一処理とは

特定の処理のみを実行する。

package main

import "fmt"

func main() {
    fmt.Println("1")
    fmt.Println("2")
    fmt.Println("3")
}

// 1
// 2
// 3


並行処理

▼ Goroutine による並行処理

指定した処理をバラバラに開始し、またそれぞれの処理がバラバラに終了する。

関数で Goroutine (go func()) を宣言すると、その関数の完了を待たずに後続の処理を並行的に実行できる。

結果、完了する順番は順不同になる。

プログラムを実行する親プロセス
├─ スレッド
│  └─ プロセッサ
│     ├─ goroutine #1(HTTPリクエスト処理)
│     └─ goroutine #3(トランザクション処理)
│
├─ スレッド
│  └─ プロセッサ
│     ├─ goroutine #1(HTTPリクエスト処理)
│     └─ goroutine #2(ファイルI/O処理)
│
└─ スレッド
   └─ プロセッサ
      ├─ goroutine #1(HTTPリクエスト処理)
      └─ goroutine #2(計算処理)

*実行例*

package main

import (
    "time"
    "fmt"
)

func main() {

    fmt.Println("main")

    go func() {
        fmt.Println("hoge")
    }()

    time.Sleep(time.Second)
}

▼ 時間のかかる処理と相性がいい

時間のかかる処理(例:for 文、HTTP リクエスト、ループ、ポーリング、DB 操作、ファイル操作、計算、暗号化、圧縮解凍など)と相性がいい。

package main

import (
)

func main() {

    go func() {
        // for文、HTTPリクエスト、ポーリング、DB操作、ファイル操作、計算、暗号化、圧縮解凍など
        // 実装方法を調べて書く
    }()

    // 並行して実行したい処理
}

▼ 返却処理はエラーになる

返却処理 (return) は、処理の完了を待つことになるため、Goroutine とは矛盾する。

そのため、Goroutine で実行する関数に返却処理があると、エラーになる。

代わりに、Goroutine 間で channel を介して処理結果を送受信する。

package main

import (
    "fmt"
)

func main() {

    // "unexpected go" というエラーになる
    result := go foo()

    fmt.Println(result)
}

func foo() string {
    return "foo"
}

▼ Goroutine を入れ子にできる

入れ子で実行した場合でも、全く独立して Goroutine を実行する。

Goroutine の親子間に依存関係はない。

package main

import (
    "fmt"
)

func main() {

    go foo()

    // Goroutineを宣言したfoo関数の完了を待たない
    fmt.Println("baz")
}

func foo() string {

    go func() {
        fmt.Println("bar")
    }

    // Goroutineを宣言した無名関数の完了を待たない
    fmt.Println("foo")
}


02. Goroutine と合わせて使用する処理

channel (チャネル)

▼ channel とは

異なる Goroutine 間で値を送受信するキューとして動作する。

キューに値を送信し、加えてキューから値を受信できる。

go_channel

package main

import "fmt"

func main() {

    // チャネルを作成
    channel := make(chan string)

    // Goroutineを宣言して並行化
    go func() {
        // 時間のかかる処理
        // チャネルに値を送信する。
        channel <- "ping"
    }()

    // チャネルから値を受信する。
    value := <-channel

    fmt.Println(value)
}

▼ Goroutine 中断方法1 (done channel、close)

Goroutine を中断するための done チャネルを作成し、これを close() 関数で中断する。

done チャネルは使い捨てのため、サイズがゼロの空構造体として定義するとよい。

package main

import (
    "fmt"
    "log"
)

func main() {

    // チャネルを作成
    channel := make(chan string)
    // Goroutineを中断するためのdoneチャネルを作成
    // 空構造体はサイズがゼロなため、使い捨てのチャネルと相性がいい
    done := make(chan struct{})

    // Goroutineを宣言して並行化
    go func() {
        // 時間のかかる処理
        // チャネルに値を送信する。
        channel <- "ping"
        // Goroutineを中断する
        close(done)
    }()

    for {
        select {

        // 先に終了したcaseに条件分岐する
        // チャネルから値を受信した場合
        case value := <-channel:
            fmt.Println(value)

        // cancel関数が実行された場合
        case <-done:
            log.Printf("Goroutineが完了しました")
            return
        }
    }
}

▼ Goroutine 中断方法2 (context.cancel、context.Done)

cancel() 関数の Goroutine の中断を検知する。

sync.WaitGroup.Done() 関数とは区別する。

package main

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

func main() {

    // タイムアウト時間を設定し、コンテキストを作成する
    ctx, cancel := context.WithTimeout(
        context.Background(),
        5 * time.Second,
    )

    // チャネルを作成
    channel := make(chan string)

    // Goroutineを宣言して並行化
    go func() {
        // 時間のかかる処理
        // チャネルに値を送信する。
        channel <- "ping"
        // Goroutineを中断する
        cancel()
    }()

    for {
        select {

        // 先に終了したcaseに条件分岐する
        // チャネルから値を受信した場合
        case value := <-channel:
            fmt.Println(value)

        // cancel関数が実行された場合
        case <-ctx.Done():
            log.Printf("Goroutineが完了しました")
            return
        }
    }
}

▼ select

チャネルに対する格納を非同期で待機する。

package main

import (
    "fmt"
    "time"
)

func main() {

    // チャネルを作成する。
    channel1 := make(chan string)
    channel2 := make(chan string)

    // Goroutineを宣言して並行化
    go func() {
        // 時間のかかる処理
        // 完了までに2秒かかるとする。
        time.Sleep(2 * time.Second)
        // 値を送信する。
        channel1 <- "one"
    }()

    // Goroutineを宣言して並行化
    go func() {
        // 時間のかかる処理
        // 完了までに1秒かかるとする。
        time.Sleep(1 * time.Second)
        // 値を送信する。
        channel2 <- "two"
    }()

    for i := 0; i < 2; i++ {
        select {
        // Goroutineの処理の完了タイミングがバラバラになる
        // channel1とchannel2の受信を非同期で待機し、受信した順番で処理する。
        // channel1 <- "one" を受信したタイミングで出力する
        case msg1 := <-channel1:
            fmt.Println("received", msg1)
        // channel2 <- "two" を受信したタイミングで出力する
        case msg2 := <-channel2:
            fmt.Println("received", msg2)
        // 受信が成功しなければ、defaultで処理する。
        default:
            fmt.Println("default")
        }
    }
}

▼ WaitGroup との使い分け

channel を使用すると、異なる Goroutine 間で値を送受信できる。

そのため、Goroutine の処理結果を使用したい場合は、WaitGroup ではなく channel を使用するほうがよい。

一方で、channel では select() 関数が必要になって、処理が複雑になる点は注意する。


WaitGroup

▼ WaitGroup とは

Goroutine を宣言した関数が終了するまで、後続の処理の実行開始を待機する。

sync.WaitGroup.Add() 関数、sync.WaitGroup.Done() 関数、sync.WaitGroup.Wait() 関数、で制御する。

Goroutine の関数の反復処理や異なる Goroutine の関数の並行実行を待機し、そのうえで後続の処理を実行するような場合に、WaitGroup は役立つ。

単一の Goroutine を待機するのは順次実行と変わらないため、WaitGroup は使用しない。

実行例

実行完了に 1 秒かかる関数があると仮定する。

反復処理でこの関数をコールする場合、毎回の走査に 1 秒かかるため、反復の回数だけ秒数が増える。

しかし、各関数を Goroutine として実行すると、各反復処理を並行実行できる。

そのため、反復回数が何回であっても、およそ 1 秒で処理が終了する。

package main

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

func main() {

    slice := []string{"a", "b", "c"}

    wg := &sync.WaitGroup{}

    // 処理の開始時刻を取得
    start := time.Now()

    // Goroutineの関数を反復処理する
    // a、b、c、の分だけ反復する
    for key, value := range slice {

        // インクリメントする
        // 完了の待機カウンターを設定する
        wg.Add(1)

        // Goroutineを宣言して並行化
        go func(key int, value string) {

            print(key, value)
            // デクリメントする
            // Wait関数のカウンターを減らす
            wg.Done()
        }(key, value)
    }

    // カウンターがゼロになるまで待機する
    // 全てのGorourineでDone関数を実行するのを待機する
    wg.Wait()

    // 開始時刻から経過した秒数を取得する
    log.Printf("経過秒数: %s\n", time.Since(start))
}

func print(key int, value string) {
    fmt.Println(key, value)
}

// 2 c
// 0 a
// 1 b
// 経過秒数: 1s

▼ channel との使い分け

WaitGroup を使用すると、Add() 関数、Done() 関数、Wait() 関数を使用して、Goroutine を簡単に制御できる。

そのため、Goroutine の処理結果を使用したい場合は、WaitGroup ではなく channel を使用するほうがよい。

一方で、WaitGroup では Goroutine 間で値の送受信はできないことに注意する。


errgroup

エラー処理を含む関数で Goroutine を宣言したいときに使用する。