Chapter 06

並行処理を支えるGoランタイム

さき(H.Saki)
さき(H.Saki)
2021.06.18に更新

この章について

ここからは、並行処理を支えるGoランタイムの中身について触れていきます。
そのためには、ランタイムで出てくる様々な「部品」について触れる必要があります。
この章では、以下のようなそれら「部品」の説明を行います。

  • ランタイム
  • G
  • M
  • P
  • sched
  • sysmon
  • プリエンプション
  • スケジューラ

用語解説

まずは、詳細を述べる際に必要になる用語について説明します。

ランタイム

ランタイムとは、「実行時に必要になるあれこれの部品・環境」のことを指します。

ランタイムが担うお仕事としては以下のようなものがあります。

  • カーネルから割り当てられたメモリを分割し、必要なところに割り当てる
  • ガベージコレクタを動かす
  • ゴールーチンのスケジューリングを行う

これらの機能・動作の実装が書かれているのがGoのruntimeパッケージです。

渋川よしきさん(@shibu_jp)のWeb連載「Goならわかるシステムプログラミング」の中に、以下のような言葉があります。

「GoのランタイムはミニOS」
Go言語のランタイムは、goroutineをスレッドとみなせば、OSと同じ構造であると考えることができます。
出典:Goならわかるシステムプログラミング 第17回 Go言語と並列処理(2)

G

Goのランタイムについて記述する文章において、ゴールーチンのことをGと表現することが多いです。
この実体は、runtimeパッケージ内で定義されているg構造体です。

type g struct {
	// (一部抜粋)
	stackguard0  uintptr	// 該当のGをプリエンプトしていいかのフラグをここに立てる
	m            *m		// 該当のGを実行しているM
	sched        gobuf	// Gに割り当てられたユーザースタック
	atomicstatus uint32	// running、waitingといったGの状態
	preempt      bool	// 該当のGをプリエンプトしていいかのフラグをここに立てる
	waiting      *sudog	// 該当のGを元に作られたsudogの連結リスト(sudogについては次章)
}

出典:runtime/runtime2.go

g構造体の中には、プログラムを実行するにあたって必要な情報[1]がまとまっています。

そのうちの一つがユーザースタックです。
ゴールーチンにはあらかじめユーザースタック(schedフィールドに対応)が割り当てられており、初期値2048byteから動的に増減します。

Gの状態を示すatomicstatusフィールドに入りうる値については、runtime/proc.goにまとめられています。

const (
	// G status
	_Gidle = iota // 0
	_Grunnable // 1
	_Grunning // 2
	_Gsyscall // 3
	_Gwaiting // 4
	// (以下略)
)

出典:runtime/proc.go

M

Goランタイムの文脈において、OSカーネルのマシンスレッドをMと表現します。
runtimeコード内でこれに対応する構造体はmです。

type m struct {
	// (一部抜粋)
	g0            *g       // スケジューラを実行する特殊なルーチンG0
	curg          *g       // 該当のMで現在実行しているG (current running goroutine)
	p             puintptr // 該当のMに紐づいているP (nilならそのMは今は何も実行していない)
	oldp          puintptr // 以前どこのPに紐づいているのかをここに保持(システムコールからの復帰に使う)
	schedlink     muintptr // Mの連結リストを作るためのリンク
	mOS // 該当のMに紐づいているOSのスレッド
}

出典:runtime/runtime2.go

P

Pは、Goプログラム実行に必要なリソースを表す概念です。

A "P" represents the resources required to execute user Go code, such as scheduler and memory allocator state.
A P can be thought of like a CPU in the OS scheduler and the contents of the p type like per-CPU state.

(訳)Pは、スケジューラやメモリアロケータの状態などの、Goコードを実行するために必要なリソースを表しています。
Pは、OSスケジューラに対するCPUのようなものと捉えることができます。また、Pの中身はCPUごとの状態と解釈できます。

出典:runtime/HACKING.md

runtimeパッケージコード内でこれに対応するのがp構造体です。

type p struct {
	// (一部抜粋)
	status      uint32 // syscall待ちなどの状態を記録
	link        puintptr // Pの連結リストを作るためのリンク
	m           muintptr   // 該当のPに紐づいているM (nilならこのPはidle状態)
	// Pごとに存在するGのローカルキュー(連結リスト)
	runqhead uint32
	runqtail uint32
	runq     [256]guintptr

	preempt bool // 該当のPをプリエンプトしていいかのフラグをここに立てる
}

