На написание этой статьи меня подтолкнуло появление аналогичной статьи, ограничившейся только перечислением распространённых представлений без уточнения их свойств и применимости. Поскольку такой подход реально чреват тяжёлыми проблемами в случае выхода за узкие рамки применимости одного представления, я решил описать проблему в полном объёме. Практическая сторона вопроса ограничена рассмотрением двух наиболее распространённых классов систем - Unix (вместе с Linux) и Microsoft Windows. Стиль написания - "трактат".

Начнём немного издалека; я постараюсь максимально сократить "матан" и философию. Кто хочет сразу практических фактов - пропустите первую четверть текста.

Что есть время?

Время нам нужно, чтобы рассчитывать события и затраты. Время в нашем понимании - счёт равных периодов. Русское слово "время" по происхождению значит "колесо" или "колесница" и родственно "вращению". Возможен ли идеально периодический процесс? Да, возможен, пока мы его не начинаем измерять. Любое измерение искажает процесс, пусть даже на 10 в минус пятидесятой степени. Атомные часы, о которых речь пойдёт далее отличаются в выгодную сторону точностью - то есть тем, что внешние влияния (включая измерительное) искажают ход часов значительно меньше других часов.

Самое общее представление о времени содержится, видимо, в теориях Эйнштейна. Если события связаны причинно-следственной связью, то причина случилась раньше. Насколько раньше - тут уже не устанавливается, и тем более не даётся никаких определённых единиц времени или абсолютных значений вроде "точки 0". В то же время, можно создать процессы, которые идут максимально равномерно в локальном времени их системы.

Как мы считаем время?

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

Возможно, в Долине Гейзеров местные жители считают также циклами выброса горячей воды из недр. Но для человечества в целом важны только названные три цикла. Остальные единицы времени - производные от них. Здесь я сразу намекаю на тему "что такое секунда?", раскрытую ниже.

Как мы определяем время событий в прошлом?

У предков не было атомных часов, радиосвязи и телевидения. Ещё 100 лет назад, до введения часовых поясов, каждый город настраивал свои часы по местному астрономическому времени (солнце в зените => полдень). Точные часы, пригодные для измерения долготы с приемлемой для мореплавания точностью, появились только в середине XVIII века (хронометр Гаррисона), хотя уже столетие до этого точность часов позволяла вырулить на нужную сторону нужного океана:) Чем дальше, тем менее точные данные нам доступны и тем хуже понимание. В древнем Риме тоже считали "часами", но от восхода до заката было 12 равных дневных часов, а от заката до восхода - 12 ночных (соответственно летом дневные часы были длиннее ночных, зимой - наоборот). Большинство дат до введения отсчёта "от сотворения мира" (в каждой стране своего) писались в стиле "на восьмом году правления Гороха 77-го", и получить смещение от другой даты можно только зная, в каком году от коронации Ягупоппа 19-го произошла коронация Гороха 77-го, и так по всей цепочке. Греки классического периода считали время по олимпиадам. Шатания стиля календарей усугубляют ситуацию: на Руси год начинался 1-го марта, потом Алексей Тишайший перенёс начало на 1-е сентября, потом Пётр I - на 1 января. Не зная точного начала для конкретного периода времени, тривиально ошибиться на год при пересчёте. Шумерские хроники утверждают, что их цари правили 7200 лет и более. Любой здравомыслящий человек отбросит от такой даты два 60-ричных "нуля". Римский счёт от основания Рима был, по сравнению с этим, эталоном точности несмотря на весьма условную опорную дату.

Наш основной счёт лет - григорианский. Тем не менее, для большой части населения Земли вместо 2015 года - 1393-й (хиджры), 5776 (от сотворения мира)... мы не перечислили все варианты. И, опять-таки, моменты нового года не совпадают.

Одна из сложнейших загадок - хронология древнего Египта. Даже отбросив все проблемы чтения древних источников, выходящие за пределы темы данной статьи, и сосредоточившись на астрономических наблюдениях, получаем три наиболее вероятные хронологии. Большинство исследователей сейчас считают наиболее вероятной "короткую" хронологию. Но любая находка сначала сравнивается с возможными предшественниками и наследниками и только после этого получает возможную абсолютную дату. Тот, кто априорно назначит "юлианский день" событию из истории Египта до греческого завоевания, будет как минимум поспешен в выводах.

Про палеонтологию даже не хочу и начинать. Желающие да погуглят.

Не думай о секундах свысока

В сутках 24 часа, в часе 60 минут, в минуте 60 секунд - это то, как большинство из нас воспринимает счёт времени. Астрономы знают, что Земля вращается неравномерно и вокруг своей оси, и вокруг Солнца, так что секунда одних солнечных суток не равна секунде других. Более того, Земля замедляет своё вращение - по некоторым данным, 400 миллионов лет назад суточный цикл составлял 9 наших часов.

Тем не менее, на ближайшие несколько тысяч лет мы можем сохранять стабильность подхода 24*60*60 в бытовых расчётах. За пределами этого периода - уже вряд ли. Однажды мне попалось на глаза предложение считать время сверхбольшим числом (256 бит) очень малых долей, предлагавший это думал, что сможет этим представлением получить шкалу на все времена от Большого взрыва. Представить-то он сможет, но для одного атома... уже для соседнего эта шкала не подойдёт. А тем более для каких-то практических целей.

Действующее с 1967 года определение секунды по атомным часам, измеряющим частоту специальной установки, привело к введению в 1972 году международного счёта времени Coordinated Universal Time (UTC; перестановка букв вызвана взаимовлиянием с французским выражением). В UTC, все секунды - атомные и равны между собой. Платой за это стала необходимость корректировки длительности года введением так называемых "високосных секунд" или "вставных секунд" (leap second), которых с 1972 до 2009 года набралось уже 24 и которые вводятся как последняя секунда июня или декабря; в такой знаменательный момент часы, работающие по UTC, должны показывать 23:59:60. Кроме того, периодически ITU грозится забрать секунду или ввести две вставные секунды подряд; пока такого не случалось. Есть ещё другие виды счёта:

