MAATRIX / Блог / Монотонные часы: почему нельзя измерять длительность обычным временем

Монотонные часы: почему нельзя измерять длительность обычным временем

MAATRIX

Код меряет длительность операции классическим способом: взял текущее время до, взял текущее время после, вычел одно из другого. Обычно это работает. А иногда — раз в несколько месяцев, без видимой причины — длительность получается отрицательной, или запрос, который занял 30 миллисекунд, в логах превращается в минус две секунды. Причина почти всегда одна: для измерения интервала использовали не те часы. В системе их на самом деле два вида, и они решают разные задачи.

Почему замер «до и после» вообще может сломаться

Самый частый паттерн измерения времени выглядит примерно так:

start = time.time()
do_something()
end = time.time()
duration = end - start

Здесь time.time() возвращает так называемое «настенное время» (wall clock) — то, что показывают часы в углу экрана, то, что хранится в системных вызовах вроде CLOCK_REALTIME. Это время привязано к календарной дате: оно должно совпадать с тем, что говорит вам NTP-сервер, с тем, что видит сосед на другом конце света, с тем, что записано в UTC. И именно поэтому оно может измениться в любой момент — не потому что кто-то полез руками в дату, а потому что фоновый демон синхронизации (chrony, ntpd, systemd-timesyncd) непрерывно сверяет часы сервера с внешним источником и подправляет их. Мы разбирали механику этого процесса в статье про то, как NTP подводит часы сервера, ни разу их не переводя — эта статья логическое продолжение: там речь о том, что часы двигаются, здесь — что происходит с кодом, который в момент этого движения оказался посередине замера.

Если между start и end демон синхронизации решил скорректировать часы — сдвинул их вперёд, назад, чуть-чуть или существенно — результат вычитания перестаёт быть длительностью операции. Он становится длительностью операции плюс-минус коррекция часов. В худшем случае, если коррекция была назад и её величина больше времени выполнения операции, end окажется меньше start, и duration уйдёт в минус.

Как обычные часы двигаются на живом сервере

Большую часть времени NTP-демон работает мягко: если рассинхронизация небольшая (условно — доли секунды), он не переставляет часы одним прыжком, а слегка меняет скорость их хода — это называется slewing. Секунда идёт чуть быстрее или чуть медленнее обычной, пока часы не догонят эталон. При таком режиме time.time() формально не скачет назад, но и не идёт с постоянной скоростью — интервал между двумя чтениями будет не совсем таким, как «должен был бы» быть по факту прошедшего реального времени.

Проблема начинается там, где рассинхронизация слишком большая для плавной коррекции. Тогда демон делает step — одномоментный скачок часов на нужную величину, в любую сторону, в том числе назад. Ситуации, где это происходит на практике:

  • сервер только что загрузился, и его аппаратные часы (RTC) успели заметно уйти в сторону, пока машина была выключена;
  • виртуальная машина восстановилась из снапшота или мигрировала между хостами, и гостевые часы резко разошлись с реальностью;
  • сеть с NTP-сервером была недоступна долго, дрейф накопился, а когда связь восстановилась, коррекция потребовалась крупная;
  • администратор вручную поменял дату командой date или timedatectl set-time;
  • в редких случаях — некорректная обработка високосной секунды на уровне ОС или демона синхронизации.

Порог, после которого демон делает step вместо плавной коррекции, настраивается (в chrony — параметром makestep, в ntpd — похожей логикой) и отличается от системы к системе. Не полагайтесь на конкретные цифры «из головы» — посмотрите актуальный конфиг на своём сервере через chronyc tracking или timedatectl timesync-status, если нужна точная картина.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Что видит код в момент коррекции часов

Разберём на конкретном примере, что произойдёт, если step случится точно посередине замера:

start = time.time()      # например, 1735689600.100
# в этот момент chronyd делает step часов назад на 2 секунды
do_something()            # реально заняло 0.05 секунды
end = time.time()         # но часы теперь показывают 1735689598.150
duration = end - start    # = -1.95, хотя операция была быстрой и успешной

Если этот duration попадает в метрику (гистограмму латентности, алерт по SLA, лог для дальнейшего анализа), результат один: либо мониторинг молча проглатывает мусорное отрицательное число и портит агрегаты (средние, перцентили), либо где-то в коде есть assert duration >= 0, и приложение падает по совершенно надуманной причине — сама операция отработала штатно.

