Хабрахабр

Тяжёлое бремя времени. Доклад Яндекса о типичных ошибках в работе со временем

В коде самых разных проектов нередко приходится оперировать временем — например, чтобы завязать логику работы приложения на текущее время у пользователя. Старший разработчик интерфейсов Виктор Хомяков описал типичные ошибки, которые встречались ему в проектах на языках Java, C# и JavaScript от разных авторов. Перед ними вставали одни и те же задачи: получить текущую дату и время, измерить интервалы или выполнить код асинхронно.

Это не как фрилансер — написал, сдал и забыл. — До Яндекса я работал в других продуктовых компаниях. И я, собственно, смотрел, читал, писал много кода на разных языках я увидел много чего интересного. Приходится очень долго работать с одной кодовой базой. В итоге у меня родилась тема этого рассказа.

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

Если вы на бэкенде, это запросы в базу данных, удаленные вызовы. Получается, что независимо от того, фронтендер вы или бэкендер, у вас возникают аналогичные задачи по работе с асинхронным кодом. Разные люди в разных проектах эти задачи решают почти одинаково, такова суть человека. Если фронтендер — у вас, естественно, есть AJAX. И логично, что при этом вы — мы, я — совершаем очень похожие ошибки. При похожей задаче вы делаете примерно похожее решение, независимо от языка, на котором вы думаете.

Об этих повторяющихся паттернах, которые возникают независимо от языка, на котором вы пишете, об ошибках, которые легко совершить, и о том, как их не совершать. О чем я хочу рассказать в итоге?

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

Дата начала — сегодня минус одни сутки, дата окончания — сегодня. Так линейно вы, в общем, и пишете код. У вас дата начала вот здесь. Казалось бы, все работает, но потом ровно в полночь у вас случается странное. После этого почему-то дата окончания отчета совершенно другая. Дата начала минус одни сутки — получается, такая.

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

Вы поняли, что время не стоит на месте. Но зато вы обогатились новым знанием. Оно может быть иногда одинаковым, но может быть и не одинаковым. То есть два раза вызывая Date.now() или получая new Date(), вы не надеетесь получить одно и то же значение. Соответственно, если у вас есть один метод, какой-то один кусочек логики, то в нем, скорее всего, должен быть только один вызов Date.now() или получение new Date(), текущего момента времени.

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

Если это тяжелая операция — важно, чтобы она не тормозила на клиенте. И вам предлагают обложить код логированием, замерять, сколько времени у вас занимает какая-то операция. Если вы на бэкенде, на Node что-то пишете, тоже тяжелую транзакцию, то вас просят: «Пожалуйста, напиши в лог, сколько она занимает, а мы потом посчитаем, как наши клиенты себя ведут в зависимости от юзер-агента».

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

И при этом они не гарантируют даже равномерного изменения. Вы извлекаете ценные знания о том, что, на самом деле, методы получения даты, времени, которыми вы пользуетесь — они всего лишь показывают, что у вас в часах операционной системы. И в принципе, они вообще не гарантируют монотонности изменения. То есть у вас за секунду реального времени может ваш Date.now() как прыгнуть на секунду, так и немножко больше — немножко меньше. То есть, как в этом примере, он может внезапно уменьшиться, значение Date.now() может внезапно уменьшиться.

В синхронизации времени. Причина в чем? И если у вас есть какое-то отставание или опережение, он может или искусственно замедлять, или ускорять ваши часы, или, если у вас сильно большой разрыв по времени, он поймет, что не сможет нагнать незаметными шажками время правильное, и просто одним скачком изменяет его. В Linux-подобных системах есть такая вещь, как NTP daemon, который синхронизирует часы вашей операционной системы с точными часами в интернете. У вас в результате получается разрыв в показаниях ваших часов.

Вот ему так захотелось. Или можно сильно не усложнять: сам пользователь, у которого есть контроль над часами, он тоже может захотеть просто поменять часы. А в логах у нас получаются разрывы. И мы не вправе его остановить. Все просто: есть поставщики времени. И, соответственно, решение этой проблемы тоже уже существует. То есть вот эти поставщики отметок времени, они всегда только увеличиваются, и при этом равномерно за одну секунду реального времени. Если вы в браузере, то это есть performance.now(), если вы в Node пишете, то там есть High Resolution Timer, которые, они оба, обладают этими свойствами равномерности и монотонности.

