経験は何よりも饒舌

10年後に真価を発揮するかもしれないブログ 

Go の main goroutine が exit する場所

ascii.jp
を進めているメモ。

package main
import "fmt"

func main() {
    fmt.Println("Hello World!")
}

VSCodeデバッグして最後にexitするのはここ。

go/src/runtime/proc.go#L277
go/proc.go at 912f0750472dd4f674b69ca1616bfaf377af1805 · golang/go · GitHub

exit(0)


あと、連載の時からwrite()メソッドが書き換わっていた。

ここから先は、環境によって固有のコードに飛びます。 Unix系OSmacOSLinuxなど)では、write() メソッドは次のようになっていると思います。 for ループに囲まれて、送信が終わるまで何度も syscall.Write を呼んでいることがわかります。 この syscall.Write がシステムコールです。

func (f *File) write(b []byte) (n int, err error) {
    for {
        bcap := b
        if needsMaxRW && len(bcap) > maxRW {
            bcap = bcap[:maxRW]
        }
        m, err := fixCount(syscall.Write(f.fd, bcap))
        n += m
        :
    }
}

実際には以下のようになっていた。

go/file_posix.go at 912f0750472dd4f674b69ca1616bfaf377af1805 · golang/go · GitHub

// write writes len(b) bytes to the File.
// It returns the number of bytes written and an error, if any.
func (f *File) write(b []byte) (n int, err error) {
	n, err = f.pfd.Write(b)
	runtime.KeepAlive(f)
	return n, err
}

f.pfd.Writefd_unix.goで呼び出されているので、「環境によって固有のコードに飛びます」はこのことを言ってそう。
syscall.Writeもここで呼び出されている。

go/fd_unix.go at 912f0750472dd4f674b69ca1616bfaf377af1805 · golang/go · GitHub

// Write implements io.Writer.
func (fd *FD) Write(p []byte) (int, error) {
	if err := fd.writeLock(); err != nil {
		return 0, err
	}
	defer fd.writeUnlock()
	if err := fd.pd.prepareWrite(fd.isFile); err != nil {
		return 0, err
	}
	var nn int
	for {
		max := len(p)
		if fd.IsStream && max-nn > maxRW {
			max = nn + maxRW
		}
		n, err := ignoringEINTR(func() (int, error) { return syscall.Write(fd.Sysfd, p[nn:max]) })
		if n > 0 {
			nn += n
		}
		if nn == len(p) {
			return nn, err
		}
		if err == syscall.EAGAIN && fd.pd.pollable() {
			if err = fd.pd.waitWrite(fd.isFile); err == nil {
				continue
			}
		}
		if err != nil {
			return nn, err
		}
		if n == 0 {
			return nn, io.ErrUnexpectedEOF
		}
	}
}

大体以下のような流れでfmt.Println("Hello World!")がされていることがわかった。

func Println(a ...interface{}) (n int, err error) =>
func Fprintln(w io.Writer, a ...interface{}) (n int, err error) =>
func (f *File) Write(b []byte) (n int, err error) =>
func (f *File) write(b []byte) (n int, err error) =>
func (fd *FD) Write(p []byte) (int, error) =>
syscall.Write(fd.Sysfd, p[nn:max]) =>
exit(0)

Goのコードはよくわからないからこんな感じで外観を掴むだけでよさそう。
go/src/runtime/proc.goのコメントを和訳してみたけどよくわからない。

ゴルーチン・スケジューラ
スケジューラの仕事は、すぐに実行可能なgoroutineをワーカースレッドに分配することです。

主な概念は次のとおりです。
G - ゴルーチン。
M - ワーカースレッド、またはマシン。
P - プロセッサ。Goコードを実行するために必要なリソースです。
Mは、Goコードを実行するために、関連するPを持っていなければなりません。
ブロックされたり、関連付けられたPを持たないシステムコールの中にいたりします。

https://golang.org/s/go11sched のデザインドキュメントをご覧ください。

