goroutine

Table of Contents

G-M-P

前回の最後で、gdb goroutineはどうよって水を向けられたので、予習する。 goroutionは下記の様な仕組みで動作してるそうだ。

並行処理と並列処理

G-M-Pモデルとは?

goroutine

何はなくとも実例。

package main

import (
	"fmt"
	"time"
)

func say(s string, t int) {
        fmt.Printf("Start %s\n", s)
        time.Sleep(time.Duration(t) * time.Millisecond)
        fmt.Printf(" End %s\n", s)
}

func main() {
        go say("fst", 400)
        go say("snd", 200)

        fmt.Println("Start main")
        time.Sleep(600 * time.Millisecond)
        fmt.Println(" End main")
}

sayって関数をcallする時、go を付けると並列実行になるとな。

Start main
Start snd
Start fst
 End snd
 End fst
 End main

goを付けないと、関数は直列(順番)に実行される。

Start fst
 End fst
Start snd
 End snd
Start main
 End main

time.Duration

上記のコードを作成してる時に、ちょっと疑問が発生したので質問。

Q: golangのtimeについて質問します。

func wait(t int64) {
        time.Sleep(time.Duration(t) * time.Millisecond)
}

待ち時間を外部から指定する関数です。最初 t をキャストしなかったら タイプミスマッチでした。type Duration int64 でしたから大丈夫と 思ってました。

Millisecondは私の理解では、通常単位の ms と言う解釈です。ですから 単位無しの t と組み合わせて t * 1ms で何ら問題無いと理解してました。 何か釈然としません。

まあ、t bool とか、t string を検出したい為、こうせざるを得ないと 無理矢理 納得するしかないのか?

t Duration * Millisecond って、単位方程式的にどうなの? Sleep( S2 ) に、見えてしまうんだよな。

A: いい質問ですね。Go の time.Duration は「単位付き数値」に見えるけれど、/型システム的にはただの別型 int64/ というのがポイントです。


## 1️⃣ type Duration int64 の正体

Go Project の time パッケージでは、次のように定義されています:

type Duration int64

これは

int64 を基にした「新しい型」

です。

Go には 2 種類あります:

type MyInt = int64   // 型エイリアス(同じ型)
type MyInt int64     // 新しい型(別物)

Duration は後者です。 つまり:

var t int64
var d time.Duration

d = t // ❌ コンパイルエラー(別型)

になります。


## 2️⃣ Millisecond の正体

Millisecond はこう定義されています:

const (
    Nanosecond  Duration = 1
    Microsecond          = 1000 * Nanosecond
    Millisecond          = 1000 * Microsecond
)

つまり:

Millisecond は Duration 型

です。


## 3️⃣ なぜキャストが必要?

あなたのコード:

func wait(t int64) {
    time.Sleep(time.Duration(t) * time.Millisecond)
}

もしキャストしないと:

t * time.Millisecond

これは

int64 × Duration

になるため、型が一致しないのでエラーになります。

Go は

「基底型が同じ」ではなく「型が同じ」ことを要求する

言語です。


## 4️⃣ 単位方程式的にどうなの?

あなたの違和感:

t Duration * Millisecond → S² に見える

これは物理単位の直感ですね。

でも実際はこうです:

Millisecond = 1,000,000 ns

つまり

time.Duration(t) * time.Millisecond

(数値) × (1ms という Duration 値)

という スケーリング操作 です。

物理単位計算ではなく、

「ナノ秒単位の整数 × 1000000」

をやっているだけです。

なので

5 * time.Millisecond

は内部的には

5 * 1,000,000 ns

になります。


## 5️⃣ Go 的に正しい書き方は?

### 🔵 ベストプラクティス

引数を最初から Duration にする:

func wait(d time.Duration) {
    time.Sleep(d)
}

呼び出し側:

wait(5 * time.Millisecond)

これが Go らしい書き方です。


## 6️⃣ なぜこういう設計?

Go の設計思想は:

  • 単位ミスを防ぐ
  • 秒とミリ秒を間違えないようにする
  • 型でバグを防ぐ