Unix-системы определяют свой счёт времени "epoch" или "unixtime" как количество секунд с полуночи 1 января 1970 года по Гринвичу (GMT). В Posix сказано, что это время считается в UTC. Но глава, посвящённая расчёту времени, устанавливает формулы, которые ничего не знают про вставные секунды; например, полночь 1 января 2009 г. по Гринвичу происходит ровно в (10*366+29*365)*86400 секунд, поэтому Posix-совместимые системы в своём счёте не знают вставных секунд. Windows тоже не учитывает вставные секунды, хотя интерфейсы уровня Win32 реализуют время, разложенное в структуру.

Как работать в условиях этого зоопарка, рассмотрим ниже.

Какое, милые, у нас тысячелетье на дворе?

Откуда мы знаем, "который час"? В большинстве случаев мы пользуемся механическими или электронными "держателями времени", попросту говоря - часами. Но откуда часы знают это время? Есть несколько вариантов:

1. Попросту ниоткуда. Это неинтересный для нас вариант, хотя в некоторых случаях почти единственно реальный (в целом классе встроенных систем, которым точное время не нужно, а достаточно более-менее стабильного источника внутреннего хода). Простой пример - независимый уличный светофор: снаружи он получает команды "обычная работа", "жёлтый мигающий", "выключен", остальное задаётся внутренними генераторами. Даже при наличии высокоточных внутренних таймеров необязательна связь со внешним временем - автомобильные инжекторы работают без него.

2. Встроенный надёжный источник. Попросту говоря, те же атомные часы, плюс бригада квалифицированных специалистов для их обслуживания. Я сомневаюсь, однако, что хоть один читатель данной статьи купит такие часы хотя бы на работу - дорого и бессмысленно (последний раз, когда я смотрел, цены начинались от 200000$ стартово и в год).

Все остальные источники времени, особенно доступные в дешёвых системах типа PC-clone, не обладают достаточной устойчивостью, чтобы давать точное время. Если часы на Вашем домашнем компьютере уходят менее чем на секунду за сутки - Вам очень повезло с производителем.

3. Синхронизацией с надёжным источником, непосредственно (в быту не встречается) или через промежуточных "достаточно надёжных" посредников. Уже простейшие солнечные часы относятся к этому классу - Солнце является первичным источником информации о времени. Мы подводим настенные часы или часы в мобильном телефоне, услышав сигналы точного времени по радио. У наших дедов и бабушек это было важной привычкой (опоздать на работу часто означало получить приговор), уже у отцов и матерей нет такого, если нет признаков сильного ухода времени. В компьютерном мире, однако, синхронизация настраивается периодически по некоторому внешнему источнику. Этот метод - регулярная внешняя синхронизация - относится к 99.999...% практически значимых систем, и далее речь пойдёт только о нём.

Сеть общедоступных бесплатных серверов времени, позволяющих установить часы с точностью выше секундной (то есть с погрешностью менее секунды), работает на протоколе NTP. В Unix мире, настройка локального времени по NTP является первым, что должно по правилам хорошего тона делаться для свежеустановленной системы без проблем доступа в Internet (пусть даже не напрямую). Не берусь решать про Windows, но не вижу причины для другой политики, кроме случаев безразличия к результату; любой анализ событий на основании логов должен начинаться со сверки часов источников.

Как именно синхронизировать? Можно получать точное время другой стороны, а затем ставить его локально. Но это означает рывки времени, что крайне неудобно для таймеров. Более практичным видится другой вариант, который и применён в NTP:

1. Выбирается некоторый источник времени среди описанных в конфигурации на основании данных от него (stratum) и устойчивости его данных (по собственным измерениям).

2. Вычисляется разница между временем другой стороны и локальным временем на основании ответа другой стороны и оценки задержки ответа.

3. По возможности, вводится корректировка темпа локального времени. (В ряде случаев допускаются изменения рывками. Чаще допускаются рывки вперёд, чем назад.) Корректировка определяет долю изменения темпа времени и длительность этого изменения. В современных Unix-системах для этого есть специальные функции (системные вызовы ядра):

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

Аналогом в Windows представляется функция SetSystemTimeAdjustment. Впрочем, она выглядит неконсистентной как с задачей, так и с родственными функциями. Установка корректировки на каждое таймерное прерывание не учитывает возможность смены темпа прерываний и требует постоянного управления со стороны контролирующего сервиса (если он не остановит вовремя, корректировка останется неостановленной и может увести часы значительно дальше). Родственные функции используют не линейное представление, а разложенное в структуру и потому требующее сложных вычислений. Упростить работу с ними можно через FILETIME, см. ниже.

How many watches is on your clock?

Итак, что мы имеем внутри типичной ОС типичного компьютера:

1. Счёт внешнего времени (wall time, называя меткой английской идиомой). В Unix он считается в UTC-без-вставных-секунд в секундах и долях секунды (далее будем называть это "unixtime") и независим от местного часового пояса - про часовой пояс знает уже userland, причём для каждого процесса может быть своя настройка (переменной TZ в окружении). В Windows (потомки NT) подход идентичен с точностью до представления (масштаб и смещение), см. ниже.

2. Счёт монотонного времени. До первого сна, оно, как правило, равно аптайму системы. В спящей системе на десктопе или лаптопе (не Android с компанией) монотонное время может стоять вместе с аптаймом или раздельно - это не важно для обсуждаемой темы; если предусмотрен таймер, который выводит из тотального сна (обязательно на современных смартфонах и планшетниках) - считается и во время сна. Монотонное время не зависит от коррекций (как плавных, так и рывком) внешнего времени, но корректировка постоянного темпа хода часов влияет на него (возможно, ограниченно по абсолютному значению). Темп и представление монотонного времени берутся максимально близким к внешнему времени.

