Golang Timer Präzision: Wie präzise kann es werden?
Emily Parker
Product Engineer · Leapcell

Erkundung des Mysteriums der Golang-Timer-Präzision
I. Problemeinführung: Wie präzise kann ein Timer in Golang sein?
In der Welt von Golang haben Timer ein breites Spektrum an Anwendungsszenarien. Die Frage, wie genau sie sind, hat Entwickler jedoch immer beschäftigt. Dieser Artikel befasst sich eingehend mit der Verwaltung des Timer-Heap in Go und dem Mechanismus zur Laufzeit-Zeiterfassung, um aufzudecken, inwieweit wir uns auf die Genauigkeit von Timern verlassen können.
II. Wie Go die Zeit erfasst
(I) Die Assembly-Funktion hinter time.Now
Wenn wir time.Now
aufrufen, wird schließlich die folgende Assembly-Funktion aufgerufen:
// 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
Hier stellt in TEXT time·now(SB),NOSPLIT,$16
time·now(SB)
die Adresse der Funktion now
dar, das Flag NOSPLIT
gibt an, dass sie nicht von Parametern abhängt, und $16
gibt an, dass der zurückgegebene Inhalt 16 Byte groß ist.
(II) Funktionsaufrufprozess
Zuerst wird die Adresse von __vdso_clock_gettime_sym(SB)
abgerufen, die auf die Funktion clock_gettime
verweist. Wenn dieses Symbol nicht leer ist, wird die Adresse oben im Stack berechnet und an SI
übergeben (mit der Anweisung LEA
). DI
und SI
sind die Register für die ersten beiden Parameter des Systemaufrufs, was dem Aufruf von clock_gettime(0, &ret)
entspricht. Wenn das entsprechende Symbol nicht initialisiert ist, wird der fallback
-Zweig betreten und die Funktion gettimeofday
aufgerufen.
(III) Stack-Speicherbegrenzung
Go-Funktionsaufrufe stellen sicher, dass mindestens 128 Byte Stack vorhanden sind (beachten Sie, dass dies nicht der Goroutine-Stack ist), und Sie können sich für Details auf _StackSmall
in runtime/stack.go
beziehen. Nach dem Betreten der entsprechenden C-Funktion wird das Wachstum des Stacks jedoch nicht mehr von Go gesteuert. Daher müssen die verbleibenden 104 Byte sicherstellen, dass der Aufruf keinen Stack-Überlauf verursacht. Glücklicherweise tritt aufgrund der Tatsache, dass diese beiden Funktionen zur Zeiterfassung nicht komplex sind, im Allgemeinen kein Stack-Überlauf auf.
(IV) VDSO-Mechanismus
VDSO, was für Virtual Dynamic Shared Object steht, ist eine virtuelle .so
-Datei, die vom Kernel bereitgestellt wird. Sie befindet sich nicht auf der Festplatte, sondern im Kernel und wird in den User Space gemappt. Dies ist ein Mechanismus zur Beschleunigung von Systemaufrufen und ein Kompatibilitätsmodus. Für Funktionen wie gettimeofday
kommt es bei Verwendung eines normalen Systemaufrufs zu einer großen Anzahl von Kontextwechseln, insbesondere bei Programmen, die häufig die Zeit erfassen. Durch den VDSO-Mechanismus wird ein Adressabschnitt separat im User Space gemappt, der einige vom Kernel bereitgestellte Systemaufrufe enthält. Die spezifische Aufrufmethode (z. B. syscall
, int 80
oder systenter
) wird vom Kernel bestimmt, um Kompatibilitätsprobleme zwischen der glibc
-Version und der Kernel
-Version zu vermeiden. Darüber hinaus ist VDSO eine verbesserte Version von vsyscall
, die einige Sicherheitsprobleme vermeidet, und die Zuordnung ist nicht mehr statisch festgelegt.
(V) Der Aktualisierungsmechanismus für die Zeiterfassung im Kernel
Aus dem Kernel geht hervor, dass die vom Systemaufruf erfasste Zeit durch den Zeitinterrupt aktualisiert wird und sein Aufruf-Stack wie folgt aussieht:
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
verwendet die Zeit von der Clock Source, und die Präzision kann die ns-Ebene erreichen. Im Allgemeinen beträgt der Zeitinterrupt des Linux-Kernels jedoch 100 Hz, in einigen Fällen kann er bis zu 1000 Hz betragen. Das heißt, die Zeit wird im Allgemeinen einmal während der Interruptverarbeitung alle 10 ms oder 1 ms aktualisiert. Aus Sicht des Betriebssystems liegt die Zeitgranularität ungefähr im ms-Bereich, dies ist jedoch nur ein Richtwert. Jedes Mal, wenn die Zeit erfasst wird, wird immer noch die Zeit von der Clock Source abgerufen (es gibt viele Arten von Clock Sources, die ein Hardwarezähler oder der Jiffy eines Interrupts sein können und im Allgemeinen die ns-Ebene erreichen können), und die Präzision der Zeiterfassung kann zwischen us und mehreren hundert ns liegen. Für eine genauere Zeit muss der CPU-Zyklus theoretisch direkt mit der Assembly-Anweisung rdtsc
gelesen werden.
(VI) Die Suche und Verknüpfung von Funktionssymbolen
Der Prozess der Suche nach den Funktionssymbolen für die Zeiterfassung umfasst den Inhalt von ELF, d. h. den Prozess der dynamischen Verknüpfung. Es löst die Adressen der Funktionssymbole in der .so
-Datei auf und speichert sie in Funktionszeigern, z. B. __vdso_clock_gettime_sym
. Andere Funktionen, z. B. TEXT runtime·nanotime(SB),NOSPLIT,$16
, haben ebenfalls einen ähnlichen Prozess, und diese Funktion kann die Zeit erfassen.
III. Die Verwaltung des Timer-Heap durch die Go-Runtime
(I) Die timer
-Struktur
// 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 }
Timer werden in Form eines Heap (Heap) verwaltet. Ein Heap ist ein vollständiger Binärbaum und kann mithilfe eines Arrays gespeichert werden. i
ist der Index des Heaps. when
ist die Zeit, zu der die Goroutine aufgeweckt wird, und period
ist das Intervall zwischen den Aktivierungen. Die nächste Weckzeit ist when + period
usw. Die Funktion f(now, arg)
wird aufgerufen, wobei now
der Zeitstempel ist.
(II) Die timers
-Struktur
var timers struct { lock mutex gp *g created bool sleeping bool rescheduling bool waitnote note t []*timer }
Der gesamte Timer-Heap wird von timers
verwaltet. gp
verweist auf die G-Struktur im Scheduler, d. h. eine Zustandsverwaltungsstruktur einer Goroutine. Sie verweist auf eine separate Goroutine des Zeitmanagers, die von der Runtime gestartet wird (sie wird nur gestartet, wenn ein Timer verwendet wird). lock
gewährleistet die Thread-Sicherheit von timers
, und waitnote
ist eine Bedingungsvariable.
(III) Die addtimer
-Funktion
func addtimer(t *timer) { lock(&timers.lock) addtimerLocked(t) unlock(&timers.lock) }
Die Funktion addtimer
ist der Einstiegspunkt für den Start des gesamten Timers. Sie sperrt einfach und ruft dann die Funktion addtimerLocked
auf.
(IV) Die Funktion 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() } }
In der Funktion addtimerLocked
wird eine timerproc
-Coroutine gestartet, wenn timers
nicht erstellt wurde.
(V) Die Funktion 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) } }
Die Hauptlogik von timerproc
besteht darin, den Timer aus dem Min-Heap zu entnehmen und die Callback-Funktion aufzurufen. Wenn period
größer als 0 ist, wird der when
-Wert des Timers geändert und der Heap angepasst. Wenn er kleiner als 0 ist, wird der Timer direkt aus dem Heap entfernt. Anschließend wird der OS-Semaphore aufgerufen, um zu schlafen und auf die nächste Verarbeitung zu warten, und er kann auch durch die Variable waitnote
aktiviert werden. Wenn keine Timer mehr vorhanden sind, wechselt die durch die G-Struktur dargestellte Goroutine in den Ruhezustand, und der durch die M-Struktur dargestellte OS-Thread, der die Goroutine hostet, sucht nach anderen ausführbaren Goroutinen, um sie auszuführen.
(VI) Der Wake-up-Mechanismus in addtimerLocked
Wenn ein neuer Timer hinzugefügt wird, wird er überprüft. Wenn sich der neu eingefügte Timer oben im Heap befindet, wird das schlafende timergorountine
aktiviert, wodurch es beginnt, nach abgelaufenen Timern im Heap zu suchen und diese auszuführen. Es gibt zwei Zustände für das Aufwecken und den vorherigen Schlaf: timers.sleeping
bedeutet, dass der OS-Semaphore-Schlaf von M aufgerufen wird, und timers.rescheduling
bedeutet, dass der Planungsschlaf von G aufgerufen wird, während M nicht schläft und G wieder in den ausführbaren Zustand versetzt.
Der Ablauf der Zeit und das Hinzufügen neuer Timer bilden zusammen die treibende Kraft für den Timerbetrieb zur Laufzeit.
IV. Faktoren, die die Timer-Präzision beeinflussen
Wenn wir auf die Ausgangsfrage „Wie präzise kann ein Timer sein?“ zurückblicken, wird sie tatsächlich von zwei Faktoren beeinflusst:
(I) Die Zeitgranularität des Betriebssystems selbst
Im Allgemeinen liegt sie auf der us-Ebene, das Zeit-Benchmark-Update liegt auf der ms-Ebene und die Zeitpräzision kann die us-Ebene erreichen.
(II) Das Planungsproblem der eigenen Goroutine des Timers
Wenn die Runtime-Last zu hoch ist oder die Last des Betriebssystems selbst zu hoch ist, führt dies dazu, dass die eigene Goroutine des Timers nicht rechtzeitig reagiert, was dazu führt, dass der Timer nicht rechtzeitig ausgelöst wird. Beispielsweise kann es vorkommen, dass ein 20-ms-Timer und ein 30-ms-Timer scheinbar gleichzeitig ausgeführt werden, insbesondere in einigen Containerumgebungen, die durch cgroup eingeschränkt sind, in denen die CPU-Zeitzuweisung sehr gering ist. Daher können wir uns manchmal nicht übermäßig auf das Timing des Timers verlassen, um den normalen Betrieb des Programms sicherzustellen. Der Kommentar zu NewTimer
betont auch, dass "NewTimer einen neuen Timer erstellt, der die aktuelle Zeit nach mindestens der Dauer d auf seinen Kanal sendet.