Как стать автором
Обновить

Время — иллюзия, время Unix — иллюзия вдвойне…

Время на прочтение13 мин
Количество просмотров30K
Автор оригинала: jschauma

Как вы хорошо знаете, в Unix-системах мы измеряем время как количество секунд, прошедших с «эпохи»: 00:00:00 UTC 1 января 1970 года. Немало людей сильно разозлилось из-за этого, да и вообще, общественное мнение сочло это ошибкой.

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

Во времени Unix каждый день гарантированно состоит из 86400 секунд и мы притворяемся, что это число равномерно увеличивается. Когда оказывается, что вышеупомянутый камень на самом деле вращался дольше, чем удобно для нас, и нам нужно добавить секунду координации, то мы просто притворяемся, что этого не было, а механизм меток времени не идентифицирует уникальный момент времени.

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

Как же мы к этому пришли? Всё началось в 1971 году, когда в в первом издании руководства для программиста Unix было дано определение времени Unix как "времени с 00:00:00 1 января 1971 года, измеряемого в шестидесятых долях секунды":


Именно так, изначально эпохой Unix было 1971-01-01T00:00:00. Вы спросите, какого часового пояса? Ну, это точно не был «UTC», потому что он заменит GMT в качестве стандартного времени только в 1972 году. Во-вторых, обратите внимание, что время измерялось в 1/60 секунды, а не в секундах. Зачем же так сделали?

Вспомним, что в то время Unix разрабатывался в США на PDP-11. Эти системы имели часы линейного времени (Line-Time Clock, LTC), использующие частоту питания переменного тока для генерации прерывания для процессора. Тогда это прерывание использовалось для обновления системных часов.

Забавно здесь то, что эта частота прерываний зависит от частоты в сети источника питания. В основной части Азии и Европы эта частота равна 50 Гц, однако в США Westinghouse Electric Corporation (конкурент Томаса Эдисона и General Electric; любопытный факт: позже Westinghouse приобрела CBS, а затем была куплена Viacom) заметила, что использовавшиеся тогда дуговые лампы с угольным электродом меньше мерцают при 60 Гц, а поэтому стандартизировала эту частоту.

(Странный побочный эффект этого различия между США и Европой заключается в том, что в Японии используются обе частоты: на западе, где первые генераторы были куплены у немецкой AEG и установлены в Токио, частота в сети равна 50 Гц; на востоке, в Осаке, были установлены генераторы G.E., и используются 60 Гц. В результате этого в стране теперь работает множество высоковольтных линий постоянного тока для преобразования электричества между двумя регионами!)


Электросеть Японии

Итак, в первом издании UNIX измерял время на этих 60 Гц с хранением в 32-битных integer, таким образом имея возможность учитывать только 2^32 / 60 тактов/с * 60 с/мин * 60 мин/ч * 24 ч/д * 365 д/г = 2,3 лет, как и написано в руководстве. Несколько позже в том же 1971 году измерению времени было дано другое определение, оно учитывалось в секундах и могло описывать до 136 лет. Датой же эпохи Unix был достаточно произвольно выбран 1970 год (вопреки распространённому заблуждению о том, что она обозначает дату рождения Unix):

«В то время у нас не было плёночных накопителей, работала пара файловых систем, и мы постоянно меняли начало отсчёта времени. Поэтому в конце концов мы сказали: давайте выберем что-то одно, что достаточно долго не будет переполняться. Нас вполне устроил 1970 год», — сказал он.

Дэннис Ритчи, Wired

(Истинная дата рождения Unix приходится примерно на 1969 год, когда Кен Томпсон портировал "Space Travel" на PDP-7, поэтому легко понять, почему эпоха Unix кажется обозначением рождения Unix.)

Секунды координации


Итак, теперь у нас есть 32-битный счётчик секунд от эпохи, который равномерно увеличивает значение с частотой 1 Гц и гарантирует нам, что будет насчитывать ровно 86400 секунд в любой 24-часовой период. Однако наш космический булыжник отсчёта замедляется в своём вращении, поэтому время от времени это число нужно изменять.