出典:runtime/runtime2.go

ランタイム上で一度にPを最大いくつ起動できるかは、環境変数GOMAXPROCSで定義されています。

また、Pの状態について示すstatusフィールドに入る値は、runtime.proc.go内に定義があります。

const (
	// P status
	_Pidle = iota
	_Prunning
	_Psyscall
	_Pgcstop
	_Pdead
)

出典:runtime/proc.go

sched

runtimeパッケージ内のグローバル変数にschedというものがあります。

var (
	// (一部抜粋)
	sched      schedt
)

出典:runtime/runtime2.go

このグローバル変数は、スケジューリングをするにあたって必要な、Goランタイム全体の環境情報を保持しておくためのものです。

このグローバル変数schedにどんな情報が格納されているのか、構造体型の定義を見てみましょう。

type schedt struct {
	// (一部抜粋)
	// Gのグローバルキュー
	runq     gQueue
	runqsize int32

	midle      muintptr // アイドル状態のMを連結リストで保持
	pidle      puintptr // アイドル状態のPを連結リストで保持
}

出典:runtime/runtime2.go

sysmon

Goのランタイムは、sysmonという特殊なスレッドMをもち、プログラム実行にあたりボトルネックがないかどうかを常に監視しています。
スケジューラによって実行が止められることがないように、sysmonが動いているMは特定のPに紐付けられることはありません。

その実体は、sysmonのMに紐づいたG上で動くsysmon関数です。

// Always runs without a P, so write barriers are not allowed.
func sysmon()

出典:runtime/proc.go

Goランタイムの全体図

これら部品を使ったランタイムの全体図は、以下のようになります。

それぞれの部品について軽く振り返ると、

  • sched.runq: 実行可能なGをためておくグローバルキュー
  • sched.midle: アイドル状態のMを保存しておく連結リスト
  • sched.pidle: アイドル状態のPを保存しておく連結リスト
  • G,M,P: 前述の通り
  • m.curg: 現在M上で動かしているG
  • G0: スケジューラを動かすための特別なG
  • p.runq: それぞれのPごとに持つ、実行可能なGをためておくローカルキュー
  • sysmon: Pなしで動くシステム監視用のM、またはその上で動くG上のsysmon関数

ランタイム中にいくつか存在するMを、多数のGで分け合って使うという状況は、いわば「OSスレッドとゴールーチンはN:M(多:多)の関係である」と捉えることができるでしょう。

実行ゴールーチンのプリエンプション

プリエンプションとは

Goのランタイムは、ずっと一つのゴールーチンを実行させることなく、適度に実行するGを取り換えることでプログラム実行の効率化を図ります。
例えば、I/Oの結果待ちになっているGを実行から外し、その間代わりにCPUリソースを必要としているGを実行すれば効率的、ということはわかると思います。

このように、実行中のタスク(ここではG)を一旦中断することを「プリエンプション」「プリエンプトする」といいます。
そして、実行のボトルネックになっているGを見つけてプリエンプトさせる役割を担っているのがsysmonです。

ここからは、どのようなときにプリエンプトされるのか(=Gの実行が止まるのか)ということについて取りあげます。

プリエンプトの挙動

sysmonによるフラグ付け

常時動いているsysmon関数の中では、retake関数というものが呼ばれています。

func sysmon() {
	// (一部抜粋)
	// retake P's blocked in syscalls
	// and preempt long running G's
	if retake(now)
}

出典:runtime/proc.go

retake関数の中で、「Pの状態がPrunningもしくはPsyscallだったら、preemptoneする」という処理をしています。

func retake(now int64) uint32 {
	// (一部抜粋)
	if s == _Prunning || s == _Psyscall {
		// Preempt G if it's running for too long.
		preemptone(_p_)
	}
}

出典:runtime/proc.go

preemptone関数の中では、Gに「もうプリエンプトしていいですよ」のフラグをつける仕事をしています。

// Tell the goroutine running on processor P to stop.
func preemptone(_p_ *p) bool {
	// (一部抜粋)
	gp.preempt = true
	// Every call in a goroutine checks for stack overflow by
	// comparing the current stack pointer to gp->stackguard0.
	// Setting gp->stackguard0 to StackPreempt folds
	// preemption into the normal stack overflow check.
	gp.stackguard0 = stackPreempt

	// Request an async preemption of this P.
	if preemptMSupported && debug.asyncpreemptoff == 0 {
		_p_.preempt = true
	}
}