Обратная ситуация — коррекция вперёд — тоже опасна, просто не так заметна: длительность операции внезапно «раздувается» на секунды, хотя реально она заняла миллисекунды. Если по этой длительности принимаются решения (например, «если запрос выполнялся дольше 5 секунд — считаем таймаутом и повторяем»), ложный таймаут запустит повторную отправку операции, которая уже успешно выполнилась — и вот вы получили дублирующуюся запись или двойное списание там, где идемпотентность не была продумана (эта тема разобрана отдельно в материале про идемпотентность и повторные запросы).

Монотонные часы: второй источник времени специально для интервалов

Именно поэтому в каждой современной ОС существует отдельный источник времени, у которого только одна задача — измерять прошедшие интервалы, и одно железное свойство: он никогда не идёт назад.

В Linux это CLOCK_MONOTONIC (и более строгий вариант CLOCK_MONOTONIC_RAW, вообще не подверженный NTP-коррекциям скорости хода). В POSIX-системах к нему обращаются через clock_gettime():

struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);

В macOS исторически использовался mach_absolute_time(), в Windows — QueryPerformanceCounter(). Смысл везде один: это не «время суток», это счётчик тиков с какой-то произвольной точки отсчёта (чаще всего — момент загрузки системы, но полагаться на это нельзя, точка отсчёта не специфицирована и может отличаться от системы к системе).

Ключевые свойства монотонных часов:

  • значение гарантированно не убывает — каждое следующее чтение больше или равно предыдущему в рамках одного процесса на одной машине;
  • на них не действует ручная смена даты, NTP-step, переход на летнее время (которого в большинстве систем уже и нет) — ничего, что может резко переставить wall clock;
  • у них нет привязки к календарной дате — значение монотонных часов бессмысленно вне контекста «сколько тиков прошло с точки X на этой конкретной машине»; сравнивать его между разными процессами, разными перезагрузками или разными серверами нельзя;
  • в некоторых реализациях (обычный CLOCK_MONOTONIC, не _RAW) скорость хода всё же может подстраиваться NTP-демоном для плавной синхронизации — но без скачков назад. Если важна абсолютная стабильность тиков даже к таким микрокоррекциям, берут CLOCK_MONOTONIC_RAW там, где эта опция доступна.

Как это выглядит в разных языках

Практически везде у монотонных часов отдельный API, и стоит явно знать, какую функцию вызывать для интервалов, а какую — для календарного времени.

Язык / платформаWall clock («сейчас»)Монотонные часы (интервалы)
Pythontime.time(), datetime.now()time.monotonic(), time.perf_counter()
Gotime.Now()time.Now() же, но разница t2.Sub(t1) использует встроенный монотонный компонент — начиная с Go 1.9 это уже сделано за вас
JavaSystem.currentTimeMillis()System.nanoTime()
Node.jsDate.now()process.hrtime.bigint(), performance.now()
C / POSIXclock_gettime(CLOCK_REALTIME, …)clock_gettime(CLOCK_MONOTONIC, …)
Ruststd::time::SystemTime::now()std::time::Instant::now()

Отдельно стоит Go: там разработчики языка решили закрыть этот класс багов на уровне рантайма. time.Time, полученный через time.Now(), хранит внутри и wall-clock, и monotonic-компонент; при вычитании двух значений time.Time через Sub() рантайм автоматически использует монотонную часть, если она доступна у обоих значений. Разработчику не нужно помнить, какую функцию звать — если только он не сериализует time.Time в строку и не десериализует обратно (тогда монотонный компонент теряется, и это осознанное поведение).

Правило: интервалы — монотонными часами, дата — обычными

Практическое правило простое и без исключений:

  • Измеряете, сколько заняла операция (латентность запроса, время выполнения функции для профилирования, таймаут ожидания ответа, интервал для экспоненциального backoff при повторах, TTL внутреннего таймера, дедлайн для select/poll) — берите монотонные часы.
  • Узнаёте, «который сейчас час» (метка времени для лога или записи в БД, момент, который нужно показать пользователю, время для сравнения с абсолютной датой типа «истекает 1 сентября», момент для запуска задачи по расписанию cron) — берите обычные, wall clock.

Если сомневаетесь, какой случай перед вами — задайте вопрос: «эта цифра должна пережить перезагрузку сервера и быть сравнимой с показаниями других машин?» Если да — это wall clock. Если цифра нужна только для вычитания двух своих же замеров в рамках одного процесса — это монотонные часы.

