Deep Engineering
Средний·Опубликовано·25 МИН

Таймаут и дедлайн: почему клиент ушёл, а работа продолжается

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

Полное техническое изложение

TL;DR

Таймаут ограничивает ожидание, а не работу. Он принадлежит тому, кто ждёт, и тому, кто работает, о нём не сообщается ничего: перестав ждать, вызывающий никого не остановил. Дедлайн — другая величина: не длительность, а точка во времени, общая для всей цепочки. Но и он останавливает исполнителя не сам по себе — только если доехал до него и если исполнитель на него смотрит.

Отсюда главное следствие: после ухода клиента работа продолжается, и она не бесплатная. Измерено на цепочке из трёх сервисов: клиент ушёл через 500 мс, а работу довели до конца все три — счётчик выполненной работы равен 3. С объявленным бюджетом на той же цепочке работал один сервис: второй отказался, не начиная, третьего не позвали вовсе, — и отказ пришёл клиенту за 401 мс против 501 мс, то есть раньше, чем истекло бы его собственное ожидание. Всё это время работа держит соединение и обработчик, который в нём занят.

Дальше — то, что отличает знающего от читавшего. Таймауты по цепочке не складываются в таймаут клиента: измерено — три сервиса, у каждого таймаут на следующего 1000 мс, ни один не превышен, а вся цепочка заняла 1203 мс; предел в три секунды при этом не замерен, а выведен из устройства цепочки. Цена продолжающейся работы в прогоне — ровно две вещи, соединение и обработчик; в настоящем сервисе к ним добавляется всё, что обработчик успел взять (соединение с базой, место в пуле, память под ответ), и при перегрузке из этого вырастает устойчивый отказ: система занята результатами, которых уже никто не ждёт. И одним словом «таймаут» называют две разные величины: таймаут соединения ограничивает установление соединения, таймаут ответа — ожидание данных, и настроить один, думая, что настроил оба, — обычная ошибка.

Порог входа
Перед уроком достаточно понимать
  • один сервис вызывает другой по сети и ждёт от него ответа;
  • вызовы выстраиваются в цепочку: тот, кого позвали, сам зовёт следующего;
  • у того, кто ждёт, терпение конечно: рано или поздно он перестаёт ждать и уходит.
Заранее знать не нужно
  • чем дедлайн отличается от таймаута и что такое отмена работы;
  • SO_RCVTIMEO, ETIMEDOUT, «бюджет запроса», таймаут соединения против таймаута ответа.

Что здесь на самом деле спрашивают

Лестница обычно такая:

  1. «Зачем нужны таймауты?» — разминка.
  2. «Какой таймаут вы поставите на вызов соседнего сервиса?» — здесь выясняется, различает ли собеседник таймаут соединения и таймаут ответа.
  3. «Клиент отвалился по таймауту. Что происходит на сервере?» — начало содержания.
  4. «Чем дедлайн отличается от таймаута?» — главный вопрос темы.
  5. «У нас на каждом шаге таймаут секунда, а запрос идёт три. Почему?» — вопрос про сложение.
  6. «Как отменить работу, которая уже не нужна?» — вопрос, на который в разных языках отвечают по-разному, а механизм один.

Числа получены прогоном bench/deadlines/chain.py и bench/deadlines/practice.py на loopback. Выдержки в 400 мс задаёт сам скрипт; содержательны здесь не миллисекунды, а счётчики выполненной и отменённой работы.

База: три разных вопроса про время

Возьмём самую обычную цепочку. Сервис A зовёт сервис B, тот зовёт сервис C, и только тогда ответ идёт обратно. Клиент, постучавшийся в A, готов ждать не больше секунды: дольше он просто уйдёт.

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

  • таймаут — сколько ждать. Это длительность, и принадлежит она тому, кто ждёт: A сам решает, сколько готов ждать ответа от B.
  • дедлайн — до какого момента. Это не длительность, а точка во времени: «ответ нужен не позже такого-то мгновения», и она одна на всю цепочку, сколько бы в ней ни было шагов.
  • отмена — просьба прекратить работу. Это уже не про время вовсе, а про сообщение: кто-то должен сказать исполнителю, что результат больше не нужен.

