著者は、 Diversity in Tech Fund を選択して、 Write forDOnationsプログラムの一環として寄付を受け取りました。

序章

Go言語の人気のある機能の1つは、同時実行のファーストクラスのサポート、またはプログラムが一度に複数のことを実行できることです。 コンピューターが単一のコードストリームをより高速に実行することから、より多くのコードストリームを同時に実行することへと移行するにつれて、コードを同時に実行できることはプログラミングの大きな部分になりつつあります。 プログラムをより高速に実行するには、プログラマーは、プログラムの各並行部分を他の部分から独立して実行できるように、プログラムを同時に実行するように設計する必要があります。 Goの2つの機能、goroutineschannelsは、一緒に使用すると並行性を容易にします。 Goroutinesは、プログラムで並行コードを設定して実行することの難しさを解決し、チャネルは、同時に実行されるコード間で安全に通信することの難しさを解決します。

このチュートリアルでは、ゴルーチンとチャネルの両方を探索します。 まず、ゴルーチンを使用して複数の関数を一度に実行するプログラムを作成します。 次に、そのプログラムにチャネルを追加して、実行中のゴルーチン間で通信します。 最後に、プログラムにさらにゴルーチンを追加して、複数のワーカーゴルーチンで実行されているプログラムをシミュレートします。

前提条件

このチュートリアルに従うには、次のものが必要です。

  • Goバージョン1.16以降がインストールされています。これは、シリーズGoのローカルプログラミング環境をインストールおよびセットアップする方法に従って実行できます。
  • Goチュートリアルで関数を定義および呼び出す方法にあるGo関数に精通していること。

Goroutinesと同時に関数を実行する

最近のコンピューターでは、プロセッサー、つまり CPU は、できるだけ多くのコードストリームを同時に実行するように設計されています。 これらのプロセッサには1つ以上の「コア」があり、それぞれが同時に1つのコードストリームを実行できます。 したがって、プログラムが同時に使用できるコアが多いほど、プログラムの実行速度は速くなります。 ただし、プログラムがマルチコアが提供する速度の向上を利用するには、プログラムを複数のコードストリームに分割できる必要があります。 プログラムをパーツに分割することは、プログラミングで行うのが難しいことの1つですが、Goはこれを簡単にするように設計されています。

Goがこれを行う1つの方法は、goroutinesと呼ばれる機能を使用することです。 ゴルーチンは、他のゴルーチンも実行されているときに実行できる特殊なタイプの関数です。 プログラムが一度に複数のコードストリームを実行するように設計されている場合、プログラムは同時にを実行するように設計されています。 通常、関数が呼び出されると、実行を継続した後、コードの前に完全に実行が終了します。 これは、プログラムが終了する前に他のことを実行できないため、「フォアグラウンド」での実行と呼ばれます。 ゴルーチンを使用すると、ゴルーチンが「バックグラウンド」で実行されている間、関数呼び出しはすぐに次のコードを実行し続けます。 コードが終了する前に他のコードの実行を妨げない場合、コードはバックグラウンドで実行されていると見なされます。

パワーゴルーチンが提供するのは、各ゴルーチンをプロセッサコアで同時に実行できることです。 コンピュータに4つのプロセッサコアがあり、プログラムに4つのゴルーチンがある場合、4つのゴルーチンすべてを同時に実行できます。 このように、コードの複数のストリームが異なるコアで同時に実行されている場合、それはparallelでの実行と呼ばれます。

並行性と並列性の違いを視覚化するために、次の図を検討してください。 プロセッサが関数を実行するとき、最初から最後まで一度に実行されるとは限りません。 関数がファイルの読み取りなど、他の何かが発生するのを待っているときに、オペレーティングシステムがCPUコア上の他の関数、ゴルーチン、または他のプログラムをインターリーブする場合があります。 この図は、並行性のために設計されたプログラムが、単一のコアと複数のコアでどのように実行できるかを示しています。 また、単一のコアで実行する場合よりも、並列で実行する場合に、ゴルーチンのより多くのセグメントが同じ時間枠に収まる可能性があることも示しています(図に示すように9つの垂直セグメント)。