Эти два счёта делаются аппаратными таймерами и их обработчиками в ОС, подробнее об этом см. ниже.

3. Счёт времени, затраченного процессом, нитью, группой процессов. Из-за существенной специфики этого измерения для каждой ОС, ограничимся упоминанием этого счёта.

Представление времени может быть линейным (единственное возможное для монотонного времени, потому что для него нет привязки к календарю) или структурным (в виде записи из отдельных полей - год, месяц, день...)

Системные функции получения текущего времени в Unix возвращают линейное время (unixtime):


- clock_gettime(CLOCK_REALTIME) - заполняет struct timespec:


struct timespec {
        time_t  tv_sec;         /* seconds */
        long    tv_nsec;        /* and nanoseconds */
};

- gettimeofday() - заполняет struct timeval - то же, но точность чуть меньше:

struct timeval {
        time_t  tv_sec;         /* seconds */
        long    tv_usec;        /* and microseconds */
};

- time() - возвращает секунды значением типа time_t (с отброшенными долями секунды).

Самая современная функция из них - clock_gettime. Несколько реликтовых интерфейсов вроде ftime() пропущены.

time_t определёно как int64_t на большинстве 64-разрядных архитектур и int32_t большинстве 32-разрядных. 32-битного счёта хватит до 2038 года (когда будут проблемы с знаковостью представления), 2106 года (если считать беззнаковым). 64-битного счёта хватит надолго в будущее (сотни миллиардов лет), что явно избыточно:)

Далее секундную часть можно через localtime() преобразовать к структурному виду в локальном времени, или через gmtime() - к структурному виду в GMT. Преобразование в локальное время зависит от установки таймзоны; её можно переопределять через переменную окружения TZ, а если она не установлена - в большинстве систем читается /etc/localtime. Временная зона (она же часовой пояс - далее будем использовать эти термины как синонимы) определяет правила конверсии unixtime в локальное время с учётом всех правил в настоящем, будущем и прошлом, принятого часового пояса, декретного времени, летнего времени и так далее.

Обратный перевод может быть выполнен из правильно заполненной структуры (struct tm) с помощью mktime() для локального времени и (не во всех системах) timegm() для GMT. (Впрочем, timegm() элементарно эмулируется в ~30 строках кода.) Однако для обратного перевода из локального времени становится принципиальным указание признака летнего времени для момента перевода стрелок. В случае Киева, 03:xx:yy (для любой секунды часа) 27 октября 2013 г. повторилось дважды с интервалом в час, в первый раз по летнему времени, во второй - по зимнему. Не указав правильное значение для tm_isdst, невозможно получить точное значение времени. Для однозначного структурного времени (любое кроме перевода назад), можно установить tm_isdst равным -1, в этом случае mktime() сама определит нужное значение.

Java тип java.util.Date и вызов java.lang.System.currentTimeMillis() используют временную шкалу, полностью аналогичную unixtime, но в миллисекундах (long - т.е. 64 бита). Документация, однако, допускает возможность счёта со вставными секундами (не верю).

Windows тип FILETIME, используемый для времени файлов и для счёта в ядре (где, по слухам, называется просто LARGE_INTEGER), представляет время в 100-наносекундных интервалах с точки отсчёта, за которую принята условная полночь (GMT) 1 января 1601 года по григорианскому календарю. Размер данного - 64 бита (в виде двух слов по 32 бита), максимальная представляемая дата - в районе 60056-го года н.э. Дискретность такого представления вполне разумна (100 наносекунд обратно к 10 МГц и вызвано, скорее всего, ориентацией на 14.3Мгц таймеры PC), однако надо отметить, что на 1601 год ещё не было достаточно точного измерения вращения Земли, поэтому наши представления об этом очень условны и считать от него бессмысленно. Правильно было бы сказать, например, "время в 100-наносекундных интервалах от 1 января 1970 года плюс константа" (в 1970 уже были атомные часы и точные измерения времени), или от любого другого современного года (если не 1970, так 1900). Но этот факт принципиален только для понимания, что это время непригодно для астрономии; практика кодирования обходится использованием констант. (Вероятно, в выборе 1601 года виноват стандарт ANSI Date, считающий дни от начала указанного года?) И, опять-таки, это время не учитывает вставных секунд - поэтому оно линейно пропорционально unixtime:

FILETIME = (unixtime + 11644473600) * 10000000
смещение 11644473600 секунд == 134774 суток по 86400 секунд, или 89 високосных лет плюс 280 невисокосных (с 1601 по 1970 год не включая последний) таких же суток, то есть (89*366+280*365)*86400 == 11644473600.

Если время в таком счёте разделить на 2**48 (сдвинуть на 48 бит вправо), получится время в периодах, приблизительно равных 11 месяцам; можно это брать для приближенного счёта в годах. Началу 2016 года соответствует 0x01D1 == 465 таких периодов. В случае unixtime, гигасекунда соответствует 32 годам - немного больше, чем 1 поколению людей, и близко к 1/3 столетия (то есть можно оценить темп как 3 гигасекунды на столетие).

Для Windows (конкретно - Win32 на NT с потомками), внутренний счёт работает в представлении FILETIME и определён следующий набор вызовов получения текущего времени:

GetSystemTimeAsFileTime
самый прямой вариант - только извлечь данные, без конверсии.
GetSystemTime
извлечение времени с конверсией в структурный формат в GMT, в структуру SYSTEMTIME.
GetLocalTime
извлечение времени с конверсией в структурный формат в локальной зоне, в структуру SYSTEMTIME.
Однако документация утверждает обратное - якобы первичным является локальное время, а SetTimeZoneInformation() устанавливает корректировку system time по сравнению с local time. Из-за вероятности скачков локального времени в моменты введения летнего или зимнего времени, этот подход некорректен и видится вызванным сугубо наследием MS-DOS.