Неважно, на каком языке вы пишете. На бэкенде та же самая проблема. Там есть та же проблема в Rust. Например, можно поискать о монотонных консистентных часах, и вам выдача дает, в которой представлены практически все языки. В этих языках люди тоже уже наступили на грабли, эта проблема известна, решение есть. Там есть и боль программиста, который в Python, и в Java, и в других языках. Например, для Java есть такой вызов, который обладает теми же свойствами равномерности и монотонности.

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

У вас, получается, логируется N времен с N систем, участвующих в обработке одного запроса. И если у вас есть проблема логировать действия, то вы можете логировать просто вектор времени. И вы такие счетчики пишете для того, чтобы связать вот эти все этапы обработки ваших каких-то запросов на разных машинах, и получить какое-то понимание о том, когда, что происходит, в какой последовательности. Или вы просто переходите к абстрактному счетчику, который просто увеличивается: 1, 2, 3, 4, 5, просто на данной машине с каждой операцией тикает равномерно.

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

Вторая часть более беспорядочная. Хватит о времени.

Есть такой очень полезный элемент интерфейса, когда пользователь не знает точно, чего он хочет. Вот пример. Мы можем подсказывать ему варианты продолжения запроса. Это называется suggest, или autocomplete. Намного удобнее ему работать, когда мы сразу показываем ему, что мы знаем, что мы можем дальше набирать. То есть для пользователя это очень большая польза получается.

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

Что у нас получается? Мы начинаем разбираться. То есть то, что он успел набрать, мы посылаем на бэкенд. Когда пользователь набирает свой текст, у нас получается генерация последовательных асинхронных запросов. Он набирает дальше, мы посылаем второй запрос на бэкенд, и при этом никто никогда нам не гарантировал, что наши callback будут вызываться ровно в той же последовательности.

Самое очевидное, когда мы пишем, мы думаем: послали первый запрос, получили первый ответ, послали второй запрос, получили ответ. Получаются вот такие возможные варианты запросов и callback. Потом пришел первый ответ, второй ответ. Если пользователь набирает очень быстро, то мы можем второй вариант придумать, что мы успели послать первый запрос, пользователь успел что-то набрать до получения первого ответа. И вот то, что мы видели на видеозаписи, когда suggest неправильно работал, это третий вариант, про который очень часто забывают, про то, что порядок ответов нам никто не гарантирует, в общем-то.

В частности, пример с suggest, с autocomplete, который мы только что видели. И у фронтендеров эта проблема очень часто встречается, если вы разрабатываете интерфейсы. То есть, есть поток запросов, и есть поток ответов, асинхронно приходящих.

Поднимите руки, кто на GitHub делал хоть один pull request когда-нибудь? Если у вас есть вкладочки. Это такой интерфейс с вкладочками. Вы помните, что там, собственно, интерфейс на вкладочках основан, то есть, там есть вкладочка, где последовательность комментариев, есть вкладочка с коммитами, и есть вкладочка с кодом непосредственно. И если вы переключаетесь на соседнюю вкладочку, то ее содержимое в первый раз подгружается асинхронно.

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

Какой-нибудь быстрый резкий пользователь натаскал десять товаров, и потом видит, как у него цена мигает и, условно говоря, 100 рублей, 10 рублей, 50 рублей, 75 рублей, и останавливается на одном рубле. Например, если у вас магазин есть, если вы быстро перетаскиваете товары в корзину. Он вам не верит, он думает, что вы плохо пишете, хотите его обмануть, и уходит из вашего магазина, ничего не покупая.

Если у вас есть какой-нибудь скрам или канбан или что-нибудь еще и вы пользуетесь электронными досками для перетаскивания карточек, вы, наверное, хоть раз при перетаскивании карточки промахивались, дропали ее не в ту колонку. Пример. Конечно, вы спохватываетесь и сразу резко ее хватаете и тащите туда, куда положено. Бывало такое? И в разных системах бывают баги, возникающие как раз после этого. При этом вы очень быстро генерируете два запроса. Очень некрасиво получается. Вы перетащили ее в правильную колонку — приходит ответ на первый запрос, и карточка опять прыгает в ту колонку, куда вы ее перебросили.

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

