Golangタイマーの精度に関する探求
Emily Parker
Product Engineer · Leapcell

Golangタイマーの精度に関する探求
I. 問題提起: Golangではタイマーはどれくらい正確か?
Golangの世界では、タイマーは幅広いアプリケーションシナリオで使用されています。しかし、タイマーがどれくらい正確であるかという問題は、常に開発者の懸念事項でした。この記事では、Goにおけるタイマーヒープの管理と、実行時に時間を取得するメカニズムを深く掘り下げ、タイマーの精度をどの程度信頼できるかを明らかにします。
II. Goが時間を取得する方法
(I) time.Now
の背後にあるアセンブリ関数
time.Now
を呼び出すと、最終的に次のアセンブリ関数が呼び出されます。
// func now() (sec int64, nsec int32) TEXT time·now(SB),NOSPLIT,$16 // Be careful. We're calling a function with gcc calling convention here. // We're guaranteed 128 bytes on entry, and we've taken 16, and the // call uses another 8. // That leaves 104 for the gettime code to use. Hope that's enough! 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
ファイルです。ディスク上にはなく、カーネル内にあり、ユーザースペースにマッピングされています。これはシステムコールを高速化するためのメカニズムであり、互換性モードです。gettimeofday
のような関数では、通常のシステムコールを使用すると、多数のコンテキストスイッチが発生します。特に、頻繁に時間を取得するプログラムの場合そうです。VDSOメカニズムを使用すると、アドレスのセクションがユーザー空間に個別にマッピングされ、カーネルによって公開される一部のシステムコールが含まれます。具体的な呼び出し方法(syscall
、int 80
、またはsystenter
など)は、glibc
バージョンとkernel
バージョンの間の互換性の問題を回避するために、カーネルによって決定されます。さらに、VDSOはvsyscall
のアップグレード版であり、一部のセキュリティの問題を回避し、マッピングは静的に固定されなくなりました。
(V) カーネルにおける時間取得の更新メカニズム
カーネルからわかるように、システムコールによって取得される時間は、時間割り込みによって更新され、そのコールスタックは次のとおりです。
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レベルですが、これは単なるベンチマーク値です。時間を取得するたびに、クロックソースからの時間が取得されます(クロックソースには多くの種類があり、ハードウェアカウンターまたは割り込みのjiffyである可能性があり、通常は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がタイムリーに応答しなくなり、タイマーがタイムリーにトリガーされなくなる可能性があります。たとえば、CPU時間の割り当てが非常に小さいcgroupによって制限されている一部のコンテナ環境では、20msのタイマーと30msのタイマーが同時に実行されるように見えることがあります。したがって、プログラムの正常な動作を保証するために、タイマーのタイミングに過度に依存することはできません。NewTimer
のコメントも、「NewTimerは、少なくともduration dの後にチャネルに現在の時間を送信する新しいTimerを作成します」と強調しています。これは、タイマーが時間通りに実行されることを誰も保証できないことを意味します。もちろん、時間間隔が非常に大きい場合、この点の影響は無視できます。
Leapcell: Golangアプリのホスティングのための次世代サーバーレスプラットフォーム
最後に、Goサービスのデプロイに最適なプラットフォームをお勧めします。Leapcell
1. 多言語サポート
- JavaScript、Python、Go、またはRustで開発します。
2. 無制限のプロジェクトを無料でデプロイ
- 使用量に対してのみ支払い、リクエストも請求もありません。
3. 無敵のコスト効率
- アイドル料金なしの従量課金制。
- 例:25ドルで、平均応答時間60msで694万のリクエストをサポートします。
4. 合理化された開発者エクスペリエンス
- 簡単なセットアップのための直感的なUI。
- 完全に自動化されたCI/CDパイプラインとGitOps統合。
- 実用的な洞察を得るためのリアルタイムのメトリックとロギング。
5. 容易なスケーラビリティと高パフォーマンス
- 高い同時実行性を容易に処理するための自動スケーリング。
- 運用上のオーバーヘッドはゼロで、構築に集中できます。
Leapcell Twitter: https://x.com/LeapcellHQ