Полный вид структуры SYSTEMTIME:


typedef struct _SYSTEMTIME {
  WORD wYear;
  WORD wMonth;
  WORD wDayOfWeek;
  WORD wDay;
  WORD wHour;
  WORD wMinute;
  WORD wSecond;
  WORD wMilliseconds;
} SYSTEMTIME, *PSYSTEMTIME;

Структура SYSTEMTIME не имеет поля признака DST (daylight saving time - летнее время), поэтому однозначный перевод такого времени в линейное (например, FILETIME) в периоды перевода назад получить невозможно. С этой точки зрения, использование промежуточных функций, работающих с локальным временем в виде SYSTEMTIME, при наличии возможности прямого вызова, например, GetSystemTimeAsFileTime() или её более раннего эквивалента NtQuerySystemTime, выглядит нелепостью. Поле wDayOfWeek при конверсии в линейное время не используется.

С третьей стороны, наличие одного только признака DST не помогает в случаях более сложного процесса (например, смены часового пояса территории). Более общей была бы возможность задавать или признак DST при местном поясе, или явное смещение от GMT. Это становится жизненно важным для внешнего хранения.

Кроме внешнего времени, большинство современных Unix-систем поддерживают отдачу монотонного времени вызовом clock_gettime(CLOCK_MONOTONIC). В большинстве реализаций это время равно аптайму системы. FreeBSD, кроме того, явно предоставляет CLOCK_UPTIME с тем же результирующим значением. По Posix, это время должно быть в SI секундах (атомных); на практике используются локальные часы, для которых в лучшем случае применена постоянная корректировка темпа согласно измерениям NTP. Но это в любом случае лучше, чем отсутствие такого счёта. Подробность такого значения в Unix - до наносекунд. Более старый times() даёт секундную точность. Аналоги в Windows - GetTickCount64(), GetTickCount() - возвращают аптайм в миллисекундах и подвержены корректировке темпа по SetSystemTimeAdjustment(). Но даже при такой подвержденности, монотонный счёт выгоднее в тех случаях, когда важно, что он именно монотонный и не подвержен рывкам вперёд или тем более назад. Ещё для монотонного счёта может быть важно, останавливается он во время сна (Suspend, Hibernate) системы - эффекты этого упоминаются в соседних главах.

Что делать со вставными секундами?

Как показала практика, на подавляющее большинство систем проблема неравномерности хода часов и меры по компенсации этой неравномерности не имеют никакого значения, пока отклонение темпа не превышает некоторую границу - даже 10% часто не замечается. Для огромного их количества вообще нет постоянной корректировки (такой, как NTP), и локальное отклонение может достигать минут и часов, что не мешает их успешному функционированию. Тем не менее, такие не все. Не приводя в пример такие особые объекты, как атомные станции (которым всё равно требуется более серьёзная реализация, чем описано здесь), можно вспомнить большое количество систем реального времени, в которых одновременно важны и точный равномерный ход времени, и соответствие внешнему гражданскому времени. Как можно их удовлетворить?

Вариант 1: счёт идёт по опорному сигналу от надёжного источника с точным счётом времени. Наиболее типичные варианты - TAI, GPS. TAI раздаёт время в атомных секундах с полуночи 01.01.1900, о нём чуть подробнее ниже. GPS раздаёт время в атомных секундах с полуночи 6-го января 1980 года по Гринвичу (а также и структурное UTC). Если я не ошибся в расчётах, это меньше TAI на константу (2524521600+6*86400+19) == 2525040019, где 2524521600 - от 1900 до 1980 года без вставных секунд, 19 - количество вставных секунд UTC от 1900 до 1980 года.

Применённая в GNU libc библиотека Olson time library позволяет такой счёт при использовании зоны с префиксом "right/":


$ env TZ=right/UTC ./t 
 1230767999 -> 2008-12-31T23:59:36
 1230768000 -> 2008-12-31T23:59:37
 1230768022 -> 2008-12-31T23:59:59
 1230768023 -> 2008-12-31T23:59:60
 1230768024 -> 2009-01-01T00:00:00
$ env TZ=right/Europe/Moscow ./t 
 1230767999 -> 2009-01-01T02:59:36
 1230768000 -> 2009-01-01T02:59:37
 1230768022 -> 2009-01-01T02:59:59
 1230768023 -> 2009-01-01T02:59:60
 1230768024 -> 2009-01-01T03:00:00
Для сравнения, чему соответствуют те же значения unixtime в обычных зонах:


$ env TZ=UTC ./t                                                          
 1230767999 -> 2008-12-31T23:59:59
 1230768000 -> 2009-01-01T00:00:00
 1230768022 -> 2009-01-01T00:00:22
 1230768023 -> 2009-01-01T00:00:23
 1230768024 -> 2009-01-01T00:00:24
$ env TZ=Europe/Moscow ./t                                                
 1230767999 -> 2009-01-01T02:59:59
 1230768000 -> 2009-01-01T03:00:00
 1230768022 -> 2009-01-01T03:00:22
 1230768023 -> 2009-01-01T03:00:23
 1230768024 -> 2009-01-01T03:00:24

Такая система оказывается завязана на то, что unixtime не соответствует требованиям Posix и все пересчёты должны гарантированно выполняться через функции libc. Иногда это ограничение становится слишком жёстким.

Для получения актуального внешнего представления требуется своевременное реконфигурирование базы вставных секунд. Таблицу вставных секунд можно получать автоматизированно с time.nist.gov. В таблице описываются моменты введения нового счёта по NTP шкале и смещение с этого момента. Последние записи на сейчас в этой таблице:


3345062400      33      # 1 Jan 2006
3439756800      34      # 1 Jan 2009
3550089600      35      # 1 Jul 2012
3644697600      36      # 1 Jul 2015