И если приходит ответ на более ранний запрос, который вам не нужен, вы тоже его явно игнорируйте. Тем самым при обработке ответов вы тоже всё контролируете.

Например, в библиотеке RxJS. Соответственно, задача существует уже давно, и решение тоже уже есть. Там прямо из коробки есть такое игнорирование ответов на более старые неправильные запросы. Это прямо пример из документации, прямо Hello world, как написать правильный autocomplete.

Но там это глубоко закопано, и явно про это не говорится, что это такой баг и мы его вот так фиксим. Если вы пишете на Redux и Redux-Saga, там это тоже есть, в общем-то, и тоже в документации все записано. Просто описание такое есть.

Раз уж мы перешли к React, подвинемся к нему еще поближе.

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

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

За эти три секунды может произойти все что угодно. Но если внимательно посмотреть, что происходит в этом коде, то мы меняем state нашего компонента через три секунды во времени. В том числе пользователь может эту карту давно уже закрыть, и ваш компонент размонтируется, почистит свое состояние.

И что же надо делать? Соответственно, вы стреляете себе в ногу, причем стреляете по баллистической траектории, которая закончится через три секунды. И в других фреймворках с другими методами life cycle то же самое логично. Не забывайте, что если вы делаете такие отложенные операции, то можно их почистить корректно при unmount. Когда у вас происходит какой-то destruct, destroy, еще что-нибудь, unmount, надо корректно такие вещи не забывать чистить за собой.

Есть такие вещи, как throttle и debounce. Откуда вообще в браузере может так отложенно вызываться ваш код? Еще есть requestAnimationFrame, еще есть requestIdleCallback. У них под капотом лежат setTimeout, setInterval, то, про что я уже показывал. Не забываем про них тоже, их тоже надо чистить. И AJAX-запросы тоже — callback AJAX-запроса может вызываться отложенно.

Мы создаем внутри долгоживущий объект, у которого срок жизни получается больше, чем у исходного. И если мы погрузимся еще на уровень дальше, то поймем, что исходно вся проблема абстрагируется до такой, что у нас есть какой-то компонент с каким-то life cycle и мы откладываем вызов. И из этого проистекают сразу два бага. То есть, есть два объекта с несовпадающим life cycle, с несовпадающим сроком жизни.

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

Она, наоборот, про синхронность. Третья часть противоположна второй.

И в этом коде, если посмотреть, если вы пишете чисто, если вы сторонник, или слышали, хотя бы, про функциональный подход, про чистые функции, про отсутствие side-эффектов, то вы можете понять, что в этом коде можно кое-что ускорить. Есть, как обычно, цепочка promise — then, then, then что-то там.

Если вы в этом не уверены, значит, вы что-то пишете не так, то есть, там у вас есть явно какие-то side-эффекты, глобальный state, и прочее. Потому что вот эти два запроса асинхронных, они явно друг от друга не зависят. Вот, кстати, явный профит от чистоты функции, от отсутствия side-эффектов. Если вы пишете хорошо, то это сразу вам становится очевидным. Они не зависят друг от друга. Потому что сразу здесь, при чтении этого кода, вы понимаете, что их можно распараллелить. И, вообще, их можно поменять даже местами, скорее всего.

Мы в параллель запускаем два запроса, ждем их окончания, и дальше выполняем следующий код. Это делается примерно вот так. В том, что, во-первых, у нас код выполняется быстрее, мы не ждем одного запроса, чтобы начать второй. То есть профит в чем? Если у нас ошибка во втором запросе, то мы не будем впустую ждать выполнения первого запроса, чтобы сразу же упасть на втором. И мы быстрее упадем.

Вот Promise.all(), который запускает все запросы в параллель, и ждет выполнения. Для полноты информации — что у нас в Promise API ещё есть? И, в общем-то, в стандартном API больше ничего нет. Есть Promise.race(), который ждет выполнения первого из них, который успел.