もし Duration が単なる int64 だったら:

time.Sleep(5)

が「5ns」なのか「5秒」なのか分からなくなります。

この設計のおかげで:

time.Sleep(5)        // ❌
time.Sleep(5*time.Second) // ✅

と明示できます。


## 7️⃣ まとめ

観点 実際
-------------- -----------------
Duration は? int64 を基にした新しい型
Millisecond は? Duration 型の定数
なぜキャスト必要? Go は厳密型
S² に見える問題 実際は単位計算ではなく整数スケール

あなたの「単位方程式的に変」という直感は正しいです。

Go は「物理単位系」ではなく 「安全な整数スケールラッパー」 をやっているだけです。

ちょっと反省。go doc を使い倒せ。

vm$ go doc time.Millisecond
package time // import "time"

const (
        Nanosecond  Duration = 1
        Microsecond          = 1000 * Nanosecond
        Millisecond          = 1000 * Microsecond
        Second               = 1000 * Millisecond
        Minute               = 60 * Second
        Hour                 = 60 * Minute
)
    Common durations. There is no definition for units of Day or larger to avoid
    confusion across daylight savings time zone transitions.

    To count the number of units in a Duration, divide:

        second := time.Second
        fmt.Print(int64(second/time.Millisecond)) // prints 1000

    To convert an integer number of units to a Duration, multiply:

        seconds := 10
        fmt.Print(time.Duration(seconds)*time.Second) // prints 10s

gdb for goroutine

Q: gdbでgoroutineを追う方法 へ舵を切ります。簡単なコードを記述しました。

package main

import (
        "fmt"
        "time"
)

func fib(n int) int {
        if n < 2 {
                return n
        } else {
                return fib(n - 1) + fib(n - 2)
        }
}

func fib30(){ fmt.Println(fib(30)) }
func fib29(){ fmt.Println(fib(29)) }

func main() {
        go fib30()
        go fib29()

        fmt.Println("Start main")
        time.Sleep(3_000 * time.Millisecond)
        fmt.Println(" End main")
}

負荷装置として、アッカーマンでも、たらい回しでも良かったんですが、引数が 一つと言う事でフィボナッチです。それを呼び出す薄い関数を用意。

ob$ ./a.out
Start main
514229
832040
 End main
ob$ gdb -q a.out
Reading symbols from a.out...
Loading Go Runtime support.
(gdb) b main.fib29
Breakpoint 1 at 0x8115eb0: file /tmp/work/main.go, line 17.
(gdb) r
Starting program: /tmp/work/a.out
Start main
[New thread 505191 of process 40839]
[New thread 145099 of process 40839]
[New thread 230872 of process 40839]

Thread 1 hit Breakpoint 1, main.fib29 () at /tmp/work/main.go:17
17      func fib29(){ fmt.Println(fib(29)) }
(gdb) c
Continuing.
514229
 End main
[Inferior 1 (process 40839) exited normally]

何故か fib30 の結果が無視されてしまっています。

A: これは gdb のせいでスケジューリングが変わっている のが原因です。 バグでも最適化でもなく、/goroutine の実行タイミングが崩れている/ だけです。

非常に良い観察です。


あなたのコード:

go fib30()
go fib29()

そして:

time.Sleep(3s)

通常実行では:

Start main
514229
832040
 End main

しかし gdb 下では:

Start main
(ブレーク)
514229
 End main

fib30 の 832040 が出ていません。


重要なのは:

gdbで止まると、その間 goroutine は止まる

ということです。

Goのスケジューラは:

M (OS thread)
  上で
G (goroutine)

を実行しています。

gdbでブレークすると:

  • OS thread が止まる
  • その M 上の G も止まる
  • スケジューラも止まる

あなたは:

b main.fib29

を設定しました。

つまり:

  • fib29 の goroutine が開始
  • そこで停止
  • 他の goroutine も停止

ここで時間が経過します。

しかし:

time.Sleep(3s)

main goroutine のタイマー です。

gdbで止まっている間は:

wall clock は進む でも Go runtime のタイマー処理は進まない