出典:runtime/proc.go

スタックチェック時等によるGの退避処理

プリエンプトフラグをたてたGがいつ実際に処理されるかというと、例えば関数実行(function prologue・スタックチェック)やGCのタイミングなど、様々な段階で発生します。

例えばスタックチェックの段階では、runtime·morestack_noctxtが呼ばれます。

// morestack but not preserving ctxt.
TEXT runtime·morestack_noctxt(SB),NOSPLIT,$0
	MOVL	$0, DX
	JMP	runtime·morestack(SB)

出典:runtime/asm_amd64.s

runtime.morestack関数にジャンプしているので、そちらもみてみます。

TEXT runtime·morestack(SB),NOSPLIT,$0-0
	// (略)
	// Call newstack on m->g0's stack.
	CALL	runtime·newstack(SB)

出典:runtime/asm_amd64.s

runtime.newstack関数を呼び出しています。

func newstack() {
	// (一部抜粋)
	if preempt {
		gopreempt_m(gp)
	}
}

出典:runtime/stack.go

プリエンプトしていい環境においてはgopreempt_m関数が呼ばれており、その中のgoschedImpl関数において実際のプリエンプト操作を行っています。

func gopreempt_m(gp *g) {
	// (略)
	goschedImpl(gp)
}

出典:runtime/proc.go

func goschedImpl(gp *g) {
	// (略)
	casgstatus(gp, _Grunning, _Grunnable)
	dropg()   // dropg removes the association between m and the current goroutine m->curg (gp for short).
	lock(&sched.lock)
	globrunqput(gp)
	unlock(&sched.lock)

	schedule()
}

出典:runtime/proc.go

ここでは実際に、

  1. GのステータスをGrunningからGrunnableに変更
  2. GとMを切り離す
  3. 切り離されたGをグローバルキューに入れる
  4. スケジューリングをし直す

という操作を行っています。

空いたMに違うGを割り振り直すスケジューリングについては後述します。

Goのスケジューラ

スケジューラの役目としては、「実行するコードであるG、実行する場所であるM、それを実行する権利とリソースであるPをうまく組み合わせる」ということです。
runtimeパッケージ内のHACKING.mdファイルには、以下のように記述されています。

The scheduler's job is to match up a G (the code to execute), an M (where to execute it), and a P (the rights and resources to execute it).
When an M stops executing user Go code, for example by entering a system call, it returns its P to the idle P pool.
In order to resume executing user Go code, for example on return from a system call, it must acquire a P from the idle pool.

(訳)スケジューラの仕事は、実行するコードであるG・実行する場所であるM・実行する権限やリソースであるPを組み合わせることです。
Mがシステムコールの呼び出しなどでコード実行を中断した場合、Mは紐づいているPをアイドルPプールに返却します。
システムコールから復帰するときなどで、プログラム実行を再開するときには、Pをアイドルプールから再び得る必要があります。

出典:runtime/HACKING.md

OSとは別に言語のスケジューラがある理由

「OSカーネルにもスレッドのスケジューラーがあるのに、なんでGoにも固有のスケジューラがあるの?」という疑問を抱く方も中にはいるでしょう。

理由としては大きく2つあります。

コンテキストスイッチのコスト削減

OSで実行するスレッドを切り替えるのには、プログラムカウンタやメモリ参照場所を切り替えるのに少なからずコストが発生します。
Goでは独自のスケジューラを導入することで、異なるゴールーチンを実行する際にわざわざOSスレッドを切り替えずに済むようにしています。
M上で実行されているGがスケジューラによって切り替えられたとしても、OS側からはコンテキストスイッチが行われたようには見せないようにさせています。

Goのモデルに合わせたスケジューリングを行うため

OSスレッドの切り替えや実行のタイミングは、それぞれの実行環境におけるOSが決定します。
そのため、例えば「今からガベージコレクトするから、スレッドを実行しないで!」というようなGoに合わせた調整をできるようにするためには、Go独自のスケジューラが必要だったという訳です。

実行するGの選び方