И вот главный вопрос урока: клиент ушёл — что происходит с работой, которую он заказал?

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

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

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

Механизм 1: таймаут — свойство ожидающего

контракт языкаПоведение таймаутов сокета, описанное в socket(7) и connect(2). Библиотеки поверх этого добавляют свои обёртки, но нижний слой у всех один.

Начнём с того, что такое таймаут на уровне сокета:

Specify the receiving or sending timeouts until reporting an error … if no data has been transferred and the timeout has been reached, then -1 is returned with errno set to EAGAIN or EWOULDBLOCK … as if the socket was specified to be nonblocking.

перевод

Задаёт таймауты приёма или отправки до сообщения об ошибке… если данные не передавались и таймаут истёк, возвращается −1, а errno устанавливается в EAGAIN или EWOULDBLOCK… как если бы сокет был объявлен неблокирующим.

socket(7), SO_RCVTIMEO

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

Проверяем цепочкой из трёх сервисов. Каждый «работает» 400 мс, клиент ждёт 500 мс:

1. THE CLIENT GAVE UP; THE WORK DID NOT
---------------------------------------
  client timeout, ms                             500
  services in the chain                          3
  work each service does, ms                     400
  what the client got                            client timeout
  how long the client waited, ms                 501
  services that did the work anyway              3
  services that refused before starting          0
  services never called at all                   0
наблюдение замераbench/deadlines/chain.py, loopback. Выдержку 400 мс задаёт сам скрипт; воспроизводится не она, а счётчик: работу довели до конца все три сервиса, хотя клиент ушёл на 501-й миллисекунде.

Клиент ушёл. Все трое доработали. Ни один не отказался — им никто ничего не сказал.

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

Механизм 2: дедлайн едет вместе с запросом

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

Разница видна на той же цепочке. Единственное изменение: клиент объявляет бюджет, каждый сервис вычитает из него потраченное и передаёт остаток дальше:

2. THE SAME CHAIN WITH A DEADLINE PASSED ALONG
----------------------------------------------
  budget the client announced, ms                500
  what the client got                            deadline
  how long the client waited, ms                 401
  services that did the work anyway              1
  services that refused before starting          1
  services never called at all                   1
наблюдение замераbench/deadlines/chain.py. Те же три сервиса и та же работа; изменилось только то, что бюджет едет вместе с запросом.

Три отличия от первого блока, и все три практические.

Работу сделал один сервис вместо трёх. Первый успел (у него было 500 мс на 400 мс работы), второй увидел остаток в сотню миллисекунд и отказался, не начиная, — а третий вообще не был вызван; в прогоне это отдельные строки: отказавших, не начиная, — один, непозванных — один.

Заметьте, чего здесь не произошло: клиент не получил две трети результата. Он не получил ничего — только отказ. Экономия тут не в частичном ответе, а в том, что двое из трёх не потратили по 400 мс на работу, которую никто не ждал.

Клиент получил ответ, а не молчание. deadline — это осмысленный отказ: по нему видно, что запрос не влезал в бюджет, а не что сеть потерялась.

Ответ пришёл раньше: 401 мс против 501. Отказ по дедлайну наступает в тот момент, когда становится ясно, что не успеть, — а не когда истечёт чужое терпение.

И теперь то, что в этом блоке легко проглядеть: работал не дедлайн, а сотрудничество. Сам по себе дедлайн ничего не останавливает — это величина, а не команда. Второй сервис отказался, не начиная, потому что выполнились два условия сразу: остаток бюджета доехал до него вместе с запросом, и он этот остаток посмотрел перед тем, как браться за работу. Уберите первое — протокол не перевозит бюджет, — и второй сервис отработает свои 400 мс, как в прошлом блоке. Уберите второе — бюджет приехал, но никто на него не смотрит, — и результат будет тот же. Именно поэтому «мы ввели дедлайны» само по себе ничего не значит: значит только то, что бюджет передаётся на каждом переходе и что каждый вызванный обязан его проверять.

