Почему time.Now() в Go работает НАСТОЛЬКО быстро? | Разбор для Linux, Windows и macOS
Как работает time.Now() в Go: путешествие от системных вызовов до наносекунд
Бенчмарки, логи, базы данных, игровые серверы — любое приложение на Go использует время. Это базовая и, казалось бы, простая операция. Но задумывались ли вы, почему time.Now()
может работать с разной скоростью на одном и том же компьютере? Откуда Go вообще берёт время? И как разработчики Linux, Windows и macOS решили одну и ту же проблему совершенно разными способами?
Сегодня мы разберёмся с этими вопросами и даже напишем свой собственный time.Now()
.
Основной способ получить время
Начнём с простого вопроса: кто на вашем компьютере знает время? Очевидно, операционная система. Именно в ней вы настраиваете часовой пояс, синхронизацию и прочее.
Как получить это время в программе? Нужно спросить у операционной системы через системные вызовы — своего рода API операционной системы. В Linux для этого есть функция gettimeofday
:
package syscall
import (
"fmt"
"syscall"
"unsafe"
)
type timespec struct {
Sec int64
Msec int64
}
func Now() (int64, int64, error) {
var ts timespec
_, _, errno := syscall.Syscall(syscall.SYS_GETTIMEOFDAY,
uintptr(unsafe.Pointer(&ts)), 0, 0)
if errno != 0 {
return 0, 0, fmt.Errorf("error getting time: %s", errno.Error())
}
return ts.Sec, ts.Msec, nil
}
Запустив этот код, мы получим текущее время:
$ go run main.go
Unix Now: 1745090299, Time Now: 1745090299
Работает! Но давайте сравним производительность нашей реализации со стандартной:
BenchmarkSyscall-2 6638930 178.0 ns/op
BenchmarkTimeNow-2 26734690 45.47 ns/op
Разница почти в 4 раза! Но что, если я немного изменю реализацию? После использования некоего vDSO (далее расскажу) мы получаем совсем другие результаты:
BenchmarkVdso-2 26806431 42.00 ns/op
BenchmarkTimeNow-2 26515209 45.92 ns/op
Мы даже обогнали time.Now
! Как так получилось? Давайте разбираться.
Цена системных вызовов и vDSO
Проблема в том, что системные вызовы — это как если бы каждый раз, когда вы хотите попить, вам приходилось бы идти в магазин за парой глоточков.
Системные вызовы подразумевают смену контекста исполнения с кода вашей программы на код операционной системы, а затем — восстановление контекста вашей программы. Это необходимо, потому что в коде вашей программы физически нет реализации получения времени. И такое переключение съедает драгоценные наносекунды.

Если проблема в отсутствии кода в нашей программе, то решение простое — подкинуть реализацию при старте программы! Именно так появился vDSO (Virtual Dynamic Shared Object).
Во время запуска программы операционная система маппит заранее подготовленную библиотеку функций для получения времени.

Получается, будто в коде вашей программы внезапно появились нужные функции. Это как если бы магазин разместил прилавок с водой прямо у вас в квартире.