Для этого Международная служба вращения земли (IERS) отправляет всем Повелителям Времени электронные письма, сообщающие, должна или нет добавляться секунда координации:


Сегодня мы не можем винить время эпохи Unix за то, что оно изначально не учитывало секунды координации, потому что их не существовало до 1972 года. С тех пор (на 2022 год) произошло уже 27 положительных секунд координации, последняя из которых была введена в конце 2016 года. Каждый раз, когда происходит секунда координации, время эпохи Unix просто притворяется, что этого не было, из-за чего две даты соответствуют одной метке времени эпохи (epoch-time.c):

From epoch to time, via gmtime(3) to strftime(3):
1483228798 2016-12-31T23:59:58
1483228799 2016-12-31T23:59:59
//здесь отсутствующая секунда координации
1483228800 2017-01-01T00:00:00

From time to epoch, via strptime(3) to mktime(3):
2016-12-31T23:59:58 is 1483228798
2016-12-31T23:59:59 is 1483228799
2016-12-31T23:59:60 is 1483228800
2017-01-01T00:00:00 is 1483228800 //здесь дублирующаяся метка времени

(И давайте не забывать обо всех отрицательных секундах координации и тот факт, что некоторые системы Unix определяют диапазон tm_sec как [0-61], учитывая мифическую «двойную секунду координации», которой на самом деле никогда не существовало.)

Хорошо в этом то, что всё это соответствует требованиям POSIX:

4.16 Секунды после эпохи

Значение, которое аппроксимирует количество секунд, прошедшее после эпохи.

Соотношение между истинным временем суток и текущим значением секунд после эпохи точно не установлено.

Способ внесения любых изменений в значение секунд после эпохи для согласования с нужным соотношением с текущим временем зависит от реализации. В формате секунд после эпохи каждый день насчитывает ровно 86400 секунд.


Проблема 2038 года


Даже при равномерном отсчёте и игнорировании секунд координации используемый для измерения секунд после эпохи тип данных time_t неизбежно придёт к переполнению. Хорошо, что есть стандарты, которые спасут нас в этой ситуации! Пусть POSIX не решает проблемы, но хорошо в стандартах то, что их много и можно выбрать подходящий, не так ли? Ох, постойте, в чём же дело? Давайте разберёмся.

Плохие новости: стандарты нас не спасут. Например, стандарт C гласит:

Диапазон и степень точности времени, представленного в clock_t и time_t, зависят от реализации.

Что ж, ладно. Имея 32-битный time_t, мы можем рассчитывать начиная с 1970 года примерно на 136 лет. Однако time_t является знаковым 32-битным integer, то есть у нас есть всего 68 лет в обоих направлениях, из-за чего возникает так называемая "Проблема 2038 года".

Она состоит в том, что самая большая дата, которую можно записать как time_t при помощи знакового 32-битного integer — это 2^31 - 1 = 2147483647 эпохи, или 2038-01-19T03:14:07Z:


Это легко исправить, правда? Мы «просто» изменяем тип данных time_t на знаковый 64-битный integer, что даёт нам теоретический максимум даты эпохи 2^63-1 = 9223372036854775807 после 1 января 1970 года. Как любят говорить люди, это значение задаёт дату примерно на 292 миллиарда лет в будущем, или примерно в 22 раз больше приблизительного возраста Вселенной, поэтому официально может считаться Чьей-то Чужой Проблемой.

Но — всегда есть «но», не правда ли? — действительно ли это произойдёт? Почему бы не попробовать и не проверить, как разные системы поведут себя, если мы передадим им для обработки не совсем логичные значения времени?

Оборот mktime(3)


Хотя время представлено в виде time_t, используется и другой популярный формат для записи разбитого на части времени — struct tm, из которого можно получить time_t, вызвав mktime(3). В POSIX зафиксированы следующие элементы struct tm:

int    tm_sec   Seconds [0,60]. 
int    tm_min   Minutes [0,59]. 
int    tm_hour  Hour [0,23]. 
int    tm_mday  Day of month [1,31]. 
int    tm_mon   Month of year [0,11]. 
int    tm_year  Years since 1900. 
int    tm_wday  Day of week [0,6] (Sunday =0). 
int    tm_yday  Day of year [0,365]. 
int    tm_isdst Daylight Savings flag.

