1. 概要#
Google が Golang を最初に書いたのは、Google 内部のビジネスの高い並行性のニーズを解決するためであり、Golang の大きな特徴は高い並行性です。この記事では、Golang の高い並行性に関連する原理、概念、技術的なポイントを紹介します。
まず、並行性と並列性、プロセス、スレッド、コルーチンの違いなどのいくつかの概念を紹介し、その後、Golang の goroutine と channel について説明します。これらは Golang が高い並行性を実現するための鍵です。次に、select、タイマー、runtime、同期ロックについて話し、最後に Go の並行性の利点、並行モデル、Go のスケジューラについて紹介します。
2. 並列性と並行性#
オペレーティングシステムを学んだことがあるなら、並列性と並行性には馴染みがあるはずです。
同時に複数のプロセッサで複数の命令が実行される
{{< /admonition >}}
同時に 1 つの命令しか実行できませんが、複数のプロセスの命令が迅速に切り替えられて実行されます(状況に応じて異なる切り替えアルゴリズムがあります)
並列性と並行性の違い:
- 並列性はマルチプロセッサシステムに存在し、並行性はシングルプロセッサとマルチプロセッサシステムの両方に存在します
- 並列性はプログラムが同時に複数の操作を実行できることを要求しますが、並行性はプログラムが同時に複数の操作を実行しているふりをすることを要求します(1 つのタイムスライスで 1 つの操作を実行し、次に複数の操作を切り替えます)
3. プロセス、スレッド、コルーチン#
コンピュータ命令、ユーザーデータ、システムデータを含むプログラムの実行環境であり、他の種類のリソースを取得することが許可されています
スレッドはプロセスよりも小さく軽量な実体であり、スレッドはプロセスによって作成され、自分自身の制御フローとスタックを持っています。プロセスとスレッドの違いは、プロセスは実行中のバイナリファイルであり、スレッドはプロセスのサブセットです。
コルーチン(goroutine)は Go プログラムの並行実行の最小単位です。goroutine は Unix のように自治的な実体ではなく、goroutine の主な利点は非常に軽量であり、何千もの goroutine を簡単に実行できることです。goroutine はスレッドよりも軽量であり、goroutine が存在するためにはプロセスの環境が必要です。goroutine を作成する際には、プロセスが必要であり、そのプロセスには少なくとも 1 つのスレッドが必要です。コルーチンはユーザーレベルの軽量スレッドであり、コルーチンのスケジューリングは完全にユーザーによって制御され、コルーチン間の切り替えはタスクのコンテキストを保存するだけで、カーネルのオーバーヘッドはありません。スレッドのスタックスペースは通常 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, "goroutinesの数")
flag.Parse()
count := *n
fmt.Printf("%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("\n終了します...")
}
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("読み取り:", <-c)
time.Sleep(1 * time.Second)
_, ok := <-c
if ok {
fmt.Println("チャネルはオープンしています!")
}else {
fmt.Println("チャネルは閉じています!")
}
}
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("整数を指定してください!")
return
}
n, _ := strconv.Atoi(os.Args[1])
fmt.Printf("%dのランダムな数を作成します。\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("終了します...")
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 を紹介する際にタイマーも使用されましたが、タイマーとは何でしょうか?
タイマーは、将来の特定の時刻にタスクを実行するために設定されたメカニズムです。
タイマーには 2 種類あります:
- 一度だけ実行される遅延モード
- 一定の時間間隔で実行される間隔モード
Go 言語のタイマーは非常に充実しており、すべての API は time パッケージにあります。
7.1 遅延モード#
遅延実行には 2 つの方法があります: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 間隔モード#
間隔モードには 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("1秒ごとに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("1秒ごとに出力")
}
}
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 言語の同期ロックには 2 つの方法があります:原子ロックとミューテックスロックです。
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 言語は 2 種類の並行形式を実現しています:
- マルチスレッド共有メモリ(共有メモリを介して通信)
- 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() {
//2つのチャネルを作成
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 言語のスケジューラは 3 つの構造を使用しています:
G は goroutine を表し、各 Goroutine は G 構造体に対応し、G は Goroutine の実行スタック、状態、タスク関数を保存し、再利用可能です。
M はカーネルスレッドを表し、実際に計算を実行するリソースです。有効な P にバインドされると、スケジュールループに入ります。スケジュールループのメカニズムは、Global キュー、P の Local キュー、wait キューから取得することです。
P は論理プロセッサを表し、スケジューリングのコンテキストを示します。これをローカルスケジューラとして考えることができ、Go コードを単独のスレッドで実行します。これは、Go が N:1 スケジューラから Mスケジューラにマッピングされるための鍵です。
G にとって、P は CPU コアに相当し、G は P にバインドされて初めてスケジュールされます。
M にとって、P は関連する実行環境(コンテキスト)を提供します。たとえば、メモリ割り当て状態(mcache)、タスクキュー(G)などです。
P の数は、システム内で最大の並行 G の数を決定します(前提:物理 CPU コア数 >= P の数)。
P の数はユーザーが設定する GoMAXPROCS によって決まりますが、GoMAXPROCS がどれだけ大きく設定されても、P の数は最大 256 です。
古典的な モグラ叩きの車を使ってレンガを運ぶ モデルを使って 3 者の関係を説明します。
モグラの作業タスクは:工事現場にあるいくつかのレンガを、小車を使って火種まで運ぶことです。
13. まとめ#
この記事では、Golang の並行性に関連するいくつかの知識を紹介しました。最初は並行性、並列性、プロセス、スレッド、コルーチンなどの基本概念から始まり、次に Golang の並行性の実際の使用法(goroutine、channel、select、タイマー、同期ロック)を紹介し、runtime について簡単に説明し、最後に Go のスケジューラモデルについて紹介しました。