この章について
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()
// (以下略)
}
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)
}
こうして、諸々の処理を終えてからPの状態をPsyscall
に変えておくことで、「プリエンプトしていいですよ」ということをsysmon
に教えておくのです。
sysmonの中
前述した通り、常時動いているsysmon
関数の中ではretake
関数というものが呼ばれています。
func sysmon() {
// (一部抜粋)
// retake P's blocked in syscalls
// and preempt long running G's
if retake(now)
}
この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_)
}
}
handoffp
関数の中では、システムコール待ちGをもつMの代わりに、アイドルプールから新しいMを持ってくるstartm
関数を実行しています。
func handoffp(_p_ *p) {
// (一部抜粋)
startm(_p_, false)
return
}
システムコールからの復帰
さて、システムコールから復帰する際には、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
}
この後処理は簡単です。GのステータスをGrunning
に変更します。こうすることで、スケジューラによって選ばれる実行対象に再び入ることになります。
// The goroutine g exited its system call.
// Arrange for it to run on a cpu again.
func exitsyscall() {
// (一部抜粋)
casgstatus(_g_, _Gsyscall, _Grunning)
}
ネットワークI/Oが発生したとき
ネットワークI/Oが発生したときには、通常その該当スレッドをブロックするような処理となります。
しかし、それでは効率が悪いので、Goでは言語固有のスケジューラの方でそれを非同期処理に変えて処理しています。
Linuxではこの「ブロック処理→非同期処理」への変更を、epollと呼ばれる仕組みを使って行っています。
epollについて
epollとは「複数のfd(ファイルディスクリプタ)を監視し、その中のどれかが入出力可能な状態(=イベント発生)になったらそれを通知する」という機能を持ちます。
epoll使用の流れとしては以下のようになります。
-
epoll_create1
関数でepollインスタンスを作り、返り値としてそのインスタンスのfdを受け取る -
epoll_ctl
関数で、epollの監視対象のfdを編集する -
epoll_wait
関数で、監視対象に何かイベントが起こっていないかをチェックする
Goのランタイム内では、このepollの仕組みが存分に利用されています。
これから詳細を見ていきましょう。
Goランタイムの中でのepoll
epollを使うためには、まずはepollインスタンスが必要です。
Goでは、ランタイム中からepollインスタンスを利用できるように、そのepollインスタンスのfdを保存しておくグローバル変数epfd
が用意されています。
epfd int32 = -1 // epoll descriptor
このepfd
変数の初期値は-1
ですが、epollインスタンスが必要になった段階でnetpollinit
が呼ばれ、本物のfdの値が格納されます。
func netpollinit() {
epfd = epollcreate1(_EPOLL_CLOEXEC) // epoll_create1関数でepollインスタンスを得る
}
I/O発生時の挙動
ここからは、このepollインスタンスを使って、ネットワークI/Oをランタイムがどう処理しているのかについて見ていきましょう。
net.Dial等でのコネクション発生時
例えば、net.Dial
関数を使ってサーバーとのコネクションができたとしましょう。
すると、内部では以下の順番で関数が呼ばれていきます。
-
net.Dial
関数 -
(*net.Dialer)DialContext
メソッド -
(*net.sysDialer)dialSerial
メソッド -
(*net.sysDialer)dialSingle
メソッド -
(*net.sysDialer)dialTCP
メソッド -
(*net.sysDialer)doDialTCP
メソッド -
net.internetSocket
関数 -
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.netFD)dial
メソッドの中身を辿っていくと、
-
(*net.netFD)fd.init()
メソッド -
(*poll.FD)Init
メソッド -
(*poll.pollDesc)init
メソッド -
poll.runtime_pollOpen
関数 -
runtime.poll_runtime_pollOpen
関数 -
runtime.netpollopen
関数 -
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
}
}
実行可能な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
}
Goプログラム開始時(bootstrap)
ここからはgo run [ファイル名].go
で作られたバイナリを実行するときに、どうやってランタイムが立ち上がり、自分が書いたmain
関数までたどり着くかについて見ていきます。
1. エントリポイントからruntimeパッケージの初期化を呼び出す
Goプログラムのバイナリを読むと、以下の処理が行われます。
-
rt0_darwin_amd64.s
ファイルを読み込む -
_rt0_amd64
関数を呼ぶ -
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)
2. ランタイム立ち上げを行うGとMを用意する
Goのプログラムを実行できるようにする処理も、Go言語ではGoで書かれています。
それはすなわち「bootstrapを行うためのGとMが必要」ということです。
runtimeパッケージ内のグローバル変数に、g0
とm0
というものがあります。
var (
m0 m
g0 g
)
ここに、最初に使う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()
}
getncpu
関数によって得られたCPU数を、runtime
パッケージのグローバル変数ncpu
に代入して保持させている様子がよくわかります。
var (
ncpu int32
)
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")
}
}
ここでは
- 前述した
osinit
関数で得たCPU数と、環境変数GOMAXPROCS
の値から、起動するPの数(=変数procs
)を決める -
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)
}
}
}
-
*p
スライス型のグローバル変数allp
に、(*p)init
メソッドで初期化したPを詰めていく - 作ったPの中から一つ取り、そのPと今動いているMとをリンクさせる
(リンク作業を行っているのは、acquirep
関数→wirep
関数) -
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.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()
}
main_main
関数を中で実行している様子が確認できます。そしてこのmain_main
こそが、ユーザーが書いたmain
関数そのものなのです。
//go:linkname main_main main.main
func main_main()
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)
}
ここでやっているのは、
という操作です。
特筆すべきなのは、ここで行っているのは「作った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()
mstart
関数はアセンブリ言語で実装され、最終的にmstart0
関数をCALLするように作られます。
mstart0
関数から先を順に追ってみると、
というように、最終的にスケジューラが呼ばれます。
この後は、Pのローカルランキューに入れられたG(=main
関数入り)がスケジューラによってMに割り当てられ、無事にユーザーが書いたプログラムが実行されるのです。