Забавно в описанных здесь диапазонах то, что они в лучшем случае являются… рекомендательными. В POSIX конкретно написано:

… исходные значения [...] компонентов не должны быть ограничены диапазонами, описанными в <time.h>.

Так что же произойдёт, если передать mktime(2) тип struct tm со значениями вне этих диапазонов? Рассмотрим следующую программу:

int main() {
        struct tm t;
        time_t epoch;

	/* 2022-12-31 */
        t.tm_year = 122; t.tm_mon = 11; t.tm_mday = 31;

	/* 22:57 */
        t.tm_hour = 22; t.tm_min = 57;

        t.tm_sec = 3785;

        if ((epoch = mktime(&t)) == -1) {
                err(EXIT_FAILURE, "mktime");
        }

        (void)printf("%s", ctime(&epoch));

	return 0;
}
$ cc -Wall -Werror -Wextra t.c
$ ./a.out
Sun Jan  1 00:00:05 2023
$ 

Здесь перед присвоением tm_sec значения наш struct tm задавал дату December 31st, 22:57:00, 2022. Но потом мы присвоили tm_sec значение 3785, то есть 1 час, 3 минуты и 5 секунд. Это приводит к тому, что выполняется инкремент tm_min на 3 минуты, что приводит к увеличению tm_hour на единицу. Затем мы ещё раз выполняем инкремент tm_hour (из оставшихся от tm_sec 3600 секунд), что приводит к оборачиванию этого значения на 00 и необходимости инкремента tm_mday. Теперь tm_mday оборачивается до 01 с инкрементом tm_year, и в результате мы получаем дату 2023-01-01T00:00:05.

Такая нормализация меток времени становится ещё более запутанной, когда мы добавляем отрицательные значения (например, tm_mday = -1 означает предыдущий день tm_mon - 1) или (снова) при участии секунд координации, что может стать причиной головной боли. Лучше избегать такой ситуации.

Забавная date(1)


Давайте рассмотрим даты эпохи в момент или рядом с эпохой. Проигнорируем тот факт, что время эпохи до 1972 года определено не очень чётко, поскольку время эпохи по определению считается в UTC, но (см. выше) UTC стандартизировали только в 1972 году. Ну, мы поступим точно так же, как Unix поступает с секундами координации, и притворимся, что это нас не волнует.

Отобразить произвольную дату при помощи команды date(1) довольно легко. Достаточно просто передать дату в формате CCyymmddHHMM.SS. Если только вы не работаете в Linux, где date(1) GNU хочет использовать, наверное, самый выбешивающий формат (MMDDhhmmCCYY.ss). Зачем вставлять год между минутами и секундами?

Однако использование любого из этих форматов всё равно бесполезно для наших целей, потому что в определённый момент нам нужно будет выйти за пределы годов из четырёх цифр, поэтому давайте укажем непосредственно секунды после эпохи ("-r <seconds>" для date(1) в BSD, "--date @<seconds>" для date(1) в GNU).

Отобразить даты в момент эпохи или рядом с ней довольно просто:

NetBSD / FreeBSD / macOS:
$ date -r 0
Thu Jan  1 00:00:00 UTC 1970
$ date -r -1
Wed Dec 31 23:59:59 UTC 1969
$ date -r 1 
Thu Jan  1 00:00:01 UTC 1970

Linux:
$ date --date @0
Thu Jan  1 12:00:00 AM UTC 1970
$ date --date @-1
Wed Dec 31 11:59:59 PM UTC 1969
$ date --date @1
Thu Jan  1 12:00:01 AM UTC 1970

OmniOS (с использованием даты GNU)
$ date -r 0
January  1, 1970 at 12:00:00 AM UTC
$ date -r -1
December 31, 1969 at 11:59:59 PM UTC
$ date -r 1
January  1, 1970 at 12:00:01 AM UTC

(date(1) GNU, использующая AM/PM вместо 24 часов, раздражает, ну да ладно.)