Вот как выглядит работа с vDSO:
package vdso
import (
"fmt"
_ "unsafe"
"golang.org/x/sys/unix"
)
// vdsoClockgettimePtr holds the address found by the runtime.
//
//go:linkname vdsoClockgettimePtr runtime.vdsoClockgettimeSym
var vdsoClockgettimePtr uintptr
// vdsoCallClockgettimeAsm is the Go declaration for our internal assembly function.
// Implementation is in vdso_asm_arm64.s
func vdsoCallClockgettimeAsm(vdsoFuncPtr uintptr, clockid uintptr, ts *unix.Timespec) int64
// vdsoClockGettimeAsmWrapper calls the assembly function and interprets the result.
// Internal helper function.
func vdsoClockGettimeAsmWrapper(clockid int) (sec int64, nsec int64, err error) {
var ts unix.Timespec
ret := vdsoCallClockgettimeAsm(vdsoClockgettimePtr, uintptr(clockid), &ts)
if ret != 0 { // ret should be -1 on error
return 0, 0, fmt.Errorf("vDSO assembly call failed (ret=%d)", ret)
}
return ts.Sec, ts.Nsec, nil
}
// Now returns the current time using CLOCK_REALTIME.
// It attempts to use the vDSO via assembly for performance,
// falling back to a standard syscall if vDSO is unavailable or fails.
func Now() (sec int64, nsec int64, err error) {
// Try vDSO first if the pointer is available
if vdsoClockgettimePtr != 0 {
sec, nsec, err = vdsoClockGettimeAsmWrapper(unix.CLOCK_REALTIME)
// If vDSO call succeeded, return the result
if err == nil {
return sec, nsec, nil
}
}
// Fallback to standard syscall if vDSO pointer is null or vDSO call failed
var ts unix.Timespec
syscallErr := unix.ClockGettime(unix.CLOCK_REALTIME, &ts)
if syscallErr != nil {
// If both vDSO (if attempted) and syscall failed, return the syscall error
return 0, 0, fmt.Errorf("vdso: syscall fallback failed: %w", syscallErr)
}
// Return result from successful syscall fallback
return ts.Sec, ts.Nsec, nil
}
Давайте по порядку разберём самое непонятное:
var vdsoClockgettimePtr uintptr
– адрес функции clockgettime в пространстве процесса//go:linkname vdsoClockgettimePtr runtime.vdsoClockgettimeSym
– ничто иное, как хак в образовательных целях, чтобы не маппить адреса clockgettime на каждой архитектуре самостоятельно. В Go уже есть все эти адреса, и черезgo:linkname
я всего лишь прошу компилятор сделать так, чтобы обращение к vdsoClockgettimePtr было на самом деле обращением к внутреннейruntime.vdsoClockgettimeSym
func vdsoCallClockgettimeAsm(vdsoFuncPtr uintptr, clockid uintptr, ts *unix.Timespec) int64
– а вот тут интересно! Заголовок функции есть, а вот реализация в соседнем файлеvdso_asm_arm64.s
TEXT ·vdsoCallClockgettimeAsm(SB), NOSPLIT, $0-32
MOVD vdsoFuncPtr+0(FP), R10 // Адрес функции vDSO
MOVD clockid+8(FP), R0 // Первый аргумент
MOVD ts+16(FP), R1 // Второй аргумент
CALL (R10) // Вызываем функцию
MOVD R0, ret+24(FP) // Возвращаем результат
RET
А почему здесь потребовался ассемблер?
Основная причина для меня – я не знаю как на Go вызвать функцию по указателю vdsoClockgettimePtr
. Поэтому я самостоятельно кладу данные в регистры и вызываю функцию через CALL
.
Как сделано в macOS: commpage
На macOS системный вызов тоже работает медленно:
BenchmarkSyscall-10 5107172 231.6 ns/op
BenchmarkTimeNow-10 29132193 41.96 ns/op
Разница более чем в 5 раз! Но vDSO там нет (поверьте). Как же тогда?
macOS использует другой подход: вместо функции она даёт доступ к особому региону памяти, в котором периодически обновляет время. Это как если бы вода удивительным образом появлялась прямо на вашем столе.
Такая общая страница называется commpage, и Go к ней даже не обращается напрямую! Вместо этого Go использует системную библиотеку:
func Now() (int64, int64, error) {
var ts unix.Timespec
// Используем высокоуровневую обёртку вместо сырого syscall
err := unix.ClockGettime(unix.CLOCK_REALTIME, &ts)
if err != nil {
return 0, 0, err
}
return ts.Sec, ts.Nsec, nil
}
А unix.ClockGettime
выглядит как:
func ClockGettime(clockid int32, time *Timespec) (err error) {
_, _, e1 := syscall_syscall(libc_clock_gettime_trampoline_addr, uintptr(clockid), uintptr(unsafe.Pointer(time)), 0)
if e1 != 0 {
err = errnoErr(e1)
}
return
}
libc_clock_gettime_trampoline_addr
И результаты говорят сами за себя:
BenchmarkSyscall-10 21755354 48.90 ns/op
BenchmarkTimeNow-10 28316968 42.57 ns/op
Системная библиотека знает про commpage и самостоятельно решает, когда использовать её, а когда — системный вызов.
Как сделано в Windows: User Shared Data
Windows пошла по пути, похожему на macOS. Традиционно посмотрим на результаты с использованием Windows API:
BenchmarkWinApi-4 29055982 41.50 ns/op
BenchmarkTimeNow-4 352143674 3.412 ns/op
Вау! time.Now()
работает в 12 раз быстрее! Как Windows добилась такой скорости?
Ответ — в прямом чтении памяти. В памяти процесса есть особая страница User Shared Data, которая находится по фиксированному адресу 0x7FFE0000
:
const (
systemTimeAddr uintptr = 0x7ffe0014
epochOffset int64 = 116444736000000000 // Разница между эпохами Windows и Unix
)
func Now() (sec int64, nsec int64, err error) {
// Читаем время напрямую из памяти
ticks := *(*int64)(unsafe.Pointer(systemTimeAddr))
// Конвертируем из Windows-эпохи в Unix-эпоху
unixTicks := ticks - epochOffset
totalNsec := unixTicks * 100
sec = totalNsec / int64(time.Second)
nsec = totalNsec % int64(time.Second)
return sec, nsec, nil
}
И если использовать User Shared Data, то результат вас удивит:
BenchmarkWinShared-4 1000000000 0.3412 ns/op
BenchmarkTimeNow-4 344631338 3.439 ns/op
Наша реализация работает в 10 раз быстрее! Это возможно, потому что мы делаем минимум работы — просто читаем значение из памяти. Go же выполняет дополнительные проверки и преобразования.
Интересно, что помимо времени в User Shared Data есть множество другой информации: количество процессоров, возможности процесса, номер сборки ОС и многое другое.

