1. 概要#
Google が Golang を最初に書いたのは、Google 内部のビジネスの高い並行処理のニーズを解決するためであり、Golang の大きな特徴は高い並行処理です。この記事では、Golang の高い並行処理に関連する原理、概念、技術的なポイントを紹介します。
まず、並行と並列、プロセス、スレッド、コルーチンの違いなどのいくつかの概念を紹介し、その後、Golang の goroutine と channel について説明します。これらは Golang が高い並行処理を実現するための鍵です。次に、select、タイマー、runtime、同期ロックについて話し、最後に Go の並行処理の利点、並行モデル、Go のスケジューラーについて紹介します。
2. 並列と並行#
オペレーティングシステムを学んだことがあるなら、並列と並行に馴染みがあるはずです。
並列:
同時に複数のプロセッサで複数の命令が実行されること
並行:
同時に一つの命令しか実行できないが、複数のプロセスの命令が迅速に切り替えられて実行される(状況に応じて異なる切り替えアルゴリズムがある)
並列と並行の違い:
- 並列はマルチプロセッサシステムに存在し、並行はシングルプロセッサとマルチプロセッサシステムの両方に存在する
- 並列はプログラムが同時に複数の操作を実行できることを要求し、並行はプログラムが同時に複数の操作を実行しているふりをすることを要求する(タイムスライスで一つの操作を実行し、次に複数の操作を切り替える)
3. プロセス、スレッド、コルーチン#
プロセス:
コンピュータ命令、ユーザーデータ、システムデータを含むプログラムの実行環境であり、他のタイプのリソースも含まれる
スレッド:
プロセスに対してより小さく軽量な実体であり、スレッドはプロセスによって作成され、自分自身の制御フローとスタックを持つ。プロセスとスレッドの違いは、プロセスは実行中のバイナリファイルであり、スレッドはプロセスのサブセットである
コルーチン:
コルーチン(goroutine)は Go プログラムの並行実行の最小単位であり、goroutine は Unix のように自治的な実体ではない。goroutine の主な利点は非常に軽量であり、数千から数万の goroutine を簡単に実行できることである。goroutine はスレッドよりも軽量であり、goroutine はプロセスの環境が必要で、goroutine を作成する際にはプロセスが必要であり、そのプロセスには少なくとも一つのスレッドが必要である。コルーチンはユーザーレベルの軽量スレッドであり、コルーチンのスケジューリングは完全にユーザーによって制御され、コルーチン間の切り替えはタスクのコンテキストを保存するだけで済み、カーネルのオーバーヘッドはない。スレッドのスタックスペースは通常 2M であり、Goroutine のスタックスペースは最小 2K である
4. goroutine#
上記でコルーチン(以下、goroutine と統一)について説明しましたが、次に goroutine の実際の構文について説明します。
Go 言語では、go キーワードの後に関数名または完全な匿名関数を定義することで新しい goroutine を開始できます。go キーワードで関数を呼び出すと、すぐに戻り、その関数はバックグラウンドで goroutine として実行され、プログラムの残りの部分は引き続き実行されます。
goroutine を作成する
package main
import (
"fmt"
"time"
)
func main() {
go function()
go func() {
for i := 10; i < 20; i++ {
fmt.Print(i, " ")
}
}()
time.Sleep(1 * time.Second)
}
func function() {
for i := 0; i < 10; i++ {
fmt.Print(i)
}
fmt.Println()
}
上記の出力が固定されていないことに気付くかもしれません(main 関数が早く終了する可能性があります)。この問題を解決するために sync パッケージを使用できます。
package main
import (
"flag"
"fmt"
"sync"
)
func main() {
n := flag.Int("n", 20, "Number of goroutines")
flag.Parse()
count := *n
fmt.Printf("Going to create %d goroutines.\n", count)
var waitGroup sync.WaitGroup //sync.WaitGroup型の変数を定義
fmt.Printf("%#v\n", waitGroup)
for i := 0; i < count; i++ { //必要な数のgoroutineを作成するためにforループを使用
waitGroup.Add(1) //呼び出すたびにsync.WaitGroup変数のカウンターが増加し、競合条件が発生しないようにする
go func(x int) {
defer waitGroup.Done() //sync.WaitGroup変数を減少させる
fmt.Printf("%d ", x)
}(i)
}
fmt.Printf("%#v\n", waitGroup)
waitGroup.Wait() //sync.Waitの呼び出しはブロックされ、sync.WaitGroup変数のカウンターが0になるまで待機し、すべてのgoroutineが完了することを保証する
fmt.Println("\nExiting...")
}
5. channel#
channel(チャネル)は Go の通信メカニズムの一つであり、goroutine 間でデータを転送することを許可します。
いくつかの明確な規定:
- 各 channel は指定された型のデータのみを交換することができ、つまりチャネルの要素型です
- channel が正常に動作するためには、チャネルにデータを受け取る方法が必要です
chan キーワードを使用して channel を宣言できます。close () 関数を使用してチャネルを閉じることができます。
関数として channel を使用する場合、単方向 channel として指定できます。
5.1 channel への書き込み#
package main
import (
"fmt"
"time"
)
func main() {
c := make(chan int)
go writeToChannel(c, 10)
time.Sleep(1 * time.Second)
}
func writeToChannel(c chan int, x int) {
fmt.Println(x)
c <- x
close(c)
fmt.Println(x)
}
5.2 channel からデータを受け取る#
package main
import (
"fmt"
"time"
)
func main() {
c := make(chan int)
go writeToChannel(c, 10)
time.Sleep(1 * time.Second)
fmt.Println("Read:", <-c)
time.Sleep(1 * time.Second)
_, ok := <-c
if ok {
fmt.Println("Channel is open!")
}else {
fmt.Println("Channel is closed!")
}
}
func writeToChannel(c chan int, x int) {
fmt.Println("l", x)
c <- x
close(c)
fmt.Println("2", x)
}
5.3 channel を関数パラメータとして渡す#
package main
import (
"fmt"
//"time"
)
func main() {
c := make(chan bool, 1)
for i := 0; i < 10; i++ {
go Go(c, i)
}
<-c
}
func Go(c chan bool, index int) {
sum := 0
for i := 0; i < 1000000; i++ {
sum += i
}
fmt.Println(sum)
c <- true
}
6. select#
Go の select 文は channels の switch 文のように見えますが、実際には select は goroutine が複数の通信操作を待機することを許可します。したがって、select を使用する主な利点は、select が複数の channels を処理し、非ブロッキング操作を行うことができることです。
注意:channels と select を使用する最大の問題は デッドロック です。デッドロック問題を解決するために、後で同期ロックについて説明します。
package main
import(
"fmt"
"math/rand"
"os"
"strconv"
"time"
)
func main() {
rand.Seed(time.Now().Unix())
createNumber := make(chan int)
end := make(chan bool)
if len(os.Args) != 2 {
fmt.Println("Please give me an integer!")
return
}
n, _ := strconv.Atoi(os.Args[1])
fmt.Printf("Going to create %d random numbers.\n", n)
go gen(0, 2*n, createNumber, end)
for i := 0; i < n; i++ {
fmt.Printf("%d ", <-createNumber)
}
time.Sleep(5 * time.Second) //gen()関数内のtime.After()関数が戻るのに十分な時間を与え、selectブランチをアクティブにします
fmt.Println("Exting...")
end <- true //gen()内のselect文のcase->endブランチをアクティブにしてプログラムを終了し、関連するコードを実行します
}
func gen(min, max int, createNumber chan int, end chan bool) {
for {
select {
case createNumber <- rand.Intn(max-min) + min:
case <- end:
close(end)
return
case <- time.After(4 * time.Second): //time.After関数は指定された時間が経過した後に戻るため、他のchannelsがブロックされているときにselect文を解除します
fmt.Println("\ntime.After()!") //このcaseをdefaultブランチとして扱うことができます
}
}
}
注意:select 文には default ブランチは必要ありません
select 文は順番に評価されるのではなく、すべての channels が同時にチェックされます。
select 文内に準備ができている channels がない場合、select 文は ブロック され、準備ができている channels があるまで待機します。Go ランタイムは、これらの準備ができている channels の間で ランダムに選択 し、公平性を保ちます。
select の最大の利点は、複数の channels を接続、編成、管理できることです。
channels が goroutine に接続されると、select はそれらの接続された goroutine の channels を接続します。
7. タイマー#
select を紹介する際にタイマーも使用されましたが、タイマーとは何でしょうか?
タイマーは、将来の特定の時刻にタスクを実行するために設定されたメカニズムです。
タイマーには二種類があります:
- 一度だけ実行される遅延モード
- 一定の間隔で実行される間隔モード
Go 言語のタイマーは非常に充実しており、すべての API は time パッケージにあります。
7.1 遅延モード#
遅延実行には二種類あります:time.After と time.Sleep
7.1.1 time.After#
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("1")
timeAfterTrigger := time.After(1 * time.Second)
<-timeAfterTrigger
fmt.Println("2")
}
time パッケージは、いくつかの int 型定数を提供しています。
const (
Nanosecond Duration = 1
Microsecond = 1000 * Nanosecond
Millisecond = 1000 * Microsecond
Second = 1000 * Millisecond
Minute = 60 * Second
Hour = 60 * Minute
)
7.1.2 time.Sleep#
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("1")
time.Sleep(1 * time.Second)
fmt.Println("2")
}
両者の違いは:time.Sleep は現在のコルーチンをブロックし、time.After は channel に基づいて実装されており、異なるコルーチン間で伝達できます。
7.2 間隔モード#
間隔モードには二種類があります:一つは N 回実行した後に終了するもので、もう一つはプログラムが休むことなく実行されるものです。
7.2.1 time.NewTicker#
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("1")
count := 0
timeTicker := time.NewTicker(1 * time.Second)
for {
<-timeTicker.C
fmt.Println("毎秒 2 を出力")
count++
if count >= 5 {
timeTicker.Stop()
}
}
}
7.2.2 time.Tick#
package main
import (
"fmt"
"time"
)
func main() {
t := time.Tick(1 * time.Second)
for {
<-t
fmt.Println("毎秒出力")
}
}
7.3 タイマーの制御#
タイマーは Stop メソッドと Reset メソッドを提供します。
- Stop メソッドの役割はタイマーを停止することです
- Reset メソッドの役割はタイマーの間隔時間を変更することです
7.3.1 time.Stop#
package main
import (
"fmt"
"time"
)
func main() {
timer := time.NewTimer(time.Second * 6)
go func() {
<-timer.C
fmt.Println("時間到達")
}()
timer.Stop()
}
7.3.2 time.Reset#
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("1")
count := 0
timeTicker := time.NewTicker(1 * time.Second)
for {
<-timeTicker.C
fmt.Println("2")
count++
if count >= 3 {
timeTicker.Reset(2 * time.Second)
}
}
}
8. runtime#
runtime は Go 言語の実行に必要な基盤であり、goroutine の制御機能、デバッグ、pprof、トレース、レース検出のサポート、メモリ割り当て、システム操作、CPU 関連操作のラッピング(信号処理、システムコール、レジスタ操作、原子操作など)、map、channel、string などの組み込み型およびリフレクションの実装を含みます。
Java や Python の runtime とは異なり、Java や Python の runtime は仮想マシンですが、Go の runtime はユーザーコードと一緒にコンパイルされて実行可能ファイルに含まれます。
runtime の発展の歴史:
9. 同期ロック#
前述の channels と select の最大の問題は デッドロック です。このセクションではデッドロックの問題を解決するための同期ロックについて説明します。
Go 言語の同期ロックには二つの方法があります:原子ロックとミューテックスロックです。
9.1 原子ロック#
特定の信号を利用してすべての goroutine にメッセージを送信できます。
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
var (
shotdown int64 // このフラグは複数のgoroutineに状態を通知します
wg sync.WaitGroup
)
func main() {
wg.Add(2)
go doWork("A")
go doWork("B")
time.Sleep(1 * time.Second)
atomic.StoreInt64(&shotdown, 1) // 変更
wg.Wait()
}
func doWork(s string) {
defer wg.Done()
for {
fmt.Printf("宿題をしている %s\n", s)
time.Sleep(2 * time.Second)
if atomic.LoadInt64(&shotdown) == 1 { // 読み取り
fmt.Printf("宿題を中止 %s\n", s)
break
}
}
}
9.2 ミューテックスロック#
ミューテックスを使用することで、クリティカルセクションを囲み、単一の goroutine のみが実行されるようにできます。
package main
import (
"fmt"
"runtime"
"sync"
)
var (
counter int
wg sync.WaitGroup
mutex sync.Mutex // クリティカルセクションを定義
)
func main() {
wg.Add(2)
go incCount(1)
go incCount(2)
wg.Wait()
fmt.Printf("最終カウンター: %d\n", counter)
}
func incCount(i int) {
defer wg.Done()
for count := 0; count < 2; count++ {
mutex.Lock()
{
value := counter
runtime.Gosched()
value++
counter = value
}
mutex.Unlock()
}
}
10. Go の並行処理の利点#
Go 言語は並行プログラミングのために内蔵された上位 API が CSP(communicating sequential processes、順序通信プロセス)モデルに基づいています。これは、明示的なロックを回避できることを意味し、Go 言語は安全なチャネルを通じてデータを送受信して同期を実現するため、並行プログラムの作成が大幅に簡素化されます。
一般的に、普通のデスクトップコンピュータで十数個から二十個のスレッドを実行すると負荷がかかりますが、同じマシンで数百から数千、さらには数万の goroutine がリソースを競合することができます。
11. Go の並行モデル#
Go 言語は二つの並行形式を実現しています:
- マルチスレッド共有メモリ(共有メモリを介して通信)
- CSP(communicating sequential processes)並行モデル(通信の方法でメモリを共有)
メモリを共有することで通信しないでください。代わりに、通信によってメモリを共有してください。
Java、C++、Python のスレッドは共有メモリを介して通信します。
Go の CSP 並行モデルは goroutine と channel を使用して実現されます。
goroutine と channel の組み合わせの使用例:
package main
import (
"fmt"
)
//データを書き込む
func writeData(intChan chan int) {
for i := 1; i <= 50; i++ {
//データを入れる
intChan<- i //
fmt.Println("writeData ", i)
}
close(intChan) //閉じる
}
//データを読む
func readData(intChan chan int, exitChan chan bool) {
for {
v, ok := <-intChan
if !ok {
break
}
fmt.Printf("readData でデータを読み取った=%v\n", v)
}
//readDataがデータを読み終えた後、タスクが完了
exitChan<- true
close(exitChan)
}
func main() {
//二つのチャネルを作成
intChan := make(chan int, 10)
exitChan := make(chan bool, 1)
go writeData(intChan)
go readData(intChan, exitChan)
for {
_, ok := <-exitChan
if !ok {
break
}
}
}
12 Go のスケジューラー#
Go 言語のスケジューラーは三つの構造を使用しています:
G:
G は goroutine を表し、各 Goroutine は G 構造体に対応し、G は Goroutine の実行スタック、状態、およびタスク関数を保存し、再利用可能です。
M:
M はカーネルスレッドを表し、実際に計算を実行するリソースを表します。有効な P にバインドされると、スケジュールループに入ります。スケジュールループのメカニズムは、Global キュー、P の Local キュー、および wait キューから取得することです。
P:
P は論理プロセッサを表し、スケジューリングのコンテキストを示します。これをローカルスケジューラーとして考えることができ、Go コードを単独のスレッドで実行します。これは Go が N:1 スケジューラーから Mスケジューラーにマッピングするための鍵です。
G にとって、P は CPU コアに相当し、G は P にバインドされて初めてスケジュールされます。
M にとって、P は関連する実行環境(コンテキスト)を提供します。例えば、メモリ割り当て状態(mcache)、タスクキュー(G)などです。
P の数は、システム内で最大の並行 G の数を決定します(前提:物理 CPU コア数 >= P の数)。
P の数はユーザーが設定した GoMAXPROCS によって決まりますが、GoMAXPROCS がどれだけ大きく設定されても、P の数は最大 256 です。
古典的な モグラ叩きのモデル を用いて三者の関係を説明します。
モグラの仕事は:工事現場にあるいくつかのレンガを小車を使って火種まで運ぶことです。
13. まとめ#
この記事では、Golang の並行処理に関連するいくつかの知識を紹介しました。最初は基本的な概念から、並列、並行、プロセス、スレッド、コルーチンまで、次に Golang の並行処理の実際の使用法、goroutine、channel、select、タイマー、同期ロックについて簡単に説明し、最後に runtime と Go のスケジューラーモデルについて紹介しました。