(Когда будут следующие корректировки - неизвестно; в ITU-R есть проект отмены корректировочных секунд до набора смещения в 1 час. Пока что достаточно слежения за новостями раз в полгода.)

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

Это наиболее практически приемлемый вариант для Unix- и Windows-систем, не знающих про вставные секунды или не получивших данных про них, для которых возникающая при такой реализации разница в ходе часов соседних систем величиной до секунды не настолько существенна, чтобы мешать функционированию (для более чем 99.99% из них).

NTP раздаёт время по шкале, основанной, как и TAI, на 1900-м годе, но игнорирующей вставные секунды. Дополнительно в день вставки/удаления передаётся специальный флаг оповещения об этом. В результате, система обладает достаточной возможностью реагировать корректировкой локального хода, при этом не храня актуальную таблицу вставных секунд. В момент добавления локальной секунды счёт времени по NTP останавливается на секунду, при удалении - перепрыгивает на секунду вперёд. (Практически, большинство локальных серверов не получают признак корректировки секунды и производят правку уже позже, так что её распространение от корневых серверов может занимать несколько часов.)

Вариант 3: локальные часы ничего не знают про вставные секунды. При добавлении секунды, локальные часы притормаживают или совсем останавливаются. При удалении секунды (ещё ни разу не было), локальные часы делают скачок вперёд на секунду. Проблема этого режима - сбои таймеров, особенно если часы делают прыжок на секунду назад (самый ужасный вариант).

Вариант 4: локальные часы ничего не знают про вставные секунды. Когда становится известно про корректировку, в последние 1000 секунд перед ней все синхронно корректируют темп локальных часов. Этот вариант известен как UTC-SLS и удобен синхронностью производимых действий.

Без таймзоны - никуда

Как уже было показано, представление времени только в виде день-час-минута-секунда неоднозначно даже в пределах одной системы с неизменными настройками. Но тем более оно неоднозначно между разными системами, потенциально находящимися в разных часовых поясах. Естественно, признак летнего времени между ними не спасёт:) Поэтому, есть два варианта, как корректно вести пригодное для всех (переносимое) время:

1. Унифицировать в общую шкалу (unixtime, FILETIME, TAI), устраняя местную особенность.

2. Передавать и хранить и локальное время и его смещение от канона (UTC, GMT). В этом случае не нужно передавать признак летнего времени или другие детали происхождения этого смещения - для унификации и сравнения значений достаточно вычесть указанное смещение.

В каких случаях становится принципиально важным указывать время в переносимом виде? Как минимум это:

  1. Базы данных.
  2. Все виды сетевого клиент-серверного взаимодействия.
  3. Сетевые файловые системы.
  4. Внешние носители данных.
это всё тавтологические переописания простого понятия "может быть использовано не только на текущей машине до любого ближайшего сдвига времени". Но раскрытие их нужно для того, чтобы не дать пропустить существенный случай. Например, СУБД может предоставлять типы данных "дата/время" и "дата/время с часовым поясом". Нет проблем с первым из них, пока мы работаем в пределах одного пояса и принудительно унифицировали часовой пояс на всех системах. Но уже подключение к серверу в Минске клиентской системы из Хабаровска приведёт к неразберихе во временах.

Стандарт ISO8601, используя текстовое представление даты в UTC счёте, устанавливает обязательное наличие указания часового пояса в виде смещения от GMT, например:

2009-11-07T12:34:56.789+0300
2009-11-06T23:34:56.789-10:00
2009-11-07T11:34:56.789B
2009-11-07T09:34:56.789Z
(один и тот же момент времени):

ASN.1 GeneralizedTime всего лишь рекомендует указывать часовой пояс - тот же момент времени в нём будет записан как

20091107123456.789+0300
20091107093456.789Z

У большинства современных СУБД существует тип данных "дата/время+пояс" или "дата+пояс". Если нет противопоказаний, следует использовать его вместо аналогичных типов без указания часового пояса.

Примером для Windows является CIM_DATETIME, представляющий время в виде строки фиксированного размера и формата (25 байт). То же самое время в нём может быть записано одним из следующих вариантов:

20091107123456.789000+180
20091107093456.789000-000
20091106233456.789000-600

Как видим, он ближе всего к GeneralizedTime, но представляет часовой пояс достаточно непривычно - в минутах (вместо традиционных форматов в часах и минутах). Что ж, Microsoft всегда отличалась оригинальностью, но сам факт записи смещения в этом формате - уже колоссальный плюс.

Что нужно, чтобы записать дату в виде локального времени со смещением, если смещение неизвестно? Ни localtime() в Unix, ни GetLocalTime() в Windows не даёт таких данных (некоторые системы, как FreeBSD, добавляют поле tm_gmtoff в struct tm, но это непереносимо). Для вычисления смещения преобразуем сначала это время в линейное (time_t или FILETIME), а уже его - в UTC (GMT). (Если линейное уже известно, один шаг можно сократить;)) Имея две структуры, представляющие один и тот же момент времени в разных форматах, можно сделать относительно тривиальные вычисления:


static inline int
day_difference(struct tm *tm_gmt, struct tm *tm_local)
{
    // Предусловие: две структуры представляют одну и ту же дату. Иначе
    // мы не имеем права сравнивать таким простым путём.
    if (tm_gmt->tm_mday == 1 && tm_local->tm_mday >= 28)
        return -1;
    if (tm_gmt->tm_mday >= 28 && tm_local->tm_mday == 1)
        return 1;
    return tm_local->tm_mday - tm_gmt->tm_mday;
}

// И ещё предусловие: все смещения часовых поясов кратны минутам.
static inline int
tzoffset_minutes(struct tm *tm_gmt, struct tm *tm_local)
{
    return day_difference(tm_gmt, tm_local) * 1440 +
           (tm_local->tm_hour - tm_gmt->tm_hour) * 60 +
           (tm_local->tm_min - tm_gmt->tm_min);
}