Есть библиотека Async, в которой есть довольно богатый выбор для управления асинхронными задачами. Мы уже понимаем, что раз проблема есть, то кто-то ее уже за нас и до нас решил. Есть методы для запуска последовательно друг за другом. Есть методы для запуска асинхронных задач в параллель. То есть вы знаете, что у вас есть, допустим, массив, на котором можно запустить forEach(). Есть методы для организации асинхронных итераторов. Вы понимаете, вызвать map() с асинхронным каким-то итератором, вызвать forEach() — там это уже есть в коробке. Но если у вас в forEach() надо вызывать асинхронную функцию, то у вас сразу или проблема, и вы отказываетесь от forEach() и пишете что-то сами, или используете готовую библиотеку, которая именно готова к использованию таки асинхронных вещей.

Там есть, как они называют, правильный Promise.any(). Другая альтернатива — библиотека bluebird. Например, он к вам ближе, для него пинги короче. То есть, предположим, у вас есть такая задача: вам нужно пропинговать N сайтов, N каких-то урлов, и кто из них первый откликнулся, того использовать. А остальные могут вообще не ответить.

И вы его здесь не сможете использовать. Если вы используете Promise.race(), то там, если promise режектится, то он, собственно, весь зарежектится. То есть как раз для такой задачи он подходит. А вот Promise.any() — он игнорирует reject. Также там есть интересный метод для запуска асинхронных задач в параллель сразу с заданной степенью конкурентности. Он все reject проигнорирует, а первый resolve успешный, он его вам вернет дальше, и не будет ждать остальных. То есть вы хотите не более пяти параллельных promise — пожалуйста, записали одним параметром.

То есть API сделано как на массиве, как в Async JS, но еще более мощно. Для тех же асинхронных итераций и коллекций там есть вещи, которые позволяют сделать map, reduce, each, filter и прочее. Он подождет, пока это все не развернется, и проитерируется с асинхронным итератором, который сам возвращает promise. Вы можете туда погрузить promise массива промизов. Довольно сложно.

У нас уже будущее наступило, есть async/await. Но зачем нам promise?

Мы пишем простой код. Мы как будто отказываемся от этого всего. Это снова наш внутренний инструмент, называется «Гермиона». Вот пример кода. Опять же, мы запускаем браузер, из нашего кода передаем в него какие-то команды, ожидаем их выполнения и получаем результат. Предназначена для тестирования в браузере, основана на webdriver. То есть это надстройка над драйвером. Но в основе все выглядит очень похоже. В основе все очень похоже с webdriver.

То есть команды по определению асинхронны. И мы снова пишем на новом модном языке, используем await. Но мы используем await, и у нас вроде бы все хорошо — кроме того, что мы умудрились с нашими новыми фичами написать медленнее, чем без них! Мы должны подождать, пока браузер нам что-то ответит. Потому что у нас снова появляется точка ожидания.

Мы снова можем распараллелить то, что записано у нас в await. И мы уже знаем правильный ответ — Promise.all().

Но также проще внести незапланированную дополнительную синхронизацию, которую легко просмотреть и пропустить глазами. Мораль: await написать проще, чем последовательность then с промизами.

Простое правило: если у вас рядом стоят два await, которые вроде бы не зависят друг от друга, — наверное, можно их распараллелить и ускорить выполнение вашего кода. И естественно, следите за вашим кодом.

Вот интересные ссылки, которые я собрал, ссылки на документацию:

И интересная подборка былинных отказов, эпик-фейлов, когда у людей в продакшене были настоящие грабли:

  • Статья о том, как из-за коррекции времени NTP daemon у человека в продакшене упал кластер Hadoop.
  • Проблема в Cloudflare DNS, возникшая всего лишь из-за одной секунды, из-за немонотонности времени.
  • Еще во «ВКонтакте» интересный баг был. Кука авторизации записывалась со временем истечения на час. И у человека с неправильно выставленным часовым поясом и временем сразу же кука истекала, он не мог авторизоваться, его сразу выбрасывало.

Что я хочу сказать в итоге? Изучайте качественный код, в том числе опенсорс — Lodash, RxJS и т. п. Там много интересного написано, интересные проблемы решаются. Избегайте типичных ошибок, которые уже кому-то встретились и решены. Не изобретайте велосипед. А если будете его изобретать — по крайней мере, знайте, что колеса должны быть круглыми. У меня всё.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть