🌟

Goのruntimeの内部状態をリアルタイムに視覚化してみる

に公開

Realtime Visualization of Go Runtime

goのruntimeのとても興味深く面白い挙動は、次の記事で丁寧にまたわかりやすく解説されています。
https://zenn.dev/hsaki/books/golang-concurrency/viewer/gointernal

当時の私でもその面白さの片鱗を感じとることができた一方、コードリーディングをしてもその実際の動きについてはどこか空を掴むような難しさを感じたことを記憶しています。

そこで、runtimeの内部状態を取得し、その動きを視覚的に理解することができないかを試してみた、というのがこの記事の趣旨です。

runtimeの内部状態をリアルタイムに取得さえできれば、視覚化には任意のツールを使うことができると思います。

私の場合はgo製のゲームエンジン Ebitengine を使って、goのruntimeをgoでリアルタイムにビジュアライズするということをやってみました。

runtimeの内部状態を取得するアプローチ

runtimeの内部状態を詳細に取得できるような関数を探したのですが、私が調べた限りでは見つけることができませんでした。
そこで、runtime2.goを自分で書き換えて、runtimeの内部状態を取得することができる怪しげな関数を生やすアプローチを思いつきました。
そこから-overlay を使用して自身が生やした関数が利用できるようにした上で main.go を起動します。

やりかた

  1. まず、自身が使用しているgoのversionに対応する runtime2.go をいずれかの場所から取得し、overlay用のディレクトリに保存します。
  2. 保存した runtime2.go に以下のような構造体と関数を追加します。goのversionによっては細かな差異があると思うので、その辺りは適宜調整をする必要があるかもしれません。なお、命名がかなり雑であることはお目溢しいただければ幸いです。
runtime2.go
type ExposureG struct {
	ID           uint64
	AtomicStatus uint32
}

func NewExposureG(g *g) *ExposureG {
	if g == nil {
		return nil
	}
	return &ExposureG{
		ID:           g.goid,
		AtomicStatus: g.atomicstatus.LoadAcquire(),
	}
}

type ExposureM struct {
	ID       int64
	CurrentG *ExposureG
	G0       *ExposureG
}

func NewExposureM(m *m) *ExposureM {
	if m == nil {
		return nil
	}
	return &ExposureM{
		ID:       m.id,
		CurrentG: NewExposureG(m.curg),
		G0:       NewExposureG(m.g0),
	}
}

type ExposureP struct {
	ID         int32
	Status     uint32 // 0:Pidle, 1:Prunning, 2:Psyscall, 3:Pstop, 4:Pdead
	RunQueue   []*ExposureG
	M          *ExposureM
	TimerCount int
	RawRunqLen int
}

func NewExposureP(p *p) *ExposureP {
	if p == nil {
		return nil
	}
	runq := make([]*ExposureG, 0, len(p.runq))
	
	h := atomic.LoadAcq(&p.runqhead)
	t := p.runqtail
	if t != h {
		runqLen := len(p.runq)
		for idx := h % uint32(runqLen); int(idx) < runqLen; idx++ {
			eg := NewExposureG(p.runq[idx].ptr())
			if eg != nil {
				runq = append(runq, eg)
			}
		}
	}

	return &ExposureP{
		ID:         p.id,
		Status:     p.status,
		RunQueue:   runq,
		M:          NewExposureM(p.m.ptr()),
		RawRunqLen: len(p.runq),
		TimerCount: len(p.timers.heap),
	}
}

type Exposure struct {
	SchedulerG []*ExposureG
	SchedulerP []*ExposureP
	AllP       []*ExposureP
	AllG       []*ExposureG
}