Diagram split into two columns, labeled Concurrency and Parallelism. The Concurrency column has a single tall rectangle, labeled CPU core, divided into stacked sections of varying colors signifying different functions. The Parallelism column has two similar tall rectangles, both labeled CPU core, with each stacked section signifying different functions, except it only shows goroutine1 running on the left core and goroutine2 running on the right core.

図の左側の「同時実行」というラベルの付いた列は、goroutine1の一部を実行し、次に別の関数、ゴルーチン、またはプログラムを実行してからgoroutine2goroutine1、というように続きます。 ユーザーには、実際には小さな部分で次々に実行されているにもかかわらず、プログラムがすべての関数またはゴルーチンを同時に実行しているように見えます。

図の右側の「並列処理」というラベルの付いた列は、2つのCPUコアを搭載したプロセッサで同じプログラムを並列実行する方法を示しています。 最初のCPUコアはgoroutine1が他の関数、ゴルーチン、またはプログラムと散在して実行されていることを示し、2番目のCPUコアはgoroutine2がそのコアで他の関数またはゴルーチンとともに実行されていることを示しています。 goroutine1goroutine2の両方が、異なるCPUコア上で同時に実行されている場合があります。

この図は、Goのもう1つの強力な特性であるスケーラビリティも示しています。 プログラムは、数個のプロセッサコアを備えた小さなコンピューターから、数十個のコアを備えた大きなサーバーまで、あらゆるもので実行でき、それらの追加リソースを利用できる場合、スケーラブルです。 この図は、ゴルーチンを使用することにより、並行プログラムが単一のCPUコアで実行できることを示していますが、CPUコアが追加されると、より多くのゴルーチンを並行して実行してプログラムを高速化できます。

新しい並行プログラムを開始するには、選択した場所にmultifuncディレクトリを作成します。 プロジェクト用のディレクトリがすでにある場合もありますが、このチュートリアルでは、projectsというディレクトリを作成します。 projectsディレクトリは、IDEまたはコマンドラインから作成できます。

コマンドラインを使用している場合は、projectsディレクトリを作成し、そこに移動することから始めます。

  1. mkdir projects
  2. cd projects

projectsディレクトリから、mkdirコマンドを使用してプログラムのディレクトリ(multifunc)を作成し、次の場所に移動します。

  1. mkdir multifunc
  2. cd multifunc

multifuncディレクトリに移動したら、nanoまたはお気に入りのエディタを使用して、main.goという名前のファイルを開きます。

  1. nano main.go

main.goファイルに次のコードを貼り付けるか入力して、開始します。

projects / multifunc / main.go
package main

import (
	"fmt"
)

func generateNumbers(total int) {
	for idx := 1; idx <= total; idx++ {
		fmt.Printf("Generating number %d\n", idx)
	}
}

func printNumbers() {
	for idx := 1; idx <= 3; idx++ {
		fmt.Printf("Printing number %d\n", idx)
	}
}

func main() {
	printNumbers()
	generateNumbers(3)
}

この初期プログラムは、generateNumbersprintNumbersの2つの関数を定義し、main関数でこれらの関数を実行します。 generateNumbers関数は、「生成」する数値の量(この場合は1から3)をパラメーターとして受け取り、それらの各数値を画面に出力します。 printNumbers関数はまだパラメーターを取りませんが、1から3までの数字も出力します。

main.goファイルを保存したら、go runを使用して実行し、出力を確認します。

  1. go run main.go

出力は次のようになります。

Output
Printing number 1 Printing number 2 Printing number 3 Generating number 1 Generating number 2 Generating number 3

関数が次々に実行され、printNumbersが最初に実行され、generateNumbersが2番目に実行されます。

ここで、printNumbersgenerateNumbersの実行にそれぞれ3秒かかると想像してください。 同期を実行する場合、または最後の例のように次々に実行する場合、プログラムの実行には6秒かかります。 最初に、printNumbersが3秒間実行され、次にgenerateNumbersが3秒間実行されます。 ただし、プログラムでは、これら2つの関数は、実行するために他方からのデータに依存しないため、互いに独立しています。 これを利用して、ゴルーチンを使用して関数を同時に実行することにより、この架空のプログラムを高速化できます。 両方の機能が同時に実行されている場合、理論的には、プログラムは半分の時間で実行できます。 printNumbersgenerateNumbersの両方の機能の実行に3秒かかり、両方がまったく同時に開始した場合、プログラムは3秒で終了する可能性があります。 (ただし、実際の速度は、コンピューターに搭載されているコアの数や、コンピューターで同時に実行されている他のプログラムの数など、外部要因によって異なる場合があります。)