Давайте посмотрим, что произойдёт, если мы попробуем проверить 32-битный time_t. Как говорилось выше, проблема 2038 года возникнет в 2147483648 / -2147483649 эпохи:

netbsd$ date -r 2147483647
Tue Jan 19 03:14:07 UTC 2038
netbsd$ date -r 2147483648
Tue Jan 19 03:14:08 UTC 2038
netbsd$ date -r -2147483648
Fri Dec 13 20:45:52 UTC 1901
netbsd$ date -r -2147483649
Fri Dec 13 20:45:51 UTC 1901

linux$ date --date @2147483647
Tue Jan 19 03:14:07 AM UTC 2038
linux$ date --date @2147483648
Tue Jan 19 03:14:08 AM UTC 2038
linux$ date --date @-2147483648
Fri Dec 13 08:45:52 PM UTC 1901
linux$ date --date @-2147483649
Fri Dec 13 08:45:51 PM UTC 1901

omnios$ date -r -2147483648
December 13, 1901 at 08:45:52 PM UTC
omnios$ date -r -2147483649
date: failed to parse -r argument: -2147483649
omnios$ date -r 2147483647
January 19, 2038 at 03:14:07 AM UTC
omnios$ date -r 2147483648
date: failed to parse -r argument: 2147483648

О, постойте-ка. Похоже, у OmniOS возникают проблемы с датами эпохи после 2^31. Интересно, что произойдёт, если мы не только отобразим дату, но и установим её? Давайте в цикле установим дату и выведем её:

omnios$ for s in 5 6 7 8; do sudo date -u 011903142038.0$s; date; done
January 19, 2038 at 03:14:05 AM UTC
January 19, 2038 at 03:14:05 AM UTC
January 19, 2038 at 03:14:06 AM UTC
January 19, 2038 at 03:14:06 AM UTC
January 19, 2038 at 03:14:07 AM UTC
December 13, 1901 at 08:45:52 PM UTC //!!!
ld.so.1: date: fatal: /lib/libc.so.1: Value too large for defined data type
Killed
omnios$ date
ld.so.1: date: fatal: /lib/libc.so.1: Value too large for defined data type
Killed
omnios$ ls
ld.so.1: ls: fatal: /lib/libc.so.1: Value too large for defined data type
Killed
omnios$ sudo reboot
sudo: unknown uid 100
sudo: error initializing audit plugin sudoers_audit
omnios$

Замечательно. Обратите внимание, что дата на самом деле оборачивается, но ОС сходит с ума. Но ведь у других систем не должно быть никаких проблем с установкой даты, правильно?

netbsd# date 197001010000; date +%s
Thu Jan  1 00:00:00 UTC 1970
0
netbsd# date 196912312359
date: settimeofday: Invalid argument
netbsd$

linux$ sudo date -s @0
date: cannot set date: Invalid argument
Thu Jan  1 12:00:00 AM UTC 1970
linux$ uptime; sudo date -s @7080
 03:34:27 up  1:57,  1 user,  load average: 0.04
Thu Jan  1 01:58:00 AM UTC 1970

Вот так, в NetBSD мы не можем установить дату до эпохи, а в Linux (с версии ядра 4.3) мы не можем установить дату до текущего аптайма. settimeofday(2) вернёт EINVAL из-за гарантий, даваемых его "CLOCK_MONOTONIC", который, согласно POSIX, "представляет количество времени после неуказанной точки в прошлом":

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

С учётом всего этого, похоже, OmniOS вычисляет аптайм на основе времени запуска относительно системной даты, а, например, NetBSD и Linux хранят отдельные счётчики:

omnios$ sudo date -u 010100001970.00
January  1, 1970 at 12:00:00 AM GMT
omnios$ uptime
00:01:46    up -49 min(s),  1 user,  load average: 0.00, 0.00, 0.02
omnios$ sudo date -u 011803142038.00
January 18, 2038 at 03:14:00 AM GMT
omnios$ uptime
03:14:11    up 5567 day(s), 7 min(s),  1 user,  load average: 0.24, 0.16, 0.07

