Chapter 07

Goランタイムケーススタディ

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

この章について

Goランタイムにどのような部品があるのか、またスケジューラとプリエンプトの挙動について理解したので、ここではそれらがある状況においてどう動くのかについて掘り下げていきましょう。

システムコールが呼ばれたとき

システムコールが呼ばれたとき、カーネルで実際に実行している間の処理待ち時間中は、そのGで実行できることは何もないので、その際は他のGにPやMといったリソースを譲るという動きが発生します。

syscall.Syscallが呼ばれたとき

os.File型のWrite()メソッドのように、システムコールが呼ばれるときには内部でsyscall.Syscall関数が呼ばれます。
これの実装はOSごとに異なりますが、例えばMacの場合はruntime.syscall_syscall関数がそれにあたります。

//go:linkname syscall_syscall syscall.syscall
func syscall_syscall(fn, a1, a2, a3 uintptr) (r1, r2, err uintptr) {
	entersyscall()
	// (以下略)
}

出典:runtime/sys_darwin.go

entersyscall関数は、内部的にはreentersyscall関数の呼び出しです。

func entersyscall() {
	reentersyscall(getcallerpc(), getcallersp())
}

出典:untime/proc.go
このreentersyscall関数の内部で、システムコールに入ったMをPから切り離す作業をしています。

// The goroutine g is about to enter a system call.
func reentersyscall(pc, sp uintptr) {
	// (一部抜粋)
	// 1. PとMを切り離す
	pp := _g_.m.p.ptr()
	pp.m = 0
	_g_.m.oldp.set(pp)
	_g_.m.p = 0
	// 2. PのステータスをPsyscallに変える
	atomic.Store(&pp.status, _Psyscall)
}

出典:runtime/proc.go

こうして、諸々の処理を終えてからPの状態をPsyscallに変えておくことで、「プリエンプトしていいですよ」ということをsysmonに教えておくのです。

sysmonの中

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

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

出典:runtime/proc.go

このretake関数ですが、システムコール時には、プリエンプトさせる他にもhandoffp関数の実行も行っています。

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

出典:runtime/proc.go

handoffp関数の中では、システムコール待ちGをもつMの代わりに、アイドルプールから新しいMを持ってくるstartm関数を実行しています。

func handoffp(_p_ *p) {
    // (一部抜粋)
    startm(_p_, false)
	return
}

出典:runtime/proc.go

システムコールからの復帰

さて、システムコールから復帰する際には、exitsyscall関数によって後処理がなされます。

//go:linkname syscall_syscall syscall.syscall
func syscall_syscall(fn, a1, a2, a3 uintptr) (r1, r2, err uintptr) {
	entersyscall()
	libcCall(unsafe.Pointer(abi.FuncPCABI0(syscall)), unsafe.Pointer(&fn))
	exitsyscall()
	return
}

出典:runtime/sys_darwin.go

この後処理は簡単です。GのステータスをGrunningに変更します。こうすることで、スケジューラによって選ばれる実行対象に再び入ることになります。

// The goroutine g exited its system call.
// Arrange for it to run on a cpu again.
func exitsyscall() {
	// (一部抜粋)
	casgstatus(_g_, _Gsyscall, _Grunning)
}

出典:runtime/proc.go

ネットワークI/Oが発生したとき

ネットワークI/Oが発生したときには、通常その該当スレッドをブロックするような処理となります。
しかし、それでは効率が悪いので、Goでは言語固有のスケジューラの方でそれを非同期処理に変えて処理しています。

Linuxではこの「ブロック処理→非同期処理」への変更を、epollと呼ばれる仕組みを使って行っています。

epollについて

epollとは「複数のfd(ファイルディスクリプタ)を監視し、その中のどれかが入出力可能な状態(=イベント発生)になったらそれを通知する」という機能を持ちます。

epoll使用の流れとしては以下のようになります。

  1. epoll_create1関数でepollインスタンスを作り、返り値としてそのインスタンスのfdを受け取る
  2. epoll_ctl関数で、epollの監視対象のfdを編集する
  3. epoll_wait関数で、監視対象に何かイベントが起こっていないかをチェックする

Goのランタイム内では、このepollの仕組みが存分に利用されています。
これから詳細を見ていきましょう。

Goランタイムの中でのepoll