ゴルーチンとして関数を同時に実行することは、関数を同期的に実行することに似ています。 (標準の同期関数ではなく)関数をゴルーチンとして実行するには、関数呼び出しの前にgoキーワードを追加するだけです。

ただし、プログラムでgoroutineを同時に実行するには、さらに1つの変更を加える必要があります。 両方のゴルーチンの実行が終了するまでプログラムが待機する方法を追加する必要があります。 ゴルーチンが終了するのを待たずにmain関数が完了すると、ゴルーチンが実行されないか、一部のみが実行されて実行が完了しない可能性があります。

関数が終了するのを待つには、GoのsyncパッケージのWaitGroupを使用します。 syncパッケージには、プログラムのさまざまな部分を同期するように設計されたWaitGroupなどの「同期プリミティブ」が含まれています。 あなたの場合、同期は両方の関数の実行が終了したことを追跡するので、プログラムを終了できます。

WaitGroupプリミティブは、AddDone、およびWait関数を使用して待機する必要があるものの数をカウントすることによって機能します。 Add関数は、関数に指定された数だけカウントを増やし、Doneはカウントを1つ減らします。 次に、Wait関数を使用して、カウントがゼロになるまで待機できます。つまり、Doneは、Addへの呼び出しを相殺するのに十分な回数呼び出されています。 カウントがゼロになると、Wait関数が戻り、プログラムは実行を継続します。

次に、main.goファイルのコードを更新して、goキーワードを使用して両方の関数をゴルーチンとして実行し、プログラムにsync.WaitGroupを追加します。

projects / multifunc / main.go
package main

import (
	"fmt"
	"sync"
)

func generateNumbers(total int, wg *sync.WaitGroup) {
	defer wg.Done()

	for idx := 1; idx <= total; idx++ {
		fmt.Printf("Generating number %d\n", idx)
	}
}

func printNumbers(wg *sync.WaitGroup) {
	defer wg.Done()

	for idx := 1; idx <= 3; idx++ {
		fmt.Printf("Printing number %d\n", idx)
	}
}

func main() {
	var wg sync.WaitGroup

	wg.Add(2)
	go printNumbers(&wg)
	go generateNumbers(3, &wg)

	fmt.Println("Waiting for goroutines to finish...")
	wg.Wait()
	fmt.Println("Done!")
}

WaitGroupを宣言した後、待機するものの数を知る必要があります。 ゴルーチンを開始する前にmain関数にwg.Add(2)を含めると、wgは、グループが終了したと見なす前に2つのDone呼び出しを待機するように指示されます。 goroutinesが開始される前にこれが行われないと、wgDone呼び出しを待機する必要があることを認識しないため、問題が発生したり、コードがパニックになったりする可能性があります。 。

次に、各関数はdeferを使用してDoneを呼び出し、関数の実行が終了した後にカウントを1つ減らします。 main関数も更新され、WaitGroupWaitの呼び出しが含まれるため、main関数は両方の関数が[を呼び出すまで待機します。 X154X]実行を継続し、プログラムを終了します。

main.goファイルを保存したら、前と同じようにgo runを使用して実行します。

  1. go run main.go

出力は次のようになります。

Output
Printing number 1 Waiting for goroutines to finish... Generating number 1 Generating number 2 Generating number 3 Printing number 2 Printing number 3 Done!

出力はここに印刷されているものとは異なる場合があり、プログラムを実行するたびに変わる可能性があります。 両方の関数が同時に実行されている場合、出力は、Goとオペレーティングシステムが各関数の実行に与える時間によって異なります。 各関数を完全に実行するのに十分な時間があり、両方の関数がシーケンス全体を中断することなく出力することがあります。 また、上記の出力のようにテキストが散在している場合もあります。

