
Первое издание Unix Programmer's Manual датировано 3 ноября 1971 года, и на странице про системный вызов time в нем написано, что система хранит время в шестидесятых долях секунды, прошедших с полуночи 1 января 1971 года. Я наткнулась на эту строчку, когда пыталась ответить себе на вопрос, который, по-моему, хоть раз возникал у каждого, кто видел в логах загадочное 1 января 1970 года: почему именно этот день. Выяснилось, что поначалу Unix собирался считать время с другого года и в других единицах, а тикали его часы от электрической розетки.
Вторая половина истории про будущее. Во вторник, 19 января 2038 года, в 06:14:07 по Москве закончится 32-битный счетчик секунд, и программы, которые на него полагаются, проснутся в пятнице, 13 декабря 1901 года. Я проверила это на C, на 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 году, в районе четвертой редакции системы, разработчики сделали две вещи разом: перешли от шестидесятых долей к целым секундам и поставили точку отсчета на полночь 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, и это же значение совершенно законно означает 23:59:59 31 декабря 1969 года. Если вам когда-нибудь попадется в данных последняя секунда шестьдесят девятого года, почти наверняка кто-то не проверил код возврата.
Все варианты счетчика я свела в одну таблицу, время везде в 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 в каждую полночь делится на 86 400 без остатка, как будто Земля вращается идеально ровно. Земля с этим не согласна, поэтому с 1972 года к шкале UTC добавили 27 високосных секунд, последнюю в ночь на 1 января 2017 года, когда часы показывали 23:59:60.
Для такой секунды в Unix-времени просто нет числа. Ядро либо повторяет одно значение дважды, либо на секунду замирает, а Google, например, размазывает лишнюю секунду на целые сутки, чуть-чуть замедляя часы своих серверов, чтобы никакой софт не увидел время, идущее назад. Отсюда забавное следствие: текущий timestamp меньше реально прошедшего с 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 <stdio.h>
#include <stdint.h>
#include <time.h>
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 %sn", t, buf);
t = (int32_t)((uint32_t)t + 1u); // +1 через 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-битный ноутбук тоже в деле
После эксперимента на C легко выдохнуть и решить, что раз у вас 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, популярный короткий способ отбросить дробную часть. Побитовые операторы в JavaScript приводят число к знаковому 32-битному целому, поэтому | 0 молча делает из 2038 года 1901-й, хотя сам Date внутри хранит миллисекунды в double и до 2038 года ему нет никакого дела. Четвертая колонка показывает, во что это превращается, если потом собрать из результата дату обратно. Гарантировать, что такого | 0 нет в вашем фронтенде, я бы не взялась, потому что выглядит он как безобидная оптимизация.
Python в похожей ситуации хотя бы падает:
>>> struct.pack('<i', 2**31)
struct.error: 'i' format requires -2147483648 <= number <= 2147483647
А дальше начинается то, что ни один язык за вас не поймает. Колонка INT в базе, куда кто-то когда-то решил складывать timestamp, потому что так компактнее, поле int32 в protobuf-схеме, (int)(System.currentTimeMillis() / 1000) в Java-коде, бинарный формат с четырьмя байтами под время в заголовке, все это доживет до 2038 года без единого предупреждения от компилятора. Отдельно стоит упомянуть тип TIMESTAMP в MySQL, который хранит время ровно так и заканчивается на 2038-01-19 03:14:07 UTC, поэтому, если в схеме есть такие столбцы с датами окончания подписок или сроками хранения, их стоит поискать уже сейчас и подумать о переходе на DATETIME. Проверить это на живом MySQL мне было не на чем, так что тут я опираюсь на документацию.
Файл из 2040 года
Самое долговечное место, где живет время, это диск, ведь метки на файлах переживают любые обновления софта. Мне стало интересно, как с 2038 годом справляется ext4, и я сделала два маленьких образа по 8 МБ, один с 128-байтными inode, второй с 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-байтном inode больше ничего нет, и это значение читается как знаковое число секунд, то есть 25 ноября 1903 года. В 256-байтном inode есть дополнительное поле, где кроме наносекунд хранятся два бита эпохи, и единичка после двоеточия как раз говорит, что к времени надо прибавить 2³² секунд. Проверим арифметикой: 0x83aa7e80 как знаковое число равно −2 085 978 496, прибавляем 4 294 967 296 и получаем 2 208 988 800, а это ровно полночь 1 января 2040 года, так что все сходится до секунды. Двух битов эпохи хватает до 2446 года, так что 256-байтные inode закрывают вопрос на ближайшие четыре века.
Оговорюсь, debugfs пишет прямо в inode в обход ядра, поэтому мой эксперимент показывает худший случай. Обычная запись через ядро, насколько я знаю, на современном Linux обрезает время по пределу файловой системы, и файл вместо прыжка в 1903 год навсегда застрянет на 19 января 2038 года, что, конечно, приятнее, но правильным временем тоже не является. Проверить, какой inode у ваших разделов, можно через tune2fs -l /dev/<раздел> | grep 'Inode size', и если там 128, то такой раздел лучше пересоздать задолго до 2038 года. У 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-битная арифметика, которая считает график платежей, споткнулась бы о него еще тогда. Долгоживущие сертификаты, cookies со сроком жизни «лет на двадцать», лицензии, отложенные задачи в планировщиках устроены точно так же: каждая из них живет в будущем и поэтому встречается с переполнением раньше остального мира.
У Unix-времени, кстати, уже были свои маленькие юбилеи, которые показали, насколько небрежно с ним обращаются. В сентябре 2001 года счетчик перевалил за миллиард секунд и стал десятизначным, после чего кое-какой софт, сортировавший timestamp'ы как строки, начал ставить новые записи перед старыми. А в феврале 2009 года, когда счетчик показал 1234567890, айтишники по всему миру устраивали по этому поводу вечеринки, и, по-моему, это лучший пример того, как глубоко эта шкала въелась в инженерную культуру.
Восемь байт хватит всем
На 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-битных системных вызовов для 32-битных архитектур (всякие clock_gettime64 и компания) к версии 5.6 в 2020 году. В том же году musl 1.2.0 просто перевел time_t на 64 бита на всех 32-битных платформах, и для дистрибутивов на musl вроде Alpine вопрос фактически закрылся пересборкой. glibc поступила осторожнее: с версии 2.34 в 2021 году программу можно собрать с -D_TIME_BITS=64 (обязательно в паре с -D_FILE_OFFSET_BITS=64, иначе glibc откажется ее собирать), но по умолчанию time_t на 32-битных системах остался 4-байтным, чтобы не ломать существующие бинарники.
Самая трудоемкая часть досталась дистрибутивам. Debian в 2024 году переводил armel и armhf на 64-битный time_t, и для этого пришлось переименовать все библиотеки с изменившимся ABI, добавив к имени пакета суффикс t64. Если вы тогда обновляли Debian unstable или Ubuntu 24.04 и видели в выводе apt пакеты вроде libssl3t64, это было именно оно. Счет таких пакетов, насколько я помню рассылки, шел на тысячи, а в стабильный Debian все это попало вместе с 13-й версией в 2025 году. i386 в Debian, если я правильно понимаю, оставили с 32-битным time_t, потому что основная работа этой архитектуры сейчас запускать старые бинарники, которым новый ABI ни к чему.
Кто останется в 1901 году
Починить ядро, libc и дистрибутив можно за несколько лет, а вот железо, которое уже стоит в стойках, щитках и автомобилях, пересобирать никто не будет. Роутер или IP-камера на 32-битном ARM или MIPS со старым SDK, промышленный контроллер с прошивкой, собранной в 2015 году и с тех пор ни разу не обновленной, медицинский прибор, софт которого нельзя трогать без повторной сертификации, все это прекрасно доживет до 2038 года физически. Срок службы у такой техники легко дотягивает до двадцати лет, и устройство, которое продается сегодня с древним тулчейном внутри, вполне застанет переполнение в рабочем состоянии. Хуже всего то, что про многие из них заранее никто даже не скажет, есть ли там проблема, потому что исходников ни у кого нет.
И у 2038 года есть родственники, которые придут примерно тогда же, хоть и по другим причинам. NTP хранит секунды в беззнаковом 32-битном поле с отсчетом от 1900 года, и его первая эра закончится 7 февраля 2036 года в 06:28:16 UTC, на два года раньше Unix. Протокол к этому готов и умеет различать эры, а вот насколько готовы все реализации, похоже, выяснится только на практике. GPS считает недели в 10-битном поле, которое обнуляется каждые 1024 недели, и после 1999 и 2019 годов следующее обнуление наступит в ночь на 21 ноября 2038 года. Так что 2038 год обещает быть урожайным на переполнения, и разбираться с ними, скорее всего, придется пачкой.
Напоминание на вторник
Пока писала этот текст, я поставила себе в календарь напоминание на вторник, 19 января 2038 года, 06:14 по Москве, и телефон принял его без единого вопроса, что я считаю маленьким поводом для оптимизма. Любопытно, что часам от розетки запаса хватало на 2,27 года, секундам от 1970-го его досталось 68 лет, то есть примерно в тридцать раз больше, а 64-битный счетчик растягивает его до 292 миллиардов лет, что больше нынешнего возраста Вселенной раз в двадцать. Так что статью про следующее переполнение я писать не планирую и с чистой совестью оставляю эту задачу тому, кто доживет.
Автор: MainEl
