
Время — самый сложный тип данных
Представьте: канун Нового 2017 года. Инфраструктура Cloudflare обслуживает миллионы DNS-запросов в секунду — миллиарды людей не подозревают, что где-то в стоечных залах по всему миру в этот момент начинается маленькая катастрофа.
Ровно в полночь по UTC официальные хранители мирового времени добавили к нему ещё одну секунду. Лишнюю. Поверх обычных 86 400. В этот момент системные часы на серверах Cloudflare сделали шаг назад — и одна строчка кода на Go не смогла это пережить.
Строчка вычитала два вызова time.Now(), чтобы узнать длительность интервала. Длительность внезапно оказалась отрицательной. Отрицательное число попало в rand.Int63n(), которая умеет работать только с положительными значениями. DNS-резолвер запаниковал и упал.
Инцидент затронул около 0,2 % DNS-запросов в 102 дата-центрах и длился почти семь часов. Исправление в коде заняло один символ — замену == 0 на <= 0. Но чтобы этот символ появился на своём месте, команде пришлось признать неудобную истину: время — не число. За кажущейся простотой Date.now() скрываются часовые пояса, меняющиеся по политическим решениям, секунды, которые длиннее других секунд, дни, которых не было, и часы, которые идут назад.
Эта статья — экскурсия по подземельям этой абстракции. С историей, примерами кода на четырёх языках и парой предупреждений на будущее.
Как человечество училось измерять время
До середины XIX века у каждого города были свои часы. Ярославль жил по ярославскому полудню, Томск — по томскому. Всё изменили железные дороги. Без единого расписания поезда сталкивались — буквально, с лязгом металла и человеческими жертвами. Нужно было договариваться.
В 1884 году в Вашингтоне прошла Международная меридианная конференция. Она назначила Гринвичский меридиан нулевым, разделила Землю на часовые пояса и впервые заставила всё цивилизованное человечество согласиться хотя бы в одном: который сейчас час.
В 1967 году физики снова всё переиграли: секунду определили не как долю солнечных суток, а как 9 192 631 770 колебаний атома цезия-133. Атомные часы точнее, чем вращение планеты. И тут выяснилось страшное: Земля — не идеальный хронометр.
Наша планета замедляется из-за приливного трения Луны, ускоряется, когда жидкое ядро перераспределяет массу, и плывёт по времени в свою сторону. Астрономическое время (UT1) и атомное (TAI) постепенно расходятся. Атомная секунда стабильна, а вот секунда «настоящая» — нет.
В 1972 году придумали компромисс: UTC (Coordinated Universal Time) и механизм високосных секунд (leap seconds). Когда UT1 начинает отставать от UTC почти на одну секунду, в UTC добавляется дополнительная — чтобы не терять связь с солнечным днём. С 1972 года таких секунд было добавлено 27, все со знаком плюс. Последняя — 31 декабря 2016 года. Сегодня UTC отстаёт от TAI на 37 секунд. И это отставание — прямое наследство кривизны небесной механики.
Високосные секунды: заплатка, которую решили отменить
Механизм выглядит безобидно: одна лишняя секунда раз в пару лет, кому это может помешать? Но оказалось, что всем.
В момент вставки минута длится не 60 секунд, а 61: после 23:59:59 идёт 23:59:60, а только потом 00:00:00. Большинство программ никогда не видели «60»-ю секунду. Они её и не ждали.
30 июня 2012 года баг в ядре Linux заставил потоки крутиться в бесконечном цикле, выжигая процессор на 100 %. Упали Reddit (полностью), Qantas Airlines (отказ системы бронирования — пассажиры застряли в аэропортах), Mozilla, LinkedIn, Yelp, FourSquare. Cloudflare в 2017 году стал продолжением той же истории, просто на другом языке программирования и в другом дата-центре.
В ноябре 2022 года 27-я Генеральная конференция по мерам и весам (CGPM) приняла историческое решение: отменить високосные секунды к 2035 году (возможно — раньше). Допустимую разницу между UT1 и UTC увеличат, позволят ей накапливаться десятилетиями. Какой именно станет новая «допустимая» величина — решит 28-я CGPM в 2026 году. Возможно, минута. Возможно, больше.
Крупные облачные провайдеры ждать не стали. С 2008 года Google использует так называемый leap smearing — «размазывание» високосной секунды на 24 часа: чуть замедляет или ускоряет все свои часы, и момент вставки проходит незаметно. Amazon и Meta применяют тот же приём. Но у этой хитрости есть побочный эффект: часы Google и часы биржи NYSE расходятся на доли секунды — что при высокочастотной торговле, где миллисекунда решает судьбу миллиона долларов, может иметь последствия.
Климат и секунда, которой ещё не было
В марте 2024 года в журнале Nature появилась статья Дункана Агню из Института океанографии Скриппса с выводом, которого никто не ожидал. Таяние ледников Гренландии и Антарктиды перераспределяет массу планеты: вода стекает от полюсов к экватору, и Земля, как фигурист, разводящий руки, начинает вращаться медленнее.
Следствие: без климатических изменений первую в истории отрицательную високосную секунду пришлось бы вставлять уже к 2026 году. Отрицательную — значит, пропустить одну секунду, пройти от 23:59:58 сразу к 00:00:00. Таяние льдов отсрочило этот момент примерно до 2029-го.
Отрицательную високосную секунду никогда не тестировали. Meta предупредила, что это может оказать «разрушительное влияние» на цифровую инфраструктуру просто потому, что никто не знает, как поведут себя триллионы строк кода, написанного в расчёте на то, что время всегда идёт вперёд. Отличный аргумент, чтобы наконец похоронить всю систему.
День, которого не было
29 декабря 2011 года, четверг, в Самоа завершился как обычно. Следующим днём стала суббота, 31 декабря. Пятница, 30 декабря, в Самоа не существовала.
Причина — экономическая. Главные торговые партнёры островного государства сдвинулись с США в сторону Австралии и Новой Зеландии. А Самоа оставалась на американской стороне линии перемены дат (UTC-11) и теряла два рабочих дня в неделю: когда в Сиднее понедельник, в Апиа ещё воскресенье. Перепрыгнув на UTC+13, страна синхронизировалась с партнёрами и стала одной из первых на планете встречать Новый год. Американское Самоа — всего в ста километрах — осталось на UTC-11. Разница между соседними островами составила 25 часов.
Тем, у кого на несуществующую пятницу была назначена смена, всё равно выплатили зарплату как за полный рабочий день. Банки специально оговорили: проценты за пропущенный день никто не начислит и не спишет.
И это был не первый такой прыжок. 4 июля 1892 года Самоа перепрыгнула в обратную сторону, чтобы синхронизироваться с тогдашним партнёром — США. В тот раз 4 июля праздновали дважды. В 1844 году Филиппины (тогда испанская колония) пропустили 31 декабря, перейдя с американского календарного дня на азиатский. В 1995 году Кирибати сдвинула линию перемены дат и создала пояс UTC+14 — самый крайний в мире.
Следствие, о котором редко думают: в любой момент на Земле одновременно существуют три разных календарных дня. Два часа в сутки — ровно так. Подумайте об этом, когда в следующий раз будете писать new Date().toDateString().
38, а не 24
Распространённое заблуждение: на Земле 24 часовых пояса. На самом деле — примерно 38, от UTC-12 до UTC+14. И не все смещения — целые часы. Индия живёт по UTC+5:30, Непал — по UTC+5:45, острова Чатем в Новой Зеландии — по UTC+12:45. Последнее означает, что перевод встречи «на 15 минут вперёд» в Чатеме может случайно прыгнуть через границу часового пояса.
Часовые пояса меняются постоянно. Это политика, а не физика. Вот беглый взгляд на последние годы из базы IANA (tzdata):
| Год | Страна | Изменение |
|---|---|---|
| 2023 | Египет | Возобновил переход на летнее время, отменённый в 2014 году |
| 2023 | Ливан | Дата перехода на DST менялась трижды за несколько недель |
| 2023 | Гренландия | Сдвиг с −03/−02 на −02/−01 |
| 2024 | Парагвай | Перешёл на постоянный UTC−03, отменив летнее время |
| 2025 | Чили (регион Айсен) | Перешёл на UTC−03 круглый год; появилась новая зона America/Coyhaique |
База IANA обновляется несколько раз в год. Если ваше приложение работает с часовыми поясами и вы не обновляете tzdata — вы прямо сейчас показываете неправильное время хотя бы одной категории пользователей. Прямо сейчас, пока вы читаете этот абзац.
Летнее время: часы-призраки и часы-двойники
При переходе на летнее время весной часы перескакивают с 1:59 на 3:00. Час с 2:00 до 3:00 не существует — любое время в этом промежутке невалидно. Осенью часы возвращаются назад, и час с 1:00 до 2:00 повторяется дважды — одно и то же «1:30» становится неоднозначным: до перевода или после?
Звучит как забавный курьёз. А вот истории, которые реальны:
- В больницах медсёстры знают: при осеннем переводе часов записи жизненных показателей за «первый» час с 1:00 до 2:00 могут быть удалены информационной системой, когда часы вернутся к 1:00 и начнут писать поверх. Некоторые клиники на время перехода переключаются на бумажные журналы;
- 9 августа 2022 года правительство Чили объявило, что летнее время начнётся 10 сентября вместо 4 сентября — с уведомлением за месяц. Microsoft Teams и Outlook сдвинули встречи на час не туда, аутентификация Kerberos сломалась из-за несоответствия временных меток;
- В 2005 году США расширили DST на четыре недели. Затраты на патчи и тестирование оценили в 350 миллионов долларов. Такой оказалась стоимость одной строчки в законе.
Два типа часов, которые все путают
Операционная система даёт программисту два совершенно разных механизма измерения времени. Смешение их — источник большинства таймерных багов на свете.
Wall clock (часы реального мира, CLOCK_REALTIME) — показывают текущую дату и время. Синхронизируются через NTP, могут быть переведены вручную, переживают високосные секунды и DST. Могут прыгать вперёд и назад в любой момент.
Monotonic clock (монотонные часы, CLOCK_MONOTONIC) — считают наносекунды с некоторого произвольного момента (обычно — с загрузки системы). Никогда не идут назад. Не привязаны к реальной дате. Бессмысленны после перезагрузки или между разными процессами — это просто счётчик, свой для каждой живой системы.
Разница между этими двумя механизмами — не вопрос эстетики, а ответ на два разных вопроса. Wall clock отвечает на «когда это случилось?» — в такую-то дату, в таком-то часовом поясе. Monotonic — на «сколько прошло между двумя точками?». Одно и то же число не может быть одновременно ответом на оба: момент можно подкрутить через NTP, длительность — нет. Отсюда и правило, за нарушение которого платят production-инциденты:
- измерение длительности (латентность, таймауты, бенчмарки) — монотонные часы;
- запись момента события (логи, временные метки, даты файлов) — wall clock.
Именно это правило Cloudflare и нарушила в 2017 году — не по глупости, а из-за языка. Go до версии 1.9 просто не предоставлял монотонных часов: time.Now() возвращал только wall clock, больше взять было негде. После инцидента Расс Кокс предложил и реализовал встраивание монотонного значения прямо в тип time.Time — и начиная с Go 1.9 каждый вызов time.Now() запоминает оба значения одновременно.
И вот тут становится интересно. Потому что теоретическое знание «для длительности нужны монотонные часы» не спасает никого. Спасает стандартная библиотека, в которой сделать неправильно либо трудно, либо вообще невозможно. Cloudflare научила этому весь мир на своих серверах — и языки ответили на урок по-разному.
Четыре языка, четыре философии
Возьмём одну и ту же небольшую задачу — измерить длительность вычисления, записать момент события, поспать миллисекунду, разобрать Unix timestamp — и посмотрим, как её решают четыре языка с четырьмя разными представлениями о том, что значит «удобно».
Исходники целиком: Rust · Go · Zig · Nim.
Rust: два типа — и точка
Rust подошёл к проблеме радикально: просто сделал монотонные и реальные часы несовместимыми типами. Компилятор не даст их перепутать.
use std::time::{Instant, SystemTime};
// Монотонные часы — для измерения длительности
let start = Instant::now();
// ... работа ...
let elapsed = start.elapsed(); // Duration, всегда >= 0
// Wall clock — для записи момента
let now = SystemTime::now();
let since_epoch = now.duration_since(SystemTime::UNIX_EPOCH); // Result!
Instant::elapsed() возвращает Duration, гарантированно неотрицательную. SystemTime::elapsed() возвращает Result<Duration, SystemTimeError> — компилятор заставляет обработать случай, когда часы пошли назад. Даже если программист этого не хочет. Особенно если не хочет.
Instant при этом нельзя сериализовать: монотонное время не имеет смысла за пределами процесса. Встроенного DateTime в std нет — для календарных операций используют крейты chrono или time.
Go: один тип, два механизма внутри
Go пошёл другим путём: оставил единственный тип time.Time, но спрятал внутри него сразу оба часовых механизма.
start := time.Now() // содержит wall clock + monotonic reading
// ... работа ...
elapsed := time.Since(start) // использует monotonic, игнорирует wall
При вычислении разницы (Sub, Since, Until) Go автоматически берёт монотонное значение, если оно есть у обоих операндов. При сериализации (MarshalJSON, Format) монотонное значение отбрасывается. То же при календарных операциях (AddDate, Round).
Опрос реальных проектов показал: около 30 % вызовов time.Now() в коде на Go измеряют длительность. Дизайн Go делает все эти вызовы корректными автоматически, без изменения публичного API.
У этой элегантности есть ловушки. time.Parse() возвращает Time без монотонного значения — если потом вычислить time.Since(parsedTime), Go откатится к wall clock, и результат может оказаться отрицательным. Ещё одна мина: == и Equal() для time.Time делают разные вещи:
t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
t2 := time.Date(2025, 1, 1, 0, 0, 0, 0, london) // Europe/London
t1 == t2 // false — разные Location в структуре
t1.Equal(t2) // true — одно и то же мгновение
И, наконец, легендарная дата-образец Go. Вместо YYYY-MM-DD HH:mm:ss используется магическое Mon Jan 2 15:04:05 MST 2006, в котором все элементы — последовательные числа: 01/02 03:04:05 ‘06 −0700. Элегантно и совершенно непривычно.
Zig: прозрачность и контроль
Несколько дней назад, 5 апреля 2026 года, вышла Zig 0.16.0 — с серьёзно перекроенной стандартной библиотекой. Теперь всё, что связано с вводом-выводом и временем, проходит через новый интерфейс std.Io, который передаётся в main первым параметром. Прежний std.time.Timer, std.time.sleep и std.fs.File.stdout() исчезли.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
// Монотонные часы (CLOCK_MONOTONIC)
const start = std.Io.Clock.Timestamp.now(io, .awake);
// ... работа ...
const elapsed = start.untilNow(io); // Duration, >= 0
// Wall clock (Unix epoch)
const wall = std.Io.Clock.Timestamp.now(io, .real);
const seconds = wall.raw.toSeconds(); // i64
const nanos = wall.raw.toNanoseconds(); // i96
// Sleep — возвращает Cancelable!void
try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
}
В Zig часы — это набор именованных источников: .awake (монотонный, не тикает во время сна системы), .boot (монотонный, тикает всегда), .real (wall clock), .cpu_process, .cpu_thread. Встроенного DateTime нет, но std.time.epoch.EpochSeconds позволяет разложить Unix timestamp на год, месяц, день и секунды. Часовые пояса — только через внешние библиотеки.
Плюс такого подхода — полная прозрачность: никакой магии, понятно, какой источник часов используется. Минус — весь этот контроль лежит на программисте.
Nim: DateTime в стандартной библиотеке
Nim — самый «высокоуровневый» из четырёх. Часовые пояса, парсинг, форматирование и календарная арифметика встроены в стандартную библиотеку, а монотонные часы отделены в собственный модуль.
import std/[times, monotimes, os]
# Монотонные часы — для измерения длительности
let start = getMonoTime()
# ... работа ...
let elapsed = getMonoTime() - start # Duration, >= 0
echo elapsed.inNanoseconds
# Wall clock и календарь
let now = getTime()
echo now.toUnix # int64 секунд
echo now.utc # DateTime в UTC
echo now.local # DateTime в локальной зоне
# Парсинг и часовые пояса
let m = dateTime(2038, mJan, 19, 3, 14, 7, zone = utc())
echo m.format("yyyy-MM-dd HH:mm:ss zzz")
Time в Nim хранится как (seconds: int64, nanosecond: NanosecondRange) — Y2038 не угрожает. DateTime содержит внутри объект Timezone и потому знает, в каком поясе был создан момент. MonoTime из std/monotimes — обёртка над CLOCK_MONOTONIC, не зависит от NTP и никогда не идёт назад.
Главная ловушка тут — та же, что у Go: при желании можно вычесть два Time, полученных в разные моменты через getTime(), и получить странный результат, если часы перед этим подкрутил NTP. Правильный путь — getMonoTime().
Сравнение
| Параметр | Rust | Go | Zig 0.16 | Nim |
|---|---|---|---|---|
| Монотонные и wall clock | Разные типы | Один тип, оба внутри | Один API, разные «источники» | Разные модули |
| Защита от отрицательной длительности | duration_since насыщает до 0 | Автоматический выбор monotonic | Duration неотрицательна | MonoTime гарантирует ≥ 0 |
| Ошибка wall clock | Возвращает Result | Откат к wall clock | Ответственность программиста | Ответственность программиста |
DateTime в std | Нет | Да (time.Date, time.Parse) | Нет | Да (с часовыми поясами) |
| Точность timestamp | u64 секунд | int64 наносекунд | i96 наносекунд | int64 секунд + наносекунды |
Четыре языка — четыре позиции на спектре: от Rust (максимум защиты, минимум удобства из коробки) до Nim (максимум удобства, аккуратность на совести программиста). Go и Zig — между ними, каждый по-своему.
Y2038: 32-битный апокалипсис
19 января 2038 года, 03:14:07 UTC — момент, когда 32-битный знаковый Unix timestamp (int32) переполнится. Число 2 147 483 647 прыгнет в −2 147 483 648, и устройства, живущие в таких часах, внезапно окажутся в декабре 1901 года.
Это не гипотеза. Вот что случалось с 32-битными счётчиками раньше:
- Boeing 787 (2015). Федеральное авиационное управление США выпустило директиву о лётной годности: блоки управления генераторами входили в аварийный режим после 248 дней непрерывной работы. Причина — переполнение счётчика сотых долей секунды в
int32(2³¹/100/86400 ≈ 248 дней). Временное решение: перезагружать самолёт минимум раз в 248 дней; - Зонд Deep Impact (2013). После успешной миссии по комете Темпеля зонд продолжал работать ещё восемь лет — пока 32-битный счётчик десятых долей секунды не переполнился (~13,5 лет). Связь с Землёй пропала навсегда;
- Microsoft Zune (31 декабря 2008). Тысячи плееров зависли одновременно: прошивка не умела обрабатывать 366-й день високосного года и уходила в бесконечный цикл. Решение от Microsoft звучало издевательски: «подождите до 1 января».
Софт давно отреагировал. В ядре Linux с 2020 года time_t — 64-битный даже на 32-битных архитектурах. Стандартные библиотеки современных языков хранят время с огромным запасом: Rust — секунды в u64, Go — наносекунды в int64 (до 2262 года), Zig — i96 наносекунд, Nim — int64 секунд. Чтобы написать сегодня приложение, которое сломается 19 января 2038, нужно постараться.
Проблема в другом — в железе и прошивках, которые написаны «на двадцать лет вперёд, и чтобы не трогали». 32-битные микроконтроллеры в домашних маршрутизаторах, промышленных контроллерах на заводах, автомобильной электронике и медицинских приборах останутся в строю ещё десятилетия. Их прошивки часто используют 32-битный time_t просто потому, что у CPU нет 64-битной арифметики, а обновления на них давно никто не выпускает. Для этого класса устройств 19 января 2038 года — не абстракция, а вполне конкретная дата, после которой часть из них просто перестанет корректно работать.
И чтобы жизнь не казалась простой: в ноябре 2038 года произойдёт очередной GPS Week Number Rollover для устройств с 10-битным счётчиком недель. Два переполнения в одном году.
Почему sleep(1) не спит ровно секунду
Сама функция sleep() (и её аналоги во всех языках) не гарантирует точную задержку. Она гарантирует не менее запрошенного времени. Операционная система может разбудить процесс позже — если процессор занят, если планировщик решил иначе, если произошло прерывание.
Демонстрация прямо из прогона Zig-примера: запросили 1 миллисекунду, получили 1290 микросекунд. На 29 % больше, и это нормальное поведение.
Для задач, где точность критична — мультимедиа, управление оборудованием, промышленные протоколы, — используют другие механизмы: таймеры высокого разрешения (CLOCK_MONOTONIC + timerfd), real-time планировщики, а в предельных случаях — busy-wait циклы, выжигающие ядро процессора ради нескольких микросекунд точности.
Заблуждения программистов о времени
В 2012 году Ноа Суссман составил знаменитый список заблуждений — из тех, что все без исключения разделяют, пока не обожгутся. Вот самые цепкие:
- «В сутках 24 часа». Нет. При переходе на летнее время бывают 23- и 25-часовые сутки. При вставке високосной секунды — 86 401 секунда вместо 86 400;
- «В каждых сутках есть полночь». Нет. При весеннем переводе часов полночь может просто не существовать;
- «Часовых поясов — 24». Нет. Как мы уже говорили, их в районе 38, от UTC−12 до UTC+14;
- «Смещения всегда кратны часу». Нет. UTC+5:30, UTC+5:45, UTC+12:45;
- «Разница между двумя часовыми поясами постоянна». Нет. Политики меняют смещения иногда за несколько дней до события;
- «Системные часы всегда идут вперёд». Нет. NTP, високосные секунды и ручная коррекция могут откатить время назад;
- «Длительность системной минуты примерно равна настоящей». Нет. NTP-slewing может растягивать или сжимать частоту, чтобы мягко догонять атомное время.
Если вы удивились хотя бы одному пункту — продолжайте читать.
Что из этого следует
Время выглядит простым типом данных: число секунд от какой-то точки. На практике это одна из самых коварных абстракций в программировании. Несколько правил, которые помогают не попасть в историю:
- Используйте монотонные часы для измерения длительности.
Instantв Rust,time.Now()+time.Since()в Go,std.Io.Clock.Timestamp.now(io, .awake)в Zig,getMonoTime()в Nim — все они защищают от прыжков wall clock. Если вы вычитаете дваSystemTimeили дваtime.Parse()-результата — вы уязвимы; - Храните время в UTC. Часовой пояс — свойство отображения, а не хранения. Сохраняйте Unix timestamp или ISO 8601 с явным смещением, а не «локальное время» без контекста;
- Обновляйте tzdata. Часовые пояса — политическое решение, а не физическая константа. Ливан менял дату перехода на DST трижды за несколько недель в 2023 году. Если ваша база устарела, вы показываете неправильное время;
- Не доверяйте
sleep(). Он гарантирует минимум, а не точность. Для критичных интервалов используйте таймеры ОС; - Готовьтесь к 2038 году. Если ваша система хранит время в
int32— у вас есть 12 лет. Для многих встроенных систем этого не хватит.
Время — это не число. Это политика, астрономия, аппаратные причуды и десятки угловых случаев, каждый из которых кто-то уже превратил в продакшен-инцидент. Следующий инцидент, возможно, — ваш.
Источники
- How and why the leap second affected Cloudflare DNS (Cloudflare, 2017) — разбор того самого новогоднего падения
- A global timekeeping problem postponed by global warming (Nature, 2024) — как таяние ледников отсрочило отрицательную секунду
- It’s time to leave the leap second in the past (Meta, 2022) — аргументы Meta против високосных секунд
- Resolution 4 of the 27th CGPM (2022) — официальное решение об отмене високосных секунд к 2035 году
- Falsehoods programmers believe about time (Noah Sussman, 2012) — классический список заблуждений
- Go Proposal: Monotonic Elapsed Time Measurements — как в Go появились монотонные часы
- Rust std::time::Instant — документация монотонных часов Rust
- Rust std::time::SystemTime — документация wall clock в Rust
- Zig 0.16.0 Release Notes — новая подсистема
std.Io - Nim stdlib: times and monotimes — документация модулей работы со временем в Nim
- Year 2038 problem (Wikipedia) — обзор проблемы Y2038
- GPS Week Number Rollover (GPS.gov) — переполнение счётчика GPS-недель
- IANA Time Zone Database — база часовых поясов
- Time in Samoa (Wikipedia) — пропущенная пятница 30 декабря 2011 года
- The hidden costs of Daylight Saving Time (High Earth Orbit) — стоимость DST для софта