для Windows SYSTEMTIME подход идентичен с точностью до переименования полей. Можно также воспользоваться GetTimeZoneInformation(), при условии, что между его вызовом и вызовом GetLocalTime() зона не менялась. Но в общем случае использование данных текущей зоны непригодно для определения смещения от GMT в прошлом или будущем, с учётом всей истории указанного места.

Самые странные принятые на Земле смещения кратны 15 минутам (Непал - 5 часов 45 минут), более частые но ещё странные - получасу (десяток стран и территорий по миру и центральный пояс Австралии). Остальные используют смещения, кратные часу. Тем не менее, RFC3339 упоминает существование в прошлом часовых поясов со смещением, не кратным минутам (хм?)

Время, выбитое на ферромагнитной поверхности

Одна достаточно интересная и путаная область представлений времени - представления в свойствах файлов.

Устоявшийся для Unix-систем метод поддерживает три времени, называемые atime, mtime и ctime. Все три исчисляются в unixtime (в переносимом случае - с точностью до секунд). Различие между ними следующее:

Точность более секунды существенно важна для Unix из-за ориентации на make как средство исполнения действий (далеко не только компиляцию, как некоторые могут подумать - через make принято реорганизовывать конфиги, строить зависимости других действий). Для устойчивой работы make нужно, чтобы квант времени файла был заведомо меньше длительности любого целевого действия. Поэтому, BSD системы в UFS добавили наносекунды к этим временам (для реального включения может потребоваться включить vfs.timestamp_precision). В Linux интерфейс ядра поддерживает наносекунды, но не все FS это умеют (ext2, ext3 - нет). FreeBSD UFS2 добавило birth time, которое не меняется при utime() или другим изменениям данных inode. В целом, Unix-системы достаточно однородно работают с этими временами (секундная точность гарантирована везде). В Posix.1-2008 стандартизован интерфейс для получения этих времён с наносекундной точностью, хотя во многих системах он уже был давно.

NTFS однородно использует FILETIME для четырёх времён (чтения, записи, создания, модификации в MFT(?)), то есть имеем точность в идеале до 100нс и однородно организованную. С FAT значительно сложнее - оно "радует" разнообразием подходов. Время последнего доступа пишется с точностью до дня, последней модификации - с точностью до 2 секунд, а создания - до сотых долей секунды (на NT?). Смысл в подобной градации мне установить не удалось (было бы более понятным самым точным хранить время последней модификации, далее - доступа, и самым грубым - создания). Это время структурное и может быть в локальной временной зоне (до NT) или в GMT (в NT и потомках). В общем, временам на FAT можно верить только после применения сложного корректировочного алгоритма. Это грустно, с учётом того, что FAT во всех вариантах (включая exFAT) активно продвигается как универсальный межсистемный формат для переноса файлов.

Отдельным вопросом для Unix-подобных систем является работа с atime. Это очень "дорогой" в сопровождении атрибут, если происходит массовое чтение файловой системы с редкими записями; далеко не всегда есть смысл обновлять atime миллионов файлов просто оттого, что кто-то на них посмотрел. Флаги noatime для файловых систем есть почти во всех современных ОС. Но в Linux по умолчанию сейчас действует "relaxed atime", по которому у файла, в который не пишут, atime меняется не чаще раза в сутки (это время фиксировано). В среднем это таки разумный вариант.

О внутреннем счёте времени

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

Все аппаратные средства локального счёта времени соответствуют следующему относительно простому набору условий:

  1. Они позволяют узнавать время с хорошей точностью (на PC - не менее чем до микросекунды) без остановки счёта.
  2. Они генерируют прерывания, "оживляющие" работу системы (шедулинг задач, сброс буферов, пересчёт статистики, etc.)
Однако, вне этих рамок особенности реализации существенно различаются.

Какой конструктив локального таймера был бы идеальным для задачи счёта времени в ОС разделения времени? В рамках современных подходов к схемотехнике, это циклический счётчик, инкрементируемый постоянной опорной частотой, и возможность его считывать, но этого недостаточно.
Базовые требования к нему:

1. Достаточно большая длительность цикла. Этот цикл должен быть не менее 2 максимально возможных длительностей сложной работы с аппаратной частью, при которой заблокированы прерывания. Значения менее секунды находятся за гранью допустимого, оценочный минимум - 2 секунды.

2. Постоянный ход независимо от процедур чтения данных. (Из этого следует, что переустановка для выбора времени следующего прерывания недопустима.)

3. Достаточная подробность показаний (опорная частота не менее 1МГц).

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

Из существующих сейчас таймеров платформы PC всем этим условиям соответствует только таймер ACPI-fast (он же ACPI-safe, он же PIIX) в сочетании с некоторым источником генерации прерываний (например i8254 или LAPIC), и HPET не со всеми стилями применения. Остальные источники или смешивают средства счёта и генерации прерываний, или теряют точность при переустановке.

Для таймера в первом IBM PC был выбран распространённый кварц (в каждом американском телевизоре;), генерирующий частоту равной 4 частотам цветовой поднесущей NTSC; точное его определение даёт 315/22 МГц = 14318181.818... Гц, кварц стандартизован с округлением до целого числа 14318182 Гц. На вход микросхемы i8254 был подан этот сигнал, разделённый на 12, что даёт 1193181,8... Гц. Отсюда "заветное" число 1193182, известное во всех руководствах по IBM PC. Для ACPI-fast, делитель равен 4 и частота таймера равна ~3579545.5 Гц. i8254 имеет только 16-разрядный счётчик, поэтому переход через ноль и генерация прерывания происходит не реже ~18.2 раза (1193181.81/65536) в секунду, можно программировать на более частые прерывания. Счётчик времени BIOS хранит количество таких прерываний (за полные 65536 тиков) от границы суток (4 байта на хранение значения до чуть более полутора миллионов). При желании можно считать текущее значение счётчиков в таймерах и получить время с точностью до микросекунды, но это требует тщательного анализа фактора перехода через 0.