epollを使うためには、まずはepollインスタンスが必要です。
Goでは、ランタイム中からepollインスタンスを利用できるように、そのepollインスタンスのfdを保存しておくグローバル変数epfdが用意されています。

epfd int32 = -1 // epoll descriptor

出典:runtime/netpoll_epoll.go

このepfd変数の初期値は-1ですが、epollインスタンスが必要になった段階でnetpollinitが呼ばれ、本物のfdの値が格納されます。

func netpollinit() {
	epfd = epollcreate1(_EPOLL_CLOEXEC) // epoll_create1関数でepollインスタンスを得る
}

出典:runtime/netpoll_epoll.go

I/O発生時の挙動

ここからは、このepollインスタンスを使って、ネットワークI/Oをランタイムがどう処理しているのかについて見ていきましょう。

net.Dial等でのコネクション発生時

例えば、net.Dial関数を使ってサーバーとのコネクションができたとしましょう。
すると、内部では以下の順番で関数が呼ばれていきます。

  1. net.Dial関数
  2. (*net.Dialer)DialContextメソッド
  3. (*net.sysDialer)dialSerialメソッド
  4. (*net.sysDialer)dialSingleメソッド
  5. (*net.sysDialer)dialTCPメソッド
  6. (*net.sysDialer)doDialTCPメソッド
  7. net.internetSocket関数
  8. net.socket関数

このnet.socket関数の返り値が、ネットワークI/Oに直接対応するfdそのものとなります。
他にもこのsocket関数の中では「この得られる返り値のfdをepollの監視対象として登録する」という処理も行っています。(該当箇所はfd.dialメソッド)

// socket returns a network file descriptor that is ready for
// asynchronous I/O using the network poller.
func socket(ctx context.Context, net string, family, sotype, proto int, ipv6only bool, laddr, raddr sockaddr, ctrlFn func(string, string, syscall.RawConn) error) (fd *netFD, err error) {
	// (一部抜粋)
	if fd, err = newFD(s, family, sotype, net); // ネットワークI/Oに対応するfdを入手
	fd.dial(ctx, laddr, raddr, ctrlFn) // epollの監視対象に入れる
	return fd, nil
}

出典:net/sock_posix.go

実際に、(*net.netFD)dialメソッドの中身を辿っていくと、

  1. (*net.netFD)fd.init()メソッド
  2. (*poll.FD)Initメソッド
  3. (*poll.pollDesc)initメソッド
  4. poll.runtime_pollOpen関数
  5. runtime.poll_runtime_pollOpen関数
  6. runtime.netpollopen関数
  7. runtime.epollctl関数

というように、ちゃんとepollctlにたどり着きます。

こうしてepollの監視対象として登録されたことで、I/Oが終了したときに処理に復帰する準備が整いました。
この後は、おそらく「実行に時間がかかりすぎているG」としてプリエンプトの対象となり、該当のGがMから外れることになるでしょう。

I/Oが終わったあと、後続の処理に復帰するための仕組みはsysmonの中で、epoll_waitを使って作られています。

sysmonの中

常時動いているsysmon関数の中では、「epollで実行可能になっているGがないかを探し(=netpoll関数)、あったらそれをランキューに入れる(=injectglist関数)」という挙動を常に実行しています。

func sysmon() {
	// (一部抜粋)
	list := netpoll(0) // non-blocking - returns list of goroutines
	if !list.empty() {
		injectglist(&list) // adds each runnable G on the list to some run queue
	}
}

出典:runtime/proc.go

実行可能なGを探し取得するnetpoll関数の内部では、まさにepoll_wait関数の存在を確認できます。
epoll_waitでイベント発生(=I/O実行待ちが終わった)が通知されたGが、まさに「実行可能なGのリスト」となるのです。

// netpoll checks for ready network connections.
// Returns list of goroutines that become runnable.
func netpoll(delay int64) gList {
	// (一部抜粋)
	// epollwaitは、epollインスタンス上でイベントがあったか監視して、
	// あったらその内容を第二引数に埋めて、イベント個数を返り値nに入れる
	var events [128]
	n := epollwait(epfd, &events[0], int32(len(events)), waitms)

	// epollwaitの結果から、Gのリストを作る
	var toRun gList
	for i := int32(0); i < n; i++ {
		ev := &events[i]
		if mode != 0 {
			pd := *(**pollDesc)(unsafe.Pointer(&ev.data))
			netpollready(&toRun, pd, mode)
		}
	}
	return toRun
}

出典:runtime/netpoll_epoll.go

Goプログラム開始時(bootstrap)

ここからはgo run [ファイル名].goで作られたバイナリを実行するときに、どうやってランタイムが立ち上がり、自分が書いたmain関数までたどり着くかについて見ていきます。

1. エントリポイントからruntimeパッケージの初期化を呼び出す

Goプログラムのバイナリを読むと、以下の処理が行われます。

  1. rt0_darwin_amd64.sファイルを読み込む
  2. _rt0_amd64関数を呼ぶ
  3. runtime.rt0_go関数を呼ぶ

runtime.rt0_go関数の中で、Goのプログラムを実行するにあたり必要な様々な初期化を呼び出しています。
関数の中身を抜粋すると以下のようになっています。

// (一部抜粋)
// 2. グローバル変数g0とm0を用意
LEAQ	runtime·g0(SB), CX
MOVQ	CX, g(BX)
LEAQ	runtime·m0(SB), AX

// save m->g0 = g0
MOVQ	CX, m_g0(AX)
// save m0 to g0->m
MOVQ	AX, g_m(CX)


// 3. 実行環境でのCPU数を取得
CALL	runtime·osinit(SB)
// 4. Pを起動
CALL	runtime·schedinit(SB)

// 5. mainゴールーチンの作成
// create a new goroutine to start program
MOVQ	$runtime·mainPC(SB), AX		// entry
PUSHQ	AX
PUSHQ	$0			// arg size
CALL	runtime·newproc(SB)
POPQ	AX
POPQ	AX

// 6. Mを起動させてスケジューラを呼ぶ
// start this M
CALL	runtime·mstart(SB)

出典:runtime/asm_amd64.s

2. ランタイム立ち上げを行うGとMを用意する

Goのプログラムを実行できるようにする処理も、Go言語ではGoで書かれています。
それはすなわち「bootstrapを行うためのGとMが必要」ということです。

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

var (
	m0           m
	g0           g
)

出典:runtime/proc.go

ここに、最初に使うGとMを代入→それぞれをリンクしておきます。

// 2. グローバル変数g0とm0を用意
LEAQ	runtime·g0(SB), CX
MOVQ	CX, g(BX)
LEAQ	runtime·m0(SB), AX

// save m->g0 = g0
MOVQ	CX, m_g0(AX)
// save m0 to g0->m
MOVQ	AX, g_m(CX)

3. 実行環境でのCPU数を取得

// 3. 実行環境でのCPU数を取得
CALL	runtime·osinit(SB)

bootstrap用のGとMの確保が終わったら、次に実行環境におけるCPU数をruntime.osinit関数で確認します。

// BSD interface for threading.
func osinit() {
	// pthread_create delayed until end of goenvs so that we
	// can look at the environment first.

	ncpu = getncpu()
	physPageSize = getPageSize()
}

出典:runtime/os_darwin.go

getncpu関数によって得られたCPU数を、runtimeパッケージのグローバル変数ncpuに代入して保持させている様子がよくわかります。

var (
	ncpu       int32
)

出典:runtime/runtime2.go

4. Pを起動

// 4. Pを起動
CALL	runtime·schedinit(SB)

runtime.osinit関数の次に、runtime.schedinit関数が呼ばれています。

func schedinit() {
	// (一部抜粋)
	procs := ncpu
	if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
		procs = n
	}

	if procresize(procs) != nil {
		throw("unknown runnable goroutine during bootstrap")
	}
}

出典:runtime/proc.go

ここでは

  1. 前述したosinit関数で得たCPU数と、環境変数GOMAXPROCSの値から、起動するPの数(=変数procs)を決める
  2. procresize関数を呼んでPを起動する

ということをやっています。

ちょっと深掘りして、procresize関数におけるPの起動を詳しく見てみます。

// Returns list of Ps with local work, they need to be scheduled by the caller.
func procresize(nprocs int32) *p {
	// (一部抜粋)
	// initialize new P's
	for i := old; i < nprocs; i++ {
		pp := allp[i]
		if pp == nil {
			pp = new(p)
		}
		pp.init(i)
	}

	// 1つPをとってきて、現在のMと繋げる
	p := allp[0]
	acquirep(p)

	// PのローカルキューにGがなくて
	// 他のPをアイドル状態にしていい状態なら
	// グローバル変数schedのpidleフィールドにアイドルなPsをストックしておく
	for i := nprocs - 1; i >= 0; i-- {
		p := allp[i]
		p.status = _Pidle
		if runqempty(p) {
			pidleput(p)
		}
	}
}