スケジューラの仕事が「実行するコードであるG、実行する場所であるM、それを実行する権利とリソースであるPをうまく組み合わせる」ことであることは前述した通りです。
これは具体的にどういうことなのかというと、「実行可能なGを見つけたら、それを実行するように取り計らう」ということです。

これを実際に実装しているのが、runtimeパッケージ内のschedule関数です。

runtime.schedule() {
    // only 1/61 of the time, check the global runnable queue for a G.
    // if not found, check the local queue.
    // if not found,
    //     check the global runnable queue.
    //     if not found, poll network.
    //     if not found, try to steal from other Ps.
}

引用:runtime/proc.go
説明コメント引用:[https://rakyll.org/scheduler/]

様々な状況の中で、このschedule関数がどのような挙動をするのかを順番にみていきましょう。

グローバルキューに実行可能なGがあった場合

あるタイミングにて、スケジューラはグローバルキューにGがないかをチェックして、あった場合は取り出して(=globrunqget関数)それを実行します。

runtime.schedule() {
	if gp == nil {
		// Check the global runnable queue once in a while to ensure fairness.
		if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
			gp = globrunqget(_g_.m.p.ptr(), 1)
		}
	}
	execute(gp, inheritTime)
}

出典:runtime/proc.go

ローカルキューに実行可能なGがあった場合

現在スケジューラが動いているPのローカルキュー中に実行可能なGがあった場合、そこからGを取り出して(=runqget関数)実行します。

runtime.schedule() {
	if gp == nil {
		gp, inheritTime = runqget(_g_.m.p.ptr())
	}
	execute(gp, inheritTime)
}

出典:runtime/proc.go

ネットワークI/Oの準備ができたGがいる場合

例えば「さっきまではネットワークから受信作業をしていたけど、それが終わってもうプログラム実行に戻れる」というGがあった場合、このGの続きを実行するようにします。

この挙動を実装しているのは、schedule関数中で呼び出されているfindrunnable関数です。

runtime.schedule() {
	if gp == nil {
		gp, inheritTime = findrunnable() // ネットワークI/Oで準備ができたやつを拾う
	}
	execute(gp, inheritTime)
}

出典:runtime/proc.go

実際に拾っているところの実装では、「netpoll関数で該当するGをとってくる」→「GのステータスをGwaitingからGrunnableに変えて返り値として返す」という風になっています。

func findrunnable() (gp *g, inheritTime bool) {
	// (一部抜粋)
	if list := netpoll(0); !list.empty() { // non-blocking
		gp := list.pop()
		casgstatus(gp, _Gwaiting, _Grunnable)
		return gp, false
	}
}

出典:runtime/proc.go

Work-Stealingした場合

スケジューラが動いているPのローカルキューに実行可能なGがなかったとしても、他のPがもつローカルキューに実行可能なGが数多く貯まっていた場合、G0のスケジューラが「そこに貯まっているGの半分を取っていて自分のP上で動かす」という挙動をします。これをWork-Stealingといいます。

この挙動を実装しているのは、またもやschedule関数中で呼び出されているfindrunnable関数です。

runtime.schedule() {
	if gp == nil {
		gp, inheritTime = findrunnable() // work-stealingもする
	}
	execute(gp, inheritTime)
}

出典:runtime/proc.go

他のPからGをstealしているところを実際にみてみましょう。
実装を担っているのはfindrunnable関数→stealWork関数→runqsteal関数です。

func findrunnable() (gp *g, inheritTime bool) {
	// (一部抜粋)
	// Spinning Ms: steal work from other Ps.
	gp, inheritTime, tnow, w, newWork := stealWork(now) // stealしてきたGを取得
	if gp != nil {
		// Successfully stole.
		return gp, inheritTime
	}
}

出典:runtime/proc.go

// stealWork attempts to steal a runnable goroutine or timer from any P.
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
	// (一部抜粋)
	if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil {
		return gp, false, now, pollUntil, ranTimer
	}
}

出典:runtime/proc.go

次章予告

次章では、これらの部品が様々な状況においてどのように動作しはたらくのかについて、図を使って詳しく説明していきます。

脚注
  1. 他の情報としては、プログラムカウンタや(今乗っている)OSスレッドなどがあります。 ↩︎

  2. pthreadとはPOSIX標準のスレッドのことを指し、ユーザースレッドに分類される(ユーザースレッドが何かについては11章を参照のこと)。 ↩︎