func RuntimeExposure() *Exposure {
	schedulerGs := make([]*ExposureG, 0, 256)
	for g := sched.runq.head.ptr(); g != nil; g = g.schedlink.ptr() {
		schedulerGs = append(schedulerGs, NewExposureG(g))
	}

	schedulerPs := make([]*ExposureP, 0, 256)
	for p := sched.pidle.ptr(); p != nil; p = p.link.ptr() {
		schedulerPs = append(schedulerPs, NewExposureP(p))
	}

	allPs := make([]*ExposureP, 0, 256)
	for _, p := range allp {
		allPs = append(allPs, NewExposureP(p))
	}

	allGs := make([]*ExposureG, 0, len(allgs))
	for _, g := range allgs {
		allGs = append(allGs, NewExposureG(g))
	}

	return &Exposure{
		SchedulerG: schedulerGs,
		SchedulerP: schedulerPs,
		AllP:       allPs,
		AllG:       allGs,
	}
}
  1. main.go 側で先ほど定義した RuntimeExposure 関数をおもむろに呼び出すようにします。
main.go
package main

import (
	"fmt"
	"runtime"
)

func main() {
	result := runtime.RuntimeExposure()
	for i := range result.AllP {
		fmt.Printf("%#v \n", result.AllP[i])
	}
}
  1. overlay.json を作成します。私の場合は以下のようになりました。
{
  "Replace":{
    "/opt/homebrew/Cellar/go@1.23/1.23.9/libexec/src/runtime/runtime2.go":"./overlay/runtime2.go"
    }
}
  1. go run -overlay overlay.json . のように起動します。
    すると、main関数実行時のruntimeの内部状態が1回ログに出て終了します。
 % go run -overlay overlay.json .
&runtime.ExposureP{ID:0, Status:0x0, RunQueue:[]*runtime.ExposureG{}, M:(*runtime.ExposureM)(nil), TimerCount:0, RawRunqLen:256} 
&runtime.ExposureP{ID:1, Status:0x1, RunQueue:[]*runtime.ExposureG{}, M:(*runtime.ExposureM)(0x140000c0000), TimerCount:0, RawRunqLen:256} 
&runtime.ExposureP{ID:2, Status:0x0, RunQueue:[]*runtime.ExposureG{}, M:(*runtime.ExposureM)(nil), TimerCount:0, RawRunqLen:256} 
&runtime.ExposureP{ID:3, Status:0x0, RunQueue:[]*runtime.ExposureG{}, M:(*runtime.ExposureM)(nil), TimerCount:0, RawRunqLen:256} 
&runtime.ExposureP{ID:4, Status:0x0, RunQueue:[]*runtime.ExposureG{}, M:(*runtime.ExposureM)(nil), TimerCount:0, RawRunqLen:256} 
&runtime.ExposureP{ID:5, Status:0x0, RunQueue:[]*runtime.ExposureG{}, M:(*runtime.ExposureM)(nil), TimerCount:0, RawRunqLen:256} 
&runtime.ExposureP{ID:6, Status:0x0, RunQueue:[]*runtime.ExposureG{}, M:(*runtime.ExposureM)(nil), TimerCount:0, RawRunqLen:256} 
&runtime.ExposureP{ID:7, Status:0x0, RunQueue:[]*runtime.ExposureG{}, M:(*runtime.ExposureM)(nil), TimerCount:0, RawRunqLen:256} 
&runtime.ExposureP{ID:8, Status:0x0, RunQueue:[]*runtime.ExposureG{}, M:(*runtime.ExposureM)(nil), TimerCount:0, RawRunqLen:256} 
&runtime.ExposureP{ID:9, Status:0x0, RunQueue:[]*runtime.ExposureG{}, M:(*runtime.ExposureM)(nil), TimerCount:0, RawRunqLen:256} 

これでruntimeの内部状態を取得できるようになりました。
あとはebitenginでこの構造体を直接使って表現をしたり、あるいはこの情報を何らかの通信でやり取りし、別のツールで表示したりすることができると思います。

余談

最初はgoのsource code自体を書き換えてbuildした改造runtimeを使っていました。
(こちらの記事を参考にさせていただいていました。)
https://yutopp.hateblo.jp/entry/2022/12/12/021142

おわりに

リアルタイムにruntimenの内部状態をビジュアライズできているものの、画面の更新よりもはるかに速くruntimeの内部状態は変化しているので、より連続的な状態変化を観察するには至っていないなと感じています。
とはいえ、やはり視覚化することでruntimeの挙動がぐっとイメージしやすくなったように感じます。
あとは表現次第だと思うので、これからも試行錯誤してみます。

Discussion