出典:runtime/proc.go

  1. *pスライス型のグローバル変数allpに、(*p)initメソッドで初期化したPを詰めていく
  2. 作ったPの中から一つ取り、そのPと今動いているMとをリンクさせる
    (リンク作業を行っているのは、acquirep関数→wirep関数)
  3. pidleput関数で、グローバル変数sched(前章参照のこと)の中にアイドル状態のPをストックしておく

このようにprocresize関数で行うPの起動といっても「今すぐ使うPをMとつなげて使用可能状態にする」という作業と「余ったPをアイドル状態にしてストックさせる」という作業の大きく2つがあることがわかります。

5. mainゴールーチンの作成

// 5. mainゴールーチンの作成
// create a new goroutine to start program
MOVQ	$runtime·mainPC(SB), AX		// entry
PUSHQ	AX
PUSHQ	$0			// arg size
CALL	runtime·newproc(SB)
POPQ	AX
POPQ	AX

バイナリの中身をみると「runtime.mainPCを引数にruntime.newproc関数を実行する」と読むことができます。

引数runtime.mainPC

まずは、引数となっているruntime.mainPCが一体何者なのでしょうか。

これはファイルasm_amd64.s内で「runtime.main関数と同じ」と定義されています。

// mainPC is a function value for runtime.main, to be passed to newproc.
// The reference to runtime.main is made via ABIInternal, since the
// actual function (not the ABI0 wrapper) is needed by newproc.
DATA	runtime·mainPC+0(SB)/8,$runtime·main<ABIInternal>(SB)
GLOBL	runtime·mainPC(SB),RODATA,$8

出典:runtime/asm_amd64.s

では、そのruntime.main関数をみてみましょう。

// The main goroutine.
func main() {
	// (一部抜粋)
	fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
	fn()
}

出典:runtime/proc.go

main_main関数を中で実行している様子が確認できます。そしてこのmain_mainこそが、ユーザーが書いたmain関数そのものなのです。

//go:linkname main_main main.main
func main_main()

出典:runtime/proc.go

runtime.newproc関数

それでは、「ユーザーが書いたmain関数」を引数にとって実行されるruntime.newproc関数の方を掘り下げてみましょう。

// Create a new g running fn with siz bytes of arguments.
// Put it on the queue of g's waiting to run.
// The compiler turns a go statement into a call to this.
func newproc(siz int32, fn *funcval) {
	// (一部抜粋)
	newg := newproc1(fn, argp, siz, gp, pc)

	_p_ := getg().m.p.ptr()
	runqput(_p_, newg, true)
}

出典:runtime/proc.go

ここでやっているのは、

  1. newproc1関数を使って新しいG(ゴールーチン)を作り、そこでユーザー定義のmain関数(=変数fn)を実行するようにする
  2. runqput関数で、作ったGをPのローカルランキューに入れる

という操作です。

特筆すべきなのは、ここで行っているのは「作ったGをランキューに入れる」までであり、「ランキューに入れたGを実行する」というところまではやっていないということです。
ランキュー内のGを動かすためにはスケジューラの力を借りる必要があり、それは次のステップで行っています。

6. Mを起動させてスケジューラを呼ぶ

// 6. Mを起動させてスケジューラを呼ぶ
// start this M
CALL	runtime·mstart(SB)

bootstrapの最後に呼んでいるのがruntime.mstart関数です。
コメントにも書かれている通り、これは新しくできたMのエントリポイントです。

// mstart is the entry-point for new Ms.
// It is written in assembly, uses ABI0, is marked TOPFRAME, and calls mstart0.
func mstart()

出典:runtime/proc.go

mstart関数はアセンブリ言語で実装され、最終的にmstart0関数をCALLするように作られます。
mstart0関数から先を順に追ってみると、

  1. mstart0関数
  2. mstart1関数
  3. schedule関数

というように、最終的にスケジューラが呼ばれます。
この後は、Pのローカルランキューに入れられたG(=main関数入り)がスケジューラによってMに割り当てられ、無事にユーザーが書いたプログラムが実行されるのです。