Механизм 3: почему таймауты по цепочке не складываются

Самая частая практическая загадка: «у нас везде таймаут секунда, откуда взялись три». Ответ в том, что каждый шаг отсчитывает время от себя.

3. WHY PER-HOP TIMEOUTS DO NOT ADD UP TO THE CLIENT'S
-----------------------------------------------------
  timeout each service sets for the next one, ms 1000
  services in the chain                          3
  what the client got                            done
  how long the whole chain took, ms              1203
  longest any single hop waited, ms              under 1000
наблюдение замераbench/deadlines/chain.py. Ни один шаг не превысил своего таймаута, а суммарное время больше любого из них.

Ни один таймаут не нарушен. Каждый сервис честно ждал меньше секунды. А клиент ждал 1203 мс — потому что ожидания идут последовательно.

Отсюда и верхняя граница, но её важно не выдать за измеренную: три секунды здесь не замерены, а выведены из устройства цепочки. Каждый шаг ставит следующему таймаут в 1000 мс (это константа скрипта), ожидания не перекрываются, значит суммарное ожидание не превысит 3 × 1000 мс. Замер даёт 1203 мс — то есть цепочка укладывается в границу, а не достигает её.

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

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

Глубже: два разных таймаута, которые называют одним словом

контракт языкаРазличение опирается на connect(2): у попытки соединения есть собственная ошибка ETIMEDOUT. Замера в этой ступени нет — здесь только то, что описано в документации.

Последний уровень — про ловушку в настройке. connect(2) описывает отдельную ошибку:

Timeout while attempting connection. The server may be too busy to accept new connections.

перевод

Таймаут при попытке соединения. Сервер может быть слишком занят, чтобы принимать новые соединения.

connect(2), ETIMEDOUT

Это таймаут соединения — время на то, чтобы соединение вообще установилось. Он про доступность: сервер не отвечает на рукопожатие, очередь переполнена (прошлый урок), пакеты теряются.

Таймаут ответа — совсем другое: соединение уже есть, запрос отправлен, и мы ждём данные. Он про скорость обработки.

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

Как отвечать на собеседовании

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

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

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

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

Дальше спросят

Спросят дальше

Как исполнитель узнаёт, что клиент ушёл?

Короткий ответ

Никак, если ему не сказали. Таймаут — решение ожидающего, и на сокете он выглядит как прекращение ожидания на его стороне; исполнителю не приходит ни сигнала, ни события.

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

Спросят дальше

Что ставить, если дедлайнов в протоколе нет?

Короткий ответ

Уменьшать таймаут на каждом шаге вручную: если у сервиса на входящий запрос есть секунда, исходящему вызову он должен дать заметно меньше — с запасом на собственную работу и на ответ. Это бедная замена дедлайну, но она сохраняет главное свойство: суммарное ожидание не растёт с длиной цепочки.

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

Спросят дальше

Дедлайн решает проблему совсем?

Короткий ответ

Не совсем: измерено, что один сервис всё-таки успел поработать впустую — тот, у которого бюджета хватало, а у следующего уже нет. Дедлайн отсекает хвост бессмысленной работы, а не всю её.

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

Спросят дальше

Какой таймаут ставить на соединение?

Короткий ответ

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

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

Частые заблуждения

Утверждение

таймаут отменяет работу на сервере

На самом деле

Не отменяет и не сообщает о себе: таймаут — свойство ожидания вызывающего. Измерено: клиент ушёл через 501 мс, а работу довели до конца все три сервиса цепочки. Исполнители узнают об уходе клиента в лучшем случае в момент записи ответа — когда ресурсы уже потрачены.

Утверждение

дедлайн — это тот же таймаут, только называется иначе

На самом деле

