Go

Go言語の基本文法5(並行処理)

こんにちは、イサムです!

本記事では、前回に引き続きGo言語の基本文法についてまとめます。

対象読者
  • Go言語の基本文法について知りたい!
この記事でやること
  • Go言語の基本文法についてのまとめ

並行処理とは

並行処理とは、複数の動作を同時に処理することで、Go言語では、ゴルーチンで、並列処理を実現することができます。
Webサーバーは複数のクライアントからリクエストが届きます。前の処理が終わるまで次の処理ができないとなると待ち時間が長くなってしまい、クライアントにとって不利益になってしまいます。そのため、並行処理があるとリクエストが届いたらすぐに処理をすることができます。
また、昨今のCPUのコア数は増えてきているため、複数のコアを十分に使い、効率の良い処理をするためにも並行処理は重要になっています。

ゴルーチン

ゴルーチンとは、軽量なスレッドのようなもので、スレッドとは、複数の処理を同時にする機能のことです。
実行の順序は保証されていないため、実行の順序が実行のたびに変わる可能性があります。
ゴルーチンは、同じアドレス空間内で実行されるため、共有メモリへのアクセスは必ず同期が必要になります。非同期だと処理を実行する度に結果が変わってしまいます。
同期するためには、syncパッケージを使用するか、チャネルまたはコンテキストを使用します。

ゴルーチンのイメージ

main関数もゴルーチンの1つになるため、Go言語のプログラムは、最低1つのゴルーチンを持つことになります。
複数の処理をしていくと、その処理毎にゴルーチンが生成され、木構造的に積み上がっていきます。
このゴルーチン間でデータをやり取りする場合、チャネルまたはコンテキストを使用することで実現できます。

ゴルーチンの起動方法

$ go 関数/メソッド名()

関数、メソッド名にgoキーワードをつけることで、異なるスレッドで指定した関数、メソッドの実行ができます。

ゴルーチンの終了条件

ゴルーチンは、下記の条件により処理が終了します。

  1. 関数の処理が終了
  2. returnで関数の処理を抜ける
  3. runtime.Goexit() を実行する

ゴルーチンの実行例

ゴルーチン起動前にmain関数の実行が終了してしまい、ゴルーチンが実行されない

func sayHello(){
  fmt.Println("Hello")
}

func main() {
  fmt.Println("Start main!")
  
  // 実行されない
  go sayHello()

  fmt.Println("Finish main!")
}

syncパッケージのWaitGroupを使用して、ゴルーチンの処理完了を待つ

syncパッケージのAdd関数は、処理するゴルーチンの数を追加し、Wait関数は、Add関数で追加したゴルーチンの数がすべて実行されるまで待機し、Done関数は、ゴルーチンが終了したことを伝播します。

import (
  "fmt"
  "sync"
}

func sayHello(wg *sync.WaitGroup){
  // 関数実行後、ゴルーチンの終了を伝播
  defer wg.Done()
  fmt.Println("ワン")
}

func main() {
  var wg sync.WaitGroup

  // 処理するゴルーチンを追加
  wg.Add(1)

  go func() {
    // 関数実行後、ゴルーチンの終了を伝播
    defer wg.Done()
    fmt.Println("柴犬")
  }()

  // 処理するゴルーチンを追加
  wg.Add(1)

  go sayHello(&wg)

  // Addしたゴルーチンが全てDoneされるまで待機
  wg.Wait()

}

複数のゴルーチンで共有リソースに同時にアクセス(競合)できないように排他権を付与

複数のゴルーチンは、同じメモリアドレス空間を使用するため、同じデータにアクセスする際に注意が必要になります。データにアクセスする順番を考慮しないと実行する度に結果が変わってしまいます。
そこで、それを防ぐために、syncパッケージのWaitGroupとMutexを使用します。
WaitGroupで処理の順番を決定します。Mutexであるゴルーチンにデータの排他権を付与し(Lock)、他のゴルーチンからその共有リソースにアクセスできないようにします。(Unlockすることで排他権をはく奪し、他のゴルーチンがデータにアクセスできるようになります。)
排他権とは、ある処理やリソースを独占し、他のものがその処理やリソースへの関与を排除する権利のことです。

import (
  "fmt"
  "sync"
)

