
Время таит в себе много опасностей. Интуитивное понимание времени может сыграть злую шутку при разработке ПО.
Disclaimer: дальнейший текст содержит в том числе субъектив��ые рекомендации. Существуют предметные области, к которым они не применимы.
Ловушка №1. Системные часы не монотонны
Все мы привыкли, что время идёт вперед. Пока никто не смог вернуться назад в будущее прошлое или остановить ход времени.
Системное время на компьютере может изменить в любую сторону служба Network Time Protocol, синхронизация по GPS. В конце концов, администратор сервера или пользователь смартфона, на котором установлено ваше ПО.
Даже если вы отключили синхронизацию через NTP и строго-настрого запретили администраторам править время — оно может быть не монотонным. В наиболее общеупотребимом для ежедневных дней нужд стандарте времен Coordinated Universal Time (UTC) существует так называемая секунда координации (англ. leap second). Дело в том, что земные сутки — вещь не особо постоянная. Вращение земли замедляется, и поэтому для того, чтобы полдень оставался полднем, в году может вводиться дополнительная секунда (в теории и отрицательная при ускорении вращения). В компьютерах же для счета времени почти всегда используется Unix time, в котором день всегда равен 86 400 секундам. Так что время может сдвинуться назад. К счастью, есть надежда, что к 2035 году от leap second откажутся.
Чуть подробнее о leap second
Существуют разные подходы к обработке leap second. В зависимости от того каким образом реализована обработка leap second время может двигаться назад, прыгать назад, прыгать вперед или "замедлятся". Например, Google использует "размазанную" (smeared) leap second за счет модификаций NTP сервера. Существует вариант UTC-SLS, в рамках которого конечные серверы должны "замедлять" ход времени, в случае анонсированной информации о предстоящей leap second. Стандарт NTP "замораживает" секунду. Но согласно обзору для RFC7164 не всё так просто и однозначно. Если вам, как разработчику прикладного ПО, действительно важна информация о leap second, обязательно уточните, какой подход принят в среде целевого развертывания.
Следует лишь помнить, что системное время не обязательно монотонно, и не пренебрегать чтением документации, в которой это зачастую описано.
Для монотонных часов во многих языках есть соответствующее API: std::chrono::steady_clock в C++, System.html#nanoTime() в Java, «совмещенная с системным» версия в Go. Монотонные часы почти всегда используются только для измерения промежутков времени.
Немного об использовании монотонных часов в Go
Go использует монотонные часы в time.Time не только для вычисления промежутков времени, но и для сравнения. Про нюанс с оператором == документация предупреждает. Однако, дорогие программисты на Go, расскажите, когда вы поняли, что Time.Equal() и его компаньоны не транзитивны и как к этому относитесь?
При запуске следующего кода может оказаться, что t1 равен t3, t3 равен t2, но t1 не равен t2. (Не запускайте на go.dev/play/ — там Now всегда возвращает одно и то же значение)
package main import ( "fmt" "time" ) func main() { t1 := time.Now() t2 := t1 for t1.Equal(t2) { t2 = time.Now() } t3 := t1.Round(0) fmt.Println(t1.Equal(t3)) // true fmt.Println(t3.Equal(t2)) // true fmt.Println(t1.Equal(t2)) // false }
Рекомендация. При проектировании ПО не принимайте допущение о монотонности системных часов. Для измерения промежутков времени используйте подходящие монотонные часы.
Ловушка №2. Монотонные часы не переносимы
Впервые узнав о монотонных часах, программист может почувствовать соблазн использовать их везде. К сожалению, монотонные часы чаще всего не привязаны к какой‑то постоянной реперной точке отсчета. Это может быть время с момента загрузки системы или с момента запуска конкретного процесса. Поэтому значения монотонных часов почти всегда не имеют смысла вне конкретного запущенного процесса.
Ловушка №3. Не бывает идеально синхронизованного времени
Подавляющее большинство аппаратных устройств имеют погрешность в измерении времени. Аппаратные системные часы так или иначе накапливают ошибку в измерении времени. Протоколы синхронизации с эталоном не устраняют её, а лишь позволяют контролировать величину расхождени��.
Если на сервере Алисы какое‑то интересное событие произошло в 11:12:13.420 по её системным часам, а на сервере Боба в 11:12:13.425 по его часам, то нельзя однозначно сказать какое из событий произошло раньше, основываясь только на временной метке. Это особенно важно для распределенных систем, где для определения порядка событий используются различные алгоритмы консенсуса.
Если задуматься, то это достаточно очевидная вещь, которую многие упускают из вида.
Ловушка №4. Существует больше одной временной зоны
Во время прототипирования ПО и разработки proof‑of‑concept иногда упускается из виду то, что пользователи системы могут быть из разных временных зон. Если ваш сервер отправит уведомление в 20 часов по Москве пользователю из Владивостока, тот возможно будет не очень рад.
Ловушка №5. Существует больше одной семантики времени
Удивительно, но существует несколько обозначений времени с разной семантикой. Различные варианты подходят для различных предметных доменов.
Конкретный момент времени (java Instant, Unix timestamp, C++ Time Point). Соответствует точке на оси времени. Чаще всего естественным определением является число секунд, прошедших с какой‑то реперной точки. Реперной точкой может выступать любая дата. Для компьютеров это обычно 1 января 1970 года, UTC+0. Самая удобная для программиста вещь. Например, подходит для определения момента события.
Локальное время. Время (часы/минуты/секунды) без привязки к конкретному часовому поясу. Такая семантика подходит, например, для будильника. Поставив будильник на 7 утра в Москве, и перелетев во Владивосток, ожидаешь, что будильник сработает в 7 утра по местному времени.
Время в часовом поясе. Время (часы/минуты/секунды) с указанием привязки к часовому поясу. Например, по будням в 11:00 по Москве вы проводите daily meeting. На примере с Владивостоком это станет 18:00 по местному времени.
Локальная дата — день календаря без указания времени и привязки к точки на земном шаре и временной зоне. Например, ваш пользователь родился 28 декабря 1969 года. Уведомление с нотификацией на телефон стоит выдать 28 декабря в подходящее местное время.
Дата во временной зоне. Редкий зверь, но так же имеющий право на существование.
Пара "локальное время" и "дата без временной зоны". Не переводится в unix time без уточнения часового пояса. Зато удобна для ежедневных нужд.
Пара "время" и "дата" с указанием временной зоны. Развивая идею с будильниками и daily meeting: если видеовстреча запланирована на 1 июня 18:00 МСК, то участникам из Владивостока придется не спать 2 июня 01:00 по местному времени, чтобы к ней подключиться.
Все эти (и не только эти) семантики требуют разного подхода к обработке и хранению.
Ловушка №6. Существует больше одного календаря
Календари и системы летоисчисления претерпели заметные изменения на протяжении истории человечества. Сегодня чаще всего мы используем Григорианский календарь. Однако существуют и другие календари. Самые простые из них похожи на привычный нам Григорианский. Очень грубо говоря, календарь или хронология описывает правила исчисления дней в разных временных отрезках и реперные точки — чаще всего называемые эрами, и правила перехода между ними. В Григорианском календаре такой реперной точкой служит 1 января 1 года и временные периоды делятся на две эры — «нашу эру» и «до нашей эры» (или «от Рождества Христова» и «до Рождества Христова»).
Календарь ISO-8601 — как не трудно догадаться, он описан в стандарте ISO-8601. Сильнее всего похож на Григорианский календарь. Но эры как таковые отсутствуют.
Юлианский календарь — отличается от Григорианского високосными годами. В России использовался до 14 февраля 1918 года (по «новому стилю»), когда его и вытеснил Григорианский календарь. Если использовать советские даты, то между 31 января 1918 года и 14 февраля 1918 года прошел всего один день.
Рекомендация. Если в вашей предметной области встречаются даты до 1920-х годов, подлежащие той или иной обработке и конвертации — не забывайте о том, что они могут быть из Юлианского календаря.
Но есть и другие календари:
Япония использует Григорианский календарь. Но так же существует и календарь, основанный на девизах (нэнго) правлений. В первом приближении эры соответствуют моментам восшествия правителей на престол. Например, 2026 год н.э. соответствует 8 году Рэйва.
В Китае существует календарь Миньго, где за реперную точку взят 1912 год.
Ловушка №7. Время в далеком прошлом и будущем может "ломаться"
С календарями вообще проблема. Календари имеют свойство нормально работать только с момента их введения. Пролептические календари добавляют даты до введения календаря. Но некоторые системы вносят интересные нюансы. Например, старое Java Time Api используют для дат до 1582 года Юлианский календарь, а после — Григорианский. Эти нюансы особенно важны для перевода времени из «календарного представления» в unix time и обратно, между различным ПО (да, да Apache Spark, Apache Hive, Apache Parquet, я говорю о вас). Можно вполне обоснованно ожидать, что даты после 1970 года работают без нареканий, даты после 1900 года сносно работают почти везде. Даты до 1900 года стоит использовать с осторожностью. А вот с очень старыми датами до 1 года нашей эры могут возникнуть дополнительные не очевидные трудности. Какая дата и время были за минуту до 0001.01.01T00:00:00Z? А согласна ли с вашим мнением используемая вами библиотека, используемые вами настройки и ваш коллега?
Скрытый текст
ISO хронология определяет нулевой год и отрицательные года, а вот Юлианские и Григорианские календари — нет. Перед 1 января 1 года н.э. идет 31 декабря 1 года до н.э. За минуту до 0001–01-01T00:00Z в ISO хронологии идет время 0000–12-31T23:59Z. Но в то же время эти даты могут быть записаны как 00:00 1 января 1 года н.э. и 23:59 31 декабря 1 года до н.э.
С датами в далеком будущем тоже есть нюанс. После завершения 9999 года строковое представление даты перестает быть лексикографически сортируемым как строки. Это может нарушить работу некоторых систем.
Рекомендация. Даты в далеком прошлом и будущем часто используются в качестве placeholder'ов или sentinel значений. Убедитесь, что такие значения могут быть корректно сериализованы и восстановлены при сохранении и передаче по сети, а также не нарушают принятых допущений.
Ловушка №8. Пренебрежение ISO 8601
Нет ничего лучше стандартов. Особенно, когда этот стандарт один. Стандарт ISO 8601 описывает хронологию ISO и способы текстового представления дат и времени для общения между системами. Вместо изобретения собственных способов записи и передачи стоит пользоваться стандартом. Это ни в коем случае не говорит, что вы должны показывать пользователю дату и время в формате ISO 8601, но для передачи по сети пары дата/время лучше использовать стандартизованную запись.
Рекомендация. Если вы всё же используете нестандартное текстовое представление, в документации вашего API обязательно детально описывайте ваши примеры. Пример для поля eventDate 26.03.01 без пояснений может трактоваться людьми как 26 марта 2001 года, так и 1 марта 2026 года.
Числовое представление идеально подходит для передачи unix timestamp, но он подходит не для каждой предметной области. Для передачи локальной даты потребуется в обязательном порядке описать правила конвертации.
Ловушка №9. Временные зоны непостоянны
Временные зоны описывают правила вычисления сдвигов между временем UTC и местным временем. Сейчас в РФ временная зона МСК имеет сдвиг +3 часа относительно UTC. При разработке систем можно наивно полагать, что временная зона МСК эквивалентна UTC+3. Еще 15 лет назад это не было так. Существование летнего и зимнего времени приводили к тому, что иногда МСК соответствовало UTC+3, а иногда UTC+4. При этом правила определения местного времени определяются государством, а не инженерами. Правила эти могут быть очень непостоянными. В 2011 году был принят федеральный закон «Об исчислении времени», устанавливавший в Москве постоянное время UTC+4. Но уже в 2014 году в него были внесены изменения, вернувшие Москву в UTC+3.
Рекомендация. Не используйте для хранения и передачи времени временную зону МСК, если она не подходит для вашей семантики. Скачок времени на час вперед, связанный с возвратом летнего или зимнего времени может быть как ожидаемым поведением системы, так и багом.
Как нет ничего более постоянного, чем временное, так нет ничего более временного, чем постоянное. Правила могут быть изменены в любой момент. С временными зонами это усугубляется еще и тем, что разные хосты получат обновления tzdata в разное время.
Заключение
К сожалению, эти 9 ловушек самые простые из известных мне. Существуют более специфические проблемы, относящиеся к конкретному ПО и форматам, которые требуют отдельных статей. Если вам известны другие интересные ситуации, где ломается интуитивное понимание времени, обязательно поделитесь в комментариях. Желаю вам не попадаться в ловушки времени!