Таймаут — длительность, которую каждый отсчитывает от себя; дедлайн — точка во времени, общая для всей цепочки и передаваемая с запросом. Измерено на одной и той же цепочке: без бюджета работали три сервиса, с бюджетом — один, и клиент получил осмысленный отказ за 401 мс вместо молчания на 501-й.

Утверждение

объявили дедлайн — значит вызванный остановится

На самом деле

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

Утверждение

если на каждом шаге таймаут секунда, клиент подождёт не больше секунды

На самом деле

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

Утверждение

работа впустую ничего не стоит — всё равно этот процесс уже запущен

На самом деле

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

Утверждение

дедлайн полностью убирает бесполезную работу

На самом деле

Убирает хвост, а не всё: измерено, что один сервис из трёх всё-таки отработал впустую — бюджета ему хватало, а следующему уже нет. Узнать заранее стоимость оставшегося пути невозможно, поэтому к бюджету добавляют собственную оценку: «моя работа занимает 300 мс, осталось 100 — отказываюсь сразу».

Утверждение

таймаут один: главное поставить его побольше, чтобы не рвать хорошие запросы

На самом деле

Их два, и они про разное. Таймаут соединения — про доступность: connect(2) описывает ETIMEDOUT как Timeout while attempting connection. The server may be too busy to accept new connections (таймаут при попытке соединения; сервер может быть слишком занят, чтобы принимать новые соединения). Таймаут ответа — про скорость обработки. Один общий таймаут заставляет одинаково долго ждать недоступную реплику и медленный ответ, хотя реакции должны быть противоположными.

Утверждение

сервис должен передавать дальше тот же таймаут, который получил

На самом деле

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

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Цепочка из трёх сервисов, каждый «работает» 400 мс, клиент ждёт 500 мс. Цепочку вызывают дважды: без объявленного бюджета и с бюджетом в 500 мс. Печатаются три вещи: что получил клиент в первом случае, сколько сервисов довели работу до конца в первом случае и что получил клиент во втором. Что напечатает этот код?
budget = int(CLIENT_TIMEOUT * 1000)
without_deadline, worked_without, _, _ = run(budget_ms=-1)
with_deadline, worked_with, refused, uncalled = run(budget_ms=budget)
print(without_deadline)
print(worked_without)
print(with_deadline)

Практика · оцените

Та же цепочка из трёх сервисов, но бюджет едет вместе с запросом. Сколько сервисов возьмутся за работу?
сервис

Проверка знаний

Вопрос 1 из 6

Клиент отвалился по таймауту через 500 мс. Что происходит с работой на сервере?

Источники и что читать дальше

2 ИСТОЧНИКА

  1. socket(7), Linux man-pages 6.7Официальная документация. Что именно ограничивает таймаут на сокете: SO_RCVTIMEO — «Specify the receiving or sending timeouts until reporting an error … if no data has been transferred and the timeout has been reached, then -1 is returned with errno set to EAGAIN or EWOULDBLOCK … as if the socket was specified to be nonblocking» (Задаёт таймауты приёма или отправки до сообщения об ошибке … если данные не передавались и таймаут истёк, возвращается −1, а errno устанавливается в EAGAIN или EWOULDBLOCK … как если бы сокет был объявлен неблокирующим). Ключевое для урока: таймаут — свойство ОЖИДАНИЯ на этой стороне, и другой стороне о нём не сообщается ничего.https://man7.org/linux/man-pages/man7/socket.7.html
  2. connect(2), Linux man-pages 6.7Официальная документация. Почему таймаут соединения и таймаут ответа — разные величины: у connect(2) есть собственная ошибка ETIMEDOUT — «Timeout while attempting connection. The server may be too busy to accept new connections» (Таймаут при попытке соединения. Сервер может быть слишком занят, чтобы принимать новые соединения). То есть один таймаут ограничивает установление соединения, другой — ожидание данных, и путать их дорого.https://man7.org/linux/man-pages/man2/connect.2.html