Что-то много очень адептов node.js и прочих «магических» технологий говорят о том, что «нити (threads) и блокирующие вызовы - suxx», а «лапша из колбэков и асинхронные вызовы - rulezzz».
TL;DR
- event-driven concurrency с колбэками эквивалентно thread-based concurrency
- можно реализовать нити (threads) с минимальным оверхедом
- нити дают возможность писать естественный, последовательный код
- поэтому threads >> events + callbacks
Немного теории.
И последовательный блокирующийся процесс вычисления, и асинхронный неблокирующийся процесс имеют определенное состояние. Например, для процесса обработки http-запроса состояние может содержать:
- сокет и связанные с ним ресурса ядра
- буфер для чтения из сокета в юзерспейсе
- состояние http-парсера
- заголовки http и тело запроса
- состояние парсера тела запроса
- буфер для ответа
- внутренние данные функции, генерирующей ответ на запрос (например, буфер под результат запроса к БД)
Это состояние есть всегда и не зависит от того, как написан код, обрабатывающий запрос - использует ли он коллбэки или представлен в виде конечного или магазинного автомата или же это последовательный код, запущенный в отдельной нити.
Пропоненты некоторых модных технологий убедительно рассказывают о том, что единственный правильный способ написания «web-scale»-приложений - это отказ от нитей и блокирующихся вызовов и переход на написание программ в стиле «лапша из колбэков». «Лапша из колбэков» есть ни что иное, как код в continuation-passing style. Осуществить преобразование последовательного кода в CPS вручную - задача довольно механическая, трудоемкая и склонная к ошибкам.
К сожалению, сложилась ситуация, когда многие программисты не имеют базового образования в области Computer Science и имеют слабое представление о процессах вычисления (в отличие, например, от выпускников университетов, где преподают курсы вроде SICP). На самом же деле, CPS и последовательный код эквивалентны друг другу: цепочка колбэков - это стек вызовов, представленный в виде односвязного списка.
Основной мой аргмент за последовательный код, выполняющийся в отдельных нитях, следующий: последовательный код легко читается, структуры управления в нем реализуются легко. Такой код надежнее, так как есть простые и надежные инструменты для обработки ошибок; есть исключения; стектрейсы содержат полезную информацию; легче следить за своевременным освобождением ресурсов.
Практика.
Основной аргумент, высказываемый против thread-based concurrency - это то, что нити занимают много ресурсов и поэтому их нельзя много создать. Для нативных нитей это так и есть: у них есть относительно большой оверхед на создание, значительно потребление памяти (место тратится под стэк (обычно несколько мегабайтов), thread-local storage, информация для планировщике нитей в ядре) и значительный оверхед на переключение контекста. Но нити, предоставленные ОС - это только один из вариантов реализации нитей: их можно реализовать с меньшим оверхедом.
Разберем, например, нити в Linux + glibc. Итак, что включает в себя нить:
- структуры данных в ядре ОС, которые содержат информацию для планировщика
- стэк
- сохраненные значения регистров CPU и FPU
- маска сигналов нити
- место под результат нити
- объекты синхронизации
Основные накладные расходы при использовании таких нитей заключается в следующем:
- переключение нитей всегда происходит из ядра ОС, т.е. всегда есть переход в userspace и обратно; а это губительно для производительности, если происходит много переключений
- планировщик нитей, в зависимости от реализации, может начать тормозить из-за большого количества нитей
- обычно у нитей достаточно большой стек, около нескольких Мб; из-за этого они занимают много памяти. Это основная причина, почему нельзя создать много нативных нитей
Первое, на что можно обратить внимание, это наличие в POSIX средств для переключения контекста - makecontext, getcontext, setcontext, swapcontext. Контекст в POSIX представляет собой все состояние нити за исключением объектов ядра и объектов синхронизации. С помощью таких контекстов можно довольно просто организовать нити. Такие нити, созданные в userspace, называются green threads. При этом, стэк для нити мы должны предоставить сами; это дает возможность управлять и минимизировать потребление памяти нитями.
Самый большой минус контекстов POSIX заключается в том, что контекст хранит также маску сигналов, и для ее переключения нужен системный вызов (чтобы обеспечить POSIX'ную семантику переключения контекста). Причем зачастую маска сигналов на самом деле и не нужна. Если реализовать swapcontext вручную (это займет несколько десяток строк кода на ассемблере для каждой поддерживаемой архитектуры), то накладные расходы сильно уменьшатся. Также можно не сохранять те регистры, которые не обязаны быть сохранены при вызове функции.
Для максимального уменьшения размера хранимого состояния в стэке необходимо реализовать анализ живости переменных в функциях с тем, чтобы более неиспользуемые переменные не занимали место в стэке.
В итоге, использование green threads позволяет писать такой же простой последовательный код и запускать много нитей. Поэтому я выбираю green threads для задач, связанных с I/O; а для остальных задач - OS threads.
See also:
PS[0]. А node.js вместе при использовании node-fibers совсем даже неплох (если закрыть глаза и не видеть javascript). node-fibers использует libcoroutine для организации легковесных нитей в v8, что спасает от лапши из колбэков.
PS[1]. Erlang с его сотнями тысяч процессов - типичный пример успешного использования green threads.