Как работает 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).

Во время запуска программы операционная система маппит заранее подготовленную библиотеку функций для получения времени.

https://man7.org/linux/man-pages/man7/vdso.7.html#ARCHITECTURE-SPECIFIC_NOTES

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

Вот как выглядит работа с 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
}

Давайте по порядку разберём самое непонятное:

  1. var vdsoClockgettimePtr uintptr – адрес функции clockgettime в пространстве процесса
  2. //go:linkname vdsoClockgettimePtr runtime.vdsoClockgettimeSym – ничто иное, как хак в образовательных целях, чтобы не маппить адреса clockgettime на каждой архитектуре самостоятельно. В Go уже есть все эти адреса, и через go:linkname я всего лишь прошу компилятор сделать так, чтобы обращение к vdsoClockgettimePtr было на самом деле обращением к внутренней runtime.vdsoClockgettimeSym
  3. 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 есть множество другой информации: количество процессоров, возможности процесса, номер сборки ОС и многое другое.

https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/ns-ntddk-kuser_shared_data

Монотонное и настенное время

Но есть ещё одна загадка. Linux предоставляет целую функцию, но Windows и macOS просто обновляют время в памяти. Но они же не могут делать это каждую наносекунду! Откуда тогда берётся наносекундная точность в бенчмарках?

И что произойдёт, если во время бенчмарка изменить системное время на час назад? Покажет ли бенчмарк отрицательное время?

Ответ в том, что существует два типа времени:

  1. Настенное время (wall clock time) — то, что вы видите на часах. Его можно переводить вперёд или назад, оно может корректироваться от сервера времени.
  2. Монотонное время (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
}

Благодаря этому бенчмарки всегда показывают реальное прошедшее время, независимо от изменений системных часов.

Итоги

Подведём итоги нашего путешествия:

  1. Наивный подход с системными вызовами работает, но медленно из-за переключения контекста.
  2. Каждая ОС решает проблему по-своему:
    • Linux использует vDSO — подкидывает функции в адресное пространство процесса
    • macOS использует commpage — обновляемую ядром страницу памяти, доступ к которой организует системная библиотека
    • Windows использует User Shared Data — страницу по фиксированному адресу, которую мы читаем напрямую
  3. Два типа времени решают разные задачи: настенное для отображения, монотонное для измерений.

Вот так простая операция time.Now() оказывается результатом десятилетий оптимизаций и архитектурных решений. В следующий раз, когда будете измерять производительность своего кода, вспомните, какой путь проходит каждый вызов получения времени — от вашей программы через хитрые оптимизации операционной системы до аппаратных счётчиков процессора.

Не забывайте подписываться и на Telegram канал, там я публикую в том числе короткие статьи о разных внутренностях Go:

Славный АйТи – анонсы, технологии, GoLang
Привет! Я GoLang разработчик, тимлид и автор канала про айтишечку https://www.youtube.com/@VyacheArt Буду радовать вас роликами про хайлоад, рекламу и про всё то, что я знаю, а вам интересно послушать. @VyacheslavG

Источники

Ниже перечислил полезные ссылки, если захотите покопать сами. Безусловно большая часть материала была основана на исходном коде в пакете runtime, но большую благодарность заслуживают ещё множество авторов, которые тоже копали время до меня.

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