func main() {
  var wg sync.WaitGroup
  var lock sync.Mutex

  var data int

  wg.Add(1)

  // Lock~Unlockで囲まれているリソースは、Unlockされるので他の処理がアクセスすることができない
  go func() {
    defer wg.Done()
    lock.Lock()
    data++
    lock.Unlock()
  }()

  // Addしたゴルーチンが完了するまで、これ以降の処理をしない
  wg.Wait()

  // Lock~Unlockで囲まれているリソースは、Unlockされるので他の処理がアクセスすることができない
  lock.Lock()
  if data == 0 {
    fmt.Println(0)
  } else {
    fmt.Println(data)
  }
  lock.Unlock()
}

syncパッケージのonceを使用して、1度のみ実行するゴルーチンを作成

syncパッケージのDo関数を使用することで、対象のゴルーチンを1度しか呼べないようにすることができます。

import (
  "fmt"
  "sync"
)

func onceDo() {
  fmt.Println("Once Do!")
}

func main() {
  var once sync.Once
  once.Do(onceDo)
  once.Do(onceDo) // 実行されない
  
  fmt.Println("2回目以降は実行されません。")
}

チャネル

チャネルとは、ゴルーチン間でのデータのやり取りを可能にします。
syncパッケージのWaitGroupを利用して、ゴルーチンの同期を行っていましたが、チャネルを利用することでもそれが可能になります。
チャネルには、データの送受信の関係があり、受信側で、受信可能なデータが来るまで処理をしないので、ゴルーチンの同期が可能になります。

チャネルのイメージ

チャネルが並行実行されるゴルーチン間をパイプのような感じで接続しており、あるゴルーチンから別のゴルーチンへ値(今回は、Helloという文字列)を渡しています。

チャネルの生成方法

  1. var 変数名 chan チャネルの型
  2. var 変数名 <- chan チャネルの型
  3. var 変数名 chan <- チャネルの型
  4. 変数名 := make(chan チャネル型)
  5. 変数名 := make(chan チャネル型, バッファ(容量))

チャネルには、バッファを持たせることができ、初期化時に指定することができます。
指定しなければ、バッファは、0になります。
チャネルのバッファがいっぱいの場合、チャネルへのデータ送信をブロックします。また、チャネル内が空の場合、チャネルからのデータの受信をブロックします。
ただし、チャネル内が空のデータを受信しようとすると、デッドロックが発生するため注意が必要になります。

// ①チャネルの生成
var ch1 chan int

// ②チャネルからデータ受信できる受信専用チャネルの生成
var ch2 <- chan int

// ③チャネルへデータ送信できる送信専用チャネルの生成
var ch3 chan <- int

// ④make関数を使ったチャネルの宣言と初期化
ch4 := make(chan int)

// ⑤make関数を使ったバッファ(容量)指定のチャネルの宣言と初期化
ch5 := make(chan int, 5)

チャネルの扱い方

チャネルのゼロ値

チャネルは初期化をしないと、ゼロ値はnilになります。

var ch chan string

fmt.Println(ch) // nil

チャネルでのデータの送受信

「<-」を用いて、データの送受信を行います。
チャネルからのデータを受信する側は、チャネルにデータが送信されるまで処理がブロックされ、待機状態になります。

ch := make(chan string)

go func() {				
  // チャネルへ送信
  ch <- "Hello" 
}()

// チャネルから受信(チャネルにデータを送信するまでブロック)
fmt.Println(<- ch) 

バッファを定義したチャネル

チャネル生成時、make関数の第2引数でバッファの長さを定義したチャネルに対して、バッファの長さ以上の値を送信、受信するとデットロックが発生するため注意が必要になります。
そこで、チャネルを操作する際、バッファ以上のデータを送信しない、バッファが空の時は、チャネルからのデータを受信しない処理を入れることが必要になります。

ch := make(chan int, 2)

ch <- 1
ch <- 2
ch <- 3 // 定義したバッファ数以上のデータを送信するとデッドロックが発生し、処理が終了

fmt.Println(<- ch) // 1
fmt.Println(<- ch) // 2
ch := make(chan int, 2)

ch <- 1
ch <- 2

fmt.Println(<- ch) // 1
fmt.Println(<- ch) // 2
fmt.Println(<- ch) // 定義したバッファ数以上のデータを受信するとデッドロックが発生し、処理が終了