Отдельно стоит измерение времени выполнения кода в проде для профилирования — там же действует то же правило, и подробнее об инструментах для этого написано в статье про профилирование приложения на живом сервере без остановки: профайлеры под капотом почти всегда используют именно монотонный источник времени, иначе их графики были бы усыпаны артефактами от коррекций NTP.

Частые грабли: где монотонные часы ломаются на практике

Само по себе знание правила не спасает от всех проблем — есть несколько мест, где монотонные часы используют неправильно даже опытные команды.

Сериализация и передача по сети. Значение монотонных часов бессмысленно за пределами процесса, который его прочитал. Нельзя записать его в файл, положить в Redis, отправить в другой сервис и там сравнить с локальным значением монотонных часов — эпохи отсчёта разные, гарантии не переносятся. Для межсервисной корреляции (трассировка запроса через несколько сервисов) всё равно нужен wall clock или логическое время (векторные часы, Lamport-таймстемпы) — это отдельная большая тема.

Библиотеки с неявным выбором часов. Не весь код одинаково аккуратен. Часть ORM, HTTP-клиентов и логирующих библиотек внутри всё ещё считает длительность через time.time()-эквивалент, просто потому что так было написано пять лет назад и никто не проверял. Если вы видите в проде метрику латентности с отрицательными или аномально большими выбросами раз в несколько недель — это первое место, куда стоит посмотреть.

Разница между хостами при распределённых замерах. Монотонные часы не синхронизированы между машинами вообще никак — даже направление у них не гарантированно совпадает. Если нужно сравнить, что произошло раньше — событие на сервере A или на сервере B, — монотонные часы для этого не подходят в принципе, нужен wall clock (с оговоркой на точность синхронизации) или явный распределённый протокол упорядочивания событий.

После восстановления из snapshot или паузы виртуальной машины. Некоторые гипервизоры при восстановлении VM «замораживают» монотонные часы вместе с состоянием машины, и после возобновления счётчик продолжает идти с той же точки — то есть реальное время, прошедшее «снаружи» (пока VM была на паузе), в монотонных часах гостя не отражается вообще. Для большинства внутренних измерений (таймауты, backoff) это не проблема, но если логика опирается на то, что монотонные часы отражают физически прошедшее время в реальном мире — это ложное допущение, которое стоит держать в голове при работе в облаке или на VPS с живой миграцией.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Если я использую монотонные часы, нужно ли вообще следить за NTP на сервере?

Да. Монотонные часы решают только проблему измерения интервалов внутри процесса. Для всего остального — логов с метками времени, сравнения событий между серверами, сертификатов, cron-расписаний — вам всё равно нужны правильно синхронизированные обычные часы, а сама механика их коррекции разобрана в статье, на которую эта ссылается в начале.

Может ли монотонные часы пойти назад хотя бы теоретически?

В рамках спецификации — нет, это и есть их единственная жёсткая гарантия. На практике были задокументированы редкие баги на уровне ядра или гипервизора (особенно на старом оборудовании или при миграции виртуальных машин между хостами с разной аппаратной реализацией счётчиков), из-за которых монотонность нарушалась. Это исключение, а не норма, и в современных ядрах Linux такие случаи целенаправленно исправляются, но защитный код вида duration = max(0, end - start) как последний рубеж всё равно не будет лишним для критичных метрик.

Что использовать для меток времени в структурированных логах — wall clock или монотонные часы?

Wall clock, однозначно. Метка времени в логе должна быть сравнима с логами других сервисов и понятна человеку. Монотонные часы годятся только для внутреннего вычисления «сколько заняла обработка этого лога», а не для того, когда именно это произошло по календарю.

Часовой пояс сервера как-то влияет на эту проблему?

Косвенно. Если сервер настроен на локальный часовой пояс вместо UTC, риск ошибок в коде, который путает wall clock и календарные вычисления, растёт — но сама проблема монотонности от часового пояса не зависит: CLOCK_REALTIME в любом часовом поясе физически может сдвинуться, CLOCK_MONOTONIC — нет. Про то, почему для серверов обычно рекомендуют UTC, есть отдельный материал про настройку часовых поясов на сервере.

Стоит ли переписывать весь существующий код, чтобы использовать монотонные часы везде?

Нет, только там, где реально измеряется интервал — таймауты, backoff, профилирование, метрики латентности. Для меток времени в БД и логах обычные часы — правильный и единственный вариант, менять там нечего.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →