Leapcell: The Next-Gen Serverless Platform for Golang app Hosting
Golangのタイマー精度の謎を探る
I. 問題の紹介:Golangのタイマーはどれほど正確なのか?
Golangの世界では、タイマーは幅広いアプリケーションシナリオで使用されています。しかし、その正確さがどの程度なのかという問題は、開発者にとって常に関心事です。この記事では、Goにおけるタイマーヒープの管理とランタイムでの時間取得メカニズムについて詳しく掘り下げ、タイマーの精度にどの程度依存できるのかを明らかにします。
II. Goが時間を取得する方法
(I) time.Now
の背後にあるアセンブリ関数
time.Now
を呼び出すと、最終的に次のアセンブリ関数が呼び出されます:
// func now() (sec int64, nsec int32)
TEXT time·now(SB),NOSPLIT,$16
// 注意。ここではgccの呼び出し規約による関数を呼び出しています。
// エントリ時には128バイトのスタックが保証されており、ここでは16バイトを使用し、
// 呼び出しではさらに8バイトを使用します。
// それでも104バイトが残り、それがgettimeコードで使用する分になります。十分になることを願います!
MOVQ runtime·__vdso_clock_gettime_sym(SB), AX
CMPQ AX, $0
JEQ fallback
MOVL $0, DI // CLOCK_REALTIME
LEAQ 0(SP), SI
CALL AX
MOVQ 0(SP), AX // sec
MOVQ 8(SP), DX // nsec
MOVQ AX, sec+0(FP)
MOVL DX, nsec+8(FP)
RET
fallback:
LEAQ 0(SP), DI
MOVQ $0, SI
MOVQ runtime·__vdso_gettimeofday_sym(SB), AX
CALL AX
MOVQ 0(SP), AX // sec
MOVL 8(SP), DX // usec
IMULQ $1000, DX
MOVQ AX, sec+0(FP)
MOVL DX, nsec+8(FP)
RET
ここで、TEXT time·now(SB),NOSPLIT,$16
の中で、time·now(SB)
は関数now
のアドレスを表し、NOSPLIT
フラグはパラメータに依存しないことを示し、$16
は返されるコンテンツが16バイトであることを示しています。
(II) 関数呼び出しのプロセス
まず、__vdso_clock_gettime_sym(SB)
のアドレスが取得され、これはclock_gettime
関数を指します。このシンボルが空でない場合、スタックの先頭のアドレスが計算され、SI
に渡されます(LEA
命令を使用)。DI
とSI
はシステムコールの最初の2つのパラメータのレジスタで、これはclock_gettime(0, &ret)
を呼び出すのと同等です。対応するシンボルが初期化されていない場合、fallback
ブランチに入り、gettimeofday
関数が呼び出されます。
(III) スタックスペースの制限
Goの関数呼び出しでは、少なくとも128バイトのスタックが保証されています(これはgoroutineのスタックではないことに注意)。詳細はruntime/stack.go
の_StackSmall
を参照できます。ただし、対応するC関数に入った後は、スタックの増加はもはやGoによって制御されません。そのため、残りの104バイトは呼び出しがスタックオーバーフローを引き起こさないように保証しなければなりません。幸い、この2つの時間取得関数は複雑ではないため、一般的にはスタックオーバーフローは発生しません。
(IV) VDSOメカニズム
VDSOはVirtual Dynamic Shared Objectの略で、カーネルによって提供される仮想の.so
ファイルです。これはディスク上に存在せず、カーネル内に存在し、ユーザ空間にマップされます。これはシステムコールを加速するためのメカニズムであり、互換性モードの1つでもあります。gettimeofday
のような関数の場合、通常のシステムコールを使用すると、大量のコンテキストスイッチが発生します。特に、頻繁に時間を取得するプログラムの場合です。VDSOメカニズムを通じて、ユーザ空間に別のアドレス領域がマップされ、そこにはカーネルが公開するいくつかのシステムコールが含まれます。具体的な呼び出し方法(例えばsyscall
、int 80
、またはsystenter
)は、カーネルによって決定され、glibc
のバージョンとkernel
のバージョンの互換性問題を防ぐためです。また、VDSOはvsyscall
のアップグレードバージョンであり、いくつかのセキュリティ問題を回避しており、マッピングももはや静的に固定されていません。
(V) カーネルにおける時間取得の更新メカニズム
カーネルから見ると、システムコールによって取得される時間は時間割り込みによって更新され、その呼び出しスタックは次のとおりです[5]:
Hardware timer interrupt (generated by the Programmable Interrupt Timer - PIT)
-> tick_periodic();
-> do_timer(1);
-> update_wall_time();
-> timekeeping_update(tk, false);
-> update_vsyscall(tk);
update_wall_time
は時計ソースからの時間を使用し、精度はnsレベルに達することができます。しかし、一般的にLinuxカーネルの時間割り込みは100HZで、いくつかの場合は1000HZにまで高くなることがあります。つまり、一般的には10msまたは1msごとに割り込み処理の際に時間が1回更新されます。オペレーティングシステムの観点から見ると、時間の粒度はおおよそmsレベルですが、これは単なる基準値です。時間を取得するたびに、時計ソースからの時間が再取得されます(時計ソースにはたくさんの種類があり、ハードウェアカウンタや割り込みのジフィーなどで、一般的にはnsレベルに達することができます)。時間取得の精度はusから数百nsの間になることができます。理論的には、より正確な時間を得るには、アセンブリ命令rdtsc
を使用してCPUサイクルを直接読み取る必要があります。
(VI) 関数シンボルの検索とリンク
時間取得の関数シンボルを検索するプロセスには、ELFの内容が関係しており、つまり動的リンクのプロセスです。.so
ファイル内の関数シンボルのアドレスを解決し、関数ポインタに格納します。例えば__vdso_clock_gettime_sym
です。他の関数、例えばTEXT runtime·nanotime(SB),NOSPLIT,$16
も同様のプロセスがあり、この関数は時間を取得することができます。
III. Goのランタイマーによるタイマーヒープの管理
(I) timer
構造体
// Package time knows the layout of this structure.
// If this struct changes, adjust ../time/sleep.go:/runtimeTimer.
// For GOOS=nacl, package syscall knows the layout of this structure.
// If this struct changes, adjust ../syscall/net_nacl.go:/runtimeTimer.
type timer struct {
i int // heap index
// Timer wakes up at when, and then at when+period, ... (period > 0 only)
// each time calling f(now, arg) in the timer goroutine, so f must be
// a well-behaved function and not block.
when int64
period int64
f func(interface{}, uintptr)
arg interface{}
seq uintptr
}
タイマーはヒープ(heap)の形式で管理されます。ヒープは完全二分木であり、配列を使用して格納することができます。i
はヒープのインデックスです。when
はgoroutineが目覚める時間で、period
は目覚める間隔です。次の目覚めの時間はwhen + period
で、以下同様です。関数f(now, arg)
が呼び出され、ここでnow
はタイムスタンプです。
(II) timers
構造体
var timers struct {
lock mutex
gp *g
created bool
sleeping bool
rescheduling bool
waitnote note
t []*timer
}
すべてのタイマーヒープはtimers
によって管理されます。gp
はスケジューラ内のG構造体を指し、つまりgoroutineの状態維持構造体です。これはタイムマネージャの別個のgoroutineを指し、ランタイムによって起動されます(タイマーが使用されるときにのみ起動されます)。lock
はtimers
のスレッドセーフを保証し、waitnote
は条件変数です。
(III) addtimer
関数
func addtimer(t *timer) {
lock(&timers.lock)
addtimerLocked(t)
unlock(&timers.lock)
}
addtimer
関数は、すべてのタイマーの開始のエントリポイントです。単にロックをかけてから、addtimerLocked
関数を呼び出します。
(IV) addtimerLocked
関数
// Add a timer to the heap and start or kick the timer proc.
// If the new timer is earlier than any of the others.
// Timers are locked.
func addtimerLocked(t *timer) {
// when must never be negative; otherwise timerproc will overflow
// during its delta calculation and never expire other runtime·timers.
if t.when < 0 {
t.when = 1<<63 - 1
}
t.i = len(timers.t)
timers.t = append(timers.t, t)
siftupTimer(t.i)
if t.i == 0 {
// siftup moved to top: new earliest deadline.
if timers.sleeping {
timers.sleeping = false
notewakeup(&timers.waitnote)
}
if timers.rescheduling {
timers.rescheduling = false
goready(timers.gp, 0)
}
}
if !timers.created {
timers.created = true
go timerproc()
}
}
addtimerLocked
関数では、timers
がまだ作成されていない場合、timerproc
コルーチンが起動されます。
(V) timerproc
関数
// Timerproc runs the time-driven events.
// It sleeps until the next event in the timers heap.
// If addtimer inserts a new earlier event, addtimer1 wakes timerproc early.
func timerproc() {
timers.gp = getg()
for {
lock(&timers.lock)
timers.sleeping = false
now := nanotime()
delta := int64(-1)
for {
if len(timers.t) == 0 {
delta = -1
break
}
t := timers.t[0]
delta = t.when - now
if delta > 0 {
break
}
if t.period > 0 {
// leave in heap but adjust next time to fire
t.when += t.period * (1 + -delta/t.period)
siftdownTimer(0)
} else {
// remove from heap
last := len(timers.t) - 1
if last > 0 {
timers.t[0] = timers.t[last]
timers.t[0].i = 0
}
timers.t[last] = nil
timers.t = timers.t[:last]
if last > 0 {
siftdownTimer(0)
}
t.i = -1 // mark as removed
}
f := t.f
arg := t.arg
seq := t.seq
unlock(&timers.lock)
if raceenabled {
raceacquire(unsafe.Pointer(t))
}
f(arg, seq)
lock(&timers.lock)
}
if delta < 0 || faketime > 0 {
// No timers left - put goroutine to sleep.
timers.rescheduling = true
goparkunlock(&timers.lock, "timer goroutine (idle)", traceEvGoBlock, 1)
continue
}
// At least one timer pending. Sleep until then.
timers.sleeping = true
noteclear(&timers.waitnote)
unlock(&timers.lock)
notetsleepg(&timers.waitnote, delta)
}
}
timerproc
の主なロジックは、最小ヒープからタイマーを取り出してコールバック関数を呼び出すことです。period
が0より大きい場合、タイマーのwhen
値が修正され、ヒープが調整されます。0より小さい場合は、タイマーが直接ヒープから削除されます。その後、OSのセマフォに入って睡眠し、次の処理を待ちます。また、waitnote
変数によって目覚めることもできます。タイマーがもう残っていない場合、G構造体で表されるgoroutineは睡眠状態に入り、そのgoroutineをホストするM構造体で表されるOSスレッドは、他の実行可能なgoroutineを探して実行します。
(VI) addtimerLocked
における目覚めメカニズム
新しいタイマーが追加されると、チェックが行われます。新しく挿入されたタイマーがヒープのトップにある場合、それは睡眠中のtimergorountine
を目覚めさせ、ヒープ上の期限切れのタイマーをチェックして実行するようになります。目覚めと前の睡眠には2つの状態があります:timers.sleeping
はMのOSセマフォ睡眠に入ることを意味し、timers.rescheduling
はGのスケジューリング睡眠に入ることを意味します。一方、Mは睡眠しておらず、Gを再び実行可能な状態にさせます。時間の期限切れと新しいタイマーの追加は、一緒になってランタイムでのタイマーの動作の駆動力を構成します。
IV. タイマー精度に影響を与える要因
最初の質問「タイマーはどれほど正確なのか?」を振り返ってみると、実際には2つの要因によって影響を受けます:
(I) オペレーティングシステム自体の時間粒度
一般的にはusレベルで、時間基準の更新はmsレベルで、時間精度はusレベルに達することができます。
(II) タイマー自体のgoroutineのスケジューリング問題
ランタイムの負荷が過度に高い場合、またはオペレーティングシステム自体の負荷が過度に高い場合、タイマー自体のgoroutineがタイムリーに応答できず、タイマーがタイムリーにトリガーされなくなります。例えば、20msのタイマーと30msのタイマーが同時に実行されているように見えることがあります。特に、cgroupによって制限されたコンテナ環境では、CPU時間の割り当てが非常に少ないためです。したがって、時にはタイマーのタイミングに過度に依存してプログラムの正常な動作を保証することはできません。NewTimer
のコメントでも、「NewTimerは、少なくとも期間dの後に、そのチャネルに現在の時間を送信する新しいTimerを作成します。」と強調されており、誰もがタイマーがタイムリーに実行されることを保証することはできないことを意味します。もちろん、時間間隔が非常に大きい場合、この点での影響は無視できます。
Leapcell: The Next-Gen Serverless Platform for Golang app Hosting
最後に、Goサービスをデプロイするのに最適なプラットフォーム:Leapcell をおすすめします。
1. 多言語対応
- JavaScript、Python、Go、またはRustで開発できます。
2. 無料で無制限のプロジェクトをデプロイ
- 使用した分のみ請求 — リクエストがなければ、請求はありません。
3. 抜群のコスト効率
- 使った分だけ支払い、アイドル時には請求されません。
- 例:平均応答時間60msで694万件のリクエストに対応して、料金は25ドル。
4. シンプルな開発者体験
- 直感的なUIで簡単にセットアップできます。
- 完全自動化されたCI/CDパイプラインとGitOpsの統合。
- 実行可能なインサイトを得るためのリアルタイムのメトリクスとロギング。
5. 簡単なスケーラビリティと高パフォーマンス
- 高い同時実行を簡単に処理するための自動スケーリング。
- オペレーションオーバーヘッドはゼロ — 構築に集中できます。
Leapcell Twitter: https://x.com/LeapcellHQ