チャネルを閉じる

close関数で、チャネルを閉じることができ、閉じた瞬間にデータを受信することができます。
チャネルを閉じ、チャネルからすべてのデータの受信が終了するとチャネル生成時に定義したデータ型のゼロ値を受信します。(何度受信してもゼロ値が返されます。)
チャネルを閉じる際の注意点

  • 閉じられたチャネルにデータを送信するとパニックし処理が終了する
  • 同じチャネルを2回以上閉じるとパニックし処理が終了する
  • 受信側で閉じた後にチャネルにデータを送信するとパニックするため、送信側が行う
ch := make(chan string)

go func() {				
  // チャネルを閉じる
  close(ch)
}()

// チャネルから受信
fmt.Println(<- ch) 

チャネルが閉じられているか確認する

データの受信式の2つ目のパラメータで、チャネルが閉じられているか確認できます。
受信する値がないまたはcloseしている場合、2つ目のパラメータは、falseになります。

ch := make(chan string)

go func() {
  ch <- "Hello"
			
  // チャネルを閉じる
  close(ch)
}()

for {
  v, ok := <- ch
  fmt.Println(v, ok) // Hello true, "" false

  // ゼロ値を繰り返し続けてしまうので、全データ受信以降は、処理しない
  if (ok == false) {
    return
  }
}

チャネルからの受信データの順序

チャネルからの受信データの順序は、FIFO(First-In-First-Out)なので、先入れ先出しになります。

ch := make(chan int, 3)

go func() {
  ch <- 1
  ch <- 2
  ch <- 3
			
  // チャネルを閉じる
  close(ch)
}()


for {
  v, ok := <- ch
  fmt.Println(v, ok) // 1 true, 2 true, 3 true, 0 false
  
  // ゼロ値を繰り返し続けてしまうので、全データ受信以降は、処理しない
  if (ok == false) {
    return
  }
}

何も受信していないチャネルからデータを受信

何も受信していないチャネルを受信するとデッドロック発生し、処理が終了するため注意が必要になります。

ch := make(chan string)

// デッドロック発生
fmt.Println(<- ch) 

複数チャネルを同時に待ち受け

selectキーワードを使用することで、複数のチャネルを同時に待ち受けることができます。
switch文のように、caseで条件分岐します。
先に受信したチャネルが処理されて、selectの処理を抜けます。
ただし、select内の処理はどのcaseが処理されるかの順番は決まっていません。

import (
  "fmt"
  "time"
)

func main() {
  ch1 := make(chan string)
  ch2 := make(chan int)

  go func() {
    time.Sleep(1 * time.Second)
    ch1 <- "hello" 
  }()

  go func() {
    time.Sleep(1 * time.Second)
    ch2 <- 1
  }()
		
  select {
    case v1 := <- ch1:
      fmt.Println(v1)
    case v2 := <- ch2:
      fmt.Println(v2)
  }
}

複数チャネルを繰り返し受信

select文だけだと、先に受信したチャネルのみ処理されましたが、for文を併せて使用することで、複数のチャネルを受信できるようになります。
無限ループを避けるため、処理を終了するdoneチャネルを用意し、doneチャネルを閉じてデータを受信後、for文を抜けるようにします。

import (
  "fmt"
  "time"
)

func main() {
  ch1 := make(chan string)
  ch2 := make(chan int)
  done := make(chan struct{})

  go func() {
    ch1 <- "Hello" 
  }()

  go func() {
    time.Sleep(1 * time.Second)
    ch2 <- 1

    // doneチャネル閉じる
    close(done)
  }()
		
  for{
    select {
      case v1 := <- ch1:
        fmt.Println(v1)
      case v2 := <- ch2:
        fmt.Println(v2)
      case <- done:
        return
    }
  }
}

関数でチャネルを受信

引数にチャネルを持つ関数は、そのチャネルがデータを受信したら実行されます。

// チャネル生成関数
func makeCh() chan int {
  return make(chan int)
}

// チャネルを引数に持つ関数
func receive(ch <-chan int) int {
  return <- ch
}

func main() {
  ch := makeCh()

  go func(ch chan<- int) {
    ch <- 5
  }(ch)

  fmt.Println(receive(ch))
}

コンテキスト

コンテキストとは、処理のキャンセルやタイムアウト、値を渡したりできる仕組みのことです。

