При участии Тима Перлса, Джошуа Блоха, Джозева Боубира, Дэвида Холмса и Дага Ли Параллельное программирование в java на
Скачать 4.97 Mb.
|
Глава 8 Применение пулов потоков В главе 6 был представлен фреймворк выполнения задач, упрощающий управление жизненным циклом задач и потоков и предоставляющий простые и гибкие средства для отделения отправки задач от политики выполнения. В главе 7 рассматривались некоторые запутанные детали жизненного цикла службы, возникающие при использовании фреймворка выполнения задач в реальных приложениях. В этой главе рассматриваются дополнительные параметры для конфигурирования и настройки пулов потоков, описываются опасности, которые следует учитывать при использовании фреймворка выполнения задач, и предлагаются некоторые более продвинутые примеры использования фреймворка Executor 8.1 Неявные связи между задачами и политиками выполнения Ранее мы утверждали, что фреймворк выполнения задач отделяет отправку задачи от ее выполнения. Подобно многим прочим попыткам развязать комплексные процессы, это было небольшим преувеличением. Хотя фреймворк Executor и предоставляет значительную гибкость в определении и изменении политик выполнения, не все задачи совместимы со всеми политиками выполнения. Типы задач, которым требуются определённые политики выполнения, включают в себя: Зависимые задачи. Наилучшим образом проявляют себя независимые задачи: те, которые не зависят от момента времени, результатов выполнения или побочных эффектов, возникающих при выполнении других задач. Когда в пуле потоков выполняются независимые задачи, можно свободно изменять размер пула и его конфигурацию, не влияя ни на что, кроме производительности. С другой стороны, когда вы отправляете задачи, зависящие от других задач, в пул потоков, вы неявно накладываете ограничения на политику выполнения, которыми необходимо тщательно управлять, чтобы избежать проблем с живучестью (см. раздел 8.1.1 ). Задачи, использующие ограничение потока. Однопоточные исполнители (executors) берут на себя более строгие обязательства, касающиеся обеспечения параллелизма, чем произвольные пулы потоков. Они гарантируют, что задачи не выполняются параллельно, что позволяет вам несколько ослабить обеспечение потокобезопасности в коде задачи. Объекты могут быть ограничены потоком задачи, что позволяет задачам, спроектированным для выполнения в этом потоке, получать доступ к этим объектам без синхронизации, даже если эти ресурсы не являются потокобезопасными. Это приводит к формированию неявной связи между задачей и её политикой выполнения - задачи определяют требование, чтобы их исполнитель был однопоточным 95 . В этом случае, при изменении однопоточной реализации интерфейса Executor на пул потоков, свойство потокобезопасности может быть потеряно. 95 Требование не такое уж сильное; было бы достаточно гарантии того, что задачи не выполняются параллельно и обеспечивается достаточный уровень синхронизации, так, чтобы воздействие, оказанное на память одной задачей, было гарантированно видно следующей задаче – именно такие гарантии и предлагает фабричный метод Executors.newSingleThreadExecutor Задачи, чувствительные ко времени ответа. ПриложенияGUI чувствительны к времени отклика: как правило, пользователей раздражает длительная задержка между нажатием кнопки и соответствующим визуальным откликом. Отправка долговременной задачи однопоточному исполнителю или отправка нескольких долговременных задач в пул потоков небольшого размера 96 , может ухудшить отзывчивость службы, управляемой этой реализацией Executor Задачи, использующие класс ThreadLocal . Класс ThreadLocal позволяет каждому потоку иметь свою собственную "версию" переменной. Однако исполнители могут свободно использовать потоки по своему усмотрению. Стандартные реализации интерфейса Executor могут уничтожать неиспользуемые потоки, когда спрос низкий и добавлять новые, когда спрос высокий, а также заменять рабочие потоки свежими, если задачей было брошено непроверяемое исключение. Класс ThreadLocal имеет смысл использовать с пулом потоков, только если локальное по отношению к потоку значение имеет жизненный цикл, ограниченный этой задачей; класс ThreadLocal не должен использоваться в пуле потоков для передачи значений между задачами. Пулы потоков лучше всего работают, когда задачи гомогенны и независимы. Смешивание длительных и кратковременных задач может привести к “засорению” пула, если он не сильно велик; отправка задач, зависящих от других задач, может привести к взаимоблокировке, если пул не является неограниченным. К счастью, запросы в типичных сетевых серверных приложениях - веб-серверах, почтовых серверах, файловых серверах - обычно соответствуют этим рекомендациям. Некоторые задачи имеют характеристики, требующие или исключающие определенную политику выполнения. Задачи, зависящие от других задач, требуют, чтобы пул потоков был достаточно большим, чтобы задачи никогда не ставились в очередь или не отклонялись; задачи, использующие ограничение потока, требуют последовательного выполнения. Документируйте эти требования, чтобы будущие сопровождающие не подрывали безопасность или живучесть, устанавливая несовместимую политику выполнения. 8.1.1 Взаимоблокировка потоков, вызванная голоданием Если задачи, зависящие от других задач, выполняются в пуле потоков, они могут попадать в состояние взаимоблокировки. В случае использования однопоточного исполнителя, задача, отправляющая другую задачу некоторому исполнителю и ожидающая результата её выполнения, всегда будет попадать в состояние взаимоблокировки. Вторая задача будет находиться в рабочей очереди до завершения первой задачи, но первая не будет завершена, так как она ожидает результата второй задачи. То же самое может произойти и в больших пулах потоков, если все потоки выполняют задачи, которые заблокированы в ожидании других задач, все еще находящихся в рабочей очереди. Это называется 96 Пул поток с небольшим количеством рабочих потоков, выполняющих задачи. взаимоблокировкой потоков, вызванной голоданием (thread starvation deadlock,), и может возникать всякий раз, когда пул задач инициирует неограниченное ожидание блокировки какого-нибудь ресурса или условия, которое может быть выполнено лишь на основе действия другой задачи из пула потоков, такого как ожидание возвращаемого значения или побочного эффекта, вызванного другой задачей, если вы не можете гарантировать, что пул достаточно большой. Класс ThreadDeadlock из листинга 8.1, иллюстрирует ситуацию голодания потока. Метод RenderPageTask отправляет две дополнительные задачи исполнителю для извлечения верхнего и нижнего колонтитулов страницы, отображает тело страницы, ожидает результатов задач получения верхнего и нижнего колонтитулов, а затем объединяет верхний и нижний колонтитулы в готовую страницу. С однопоточным исполнителем в классе ThreadDeadlock всегда будет возникать взаимоблокировка. Аналогично, задачи осуществляющие координацию друг с другом с помощью барьера, также могут приходить к состоянию взаимоблокировки связанной с голоданием потока, если пул потоков недостаточно велик. public class ThreadDeadlock { ExecutorService exec = Executors.newSingleThreadExecutor(); public class RenderPageTask implements Callable Future String page = renderBody(); // Will deadlock -- task waiting for result of subtask return header.get() + page + footer.get(); } } } Листинг 8.1 Задача, попадающая в состояние взаимоблокировки в однопоточном экземпляре Executor . Не делайте так. Всякий раз, когда вы отправляете исполнителю задачи, которые не являются независимыми, знайте о возможности возникновения взаимоблокировки, вызванной голоданием потока, и документируйте любые ограничения, налагаемые на размер пула или конфигурацию в коде или файле конфигурации, где конфигурируется экземпляр Executor. Помимо явных ограничений, накладываемых на размер пула потоков, также могут быть и неявные ограничения, возникающие из-за ограничений на другие ресурсы. Если приложение использует пул соединений JDBC с десятью соединениями, и каждой задаче требуется подключение к базе данных, то пул потоков будет иметь только десять потоков, поскольку задачи, количество которых превышает десять, будут блокироваться в ожидании получения соединения. 8.1.2 Долговременные задачи У пулов потоков могут возникать проблемы с отзывчивостью, если задачи могут блокироваться на длительные периоды времени, даже если взаимоблокировка невозможна. Пул потоков может “засоряться” длительными задачами, что приводит к увеличению времени обслуживания даже для коротких задач. Если размер пула слишком мал по сравнению с ожидаемым устойчивым количеством долговременных задач, в конечном итоге все потоки в пуле будут выполнять долговременные задачи, и отзывчивость ухудшится. Одним из способов, позволяющих смягчить неблагоприятные последствия от длительного выполнения задач заключается в том, чтобы задачи использовали ограниченное по времени ожидание ресурсов, взамен неограниченного по времени. Большинство блокирующих методов в библиотеках платформы, таких как Thread.join , BlockingQueue.put , CountDownLatch.await , и Selector.select , поставляются в обеих, ограниченной и неограниченной по времени, версиях, если время ожидания истекло, можно пометить задачу как завершившуюся с ошибкой и прервать ее или заново поместить в очередь для последующего выполнения. Такой подход гарантирует, что каждая задача в конечном итоге будет продвигаться к успешному или неудачному завершению, освобождая потоки для задач, которые смогут завершиться быстрее. Если часто возникает такая ситуация, что пул потоков заполнен заблокированными задачами, это может быть свидетельством того, что размер пула слишком мал. 8.2 Размеры пулов потоков Идеальный размер пула потоков зависит от типов отправляемых задач и характеристик системы развертывания. Размеры пула потоков редко когда должны быть жестко заданы; вместо этого размеры пула должны предоставляться механизмом конфигурации или вычисляться динамически, с помощью метода Runtime.availableProcessors. Определение размеров пулов потоков не является точной наукой, но, к счастью, вам только и нужно, что избегать крайностей “слишком большой” и “слишком маленький”. Если пул потоков слишком велик, потоки конкурируют за ограниченные ресурсы ЦП и памяти, что приводит к увеличению использования памяти и возможному исчерпанию ресурсов. Если размер пула слишком мал, пропускная способность падает, поскольку процессорные мощности не используются в полном объёме, несмотря на доступную работу. Чтобы правильно определить размер пула потоков, необходимо понимать вычислительную среду, бюджет ресурсов и характер задач. Сколько процессоров в системе развертывания? Сколько памяти? Выполняют ли задачи в основном вычисления, операции ввода/вывода или какую-то их комбинацию? Они требуют дефицитного ресурса, такого как соединения JDBC? Если у вас в наличии различные категории задач с очень разным поведением, рекомендуется использовать несколько пулов потоков, чтобы каждый из них можно было настроить в соответствии с определённым типом рабочей нагрузки. Для ресурсоемких задач Ncpu-процессорная система обычно достигает оптимального использования ресурсов с размером пула Ncpu + 1 потоков. (Даже ресурсоемкие потоки иногда натыкаются на ошибку выделения страницы памяти или встают на паузу по какой-либо другой причине, поэтому “дополнительный” запускаемый поток предотвращает простаивание циклов CPU в такой ситуации.) Для задач, которые также включают операции ввода/вывода или другие блокирующие операции, требуется больший пул, поскольку не все потоки будут доступны для планирования. Чтобы правильно определить размер пула, необходимо оценить отношение времени ожидания ко времени вычисления задач; эта оценка не обязательно должна быть точной и может быть получена с помощью профилирования или с помощью других измерительных средств. Кроме того, размер пула потоков можно настроить, запустив приложение с использованием нескольких различных размеров пула потоков при тестовой нагрузке и наблюдая за уровнем загрузки ЦП. Примем такие определения: N cpu = количество CPU U cpu = целевая загрузка CPU, 0 ≤ Ucpu ≤ 1 W = отношение времени ожидания к времени вычисления C Оптимальный размер пула для поддержания желаемого уровня использования процессора: N threads = N cpu * U cpu * ( 1 + W ) C Количество процессоров можно определить с помощью класса Runtime : int N_CPUS = Runtime.getRuntime().availableProcessors(); Конечно, циклы CPU не единственный ресурс, которым можно управлять с помощью пулов потоков. Другие ресурсы, такие как размер памяти, дескрипторы файлов, дескрипторы сокетов, подключения к базе данных, также могут способствовать в определении размеров ограничений. Вычисление ограничений размера пула для этих типов ресурсов проще: просто сложите количество единиц этого ресурса, требуемого каждой задаче, и разделите его на общее доступное количество единиц ресурса. В результате будет получена верхняя граница размера пула потоков. Когда задачам требуется ресурс, находящийся в пуле ресурсов, например, такой как соединение с базой данных, размер пула потоков и размер пула ресурсов оказывают друг на друга взаимное влияние. Если для каждой задачи требуется соединение, эффективный размер пула потоков ограничивается размером пула соединений. Аналогично, если единственными потребителями соединений являются задачи пула потоков, эффективный размер пула соединений ограничен размером пула потоков. 8.3 Конфигурирование класса ThreadPoolExecutor Класс ThreadPoolExecutor предоставляет базовую реализацию для исполнителей, возвращаемых фабричными метода newCachedThreadPool , newFixedThreadPool , и newScheduledThreadExecutor класса Executors . Класс ThreadPoolExecutor представляет собой гибкую и надежную реализацию пула, которая позволяет выполнять различные настройки. Если политика выполнения по умолчанию не соответствует вашим потребностям, вы можете создать экземпляр ThreadPoolExecutor с помощью его конструктора и настроить его по своему усмотрению; вы можете обратиться к исходному коду класса Executors , чтобы увидеть политики выполнения для конфигураций по умолчанию и использовать их в качестве отправной точки. Класс ThreadPoolExecutor имеет несколько конструкторов, наиболее общий из которых показан в листинге 8.2. public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue ThreadFactory threadFactory, RejectedExecutionHandler handler) { ... } Листинг 8.2 Общий конструктор класса ThreadPoolExecutor 8.3.1 Создание и удаление потока Корневой размер пула, максимальный размер пула и время жизни определяют создание и удаление потоков. Корневой размер является целевым размером; реализация пытается поддерживать пул в пределах этого размера, даже если нет никаких задач для выполнения 97 , и не будет создавать потоков больше, чем заданное количество, пока рабочая очередь не заполнится 98 . Максимальный размер пула представляет собой верхнюю границу количества одновременно активных потоков в пуле. Поток, который простаивает дольше установленного времени ожидания активности (keep-alive times), становится кандидатом на поглощение и может быть завершен, если текущий размер пула превысит корневой размер. Настраивая размер корневого пула и время ожидания активности, можно поощрять пул к освобождению ресурсов, используемых простаивающими потоками, что делает их доступными для более полезной работы. (Подобно прочему, это компромисс: поглощение бездействующих потоков приводит к дополнительной задержке в связи с необходимостью создания потоков, когда позже потоки должны будут быть созданы при увеличении спроса.) Фабричный метод newFixedThreadPool устанавливает и корневой, и максимальный размер пула до требуемых значений, при этом определяя бесконечное время ожидания; фабричный метод newCachedThreadPool устанавливает максимальный размер пула в значение Integer.MAXVALUE и корневой размер пула равным нулю, с таймаутом в одну минуту, создавая эффект бесконечно расширяемого пула потоков, который будет сжиматься снова при 97 Когда класс ThreadPoolExecutor изначально создаётся, корневые потоки запускаются не сразу, а по мере отправки задач, если только вы не вызовите метод prestartAllCoreThreads 98 Иногда у разработчиков возникает соблазн установить корневой размер пула равным нулю, чтобы рабочие потоки в конечном итоге поглощались, и, как следствие, не препятствовали завершению работы JVM, но это может вызвать странное поведение в пулах потоков, которые не используют класс SynchronousQueue для своей рабочей очереди (например, как это делает newCachedThreadPool ). Если пулу уже установлен корневой размер, класс ThreadPoolExecutor создаст новый поток только тогда, когда рабочая очередь заполнится. Таким образом, задачи, отправленные в пул потоков с рабочей очередью, имеющей любую емкость и нулевой размер ядра, не будут выполняться до тех пор, пока очередь не заполнится, что обычно является нежелательным. Начиная с Java 6, метод allowCoreThreadTimeOut позволяет отправить всем потокам в пуле указание на завершение выполнения по тайм-ауту; включите эту функцию с корневым размером пула равным нулю, если вам требуется ограниченный пул потоков с ограниченной рабочей очередью, но при этом все потоки будут поглощаться, когда для них не будет работы. |