Потоки (thread) в приложении можно разделить на три категории:
Нагружающие процессор (CPU bound).
Блокирующие ввод-вывод (Blocking IO).
Неблокирующие ввод-вывод (Non-blocking IO).
У каждой из этих категорий своя оптимальная конфигурация и применение.
Для задач, требующих процессорного времени, нужен пул с заранее созданными потоками с количеством потоков равным числу процессоров. Единственная работа, которая будет выполняться в этом пуле, — вычисления на процессоре, и поэтому нет смысла превышать их количество, если только у вас не какая-то специфическая задача, способная использовать Hyper-threading (в таком случае вы можете использовать удвоенное количество процессоров). Обратите внимание, что в старом подходе "количество процессоров + 1" речь шла о смешанной нагрузке, когда объединялись CPU-bound и IO-bound задачи. Мы не будем такого делать.
Проблема с фиксированным пулом потоков заключается в том, что любая блокирующая операция ввода-вывода (да и вообще любая блокирующая операция) "съест" поток, а поток — очень ценный ресурс. Получается, что нам нужно любой ценой избегать блокировки CPU-bound пула. Но к сожалению, это не всегда возможно (например, при использовании библиотек с блокирующим вводом-выводом). В этом случае всегда следует переносить блокирующие операции (ввод-вывод и другие) в отдельный пул. Этот отдельный пул должен быть кэшируемым и неограниченным, без предварительно созданных потоков. Честно говоря, такой пул очень опасен. Он не ограничивает вас и позволяет создавать все больше и больше потоков при блокировке других, что очень опасно. Обязательно стоит убедиться, что есть внешние ограничения, то есть существуют высокоуровневые проверки, гарантирующие выполнение в каждый момент времени только фиксированного количества блокирующих операций (это часто делается с помощью неблокирующей ограниченной очереди).
Последняя категория потоков (если у вас не Swing / SWT) — это асинхронный ввод-вывод. Эти потоки в основном просто ожидают и опрашивают ядро на предмет уведомлений асинхронного ввода-вывода, и пересылают эти уведомления в приложение. Для этой задачи лучше использовать небольшое число фиксированных, заранее выделенных потоков. Многие приложения для этого используют всего один поток! У таких потоков должен быть максимальный приоритет, поскольку производительность приложения будет ограничена ими. Однако вы должны быть осторожны и никогда не выполнять какую-либо работу в этом пуле! Никогда, никогда, никогда. При получении уведомления вы должны немедленно переключиться обратно на CPU-пул. Каждая наносекунда, потраченная на поток (потоки) асинхронного ввода-вывода, добавляет задержки в ваше приложение. Поэтому производительность некоторых приложений можно немного улучшить, сделав пул асинхронного ввода-вывода в 2 или 4 потока, а не стандартно 1.
Глобальные пулы потоков
Я часто встречал советы о том, чтобы не использовать глобальные пулы потоков, такие как scala.concurrent.ExecutionContext.global
. Этот совет основан на том, что к глобальным пулам потоков может получить доступ произвольный код (часто из библиотек), и вы не можете (легко) гарантировать, что этот код использует пул потоков правильно. Насколько это критично во многом зависит от вашего classpath
. Глобальные пулы потоков довольно удобны, но можно создать свои глобальные пулы для приложения.
Относитесь осторожно к любому фреймворку или библиотеке, затрудняющему настройку пула потоков или устанавливающему по умолчанию пул, которым вы не можете управлять.
В любом случае у вас почти всегда будет какой-то синглтон, который будет содержать эти, предварительно настроенные, три пула. Если вы используете неявный ExecutionContext
, то неявным стоит сделать CPU-пул, а остальные указывать явно.
Материал подготовлен в рамках курса «Scala-разработчик».