Монотонное и настенное время
Но есть ещё одна загадка. Linux предоставляет целую функцию, но Windows и macOS просто обновляют время в памяти. Но они же не могут делать это каждую наносекунду! Откуда тогда берётся наносекундная точность в бенчмарках?
И что произойдёт, если во время бенчмарка изменить системное время на час назад? Покажет ли бенчмарк отрицательное время?
Ответ в том, что существует два типа времени:
- Настенное время (wall clock time) — то, что вы видите на часах. Его можно переводить вперёд или назад, оно может корректироваться от сервера времени.
- Монотонное время (monotonic time) — непреклонный аппаратный счётчик с момента включения компьютера. Оно никогда не идёт вспять и не ускоряется, и в этом его прелесть.
Когда в Go вы вычитаете одно время из другогоtime.Sub(...)
, Go проверяет наличие монотонного времени в структуре и использует именно его:
// В структуре time.Time есть поле для монотонного времени
type Time struct {
wall uint64
ext int64 // Может содержать монотонное время
loc *Location
}
func (t Time) Sub(u Time) Duration {
//Если есть монотонное время, то...
if t.wall&u.wall&hasMonotonic != 0 {
//вычитаем именно его!
return subMono(t.ext, u.ext)
}
d := Duration(t.sec()-u.sec())*Second + Duration(t.nsec()-u.nsec())
// Check for overflow or underflow.
switch {
case u.Add(d).Equal(t):
return d // d is correct
case t.Before(u):
return minDuration // t - u is negative out of range
default:
return maxDuration // t - u is positive out of range
}
}
func subMono(t, u int64) Duration {
d := Duration(t - u)
if d < 0 && t > u {
return maxDuration // t - u is positive out of range
}
if d > 0 && t < u {
return minDuration // t - u is negative out of range
}
return d
}
Благодаря этому бенчмарки всегда показывают реальное прошедшее время, независимо от изменений системных часов.
Итоги
Подведём итоги нашего путешествия:
- Наивный подход с системными вызовами работает, но медленно из-за переключения контекста.
- Каждая ОС решает проблему по-своему:
- Linux использует vDSO — подкидывает функции в адресное пространство процесса
- macOS использует commpage — обновляемую ядром страницу памяти, доступ к которой организует системная библиотека
- Windows использует User Shared Data — страницу по фиксированному адресу, которую мы читаем напрямую
- Два типа времени решают разные задачи: настенное для отображения, монотонное для измерений.
Вот так простая операция time.Now()
оказывается результатом десятилетий оптимизаций и архитектурных решений. В следующий раз, когда будете измерять производительность своего кода, вспомните, какой путь проходит каждый вызов получения времени — от вашей программы через хитрые оптимизации операционной системы до аппаратных счётчиков процессора.
Не забывайте подписываться и на Telegram канал, там я публикую в том числе короткие статьи о разных внутренностях Go:

Источники
Ниже перечислил полезные ссылки, если захотите покопать сами. Безусловно большая часть материала была основана на исходном коде в пакете runtime, но большую благодарность заслуживают ещё множество авторов, которые тоже копали время до меня.
- https://pkg.go.dev/time
- https://man7.org/linux/man-pages/man2/settimeofday.2.html
- https://man7.org/linux/man-pages/man7/vdso.7.html
- go/src/runtime/time_linux_amd64.s
- https://docs.darlinghq.org/internals/macos-specifics/commpage.html
- https://deviltux.thedev.id/notes/pure-reverse-engineering/
- Описание структуры KUSER_SHARED_DATA в доке Microsoft
- Подробная история, назначение и множество информации о KUSER_SHARED_DATA (не Microsoft)
- Обращение к KSHARED_USER_DATA d ReactOS
- Обращение к libc на macOS при использовании time.Now
- https://www.willem.dev/articles/time-now-monotonic-clock/
- https://tpaschalis.me/golang-time-now/