Goの勉強 共有された変数による並行性

Goの勉強をやり始めたのでメモ。
プログラミング言語 Go」を読んでる。

競合状態

  • あるゴルーチン内のイベントxと別のゴルーチン内のイベントyがあり、xとyのどちらのイベントが先に発火するか不明な場合、xとyは並行であると言う。
  • ある関数が2つ以上のゴルーチンから同期なく呼び出しても正しく動作を続ける場合、その関数は並行的に安全と言う。
  • ドキュメントで型が並行的に安全であると述べられている場合に限り、並行に変数へアクセスすべきである。
    • そうでない場合は単一のゴルーチンに閉じ込めるか、相互排他の高度な不変式を維持する必要がある。
  • パッケージレベルの変数は並行的に安全であることが期待される。
  • データ競合は、2つのゴルーチンが同じ変数へ並行にアクセスし、かつ書き込みがある場合に発生する。
var x []int  
go func() { x = make([]int, 10) } // *1  
go func() { x = make([]int, 100000) } // *2  
x[99999] = 1  

とした場合、1 でスライスのポインタが設定され、2でスライスの長さが設定されるかもしれない。
この場合、基底配列は10の長さしかないため、関係のないメモリを更新してしまう可能性がある。
これを 未定義な振る舞い と言う。

データ競合を回避する方法は3つある。

  • データを変数へ書き込まない
  • 複数のゴルーチンからの変数へのアクセスを避ける
    • ゴルーチンに閉じ込められた変数へのアクセスを仲介するゴルーチンのことをモニターゴルーチンと言う
    • パイプラインを用いて変数を共有した際に、次のステージへ渡した後にその変数にアクセスしないのであれば、アクセスが逐次的になる。この方法を順次閉じ込めと言う。
    • 複数のゴルーチンからの変数へのアクセスを許可するが、一度に単一のゴルーチンだけにアクセスさせる。
      • 相互排他と言う。

相互排他:sync.Mutex

  • 同時実行を制御するためのチャネルを計数セマフォと呼び、その中でも1までしか数えないセマフォバイナリセマフォと言う。
  • このパターンの実装として sync.Mutex がある。
var (  
    mu      sync.Mutex  
    balance int  
)  

func Deposit(amount int) {  
    mu.Lock()  
    balance = balance + amount  
    mu.Unlock()  
}  
  • LockUnlock の間の領域をクリティカルセクションと言う。
  • 上記のように関数・ミューテックスロック・変数を扱うことをモニターと言う。
  • sync.Mutex は再入可能ではないため、二重に呼び出すことはできない。
  • 対応策として、公開関数と内部関数に処理を分ける方法がある。
func Deposit(amount int) {  
    mu.Lock()  
    defer mu.Unlock()  
    deposit(amount)  
}  

func deposit(amount int) {  
    balance += amount  
}  

func Withdraw(amount int) bool {  
    mu.Lock()  
    defer mu.Unlock()  
    deposit(-amount)  
    if balance < 0 {  
        deposit(amount)  
        return false  
    }  
    return true  
}  

リード/ライトミューテックス:sync.RWMutex

  • sync.Mutex のロックを使用する場合、読み込み同士でもロック待ちが発生してしまう。
  • sync.RWMutex.RLock() を使用することで、共有ロックを取得でき、読み込み同士であればロック待ちが発生しない。
  • sync.Mutex.Lock() でロックが取得されている間は sync.RWMutex.RLock() は待たされることになる。

メモリの同期

うーん、わからんかった。

  • 同期をすることにより、タイミングだけでなくメモリの状態も同期することができる。
  • 同期をしない場合、一方で設定した値をもう一方で取得できるとは限らない。
    • CPUコアが複数ある場合など、メモリの持ち方や参照のされ方はハードウェアに依存する。

遅延初期化: Sync.Once

  • コストの高い初期化ステップを必要になるまで遅延させることがある。
  • 一方でそういった処理は並行的に安全に書くコストが高い。
  • Goでは Sync.Once を使って初期化処理を簡単に書くことができる。
var loadIconsOnce sync.Once  
var icons map[string]image.Image  

func Icon(name string) image.Image {  
        loadIconsOnce.Do(loadIcons)  
        return icons[name]  
}  

競合検出器

  • 並行性のミスを発見するための動的解析ツールである競合検出器がGoランタイムにある。
  • --race オプションを付けることで使用できる。
  • 競合検出器はデータ競合をレポートするが、実行時に発生したデータ競合しか検出できない。

ゴルーチンとスレッド

伸長可能なスタック

  • OSのスレッドはたいてい2MBのスタックと呼ばれるメモリ領域を確保する。
    • スタックは実行中の関数呼び出しやローカル変数が保存されている活動領域。
  • 2MBの領域は大きすぎるし、小さすぎる。
    • 簡単な計算しかしないものについては2MBは大きすぎる。
    • ネストの深い複雑な関数を呼び出しているものについては2MBは小さすぎる。
  • ゴルーチンはたいてい2KBのスタックで活動する。
  • ただし、必要に応じて領域を拡張したり縮小したりできる。
  • ゴルーチンのスタックの大きさの制限は1GBである。

ゴルーチンのスケジュール

  • OSスレッドはOSカーネルによってスケジュールされる。
    • 定期的にハードウェアタイマーがプロセッサに割り込み、スケジューラを起動させる。
    • スケジューラは現在のスレッドを一時停止させ、次に実行されるスレッドを調べる。
    • 別のスレッドを実行する必要がある場合は、メモリ上にレジスタの情報を保存し、実行するスレッドのレジスタ情報をメモリから読み込む。
  • OSスレッドは頻繁にメモリの読み書きが発生する。
  • Goのランタイムはm個のゴルーチンをn個のOSスレッドで多重化する。(m:nスケジューリング
    • 時間単位ではなく、 time.Sleep やチャネルの操作があるとスケジューリングを行う。

GOMAXPROCS

  • 能動的に実行できるOSスレッド数を GOMAXPROCS で設定できる。
    • デフォルトはCPU数。
    • ちなみに僕のマシンのCPUは10個でした。
  • runtime.GOMAXPROCS関数を使うか、環境変数を設定することによりこの値を変更できる。
    • 引数に-1を渡すと設定を変えずに現在の値を見ることができる。
  • 以下のゴルーチンはGOMAXPROCSの管理に含まれない。
    • スリープしていたり、通信で待たされているゴルーチン。
      • こちらはそもそもOSスレッドを必要としない。
    • I/Oや他のシステムコール、Goで書かれていない関数呼び出しをしているゴルーチン。

ゴルーチンは識別子を持たない

  • 一般的なマルチスレッドサポートのプログラミング言語では、スレッドに識別子を持たせている
  • これをキーにしたグローバルなマップをスレッドローカル領域と呼び、スレッドごとに独立した値を保存している
  • Goではこの識別子をプログラマが知る手段を提供していない
    • なぜなら乱用されがちだから