Попытка установить дату до эпохи тоже приводит к разным результатам. В NetBSD и Linux это сделать просто не удастся, а в OmniOS будет установлен месяц, день, час, минута и секунда, однако год будет ограничен значением 1970:

omnios$ sudo date -u 020100001969.00
February  1, 1970 at 12:00:00 AM GMT
omnios$ date
February  1, 1970 at 12:00:01 AM UTC
omnios$ sudo date -u 121308451901.52
December 13, 1970 at 08:45:52 AM GMT
omnios$ date
December 13, 1970 at 08:45:54 AM UTC

Но какую максимальную дату мы можем установить в системах, использующих 64-битный time_t? Как говорилось выше, можно ожидать, что доступно время до 2^63 - 1 = 9223372036854775807 эпохи. Давайте сначала отобразим дату, а затем попытаемся её установить:

netbsd$ date -r 9223372036854775807
date: 9223372036854775807: localtime: Value too large to be stored in data type
netbsd$ date -r 67768036191676799
Wed Dec 31 23:59:59 UTC 2147485547
netbsd$ date -r 67768036191676800
date: 67768036191676800: localtime: Value too large to be stored in data type

linux$ date --date @9223372036854775807
date: time ‘9223372036854775807’ is out of range
linux$ date --date @67768036191676799
Wed Dec 31 11:59:59 PM UTC 2147485547
linux$ date --date @67768036191676800
date: time ‘67768036191676800’ is out of range
linux$

freebsd$ date -r 9223372036854775807
date: invalid time
freebsd$ date -r 67768036191676799
date: invalid time
freebsd$ date -r 67767976233532799
Tue Dec 31 23:59:59 UTC 2147483647
freebsd$ date -r 67767976233532800
date: invalid time

Это интересно. Несмотря на то, что нам обещали 64-битный time_t, мы не можем установить время на 9223372036854775807. Похоже, максимальное значение равно 67768036191676799 в 2147485547 году. Хотя поначалу это значение кажется произвольным, можно заметить, что 2147485547 — это 2^31 - 1, и внезапно всё обретает смысл: даже несмотря на то, что time_t является 64-битным, tm_year структуры struct tm всё равно остаётся 32-битным, а потому максимальным значением, которое он может задать, является последняя секунда 2147485547 года.

А как дела в FreeBSD? Почему в ней время 67768036191676799 эпохи является недопустимым, а 67767976233532799 эпохи соответствует тому, что на других платформах является 67768036191676799? Если провести вычисления, то можно заметить, что разница между этими двумя моментами времени эпохи равна 1900 годам, то есть, очевидно, FreeBSD основывает свой tm_year не на 1900 годе (как заявляется struct tm в <time.h>), а на 0 годе? Так как я не смог с этим разобраться, то отправил баг-репорт.

Если мы попытаемся установить дату, то поначалу заметим, что при использовании date(1) NetBSD невозможно установить дату выше 9999 года (ещё один баг-репорт), но забавно, что на самом деле это не важно, поскольку мы всё равно не можем добраться выше 4147 года:

netbsd# date 414708200732.17
date: settimeofday: Invalid argument
netbsd# date 414708200732.16; date +%s
Sun Aug 20 07:32:16 UTC 4147
68719476736

Так получилось потому, что в NetBSD есть жёстко прописанное ограничение в 2^36 = 68719476736 для значения tv_sec, которое принимается при установке времени, потому что бОльшие значения вызывают недовольство KUBSAN (код):

        /*
         * Установка завышенного значения времени
         * приводит к недопустимому поведению системы.
         */
        if (ts->tv_sec < 0 || ts->tv_sec > (1LL << 36))
                return EINVAL;

В Linux используется другой практический максимум даты, установленный на 2232 год:

linux$ sudo date -s @8277292036
date: cannot set date: Invalid argument
Wed Apr 18 11:47:16 PM UTC 2232
linux$ sudo date -s '@8277292035'
Wed Apr 18 11:47:15 PM UTC 2232
linux$ sleep 10; date +%s
8277292045
linux$ sudo date -s @$(date +%s)
date: cannot set date: Invalid argument
Wed Apr 18 11:47:25 PM UTC 2232