ACPI-fast реализован как циклический счётчик размером 24 бита (тогда он совершает полный цикл за ~4.7 секунды) или 32 бита (20 минут соответственно). Переустанавливать его на ходу нельзя, что является выдающимся плюсом. Отрицательная черта - сложность процедуры чтения из-за отсутствия регистра-защёлки для чтения: нужно получить три значения подряд и убедиться в их монотонности.

Самая современная разработка Intel - HPET (high performance event timers) - тщательно разработана в плане управления раутингом прерываний, но опять-таки страдает потерей точности при необходимости перенастройки для периодического режима. Однако, если настраивать таймер в непериодическом режиме, этой проблемы нет. До ~2007 года HPET обычно "кормили" неразделённой исходной частотой телевизионного кварца 14.318181...МГц, после - чаще видно 25 МГц (напрямую с основного кварца, с которого берутся опорные частоты для генерации тактовых сигналов шин и процессоров); требования Intel - не менее 10МГц. Intel хочет устранить старые таймеры (кроме HPET), но это вряд ли будет возможно до того, как вообще перестанет работать MS-DOS и старые стили BIOS.

Какая требуется точность значения времени для пользовательских запросов? Понятно, что если уже известно достаточно точное значение, то экономить на его представлении обычно смысла мало. Но разница между стоимостью операции, которая обращается к аппаратному таймеру, и операцией, которая отдаёт готовое значение после последнего события, которое обновило это значение (срабатывание шедулера, прерывание аппаратного таймера, etc.) может быть разительной.

Самый гранулированный подход из известных показывает FreeBSD - начиная с 7-й версии были разделены операции точного снятия и использования уже готового значения - например, CLOCK_REALTIME_PRECISE, которая вызывает перечитывание аппаратного таймера; CLOCK_REALTIME_FAST, которая полагается на последнее обновление; и наконец CLOCK_SECOND, которая не требует долей секунды и точности более секунды. Для подавляющего большинства применений достаточно "fast" вариантов. Unix-like системы (начиная с Linux) стараются сейчас организовать чтение времени вообще без перехода в ядро; для этого используется механизм модификации ссылок на динамически линкуемые функции и отображение памяти аппаратных таймеров только для чтения. В Windows, судя по доступным данным, GetSystemTime() и аналоги используют значение из разделяемой памяти, не требуя его обновления, но там хранится только количество сработавших прерываний; следствие - точность более ~10мс (при 100Гц прерываниях таймера) от этих средств недоступна, для этого нужно использовать другие функции (QueryPerformanceCounter, но синхронизацию с внешним временем надо вычислять самому). (Уточнить на современные версии.)

По каким часам спим?

Интересным и существенным вопросом является выбор счёта времени для внутренних программных таймеров.

До начала-середины 2000-х, единственным вариантом, о котором массово думал использующий народ и который существовал во всех интерфейсах, было внешнее время (unixtime, wall clock, CLOCK_REALTIME). Многие интерфейсы и сейчас имеют это единственным вариантом. Реализации с монотонным временем начались только вместе с timer_create() из состава бывшего POSIX realtime API (связь с realtime тут только та, что последнее обострило нужду в подобных средствах до уровня полной неизбежности). FreeBSD имеет pthread_condattr_setclock(), которая задаёт, какое время понимается в аргументах - pthread_cond_timedwait() - внешнее или монотонное.

Ядро Linux уже заметно давно перешло на показ таймстампов при сообщениях в monotonic time (не считая периодов сна, совпадает с uptime), например:

[    0.000000] e820: BIOS-provided physical RAM map:
[    0.000000] BIOS-e820: [mem 0x0000000000000000-0x000000000009cbff] usable
[    3.693135] hda-intel 0000:01:00.1: Handle VGA-switcheroo audio client
[299334.802152] type=1400 audit(1454391963.188:89): apparmor="STATUS" operation="profile_replace" profile="unconfined" name="/usr/sbin/cupsd" pid=28046 comm="apparmor_parser"

До этого использовалось "wall clock".

Тем не менее, проблема того, что логика "я хочу спать тут 1 час" (а не до конкретного момента времени по какой-то из шкал) не содержит никакого прямого указания, это абсолютное, относительное время или какое-то другое - остаётся в полный рост. Как можно решать эту проблему? Мне видится, например, вариант организовать для kqueue/epoll/etc. заказ ожидания "разница между абсолютным и относительным временем вышла за указанные границы" с заданием минимума и максимума допустимой разницы.

Календарь недалёкого прошлого

Связному счёту прошлого времени в Европе мы обязаны Жозефу Жюсту Скалигеру, который проработал и закрепил известный нам счёт "от рождества Христова". Кроме этого, он ввёл счёт в так называемых "юлианских днях". Счёт этот достаточно своеобразен - граница дня приходится не на полночь; точка 0 - полдень 1 января 4713 г. до н.э. по юлианскому календарю. Все исторически зафиксированные события попадают на положительную сторону оси такого отсчёта (самое раннее что нам относительно достоверно известно - хронология древнего Шумера - начинается с середины 4-го тысячелетия до н.э. и имеет погрешность в годы и десятки лет; установленные с точностью до дня даты начинаются значительно позже), поэтому у такого счёта почти нет проблемы с понятием нуля и методом понимания отрицательных значений (сколько лет между 1-м годом до н.э. и 1-м годом н.э.?)