コンテキストには下記のような役割があり、1つの処理が複数のゴルーチンをまたいで行われる場合に有効です。
並列処理の終了には、doneチャネルを使用していましたが、キャンセル理由の伝播やタイムアウト処理を追加すると複雑になってしまいますが、コンテキストを使用することで簡単に実装できるようになります。
各ゴルーチンで起動する関数の第1引数に、contextパッケージの、context.Contextの型を渡すだけで実現することができます。

  • 処理の締め切り(タイムアウト時間)を伝達
  • 処理のキャンセル信号の伝達
  • リクエストスコープ値の伝達


コンテキストには、Deadline(), Done, Err(), Value()の4つのメソッドを持っており、これらのメソッドを使用することで、異なるゴルーチン間の情報伝達ができるようになります。
これらのメソッドは、冪等性を持つように設計されているので、メソッドをいつ呼んでも同じ情報を取得できます。

コンテキストの生成方法

コンテキストを生成するためには、contextパッケージのBackground関数を使用します。

func Background() Context
import "context"

func main() {
  ctx := context.Background()
}

コンテキストのインターフェース

コンテキストには、下記の4つのメソッドが実装されており、生成したコンテキストはこれらのメソッドを使用することができます。

  1. Deadline()
    • 設定したタイムアウト時刻とタイムアウトが設定されているかの論理値を返す
  2. Done()
    • 空構造体の受信専用チャネルを返す
  3. Err()
    • 使用しているコンテキストのエラーメッセージを返す
  4. Value()
    • 指定したキーの値を返す
type Context interface {

  Deadline() (deadline time.Time, ok bool)

  Done() <-chan struct{}

  Err() error

  Value(key interface{}) interface{}
}

コンテキストにキャンセル機能を追加

生成したコンテキストにキャンセル機能を追加するためには、contextパッケージのWithCancel関数を使用します。
WithCancel関数は、2つの値を返し、1つ目は、「引数で渡されたコンテキストにDoneメソッドからキャンセル有無が判断できる機能を追加したコンテキスト」、2つ目は、「コンテキストをキャンセルするための関数」を返します。
2つ目の戻り値で得られたキャンセル関数を使用することで、Doneメソッドから得られるチャネルを閉じることができます。

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
import "context"

func main() {
  ctx, cancel := context.WithCancel(context.Background())
}

コンテキストにタイムアウト機能を追加

生成したコンテキストにタイムアウト機能を追加するためには、contextパッケージのWithDeadline関数/WithTimeout関数を使用します。
WithDeadline関数は、2つの値を返し、1つ目は、「第1引数で渡されたコンテキストにDoneメソッドから得られるチャネルを第2引数で渡した時刻に自動で閉じる機能を追加したコンテキスト」、2つ目は、「コンテキストを手動でキャンセルするための関数」を返します。
WithTimeout関数は、WithDeadline関数と内容はほぼ同じで、違いはタイムアウトは時刻ではなく、時間を指定します。
どちらも2つ目の戻り値で得られたキャンセル関数を使用することで、Doneメソッドから得られるチャネルを手動で閉じることもできます。
閉じられたチャネルを再度閉じるとパニックが発生しますが、タイムアウト後にキャンセル関数で閉じても、パニックは発生しません。なので、タイムアウトを設定したとしても、明示的にキャンセル関数を呼ぶことが望ましいです。
また、Deadlineメソッドを使用することで、タイムアウト時刻とタイムアウトが設定されているかどうかの論理値を取得できます。

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
import "context"

func main() {
  ctx1, cancel1 := context.WithDeadline(context.Background(), time.Now().Add(time.Second))

  ctx2, cancel2 := context.WithTimeout(context.Background(), time.Second)
}

コンテキストにデータを追加

生成したコンテキストにデータを追加するためには、contextパッケージのWithValue関数を使用します。
WithValue関数は、「第1引数で渡されたコンテキストに第2引数で渡されたキーと第3引数に渡された値のデータを追加したコンテキスト」を返します。
また、Valueメソッドでキーを指定することで、追加したデータを取得することができます。

func WithValue(parent Context, key, val interface{}) Context
import "context"

func main() {
  ctx := context.WithValue(context.Background(), "name", "Osamu")
}

コンテキストの実行例

DoneメソッドとWithCancel関数による手動キャンセル

ゴルーチンの呼び出し側からcancel関数を呼び出すことで、Doneメソッドで返すチャネルが閉じられ、処理をキャンセルすることができます。

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

func main() {
  // コンテキストの生成とキャンセル関数を取得
  ctx, cancel := context.WithCancel(context.Background())

  // 生成したコンテキストを渡し、キャンセル関数が実行されることで処理がキャンセルされる
  go func(ctx context.Context) {
    for{
      select {
      case <-ctx.Done():
        fmt.Println("キャンセル")
      default:
        fmt.Println("実行")
       }
      time.Sleep(1 * time.Second)
    }
  }(ctx)

  time.Sleep(500 * time.Millisecond)

  // キャンセル関数を実行し、コンテキストを渡したゴルーチンの処理をキャンセル
  cancel()

  time.Sleep(1 * time.Second)
}

DoneメソッドとWithDeadline関数によるタイムアウトキャンセル

WithDeadline関数で指定した時刻に、Doneメソッドで返すチャネルが自動で閉じられ、処理をキャンセルすることができます。
WithTimeout関数でも実現できます。

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

func main() {
  // 指定期間後にキャンセルするコンテキストを取得
  ctx, _ := context.WithDeadline(context.Background(), time.Now().Add(time.Second))

  select {
  case <- time.After(2 * time.Second):
    fmt.Println("overslept")
  case <- ctx.Done(): // キャンセルされると実行される
    fmt.Println(ctx.Err())
  }
}

Deadlineメソッドによるタイムアウト設定確認

生成したコンテキストのタイムアウト時刻とタイムアウトが設定されているかどうかの論理値を取得できます。

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

// タイムアウトが設定されているか確認
func checkTimeout(ctx context.Context) {
  fmt.Println(ctx.Deadline()) 
}

func main () {

  ctx1, _ := context.WithTimeout(context.Background(), time.Second)

  ctx2 := context.Background()

  checkTimeout(ctx1) // 2022-03-05 13:24:13.242427 +0900 JST m=+1.000000001,  true
  checkTimeout(ctx2) // 0001-01-01 00:00:00 +0000 UTC,  false
}

Errメソッドでコンテキストのエラー内容確認

Errメソッドでは、下記のエラーを取得することができます。

  1. コンテキストがキャンセルされていない場合: nil
  2. コンテキストが明示的にキャンセルされていた場合: Canceledエラー
  3. コンテキストがタイムアウトしていた場合: DeadlineExceededエラー
import (
  "fmt"
  "time"
  "context"
)

// どんなエラーメッセージがでているか確認
func checkErrMsg(ctx context.Context) {
  fmt.Println(ctx.Err()) 
}

func main () {

  ctx1, _ := context.WithCancel(context.Background())

  ctx2, cancel := context.WithCancel(context.Background())
  cancel()
  
  ctx3, _ := context.WithDeadline(context.Background(), time.Now().Add(time.Second))
  
  time.Sleep(2*time.Second)

  checkErrMsg(ctx1) // nil
  checkErrMsg(ctx2) // context canceled
  checkErrMsg(ctx3) // context deadline exceeded
}

ValueメソッドでWithValue関数で追加した値の取得

Valueメソッドでは、コンテキストにWithValue関数で追加したデータをキーを指定し取得することができます。
Valueメソッドの戻り値は、interface型なので、型アサーションを行い、データを取得します。

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

func main () {

  ctx1 :=context.Background()

  // コンテキストにデータを追加
  ctx2 := context.WithValue(ctx, "id", 1)

  // 追加したデータを取得
  val := ctx2.Value("id")
  data, ok := val.(int)
  fmt.Println(data, ok) // 1 true

}

まとめ

この記事では、前回に引き続きGo言語の基礎文法をまとめました。

少しGo言語をいじってみたいと思ったら、ブラウザ上で実行できる公式チュートリアルでコードを実際に書いてみると雰囲気がつかめるかもしれません。
今後、さらにGo言語のことについて記事にしていきたいと思います。

もし「記事の内容が間違えている!」等ありましたら、Twitterまたはお問い合わせフォームからご連絡いただければと思います。

最後までご覧いただきありがとうございました。

参考サイト

A Tour of Go

よくわかるcontextの使い方

context#Context