0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Golang のTimer精度はどの程度?

Posted at

Group163.png

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命令を使用)。DISIはシステムコールの最初の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メカニズムを通じて、ユーザ空間に別のアドレス領域がマップされ、そこにはカーネルが公開するいくつかのシステムコールが含まれます。具体的な呼び出し方法(例えばsyscallint 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を指し、ランタイムによって起動されます(タイマーが使用されるときにのみ起動されます)。locktimersのスレッドセーフを保証し、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 をおすすめします。

barndpic.png

1. 多言語対応

  • JavaScript、Python、Go、またはRustで開発できます。

2. 無料で無制限のプロジェクトをデプロイ

  • 使用した分のみ請求 — リクエストがなければ、請求はありません。

3. 抜群のコスト効率

  • 使った分だけ支払い、アイドル時には請求されません。
  • 例:平均応答時間60msで694万件のリクエストに対応して、料金は25ドル。

4. シンプルな開発者体験

  • 直感的なUIで簡単にセットアップできます。
  • 完全自動化されたCI/CDパイプラインとGitOpsの統合。
  • 実行可能なインサイトを得るためのリアルタイムのメトリクスとロギング。

5. 簡単なスケーラビリティと高パフォーマンス

  • 高い同時実行を簡単に処理するための自動スケーリング。
  • オペレーションオーバーヘッドはゼロ — 構築に集中できます。

Frame3-withpadding2x.png

ドキュメントで詳細を確認してください!

Leapcell Twitter: https://x.com/LeapcellHQ

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?