При Скалигере граница дня определялась по меридиану Александрии. Современное определение уточняет, что опорный часовой пояс для такого счёта с точностью до секунды равен Гринвичу (сейчас рекомендуется Terrestrial Time). Независимо от выбора точной шкалы для счёта юлианских дней, они связана именно с Солнцем, поэтому особенности счёта UTC (вставные секунды) не влияют на них.

Соответствия между Julian Day и привычными нам линейными временами:


unixtime = (JD-2440587.5)*86400
windows_FILETIME = (JD-2305812.5)*86400*10000000
(смещения взяты из википедии, не проверял). При переводе надо помнить, что неточность реальных событий в юлианских днях вряд ли меньше целых минут, а то и часов. В то же время это уже неплохая основа для астрономических шкал. Не следует, однако, забывать, что пересчитывая в прошлое и сравнивая даты, надо всегда уточнять стиль календаря. Различие между григорианским и юлианским счётом - минимально ошибочное среди возможных; неправильно определив принятое начало года, можно промахнуться на год. Это уже область заботы историка и филолога.

Существует около десятка модификаций такой шкалы с другими опорными моментами; большинство из них тоже называются "юлианскими днями", но с разнообразными префиксами (модифицированный, сокращённый, etc.) Для нас это различие непринципиально. Выше упоминалась ANSI date из того же ряда.

Кто виноват и что делать?

Как видно из изложенного, если бы не грубая реальность, мы бы не имели вообще никаких проблем со временем.;) Так как устранить законы природы или даже сделать идеально стабильное движение Земли мы не в состоянии, предлагаю считать первый вечный вопрос решённым и перейти ко второму.

Рамочные принципы работы (то есть не "как делать", а "до чего не доходить") прикладных приложений со временем можно описать следующим образом:

  1. Не делать ложных допущений.
  2. Не допускать незаметной потери точности.

Все описанные "подводные камни" так или иначе ложатся на какие-то принципы из этого списка. Например, незнание вставных секунд - ложные допущения. Отсутствие признака DST в структуре SYSTEMTIME - незаметная потеря точности, проявляющаяся в маргинальных случаях. Неиспользование часового пояса в базе данных - и первое, и второе. Предположение, что все даты в прошлом записаны по какому-то конкретному календарю (юлианскому, григорианскому...) - ложные допущения. И так далее. К сожалению, мест и ситуаций, где можно нарушить эти простые правила не заметив нарушения, больше, чем кажется.

Конструктивный метод определения подходящего представления времени и правил работы со временем можно определить следующим образом:

I. Выберите необходимый тип задачи:

Каждый из этих вариантов вносит свои требования и ограничения:

Счёт реального времени, точнее секунды
Счёт по UTC или TAI, вычисление внешнего времени со вставными секундами. Регулярная (чаще чем раз в полгода) синхронизация базы вставных секунд. Непригодность стандартного системного API.
Счёт реального времени для обычных задач, секундная точность
Как в предыдущем пункте, или упрощая (unixtime, NTP time).
Счёт реального времени для обычных задач, часовая или суточная точность
Достаточно знания времени до минуты или до часа.
Счёт времени независимо от точки отсчёта, произвольная точность
Используются собственные генераторы постоянного темпа и свои точки отсчёта.
Счёт прошлого времени, точность не ниже суточной
Используются календарные даты в структурном представлении или юлианские дни.
Счёт прошлого времени на основании опорных точек без точной привязки к календарю
Счёт ведётся в отдельной шкале (не обязательно равномерной), связь с календарными данными устанавливается вне счёта.

Что улучшить?

Понятно, что существующие средства неидеальны; более того, за счёт описанных в начале факторов они не могут быть идеальны под любую задачу. Но есть ряд достаточно очевидных "оптимизаций" средств работы со временем прямо сейчас.

Во-первых, при реализации внутренних таймеров системы опорным временем должно быть uptime (время работы самой системы), при условии, что оно только меняет темп, но не может двигаться рывками. Проблема рывков внешнего времени в том, что, если таймеры опираются на него, они будут вести себя некорректно - останавливаться совсем, срабатывать несколько раз и т.д. Проблема в Linux 30 июня 2012 г. это ясно показала. Нет причин не поддерживать и таймеры, работающие по внешнему времени - для тех, кто уверен в необходимости именно такого механизма - но основным должен быть таки вариант, считающий по uptime.

В современных системах этому соответствует, например, timer_create в Posix realtime API или timerfd_create в Linux с указанием для обоих CLOCK_MONOTONIC в качестве опорного счётчика.

Разумеется, монотонное время (uptime) не должно подвергаться корректировке при сдвиге unixtime/nttime, часовых поясов, вставных секунд или любых других аналогичных внешних факторов.

Во-вторых, как обеспечить связь с изменениями внешнего времени для тех систем, которым нужно учитывать внешнее время? Самым удобным видится вариант, когда в ядре регистрируется запрос на оповещение по выходу значения (walltime-uptime) за пределы заданного диапазона из двух значений (walltime здесь может быть unixtime для Unix-систем, nttime для Windows, и так далее). Конкретный механизм оповещения зависит от ОС (kqueue для *BSD, специальный дескриптор в стиле signalfd или timerfd для Linux, и так далее) и находится за пределами темы данного документа - лишь бы при выходе за пределы разрешённого отклонения он начал требовать внимания.

В-третьих, в общих настройках системы должно быть сказано, как она работает со вставными секундами. Полное игнорирование, как в Windows или FreeBSD, выглядит в общем случае лучше, чем скачок или замирание на секунду, как в Linux; хотя UTC-SLS было бы, вероятно, лучшим вариантом хотя бы потому, что оно синхронно для всех. Для надёжной работы с настоящими, а не фиктивными, UTC, TAI и аналогами следует предусмотреть отдельное API.

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

Ссылки

Copyright © 2009-2016 Valentin Nechayev. All rights reserved.
Разрешается полное или частичное копирование, цитирование. При полном или частичном копировании ссылка на оригинал обязательна.