Lazy threads: опциональный параллелизм
Статья-гипотеза. Описанное нигде не было реализовано, хотя, в принципе, ничто не мешает запилить такое в Фантоме.
Эта идея пришла мне в голову очень давно и даже где-то была мной описана. Триггер к тому, чтобы её описать сегодня — обсуждение сетевых драйверов Линукса в комментариях к Анатомии драйвера.
Сформулирую проблему, описанную там, как я её понимаю: сетевой драйвер Линукса работает в отдельной нити, которая читает принятые пакеты из устройства и синхронно их обрабатывает. Прогоняет через роутинг, файрволл и, если пакет не нам, отправляет его в исходящий интерфейс.
Понятно, что некоторые пакеты обслуживаются быстро, а иные могут потребовать много времени. В такой ситуации хотелось бы иметь механизм, который динамически порождает обслуживающие нити по мере необходимости, и механизм достаточно дешёвый в ситуации, когда лишние нити не нужны.
То есть хотелось бы такого вызова функции, который при необходимости можно конвертировать в старт нити. Но по цене вызова функции, если нить реально не оказалась нужна.
Мне эта идея пришла когда я рассматривал совершенно фантастические модели для Фантом, включая акторную модель с запуском нити вообще на любой вызов функции/метода. Саму модель я отбросил, а вот идея lazy threads осталась и до сих пор кажется интересной.
Как это.
Предположим, что мы запускаем функцию void worker (packet), которая должна что-то молча свершить. Нас не интересует код возврата (или он отдаётся нам асинхронно), и мы бы хотели выполнить функцию в рамках нашей нити, если она короткая, и в рамках отдельной, если длинная.
Понятие «длинная» здесь открыто, но разумно было бы для него применить простую точку оценки — если мы уложились в собственный квант планирования — функция короткая. Если в течение жизни функции случился preemption и у нас забирали процессор — длинная.
Для этого запустим её через прокси lazy_thread (worker, packet), который выполняет очень простую операцию — фиксирует ссылку на стек в момент перед вызовом функции worker в специальной очереди lazy_threads_queue, и заменяет стек на новый:
push( lazy_threads_queue, arch_get_stack_pointer() );
arch_set_stack_pointer(allocate_stack())
Если worker вернулся, то отменим эту операцию:
tmp = arch_get_stack_pointer()
arch_set_stack_pointer( pop( lazy_threads_queue ) );
deallocate_stack(tmp)
И продолжим как ни в чём не бывало. Всё обошлось нам в пару строк кода.
Если же прошло значительное время, а worker всё ещё работает, произведём простую операцию — в точке смены стека выполним раздвоение нитей постфактум: сделаем вид, что внутри lazy_thread () произошло полноценное создание нити: скопируем свойства старой нити в новую, адрес возврата на новом стеке (который мы выделили в lazy_thread) переставим так, чтобы он указывал на функцию thread_exit (void), а в старой нити указатель следующей инструкции установим на точку выхода из функции lazy_thread.
Теперь старая нить продолжает работу, а новая исполнит начатое, и уничтожится там, где она в оригинальном сценарии вернулась бы из lazy_thread.
То есть: фактическое решение о запуске нити для обработки определённого запроса произошло уже после того, как мы начали его выполнять и смогли оценить фактическую тяжесть этого запроса. Можно наложить на точку принятия решения о запуске ленивой нити дополнительные ограничения — например, средний load average за 15 сек меньше 1.5/процессор. Если он выше — распараллеливание вряд ли поможет, мы больше потратим ресурсов на старты бессмысленных нитей.
В современном мире, когда обычное дело — 4 процессора в карманной машине и 16 в настольной, явно нужны механизмы, которые помогают коду адаптироваться к нагрузочной способности аппаратуры. Может быть — так?