試すことができる実験は、main関数のwg.Wait()呼び出しを削除し、go runでプログラムを数回再度実行することです。 お使いのコンピューターによっては、generateNumbersおよびprintNumbers関数からの出力が表示される場合がありますが、それらからの出力がまったく表示されない場合もあります。 Waitの呼び出しを削除すると、プログラムは両方の関数の実行が終了するのを待たずに続行します。 main関数は、Wait関数の直後に終了するため、プログラムがmain関数の最後に到達し、ゴルーチンの実行が終了する前に終了する可能性があります。 これが発生すると、いくつかの数字が印刷されますが、各関数の3つすべてが表示されるわけではありません。

このセクションでは、goキーワードを使用して、2つのゴルーチンを同時に実行し、数列を出力するプログラムを作成しました。 また、sync.WaitGroupを使用して、プログラムを終了する前に、これらのゴルーチンが終了するのをプログラムに待機させました。

generateNumbersおよびprintNumbers関数には戻り値がないことに気付いたかもしれません。 Goでは、ゴルーチンは標準関数のように値を返すことができません。 goキーワードを使用して値を返す関数を呼び出すことはできますが、それらの戻り値は破棄され、アクセスできなくなります。 では、値を返すことができない場合、あるゴルーチンのデータが別のゴルーチンに必要な場合はどうしますか? 解決策は、「チャネル」と呼ばれるGo機能を使用することです。これにより、あるゴルーチンから別のゴルーチンにデータを送信できます。

チャネルを使用してGoroutine間で安全に通信する

並行プログラミングのより難しい部分の1つは、同時に実行されているプログラムのさまざまな部分の間で安全に通信することです。 注意しないと、並行プログラムでのみ発生する可能性のある問題が発生する可能性があります。 たとえば、データレースは、プログラムの2つの部分が同時に実行されており、一方の部分が変数を更新しようとし、もう一方の部分が同時にそれを読み取ろうとしている場合に発生する可能性があります。 これが発生すると、読み取りまたは書き込みが順不同で発生し、プログラムの一方または両方の部分が間違った値を使用する可能性があります。 「データレース」という名前は、データにアクセスするために互いに「レース」するプログラムの両方の部分に由来しています。

Goでのデータの競合などの同時実行の問題が発生する可能性はありますが、この言語は、それらを回避しやすくするように設計されています。 ゴルーチンに加えて、チャネルは並行性をより安全で使いやすくするもう1つの機能です。 チャネルは、データを送信できる2つ以上の異なるゴルーチン間のパイプのように考えることができます。 1つのゴルーチンがパイプの一方の端にデータを入れ、別のゴルーチンが同じデータを取り出します。 データが一方から他方に安全に移動することを確認することの難しい部分は、あなたのために処理されます。

Goでのチャネルの作成は、組み込みのmake()関数を使用してスライスを作成する方法と似ています。 チャネルの型宣言では、chanキーワードに続いて、チャネルで送信するタイプのデータを使用します。 たとえば、int値を送信するためのチャネルを作成するには、タイプchan intを使用します。 []byte値を送信するためのチャネルが必要な場合は、次のようにchan []byteになります。

bytesChan := make(chan []byte)

チャネルが作成されると、矢印のように見える<-演算子を使用して、チャネルでデータを送受信できます。 チャネル変数に対する<-演算子の位置によって、チャネルからの読み取りかチャネルへの書き込みかが決まります。

チャネルに書き込むには、チャネル変数から始めて、<-演算子、チャネルに書き込む値の順に続けます。

intChan := make(chan int)
intChan <- 10

チャネルから値を読み取るには、値を入力する変数から始めて、=または:=のいずれかで変数に値を割り当て、次に [X173X ]演算子、次に読み取りたいチャネル:

intChan := make(chan int)
intVar := <- intChan

これらの2つの操作をまっすぐに保つには、<-の矢印が常に左を指し(->ではなく)、矢印が値の移動先を指していることを覚えておくと便利です。 チャネルに書き込む場合、矢印は値をチャネルに向けます。 チャネルから読み取る場合、矢印はチャネルを変数に向けます。