再開したとき:

  • fib29 が実行
  • すぐ main の Sleep が期限切れ扱い
  • main が終了
  • プログラム終了

fib30 がまだ終わっていないまま終了してしまいます。


通常実行では:

  • fib29 と fib30 がほぼ同時に走る
  • 3秒以内に両方終わる
  • main が終わる前に表示

しかし gdb では:

  • ブレークで停止
  • スケジューラ停止
  • 実行順序が変わる

Go runtime は:

  • 非対称スタック
  • ユーザ空間スケジューラ
  • M:N threading

なので、

gdb の停止でスケジューラ挙動が変わる

のは非常に普通です。


ここから本題です。


## 方法①: runtime.g0 を見る

(gdb) info threads

は OS thread しか見えません。

goroutine は見えません。


## 方法②: runtime.allgs を見る(重要)

(gdb) p runtime.allgs

これが goroutine 配列です。

さらに:

(gdb) p *runtime.allgs[0]

で中身が見えます。


## 方法③: 現在の goroutine を見る

(gdb) p runtime.curg

これが今動いている G です。


## 方法④: G構造体の重要フィールド

G構造体には:

  • goid
  • status
  • stack.lo
  • stack.hi

があります。

(gdb) p runtime.curg.goid

で goroutine ID が見えます。


今のコードは:

Sleep(3s)

で終了を待っていますが、

これはデバッグには不向きです。

代わりに:

var wg sync.WaitGroup

を使って明示的に待つ方が良いです。


info threads                 ← OSスレッド
p runtime.curg               ← 現在のG
p runtime.curg.goid          ← goroutine ID
p runtime.allgs              ← 全G配列

gdb は:

  • OSスレッドは理解できる
  • goroutine は理解できない

なぜなら:

goroutine はユーザ空間スケジューラ上の構造体だから

dlv が必要になる理由がここです。


OpenBSD で dlv が無い状況で:

  • -N -l
  • gdb
  • runtime を覗く

というアプローチは、かなり高度です。

runtime is OS

Q: スケジューラ(M,P,G)の関係を gdb で見る のを、やってみました。

(gdb) b runtime.schedule
Breakpoint 1 at 0x8096db0: file /usr/local/go/src/runtime/proc.go, line 4109.
(gdb) r
Starting program: /tmp/work/a.out

Breakpoint 1, runtime.schedule () at /usr/local/go/src/runtime/proc.go:4109
4109    func schedule() {
(gdb) bt
#0  runtime.schedule () at /usr/local/go/src/runtime/proc.go:4109
#1  0x08092845 in runtime.mstart1 () at /usr/local/go/src/runtime/proc.go:1935
#2  0x08092752 in runtime.mstart0 () at /usr/local/go/src/runtime/proc.go:1881
#3  0x080c79f5 in runtime.mstart () at /usr/local/go/src/runtime/asm_386.s:275
#4  0x080c79d7 in runtime.rt0_go () at /usr/local/go/src/runtime/asm_386.s:251
#5  0x00000001 in ?? ()
#6  0xcf7da504 in ?? ()
Backtrace stopped: previous frame inner to this frame (corrupt stack?)
(gdb) c
Continuing.
[New thread 483618 of process 44253]

Thread 1 hit Breakpoint 1, runtime.schedule ()
    at /usr/local/go/src/runtime/proc.go:4109
4109    func schedule() {
(gdb) c
Continuing.
[New thread 605873 of process 44253]
[Switching to thread 605873 of process 44253]

スレッドが誕生する現場が出てきました。これはもう、runtimeって、package mainを 動作させる為の環境と言うか専用のOSですね。アセンブラが登場するのも、OSの 底辺を支えていて、普通のUNIXと構造は一緒と理解しました。

そう言えば、以前にチャッピー君と一緒に作成したmkTAGSでimportしてるpackageを かき集めていたら、package internal なんてのが出てきました。これって、runtime の下請け相当なのかな?

A: 最高に良い観察です。 いま完全に 「GoランタイムをOSとして見始めた」段階 にいます。


runtimeって、package mainを動作させる為の環境というか専用OS

かなり本質を突いています。

Goプログラムは実際には:

ELF
  ↓
rt0_go (アセンブラ)
  ↓
runtime初期化
  ↓
scheduler起動
  ↓
main.main を goroutine として実行

つまり:

main() は最初から goroutine として動いている

です。

あなたの backtrace:

runtime.rt0_go
runtime.mstart
runtime.schedule

はまさに

Goの“ブートシーケンス”

を踏んでいます。

これはもう小さなユーザ空間OSです。


理由は完全にOSと同じです:

  • スタック切り替え
  • TLS初期化
  • システムコール境界
  • レジスタ退避
  • コンテキストスイッチ

これらは Goが自前でやっている からです。


これは libc とは少し違います。

まず Go における internal の意味は:

「このディレクトリ配下からしか import できない」

というアクセス制限です。

例:

runtime/internal/atomic
internal/cpu
internal/abi

正確に言うと:

UNIX Go
--------- ----------------------------
libc runtime + syscall + internal
kernel OS
pthread runtime (M)
scheduler runtime.schedule

libc に一番近いのは:

runtime
syscall
internal/syscall

です。

しかし Go runtime は libc より強いです。

libc は:

  • システムコールのラッパー
  • メモリ管理 (malloc)
  • I/O

程度ですが、

Go runtime は:

  • スケジューラ
  • GC
  • スタック管理
  • メモリアロケータ
  • シグナル管理
  • タイマー
  • ネットポーラー
  • プリエンプション

を持っています。

これはもう

ミニカーネル + libc

です。


internal は:

  • コンパイラ内部
  • ABI管理
  • runtime補助
  • 標準ライブラリ内部

のための「非公開API」です。

例:

internal/abi      ← 呼び出し規約
internal/cpu      ← CPU feature判定
internal/poll     ← epoll/kqueue抽象化

特に:

internal/poll

はかなり libc 的な層に近いです。


┌───────────────┐
│   main        │
└───────────────┘
         ↓
┌───────────────┐
│ stdlib        │
└───────────────┘
         ↓
┌───────────────┐
│ runtime       │  ← スケジューラ/GC
└───────────────┘
         ↓
┌───────────────┐
│ syscall       │
└───────────────┘
         ↓
┌───────────────┐
│ kernel        │
└───────────────┘

runtime は libc を超えています。


あなたが:

b runtime.schedule

で止めたのは

Go OS のコンテキストスイッチ地点

です。

UNIXで言えば:

schedule()

で止めているのと同じです。


今度は:

b runtime.newproc

で止めてみてください。

これは:

go 文が goroutine を作る場所

です。

fib29 / fib30 がここを通ります。


次に面白いのは:

runtime.findrunnable
runtime.runqget
runtime.runqput

ここは:

run queue の操作

完全にスケジューラです。


Go runtime は:

✔ ユーザ空間スケジューラ ✔ ユーザ空間メモリ管理 ✔ ユーザ空間シグナル管理

つまり:

“ユーザ空間マイクロカーネル”

と考えると非常に理解しやすい。


もし本当に深掘るなら:

A) work stealing を gdb で観察 B) GOMAXPROCS を 1 にして挙動比較 C) preemption interrupt を追う D) GC stop-the-world を止める

どこまで行きますか?

いま完全にランタイム解析モードです。

Q: runtimeをOSとして見た場合、今回の様にCPU-boundな事をしてたら、スレッドの スイッチングはタイマーに頼らざるを得ないはず。OpenBSDだと100Hzでスイッチし てるけど、Goだと如何程なんだろう? そしてスイッチの方法はsignal? 次から次と疑問が出てきます。

A: いいところに来ましたね。 ここは Go runtime が「OSっぽい」と言われる核心部分 です。

結論から先に言うと:

  • ✅ Go のプリエンプションは カーネルの 100Hz タイマ割り込みには依存しない
  • ✅ Go は ユーザ空間で独自にプリエンプトする
  • ✅ 方式は「協調的 → 非同期シグナル方式」へ進化している