ワーカースレッドのパーキング/アンパーキング。
利用可能なハードウェアの並列性を利用するために十分な数のワーカースレッドを走らせておくことと、ハードウェアの並列性を利用するために十分な数のワーカースレッドを確保することとを駐留させて、CPUリソースと電力を節約することが必要です。
これは2つの理由から簡単ではありません。
(1) スケジューラの状態は(特に、P単位の作業キュー)が意図的に分散されているため、高速な経路でグローバルな述語を計算することはできません。
(2) 最適なスレッド管理のためには、未来を知る必要がある(近い将来、新しいゴルーチンが準備されるときにワーカスレッドをパークしない)。

悪い方向に働くであろう3つの拒絶されたアプローチ
1. スケジューラの状態をすべて集中管理する(スケーラビリティが阻害される)。
2. ゴルーチンの直接ハンドオフ。つまり、新しいゴルーチンの準備ができて、予備のPがあるときにスレッドをアンパークし、スレッドとゴルーチンをハンドオフします。
これでは、ゴルーチンを準備したスレッドがスレッドの状態を崩してしまうことになります。
ゴルーチンを準備したスレッドは、次の瞬間には仕事をしていない可能性があるので、それをパークする必要があります。
また、計算の局所性も失われてしまいます。
また、同一スレッド上に依存するゴルーチンを保持したいため、計算の局所性が失われ、さらなるレイテンシーが発生します。
3. ゴルーチンの準備ができてアイドルPがあるときは、追加のスレッドをアンパークします。
アイドル状態のPがあるときに追加のスレッドをアンパークするが、ハンドオフは行わない。これは、追加スレッドのパーキング/アンパーキングが過剰になります。
スレッドを過剰にパーキング/アンパーキングすることになります。
する作業を発見することなく、即座にパークしてしまうからです。

現在のアプローチは(1) アイドル状態の P があり、「回転している」ワーカースレッドがない場合、ゴルーチンの準備時に追加のスレッドをアンパークします。
アイドルPが存在し、「回転している」ワーカースレッドが存在しない場合。ワーカースレッドが回転していると考えられるのは
紡いでいるとみなされるのは、ローカルな仕事がなく、グローバルなランキュー/ネットポラーで仕事を見つけられなかった場合です。
スピニング状態は m.spinning および sched.nmspinning で示されます。
このようにしてパークされていないスレッドもスピンしているとみなされますが、私たちはゴルーチンハンドオフを行わないので、そのようなスレッドは最初は仕事がありません。
スピンするスレッドは、パーキングする前に、Pごとの実行キューで仕事を探すためにいくつかのスピンを行います。
もしスピニングスレッドは仕事を見つけると、自分自身をスピニング状態から解放し、実行に進みます。
仕事を見つけられなかった場合は、スピニング状態から抜け出してパークします。
少なくとも1つの回転中のスレッドがある場合(sched.nmspinning>1)、ゴルーチンの準備中に新しいスレッドをアンパークすることはありません。
ゴルーチンを準備する際に新しいスレッドをアンパークしません。
これを補うために、最後に回転していたスレッドが仕事を見つけて回転を止めた場合は新しい回転中のスレッドをアンパークしなければなりません。
この方法により、スレッドのアンパークが不当に急増することがなくなります。

主な実装上の問題点は、スピンしているスレッドとスピンしていないスレッドの間の紡績->非紡績のスレッド遷移の際に非常に注意する必要があることです。
この移行は、新しいゴルーチンの投入と競合する可能性があります。
両方がアンパークに失敗すると、半永久的にCPUの使用率が低下することになります。
ゴルーチンの準備の一般的なパターンは、ゴルーチンをローカルワークキューに投入し、#StoreLoadスタイルのメモリバリアを行い、sched.nmspinningをチェックします。
spinning->non-spinningの遷移の一般的なパターンは、nmspinningをデクリメントすることです。
Nmspinningをデクリメントし、#StoreLoad形式のメモリバリアで、すべてのper-Pのワークキューに新しい仕事がないかチェックする。
これらの複雑さは、グローバルランキューには適用されないことに注意してください。
グローバルキューへの投入時にスレッドのアンパークを怠らないからです。また、コメントを参照してください。