スライスのように、チャネルはforループrangeキーワードを使用して読み取ることもできます。 rangeキーワードを使用してチャネルが読み取られると、ループの各反復でチャネルから次の値が読み取られ、ループ変数に入れられます。 次に、チャネルが閉じられるか、forループがbreakなどの他の方法で終了するまで、チャネルからの読み取りを続行します。

intChan := make(chan int)
for num := range intChan {
	// Use the value of num received from the channel
	if num < 1 {
		break
	}
}

場合によっては、関数がチャネルからの読み取りまたはチャネルへの書き込みのみを許可し、両方を許可したくない場合があります。 これを行うには、<-演算子をchan型宣言に追加します。 チャネルからの読み取りと書き込みと同様に、チャネルタイプは<-矢印を使用して、変数がチャネルを読み取りのみ、書き込みのみ、または読み取りと書き込みの両方に制限できるようにします。 たとえば、int値の読み取り専用チャネルを定義するには、型宣言は<-chan intになります。

func readChannel(ch <-chan int) {
	// ch is read-only
}

チャネルを書き込み専用にする場合は、chan<- intとして宣言します。

func writeChannel(ch chan<- int) {
	// ch is write-only
}

矢印が読み取り用のチャネルを指し、書き込み用のチャネルを指していることに注意してください。 chan intの場合のように、宣言に矢印がない場合は、チャネルを読み取りと書き込みの両方に使用できます。

最後に、チャネルが使用されなくなったら、組み込みのclose()機能を使用してチャネルを閉じることができます。 チャネルが作成され、プログラム内で何度も使用されないままになると、メモリリークと呼ばれるものが発生する可能性があるため、この手順は不可欠です。 メモリリークとは、プログラムがコンピュータのメモリを使い果たすものを作成したが、使用が完了するとそのメモリをコンピュータに解放しない場合です。 これにより、プログラムは、水漏れのように、時間の経過とともにより多くのメモリを消費するようになります(またはそれほど遅くない場合もあります)。 make()でチャネルを作成すると、コンピュータのメモリの一部がチャネルに使用され、チャネルでclose()が呼び出されると、そのメモリがコンピュータに戻されて使用されます。他の何かのために。

次に、プログラムのmain.goファイルを更新して、chan intチャネルを使用してゴルーチン間で通信します。 generateNumbers関数は数値を生成してチャネルに書き込み、printNumbers関数はそれらの数値をチャネルから読み取って画面に出力します。 main関数では、他の各関数にパラメーターとして渡す新しいチャネルを作成し、チャネルでclose()を使用して、使用されなくなるため、チャネルを閉じます。 。 generateNumbers関数も、その関数の実行が完了すると、プログラムは必要なすべての数値の生成を終了するため、もはやゴルーチンではありません。 このように、close()関数は、両方の関数の実行が終了する前にのみチャネルで呼び出されます。

projects / multifunc / main.go
package main

import (
	"fmt"
	"sync"
)

func generateNumbers(total int, ch chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()

	for idx := 1; idx <= total; idx++ {
		fmt.Printf("sending %d to channel\n", idx)
		ch <- idx
	}
}

func printNumbers(ch <-chan int, wg *sync.WaitGroup) {
	defer wg.Done()

	for num := range ch {
		fmt.Printf("read %d from channel\n", num)
	}
}

func main() {
	var wg sync.WaitGroup
	numberChan := make(chan int)

	wg.Add(2)
	go printNumbers(numberChan, &wg)

	generateNumbers(3, numberChan, &wg)

	close(numberChan)

	fmt.Println("Waiting for goroutines to finish...")
	wg.Wait()
	fmt.Println("Done!")
}

generateNumbersおよびprintNumbersのパラメーターでは、chanタイプが読み取り専用および書き込み専用タイプを使用していることがわかります。 generateNumbersはチャネルに番号を書き込むことができるだけでよいため、<-矢印がチャネルを指す書き込み専用タイプです。 printNumbersはチャネルから番号を読み取ることができる必要があるだけなので、<-矢印がチャネルから離れる方向を指す読み取り専用タイプです。

これらのタイプはchan intであり、読み取りと書き込みの両方が可能ですが、[ X243X]デッドロック