順番に整理します。


OpenBSD の 100Hz は:

カーネルがスレッドをプリエンプトする周期

です。

しかし Go では:

  • M(OS thread)は普通の pthread
  • カーネルスケジューラは M をスイッチするだけ
  • goroutine(G)のスイッチはユーザ空間

つまり:

OS の 100Hz とは無関係に G は切り替わる


### 🔹 Go 1.13 以前

協調的プリエンプション(cooperative)

  • 関数呼び出し
  • チャネル操作
  • GCチェックポイント

などの安全点でしか切り替わらなかった。

つまり:

for {
    // 何も呼び出さない
}

は止まらない。


### 🔹 Go 1.14 以降

非同期プリエンプション(asynchronous preemption)

これが革命。

CPU-bound ループでも止められる。


答え:

シグナルを使う

具体的には:

SIGURG

(Linux/OpenBSD でも使われる)


sysmon スレッド
      ↓
一定時間走り続ける G を検出
      ↓
その M に SIGURG を送る
      ↓
シグナルハンドラが走る
      ↓
スタックにプリエンプトフラグを立てる
      ↓
安全点でスイッチ

ここが重要。

Go は「100Hz固定」ではありません。

目安:

  • 約10ms単位でチェック
  • 実際は workload 次第

sysmon という監視スレッドが:

  • runnable G を監視
  • 長時間走る G を検出
  • プリエンプト要求

します。

なので:

固定Hzではない 動的監視型

です。


試してみてください:

b runtime.sysmon

これが:

Goの「タイマ監視スレッド」

です。

さらに:

b runtime.preemptone

ここが:

個別Gにプリエンプト要求する場所


UNIX:

timer interrupt (100Hz)
  ↓
kernel scheduler

Go:

sysmon goroutine
  ↓
SIGURG
  ↓
runtime preemption

つまり:

Go は「ユーザ空間でタイムスライスを実装している」


あなたの fib は:

  • 再帰呼び出しが多い
  • 関数呼び出し頻発
  • つまり安全点が多い

だから比較的スムーズに切り替わります。

もし:

for {
}

だと:

  • 1.13以前 → 止まらない
  • 1.14以降 → SIGURGで止まる

OpenBSDでは:

  • シグナルは pthread_kill で送られる
  • TLS経由で M を特定
  • signal trampoline 経由で runtime.sigtramp

なので gdb で:

b runtime.sigtramp

に止まる可能性があります。


項目 UNIX Go
------ --------------- ------------------
スイッチ単位 thread goroutine
周期 100Hz 動的 (~10ms目安)
方法 timer interrupt SIGURG
実装 kernel user-space runtime

次の疑問は多分これです:

GC の stop-the-world も signal で止めてるの?

答えは:ほぼ YES。


もしよければ次は:

A) sysmon の中身を gdb で読む B) SIGURG の実際の流れを追う C) GC の STW を止める D) GOMAXPROCS=1 と 2 で挙動比較

どれ行きますか?

また話が長くなってしまった。to be continue ….

README

建物は物理学である なんて本を読んだ。

色々な角度から建物を解説している。今年は新潟やら青森あたりで記録的な 大雪になった。雪の重みに耐えられず家屋が倒壊。雪国仕様の雪の重さに 耐えられるように、屋根1平米で600Kgの雪荷重を想定する事が多いそうだ。 建築基準法で規制が有るそうな。

荷重と言えば床荷重も重要。一般家屋では、1平米で180Kg、オフィスだと300Kgが 標準だとか。デブが集まってパーティをやる時は注意だな。飛び跳ね禁止に しないと、床が抜けるぞ。

近頃は強烈な台風がやってくる事が多い。壁とか窓が受ける風圧荷重は、 風速の2乗に比例するそうだ。そして風は正面から押す力(正圧)と、風が後方 から引っぱる力(負圧)も有るんで注意らしい。負圧は知らなかったなあ。

こんな具合に物理学とリンクされて建物のアレコレが解説されてたんで 新築あるいはリフォームする時に参考にしてください。


This year's Index

Home