Причина этого ограничения, найденная в исходном коде, заключается в том, что оно может использовать 30-летний аптайм до оборачивания счётчика:

#define NSEC_PER_SEC    1000000000L
#define KTIME_MAX       ((s64)~((u64)1 << 63))
#define KTIME_SEC_MAX   (KTIME_MAX / NSEC_PER_SEC)

/*
 * Ограничения settimeofday():
 *
 * Чтобы предотвратить установку времени близко к точке оборачивания,
 * устанавливаемое время ограничено так, чтобы можно было использовать разумный аптайм.
 * Достаточно будет аптайма в 30 лет,
 * то есть точкой отсечки является 2232. На этом этапе эта отсечка
 * является просто небольшой частью более серьёзной проблемы *
 */
#define TIME_UPTIME_SEC_MAX    (30LL * 365 * 24 *3600)
#define TIME_SETTOD_SEC_MAX    (KTIME_SEC_MAX - TIME_UPTIME_SEC_MAX)

Это означает, что в Linux знаковое 64-битное максимальное значение времени (2^63 - 1 = 9223372036854775807) не обозначает секунды после эпохи, а подсчитывает наносекунды после эпохи, поэтому теоретическая максимальная дата Linux (KTIME_SEC_MAX) снижается до всего лишь 9223372036 секунд эпохи, или даты 2262-04-23T11:47:16, что очень далеко от «22 приблизительных возрастов Вселенной», и ближе к тому, чтобы стать реальной проблемой.

Проверяя, какое значение FreeBSD позволяет установить для часов, я выяснил, что в двух последних релизах это значение различается, но оба релиза имели неприятную проблему, приводящую к спонтанной перезагрузке системы:

freebsd# date -u -f "%s" 49282253052249598
Fri Dec 31 23:59:58 UTC 1561694399
49282253052249598
freebsd# sleep 1; date; date +%s
Fri Dec 31 23:59:59 UTC 1561694399
49282253052249599
freebsd# sleep 1; date; date +%s
Sat Jan  1 00:00:00 UTC 1561694400
49282253052249600
[ system reboots ]

Примечание: изначально я могу установить дате значение больше 49282253052249598, но спустя примерно три секунды система перезагружается. Если задать дату на одну секунду меньше, то есть 49282253052249597, то система не перезагружается, даже когда системное время уходит дальше следующего значения. Разве компьютеры — это не чудесно?

О, и ещё кое-что...


Всем нам нравится, что macOS — это UNIX; она является одной из всего шести зарегистрированных на данный момент систем (остальные — это AIX, EulerOS (коммерческий дистрибутив Linux, созданный Huawei), HP-UX, Xinuos (ранее UnixWare, создан старыми AT&T Unix System Laboratories + Novell, SCO,
Caldera и UnXis) и z/OS). Однако фреймворк Core Foundation компании Apple не использует эпоху Unix как основу своего времени. В качестве его даты отсчёта используется 2001-01-01T00:00:00 GMT:

Все варианты CLOCK_MONOTONIC гарантируют, что время, возвращаемое Core
Foundation, измеряет время в секундах. В качестве базового типа данных используется CFTimeInterval, измеряющий разность в секундах между двумя моментами времени. Фиксированные моменты времени, или даты определяются типом данных CFAbsoluteTime, измеряющим интервал времени между конкретной датой и абсолютной датой отсчёта Jan 1 2001 00:00:00 GMT.

То есть если вы захотите преобразовать эти метки времени в эпоху Unix, нужно будет прибавить 978307200.



Как я и сказал, время — иллюзия, а время Unix — иллюзия вдвойне. И хотя вас может и не беспокоить проблема 2038 года (наверно, только если вы не пользуетесь OmniOS), возможно, мне удалось показать, что внутри может таиться множество других сюрпризов. А ведь мы ещё не касались безумия, творящегося с часовыми поясами и летним временем
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 62: ↑61 и ↓1+60
Комментарии77

Публикации

Истории

Работа

Ближайшие события