Эпоха Unix: почему отсчет времени начинается с 1970 года и чего ждать в 2038-м
Первое издание справочника программиста Unix увидело свет 3 ноября 1971 года. В разделе, посвященном системной функции time, указано, что время фиксировалось в шестидесятых долях секунды, прошедших с полуночи 1 января 1971 года. На эту деталь я обратила внимание в поисках ответа на вопрос, который наверняка задавал себе каждый, кто хоть раз замечал в логах загадочную дату — 1 января 1970 года: почему выбор пал именно на нее? Оказалось, что в истоках Unix время планировали отсчитывать иначе, а саму хронологию задавала обычная электрическая розетка.
Вторая часть нашего повествования устремлена в будущее. Во вторник, 19 января 2038 года, ровно в 06:14:07 по московскому времени, исчерпает себя 32-битный счетчик секунд. Программы, завязанные на него, неожиданно для себя окажутся в пятнице, 13 декабря 1901 года. Я проверила это поведение на Си, JavaScript и файловой системе ext4: файл с датой в 2040 году откатился в 1903-й. Заодно выяснилось, что в некоторых системах 2038 год наступил минуть двадцать назад.
Шестьдесят герц на всех
Ранняя Unix функционировала на компьютерах PDP-11, где использовался так называемый сетевой таймер (line clock), генерировавший прерывание на каждом цикле переменного тока. Частота американской электросети составляет 60 Гц, поэтому самым простым и доступным способом измерения времени без усложнения «железа» стали именно шестидесятые доли секунды. Меня восхищает эта особенность: базовая единица системного времени первой версии Unix определялась характеристиками электростанции. Находись разработчики из Bell Labs в Европе, где частота сети равна 50 Гц, счетчик работал бы совершенно по-другому, потребовав пересмотра всей сопутствующей арифметики.
Значение хранилось в паре 16-битных слов, образуя 32-битное число. Давайте оценим его запас прочности. 2³² / 60 = 71 582 788 секунд, что эквивалентно 828,5 суткам или приблизительно 2,27 года. При отсчете с 1 января 1971 года лимит исчерпывался 8 апреля 1973 года около полудня по Гринвичу (для точного расчета пришлось привлечь Python, так как переводить 71 миллион секунд в календарную дату в уме мне не улыбалось).
Создатели системы прекрасно осознавали это ограничение. В документации тех лет содержалась ироничная ремарка о том, что внимательный к деталям пользователь легко заметит ресурс примерно на два с половиной года. Мой пересчет дал 2,27 года, так что авторы немного округлили цифру в свою пользу. Судя по сохранившимся свидетельствам, точку отсчета эпохи сдвигали вперед как минимум единожды, чтобы ресурс счетчика не иссяк на глазах у изумленной публики. Было очевидно, что жить с часами, требующими ручного обновления каждые пару лет, долго не выйдет.
Назад, в семидесятый
Примерно в 1973 году, ближе к выходу четвертой редакции Unix, разработчики совершили два ключевых шага: отказались от долей секунд в пользу целых единиц и перенесли точку отсчета на полночь 1 января 1970 года по Гринвичу. Деннис Ритчи впоследствии неоднократно пояснял этот выбор, и его слова разочаруют любителей красивых мифов: инженерам требовалась просто круглая дата, близкая к текущему моменту, и рубеж десятилетий подошел идеально. Отметка получилась удобной для ментального счета, чего в начале семидесятых было вполне достаточно. Так что романтические гипотезы о дне рождения Unix или дате запуска первого компьютера можно смело отмести.
С целыми секундами арифметика изменилась кардинально. Счетчик остался 32-битным, но приобрел знак, выделив на положительный диапазон 2³¹ − 1 = 2 147 483 647 секунд. Разделив это число на среднюю продолжительность года (31 556 952 секунды), получаем около 68,05 года. После капризных часов, истекавших через пару лет, это казалось вечностью, и в 1973 году вряд ли кто-то всерьез переживал по поводу далекого 2038-го.
Знаковый формат потребовался потому, что Unix работает не только с текущим моментом. Пользователи рождались до 1970 года, на систему переносили архивы со старого оборудования, и отрицательные значения позволяли без костылей кодировать любые даты вплоть до конца 1901 года. У этого решения обнаружился и побочный эффект, до сих пор аукающийся в логах: функции вроде time() и mktime() при сбое возвращают (time_t)-1. Точно такое же значение абсолютно легитимно обозначает последнюю секунду 31 декабря 1969 года (23:59:59). Если в ваших данных мелькает этот момент, почти наверняка кто-то проигнорировал проверку кода возврата.
Все варианты конфигураций счетчика я свела в общую таблицу (время указано в UTC). Для 32-битных диапазонов значения проверялись через date -u -d @..., а 64-битный вариант рассчитан теоретически.
|
Счетчик |
Откуда |
Докуда |
|---|---|---|
|
32 бита, 1/60 с, отсчет от 1971 года |
1971-01-01 00:00:00 |
1973-04-08, около 12:06 |
|
32 бита со знаком, секунды от 1970 года |
1901-12-13 20:45:52 |
2038-01-19 03:14:07 |
|
32 бита без знака, секунды от 1970 года |
1970-01-01 00:00:00 |
2106-02-07 06:28:15 |
|
64 бита со знаком, секунды от 1970 года |
около 292 млрд лет назад |
около 292 млрд лет вперед |
Бесзнаковый формат временами предлагают как простое панацею, ведь он отодвигает проблему до 2106 года без изменения архитектуры структур. Платой за это становится полная потеря дат до 1970 года и превращение знакомого кода ошибки −1 в вполне реальное будущее — 7 февраля 2106 года.
Секунда, которой не было
Раз уж мы заговорили о секундах, стоит признать: Unix воспринимает их несколько умозрительно. Согласно стандарту POSIX, каждые сутки содержат ровно 86 400 секунд, и значение time_t делится на это число без остатка, будто Земля вращается с идеальной стабильностью. Наша планета с этим не согласна, поэтому с 1972 года в шкалу UTC добавили 27 високосных секунд. Последняя из них была внедрена в ночь на 1 января 2017 года, когда циферблаты показывали 23:59:60.
Для подобных моментов в системном времени Unix попросту не предусмотрено цифрового обозначения. Ядро либо дублирует одно и то же значение дважды, либо на мгновение останавливает отсчет. Google, к примеру, поступает иначе: компания плавно распределяет добавочную секунду на протяжении целых суток, незаметно замедляя серверные часы, чтобы софт не фиксировал движение времени вспять. Отсюда забавный курьез: текущий штамп времени отстает от реально прошедших с 1970 года секунд СИ почти на тридцать секунд (сюда входят 27 високосных вставок и пара корректировок из 1970–1971 годов, когда UTC регулировался иначе).
На события 2038 года все это не повлияет: переполнение произойдет на уровне счетчика, который ничего не знает о високосных секундах. Тем более что в 2022 году Международный комитет мер и весов постановил отказаться от добавления таких секунд к 2035 году, так что эта головная боль разрешится раньше намеченного срока.
Пятница, тринадцатое
Теории было мало, хотелось понаблюдать за коллапсом воочию. Я попыталась скомпилировать код под 32-битную архитектуру, где time_t занимает положенные 4 байта, но компилятор воспротивился:
$ gcc -m32 t.c -o t32
In file included from t.c:1:
/usr/include/stdio.h:28:10: fatal error: bits/libc-header-start.h: No such file or directory ← 32-битных заголовков в системе нет
Устанавливать multilib ради разового эксперимента не хотелось, поэтому я пошла обходным путем. На 64-битной машине time_t изначально 64-битный, так что модель переполнения можно смоделировать вручную, удерживая значение в переменной int32_t и инкрементируя его так, как это делал бы устаревший 32-битный код (в реальных приложениях переполнение заложено в арифметике времени, а явный инкремент здесь используется исключительно для демонстрации).
#include
#include
#include
int main(void) {
int32_t t = INT32_MAX; // последняя секунда, умещающаяся в 31 бит
for (int i = 0; i < 3; i++) {
time_t tt = (time_t)t; // передаем в 64-битный gmtime как есть
char buf[64];
strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S %a", gmtime(&tt));
printf("%11d %s\n", t, buf);
t = (int32_t)((uint32_t)t + 1u); // инкремент через unsigned во избежание UB
}
return 0;
}
Результат выполнения программы впечатляет:
$ gcc t.c -o t && ./t
2147483647 2038-01-19 03:14:07 Tue последняя штатная секунда
-2147483648 1901-12-13 20:45:52 Fri прибавили единицу и провалились в прошлое
-2147483647 1901-12-13 20:45:53 Fri время продолжает идти дальше
Обратите внимание на день недели. Счетчик совершает скачок со вторника 2038 года прямо на пятницу, 13 декабря 1901 года — сценарист фильма не смог бы придумать декорации лучше. Второй любопытный штрих — время 03:14:07 в момент сбоя. Оно выглядит так, будто подогнано под число Пи, хотя является чистым математическим следствием от полуночи 1970 года.
Самое неприятное заключается в том, что программа не выдает никаких предупреждений об ошибке. Она не аварийно завершается, а просто начинает функционировать в 1901 году, ломая всю логику, завязанную на сравнение временных меток. Таймауты, обязанные сработать через секунду, внезапно оказываются истекшими 136 лет назад, свежие сертификаты безопасности обретают статус еще не выпущенных, а кэш начинает считать древние записи вечно актуальными.
Мой 64-битный ноутбук тоже в зоне риска
После экспериментов на Си легко успокоить себя мыслью, что на современном 64-жном железе эта проблема вас не касается. Я тоже так думала, пока не вспомнила популярный в JavaScript трюк для получения текущего времени в секундах:
$ node -e 'const t=Date.parse("2038-01-19T03:14:08Z")/1000; console.log(t, t|0, Math.floor(t), new Date((t|0)*1000).toISOString())'
2147483648 -2147483648 2147483648 1901-12-13T20:45:52.000Z
Первая колонка отражает корректное время спустя секунду после переполнения, третья демонстрирует тот же результат через Math.floor, а вторая получена с помощью конструкции | 0, излюбленного приема для отсечения дробной части. Побитовые операции в JS приводят операнд к знаковому 32-битному целому, поэтому оператор | 0 бесшумно превращает 2038 год в 1901-й, хотя внутренний объект Date оперирует числами двойной точности и проблемы не замечает. Четвертая колонка показывает, во что превратится дата при обратной сборке. Поручиться за то, что подобный трюк не закрался в ваш фронтенд под видом безобидной оптимизации, я бы не рискнула.
Python в аналогичном контексте ведет себя честнее и вызывает ошибку:
>>> struct.pack('<i', 2**31)
struct.error: 'i' format requires -2147483648 <= number <= 2147483647
Дальше вступают в силу факторы, которые не заблокирует ни один язык программирования. Колонка INT в базе данных, куда когда-то записали штамп ради экономии места, поле int32 в схемах protobuf, конструкция (int)(System.currentTimeMillis() / 1000) в Java или бинарный заголовок с четырьмя байтами под время — все это благополучно доживет до 2038 года без единой жалобы от компилятора. Отдельного упоминания заслуживает тип TIMESTAMP в MySQL, который хранит время ровно в таком формате и завершает свой цикл как раз 19 января 2038 года в 03:14:07 UTC. Если ваши базы содержат подобные столбцы с датами истечения подписок, самое время аудировать их и планировать миграцию на DATETIME. Живого экземпляра MySQL под рукой у меня не было, поэтому здесь я полагаюсь на официальную спецификацию.
Файл из 2040 года
Самым долговечным хранилищем времени неизменно остается диск: файловые метки переживают любые софтвервые апгрейды. Мне стало любопытно, как с рубежом 2038 года справляется ext4, для чего я создала два тестовых образа по 8 МБ с разным размером инод — 128 и 256 байт. Первый сюрприз подстерег на стадии форматирования: утилита mkfs выдала недвусмысленное предупреждение:
$ mkfs.ext4 -I 128 ext128.img 8M
128-byte inodes cannot handle dates beyond 2038 and are deprecated
Затем я поместила в каждый образ файл через debugfs, принудительно установив время модификации на 1 января 2040 года, после чего прочитала атрибуты обратно:
$ debugfs -w -R "set_inode_field f.txt mtime 20400101000000" ext128.img
$ debugfs -R "stat f.txt" ext128.img | grep mtime
mtime: 0x83aa7e80 -- Wed Nov 25 17:31:44 1903
$ debugfs -R "stat f.txt" ext256.img | grep mtime
mtime: 0x83aa7e80:00000001 -- Sun Jan 1 00:00:00 2040
Механизм устроен наглядно. В базовое поле пишутся младшие 32 бита времени, и в обоих случаях мы видим идентичное значение 0x83aa7e80. В 128-байтной иноде дополнительные данные отсутствуют, поэтому шестнадцатеричный код трактуется как знаковое число, отсылая нас к 25 ноября 1903 года. В 256-байтной структуре предусмотрено дополнительное поле: помимо наносекунд, оно хранит два бита эпохи, где единица после двоеточия сигнализирует о необходимости прибавить к значению 2³² секунд. Проверяем арифметику: 0x83aa7e80 в знаковом представлении равно −2 085 978 496, прибавляем 4 294 967 296 и получаем ровно 2 208 988 800, что соответствует полуночи 1 января 2040 года. Двух битов эпохи хватит до 2446 года, так что расширенные иноды закрывают вопрос на ближайшие четыре столетия.
Стоит оговориться: debugfs модифицирует иноду напрямую в обход ядра, отражая худший сценарий. Штатная запись через современный Linux, насколько мне известно, усекает временную метку по максимальному пределу файловой системы, избавляя файл от путешествия в 1903 год, но фиксируя его в тупиковом значении 19 января 2038 года, что тоже нельзя назвать корректным. Проверить размер инод на ваших разделах можно командой tune2fs -l /dev/<раздел> | grep 'Inode size'. Если показатель равен 128, раздел лучше пересоздать задолго до часа икс. В файловой системе XFS аналогичная проблема решена с помощью опции bigtime, появившейся в ядре 5.10 и сдвигающей предел до 2486 года. В свежих дистрибутивах xfsprogs она активна по умолчанию, однако старые тома требуют ручного вмешательства.
2038 год уже наступал
Если воспринимать надвигающийся кризис лишь как утреннее событие 19 января 2038 года, может показаться, что в запасе уйма времени. На практике переполнение происходит ровно в тот момент, когда софт пытается оперировать датами, выходящими за этот рубеж, а подобные вычисления производятся задолго до самой даты.
Хрестоматийный пример датируется 2006 годом и связан с веб-сервером AOLserver. Согласно разборам того сбоя, в нем был заложен дефолтный таймаут длительностью в миллиард секунд (фактический эквивалент понятия «никогда»), который прибавлялся к текущему моменту. Давайте вычислим точку, когда сумма перестала помещаться в знаковые 32 бита: 2 147 483 647 − 1 000 000 000 = 1 147 483 647. Команда date -u -d @1147483647 возвращает 13 мая 2006 года, 01:27:27 UTC. Начиная с этой секунды «бесконечное» ожидание превращалось в далекое прошлое, и таймауты срабатывали мгновенно.
Та же логика применима к любым долгосрочным горизонтам. Тридцатилетняя ипотека, оформленная после 19 января 2008 года, захватывает период после 2038-го, и любая 32-битная логика расчета графиков платежей дала бы сбой еще тогда. Долгосрочные SSL-сертификаты, куки с двадцатилетним сроком годности, лицензии и отложенные задачи в планировщиках функционируют по тому же принципу: заглядывая в будущее, они сталкиваются с переполнением раньше остальных.
К слову, у Unix-времени уже случались локальные юбилеи, наглядно продемонстрировавшие степень беспечности разработчиков. В сентябре 2001 года счетчик перешагнул отметку в миллиард секунд, став десятизначным, после чего отдельные системы, сортировавшие штампы в алфавитном порядке как строки, начали выстраивать новые файлы перед старыми. А в феврале 2009 года, когда счетчик показал символичные 1234567890, IT-сообщество устраивало тематические вечеринки — пожалуй, это лучший показатель того, как глубоко эта шкала укоренилась в инженерной культуре.
Восемь байт решат всё
В средах 64-битных Linux, BSD и macOS переменная time_t занимает 8 байт уже довольно давно, поэтому рядовые серверы и ноутбуки переживут 2038 год так же буднично, как и сезонный перевод стрелок. Настоящая зона бедствия — 32-битный мир, где исправление проблемы оказалось куда сложнее простой замены одного typedef.
Главная сложность кроется в интеграции time_t в фундаментальные структуры данных: struct stat, struct timeval, struct timespec, а также в заголовки сторонних библиотек и бинарные форматы файлов. Стоит расширить тип с 4 до 8 байт, как меняются размеры и выравнивание этих структур. Любая скомпилированная ранее программа, передающая такую структуру в обновленную библиотеку, начнет считывать мусор. Именно поэтому 32-битные системы модернизировали послойно, добиваясь сохранения обратной совместимости со старыми бинарниками.
Первопроходцами стали разработчики BSD, позволив себе радикальный слом ABI: NetBSD перешла на 64-битный time_t в релизе 6.0 (2012 год), а OpenBSD — в версии 5.5 (2014 год). Эволюция Linux оказалась протяженнее. Ядро получило полный комплект 64-битных системных вызовов для устаревших архитектур (вроде clock_gettime64) лишь в версии 5.6 (2020 год). Тогда же библиотека musl в версии 1.2.0 перевела time_t на 64 бита повсеместно, закрыв вопрос для дистрибутивов вроде Alpine простой пересборкой. Проект glibc подошел к задаче осторожнее: с релиза 2.34 (2021 год) программы можно компилировать с флагом -D_TIME_BITS=64 (в обязательном тандеме с -D_FILE_OFFSET_BITS=64), однако по умолчанию 4-байтный размер сохранился во избежание поломки существующих бинарников.
Самый трудоемкий фронт работ лег на плечи дистрибутивов общего назначения. В 2024 году Debian переводил архитектуры armel и armhf на 64-битный time_t, для чего потребовалось переименовать тысячи библиотек с изменившимся ABI, добавив к именам пакетов суффикс t64. Пользователи, обновлявшие Debian unstable или Ubuntu 24.04, наверняка помнят изобилие пакетов вроде libssl3t64. В стабильную ветку Debian эти изменения вошли с релизом 13 в 2025 году. Архитектуру i386 при этом оставили на 32-битном time_t, поскольку ее ключевое назначение сегодня — запуск устаревшего софта, не нуждающегося в новом ABI.
Кто останется в 1901 году
Ядро, системные библиотеки и дистрибутивы можно пропатчить за несколько лет, но аппаратуру, установленную в телекоммуникационных шкафах, автомобилях и промышленных щитках, никто пересобирать не станет. Какой-нибудь роутер или IP-камера на старом SDK под 32-битный ARM/MIPS, промышленный контроллер с прошивкой образца 2015 года или медицинский комплекс, софт которого запрещено трогать без повторной сертификации, физически дотянут до 2038 года. Срок эксплуатации подобного оборудования нередко исчисляется десятилетиями, и устройство, проданное сегодня с архаичным тулчейном на борту, гарантированно застанет момент коллапса в активном режиме. Худшее здесь то, что об уязвимости многих подобных систем никто заранее не узнает из-за отсутствия исходного кода.
Любопытно, что у 2038 года есть технологические «родственники», которые наступят примерно в тот же период по иным причинам. Протокол NTP хранит секунды в беззнаковом 32-битном поле относительно 1900 года, поэтому его первая эра завершится 7 февраля 2036 года в 06:28:16 UTC — на два года раньше Unix-кризиса. Сам протокол к этому готов и поддерживает концепцию эр, но степень готовности конкретных реализаций покажет лишь практика. В то же время системы GPS отсчитывают недели в 10-битном поле, сбрасывающемся каждые 1024 недели: после сбоев 1999 и 2019 годов следующий обнуление произойдет в ночь на 21 ноября 2038 года. Так что предстоящий рубеж обещает быть урожайным на аномалии, и разбираться с ними придется комплексно.
Напоминание на вторник
Завершая работу над этим материалом, я внесла в календарь смартфона напоминание на вторник, 19 января 2038 года, 06:14 по Москве. Устройство приняло задачу без возражений, что само по себе вселяет скромный оптимизм. Забавно сопоставлять масштабы: часам от розетки отводилось всего 2,27 года, секундам от 1970-го досталось 68 лет (примерно в тридцать раз больше), а 64-битный счетчик расширяет горизонт до 292 миллиардов лет, что в два десятка раз превосходит текущий возраст Вселенной. Так что писать статью о следующем переходе я точно не планирую, оставляя эту почетную обязанность тем, кому посчастливится дожить.
Интервью с Роджером Пенроузом: о Вселенной, сознании и пространстве-времени
Первая часть 16-й проблемы Гильберта: перебор схем 8-й степени с ограничениями
У астероида неожиданно нашли кольца: ученые в недоумении
Кого ждет рынок труда России в 2032 и 2040 годах: демографический анализ Росстата
Первопроходцы открывают морские пути
Новое редкое доказательство найдено для теоремы о четырёх красках
Сколько близких звезд мы упустили из виду?
18 снимков наглядно покажут колоссальные масштабы нашей вселенной