24 мая 2021 г.

Когда имеет смысл передавать IO в отдельный поток?

Допустим, у нас есть простая система, которая принимает запросы из сети, как-то их обрабатывает ("бизнес-логика"), и отправляет результат назад, в сеть. Мы заинтересованы в быстром отклике (=latency), а отправка – это IO, так что возникает идея ее снести в отдельный поток.

Но тогда придется передавать данные из основного потока в поток отправки – а межпоточная коммуникация это какие-то накладные расходы (копирование, инструкции синхронизации, т.п.)

Стоит ли вообще игра свеч, и если стоит – то когда?

Разбирал код legacy системы, где автор реализовал оба варианта – с отправкой в основном потоке, и в отдельном. Вариант с делегированием отправки в отдельный поток он подавал как low-latency, а вариант где все в одном потоке как экономичный режим.

Я усомнился. Low-latency системы обычно функционируют при низкой загрузке – иначе малое время отклика не обеспечить. Интуитивно, однопоточная версия будет выигрывать при низких нагрузках, потому что в ней меньше чистое время обработки запроса – нет накладных расходов на передачу запроса в другой поток. У двух-поточной версии выше пропускная способность, и это даст преимущество при росте нагрузке – медленнее будет копиться очередь на входе, а время ведь ожидания в очереди плюсуется к общему времени отклика системы 1.

На графиках ниже – зависимость среднего времени отклика от нагрузки, E[RTT] =f(load) для обоих вариантов, по результатам симуляций. Параметры модели это соотношения длительности фаз обработки: "бизнес-логики" $$T_{bl}$$, "отправки" $$T_{s}$$, и межпоточной координации $$T_{itc}$$.

(Выглядит похоже на J-curve – что неудивительно) И действительно: однопоточная версия показывает меньшее время ожидания при низкой нагрузке, а при росте нагрузки двухпоточная версия начинает отыгрывать отставание. Точка перелома – при какой нагрузке двухпоточная версия обойдет однопоточную – зависит от тех самых параметров $$T_{bl},\ T_s,\ T_{itc}$$

Интересно, что несмотря на простоту модели, получить какую-то простую наглядную формулу для этой "переломной" нагрузки – не получается. Точнее – у меня не получилось. Даже в первом, линейном, приближении, формула получается не очень наглядная. Лучшее, чего удалось добиться, это такое вот выражение

$$N \sim \frac{1}{3}\frac{T_{itc}}{T_{s}}\frac{1}{T_{bl}}$$

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

Я прогнал пару десятков симуляций для разных параметров, и сравнил результаты с предсказаниями формулы – погрешность где-то +/- 150%, как rule of thumb сойдет. Я вообще-то ожидал еще большего расхождения – возможно, недостаточно творчески подошел к выбору параметров для симуляций.

…Но, в итоге получается, что автор оригинального кода был, скорее, прав: хотя при низкой нагрузке однопоточная версия может выиграть выигрывать, но выигрыш небольшой, и с ростом нагрузки исчезает довольно быстро. А если смотреть не на среднее время отклика, а на верхние квантили его распределения – то и еще быстрее. А версия с IO в отдельном потоке, хоть и чуть хуже при низких нагрузках, но обеспечивает более стабильное время отклика на большем диапазоне входных нагрузок.

Примечания

  1. Я подразумеваю, что время отправки и время "бизнес-логики" – сами по себе одинаковы в одно- и двух-поточной версиях. Это, конечно упрощение: в реальном мире может оказаться, что отправка сама по себе становится медленнее/быстрее, когда делается из другого потока – из-за каких-то деталей реализации (например, потому что где-то под ковром в реализации затесалась лишняя блокировка). Но, в первом приближении, я об этом решил не задумываться.

  2. Формула получена из самой простой линейной аппроксимации времени отлика от нагрузки, вида $$E[RTT] \approx T (1 + NT)$$.

    Константа 3 в знаменателе – на самом деле не константа, а крокодиловое выражение из тех же $$T_{bl},\ T_s,\ T_{itc}$$ – но оно получилось совершенно не наглядное, поэтому я оценил его диапазон при практически интересных значениях параметров.

    Ведь нет смысла же рассматривать случай $$T_s < T_{itc}$$ – если передача запроса в другой поток занимает больше времени, чем сама отправка в сеть, то нет никакого смысла во втором потоке. Аналогично, если $$T_{bl} \ll T_{s}$$ то второй поток тоже очень осмысленен. С учетом таких ограничений получается диапазон 2..10, т.е.

    $$\frac{1}{10}\frac{T_{itc}}{T_{s}}\frac{1}{T_{bl}} \le N \le \frac{1}{2}\frac{T_{itc}}{T_{s}}\frac{1}{T_{bl}}$$

    ..ну, или взять $$\frac{1}{3}\frac{T_{itc}}{T_{s}}\frac{1}{T_{bl}}$$ за примерную середину этого диапазона

7 комментариев:

  1. есть существенное отличие. "inline" write vs enqueue for other thread... вторая схема позволяет аггрегировать сообщения на транспортном уровне, а первая нет. поясню - предположим три запроса {a, b, c} генерируют ответы {a1, a2, a3, .. a10, b1, c1} в первой схеме будет эффект latency propagation и неээфективное использование io в предположении что мы можем писать 4 сообщения в один пакет сетевые пакеты будут {{a1..a4}, {a5..a9}, {a10}, {b1}, {c1}}... во-втором случае мы запишем {{a1..a4}, {a5..a9}, {a10, b1, c1}} т.е. в первом случае будет отослано 5 (udp/tcp) пакетов, а во втором 3... и это big deal в low latency

    ОтветитьУдалить
    Ответы
    1. Да, все так. В моем случае на один запрос может быть только один ответ, поэтому эффект не так заметен, но все равно имеет место: если буферизовать отправляемые сообщения, то появляется возможность batching-а: можно отправить несколько сообщений в одном системном вызове, и уместить их в одном сетевом фрейме.

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

      Удалить
  2. А как ты подбирал параметры пулов потоков, обрабатывающих бизнес-логику и отправку? Сдается мне, это тоже будет заметно влиять на результаты замеров.

    ОтветитьУдалить
    Ответы
    1. В реальном приложении нет пулов -- там один или два выделенных потока, прибитых к соответствующим CPU (в low-latency сетапе).

      А на графиках -- результаты симуляций. Мне хотелось посмотреть на поведение при разных параметрах, а в реальном приложении "стоимость" отправки фиксирована. Да и вообще, там слишком много деталей, которые зашумляют картину

      Удалить
  3. Нет, я все же не понимаю за счет чего получается выигрыш при вынесении отправки в отдельный поток. Мы добавляем itc. Это может быть и мало, но все равно нам в минус. А где мы экономим? Какое-то переключение контекста потока с вычислений на IO? Прогретость каких-то кэшей?

    ОтветитьУдалить
    Ответы
    1. Так входная очередь же -- меньше.

      Общее время отклика + время ожидания на входе в систему + время обработки (которое включает "бизнес-логику", itc, и отправку). Когда мы выносим "отправку" в другой поток, мы уменьшаем время, которое /первый поток/ тратит над каждой задачей -- а значит первый поток быстрее освободится, и заберет следующую задачу из очереди. В итоге, среднее время ожидания в очереди будет меньше.

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

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

      Удалить
    2. Конечно же! Затупил :)
      Спасибо!

      Удалить