При участии Тима Перлса, Джошуа Блоха, Джозева Боубира, Дэвида Холмса и Дага Ли Параллельное программирование в java на
Скачать 4.97 Mb.
|
Глава 6 Выполнение задач Большинство параллельных приложений организованы вокруг выполнения задач: абстрактных, дискретных единиц работы. Разделение работы выполняемой приложением на задачи, упрощает организацию программы, облегчает восстановление после возникновения ошибок путём предоставления естественных границ транзакций, а также способствует параллелизму, предоставляя естественную структуру для распараллеливания работы. 6.1 Выполнение задач в потоках Первым шагом в организации программы вокруг выполнения задачи является определение разумных границ задачи (task boundaries). В идеале задачи представляют собой независимые активности: работа, которая не зависит от состояния, результата или побочных эффектов, возникающих при выполнении других задач. Независимость облегчает реализацию параллелизма, так как независимые задачи могут выполняться параллельно при наличии достаточных вычислительных ресурсов. Для большей гибкости при планировании и балансировке нагрузки, каждая задача также должна представлять собой небольшую часть вычислительной мощности приложения. Серверные приложения должны демонстрировать хорошую пропускную способность и высокую скорость отклика при нормальной нагрузке. Поставщики приложений хотят, чтобы приложения поддерживали как можно больше пользователей, чтобы снизить затраты на обеспечение работы для каждого пользователя; в свою очередь, пользователи хотят быстро получать ответы. Кроме того, приложения должны демонстрировать плавную деградацию по мере перегрузки, а не просто падать под большой нагрузкой. Грамотный выбор границ задачи в сочетании с разумной политикой выполнения задачи (см. раздел 6.2.2 ) может помочь в достижении этих целей. Большинство серверных приложений предлагают естественный выбор границ задач: индивидуальные запросы клиентов. Веб-серверы, почтовые серверы, файловые серверы, контейнеры EJB и серверы баз данных принимают запросы от удаленных клиентов через сетевые подключения. Использование отдельных запросов в качестве границ задачи обычно определяет как независимость, так и соответствующий размер задачи. Например, на результат отправки сообщения почтовому серверу не влияют другие сообщения, обрабатываемые в тот же момент времени, и обработка одного сообщения обычно требует очень малого процента от общей вычислительной емкости сервера. 6.1.1 Последовательное выполнение задач Существует ряд возможных политик для планирования задач в приложении, некоторые из которых лучше других используют потенциал параллелизма. Проще всего выполнять задачи последовательно в одном потоке. Однопоточный Веб- сервер из листинга 6.1 обрабатывает свои задачи - HTTP-запросы поступающие на 80 порт - последовательно. Детали обработки запроса не так важны; нас интересуют характеристики параллелизма при использовании различных политик планирования. class SingleThreadWebServer { public static void main(String[] args) throws IOException { ServerSocket socket = new ServerSocket(80); while (true) { Socket connection = socket.accept(); handleRequest(connection); } } } Листинг 6.1.Веб-сервер с последовательной обработкой входящих запросов Класс SingleThreadedWebServer прост и теоретически корректен, но плохо работает в продуктиве, поскольку может обрабатывать за раз только один запрос. Основной поток чередует операции принятия соединений и обработки связанных запросов. В то время как сервер обрабатывает запрос, новые соединения вынуждены ожидать, пока он не завершит обработку текущего запроса и вновь не вызовет метод accept . Такой подход мог бы иметь право на существование, если бы обработка запроса была настолько быстрой и эффективной, что метод handleRequest возвращал бы управление немедленно, но это не соответствует ни единому описанию веб-сервера в реальном мире 71 Обработка веб-запроса включает в себя сочетание вычислений и операций ввода/вывода. Сервер должен выполнить операции ввода/вывода с сокетом (socket) для чтения запроса и записи ответа, сокет может быть заблокирован из-за перегрузки сети или проблем с подключением. Он также может выполнять файловый ввод/вывод или выполнять запросы к базе данных, которые также могут блокироваться. В однопоточном сервере блокировка не только задерживает выполнение текущего запроса, но и препятствует обработке отложенных запросов. Если один запрос блокируется в течение необычно длительного промежутка времени, пользователи могут подумать, что сервер недоступен, поскольку он не отвечает. В то же время ресурсы используются крайне неэффективно, так как ЦП простаивает, в то время как единственный поток ждет завершения операций ввода/вывода для завершения обработки запроса. В серверных приложениях последовательная обработка редко обеспечивает хорошую пропускную способность или высокую скорость отклика. Есть исключения - например, когда задач мало и они “долгоиграющие”, или когда сервер обслуживает одного клиента, который делает только один запрос за раз - но большинство серверных приложений не используют такой подход в своей работе 72 6.1.2 Явное создание потоков для задач Более эффективный подход заключается в создании нового потока для обслуживания каждого входящего запроса, как показано в классе ThreadPerTaskWebServer , в листинге 6.2. class ThreadPerTaskWebServer { 71 Речь идёт о продуктиве. Домашние странички в расчёт не берутся. 72 В некоторых ситуациях последовательная обработка может предложить преимущества простоты или безопасности; большинство фрэймворков GUI обрабатывают задачи последовательно, используя единственный поток. Мы вернемся к последовательной модели в главе 9. public static void main(String[] args) throws IOException { ServerSocket socket = new ServerSocket(80); while (true) { final Socket connection = socket.accept(); Runnable task = new Runnable() { public void run() { handleRequest(connection); } }; new Thread(task).start(); } } } Листинг 6.2 Веб сервер, запускающий новый поток для обработки каждого входящего запроса Класс ThreadPerTaskWebServer схож по своей структуре с однопоточной версией - основной поток по-прежнему чередует прием входящих соединений и отправку запросов. Разница заключается в том, что для каждого входящего соединения главный цикл создает новый поток для обработки запроса, а не обрабатывает его в основном потоке. Это позволяет сделать три основных вывода: • Обработка запросов выводится за рамки основного потока, позволяя главному циклу быстрее возвращаться к ожиданию последующих входящих соединений. Это позволяет принимать новые подключения до завершения обработки предыдущих запросов, что приводит к улучшению отзывчивости. • Задачи могут обрабатываться параллельно, что позволяет обслуживать несколько запросов одновременно. Это может привести к повышению пропускной способности, если имеется несколько процессоров или в случае, если задачи по любым причинам зависят от блокировок, например завершение операции ввода/вывода, захват блокировки или ожидание доступности ресурсов. • Код обработки задач должен быть потокобезопасным, так как он может вызываться одновременно для нескольких задач. В свете умеренной нагрузки, подход “один поток на одну задачу” имеет преимущества по сравнению с последовательным выполнением. До тех пор, пока скорость поступления запросов не превысит возможности сервера по обработке запросов, этот подход обеспечивает лучшую отзывчивость и пропускную способность. 6.1.3 Недостатки неограниченного создания потоков Однако, использование в продуктиве подхода “один поток на одну задачу” имеет некоторые практические недостатки, особенно при создании огромного числа потоков: Накладные расходы на поддержание жизненного цикла. Создание потока и его завершение не даются даром. Фактические накладные расходы варьируются в зависимости от платформы, но создание потока занимает время, добавляю задержку ко времени обработки запроса, и требует некоторой обработки активностями JVM и ОС. Если запросы выполняются часто и являются легковесными, как в большинстве серверных приложений, создание нового потока для каждого запроса может привести к потреблению значительных вычислительных ресурсов сервера. Потребление ресурсов. Активные потоки потребляют системные ресурсы, особенно память. Если количество запускаемых потоков превышает количество доступных процессоров, потоки бездействуют. Множество свободных потоков может связать много оперативной памяти, приводя к повышению нагрузки на механизм сборщика мусора, а также, располагая множеством потоков, конкурирующих за процессоры, можно нарваться на другие эксплуатационные расходы. Если количество потоков достаточно для загрузки всех процессоров, создание большего количества потоков не только не поможет, а напротив, может даже ухудшить ситуацию. Масштабируемость. Существует ограничение на количество создаваемых потоков. Ограничение зависит от платформы и зависит от таких факторов, как параметры вызова JVM, запрошенный размер стека в конструкторе потоков и ограничения на потоки, накладываемые базовой операционной системой 73 . При достижении этого предела, наиболее вероятным результатом является возбуждение исключения OutOfMemoryError . Попытка восстановления после такой ошибки очень рискованна; гораздо проще структурировать программу так, чтобы избежать выхода за доступные пределы. До определенного момента увеличение количества потоков может повышать пропускную способность, но при превышении некоторого уровня, создание большего числа потоков приведёт к замедлению работы приложения, а создание слишком большого числа потоков может привести к краху всего приложения. Чтобы избежать опасности, необходимо установить некоторые ограничения на количество потоков, создаваемых приложением, и тщательно протестировать приложение, чтобы гарантировать, что даже при достижении лимита ресурсы не закончатся. Проблема подхода “один поток на одну задачу” заключается в том, что ничто не ограничивает количество созданных потоков, кроме скорости, с которой удаленные пользователи могут отправлять HTTP-запросы. Подобно прочим угрозам параллелизму, создание неограниченного количества потоков может работать очень хорошо во время прототипирования и в процессе разработки, но проблемы, как правило, возникают только при развертывании приложения в продуктиве и под большой нагрузкой. Таким образом, как злонамеренные пользователи, так и вполне рядовые, могут обрушить веб-сервер, если траффик когда-либо достигнет определенного порогового значения. Для серверного приложения, которое должно обеспечивать высокую доступность и плавное снижение нагрузки, это очень серьезная проблема. 73 В 32-разрядных машинах, основным ограничивающим фактором является размер адресного пространства для стеков потока. Каждый поток поддерживает два стека выполнения: один для кода Java и один для машинного кода. Типичные значения по умолчанию для JVM порождают общий размер стека около 0.5 мегабайта (Вы можете изменить это значение с помощью флага JVM -Xss или с использованием конструктора потока). Если вы разделите размер стека потока на 2 32 , то получите ограничение в несколько тысяч или десятков тысяч потоков. Другие факторы, такие как ограничения ОС, могут налагать более строгие ограничения. 6.2 Фрэймворк Executor Задачи - это логические единицы работы, а потоки - это механизм, с помощью которого задачи могут выполняться асинхронно. Мы рассмотрели две политики для выполнения задач с помощью потоков - выполнение задач последовательно в одном потоке и выполнение каждой задачи в своем собственном потоке. У обоих подходов есть серьезные ограничения: последовательный подход страдает от плохой отзывчивости и пропускной способности, а подход “один поток на одну задачу” страдает от плохого управления ресурсами. В главе 5 мы увидели, как использовать ограниченные очереди, для предотвращения переполнения памяти приложения. Пулы потоков предлагают те же преимущества, только для управления потоками, а пакет java.util.concurrent обеспечивает гибкую реализацию пула потоков в рамках фреймворка Executor . Основной абстракцией для выполнения задач в библиотеках классов Java является не поток, а интерфейс Executor , показанный в листинге 6.3. public interface Executor { void execute(Runnable command); } Листинг 6.3 Интерфейс Executor Может быть, Executor и является простым интерфейсом, но он формирует основу гибкого и мощного фреймворка для асинхронного выполнения задач, поддерживающего широкий спектр политик выполнения задач. Он предоставляет стандартное средство, отделяющее отправку задачи от выполнения задачи, описывая задачи с помощью интерфейса Runnable . Реализации интерфейса Executor также обеспечивают поддержку жизненного цикла и хуки для добавления сбора статистики, управления приложениями и мониторинга. Реализация интерфейса Executor основана на шаблоне производитель- потребитель, где активности, которые предоставляют задачи, являются производителями (производят единицы работы, которые должны быть выполнены), а выполняющие задачи потоки, являются потребителями (потребители этих единиц работы). Использование интерфейса Executor обычно является самым простым способом реализации архитектуры производитель-потребитель в вашем приложении. 6.2.1 Пример: веб-сервер использующий фреймворк Executor Создать веб-сервер с помощью фреймворка Executor очень просто. Класс TaskExecutionWebServer из листинга 6.4 заменяет жестко закодированное создание потока использованием экземпляра интерфейса Executor . В этом случае мы используем одну из стандартных реализаций интерфейса Executor - пул фиксированного размера на 100 потоков. class TaskExecutionWebServer { private static final int NTHREADS = 100; private static final Executor exec = Executors.newFixedThreadPool(NTHREADS); public static void main(String[] args) throws IOException { ServerSocket socket = new ServerSocket(80); while (true) { final Socket connection = socket.accept(); Runnable task = new Runnable() { public void run() { handleRequest(connection); } }; exec.execute(task); } } } Листинг 6.4 Веб-сервер использующий пул потоков В классе TaskExecutionWebServer отправка задачи обрабатывающей запрос отделена от её выполнения с помощью экземпляра Executor , и ее поведение можно изменить, просто подставив другую реализацию интерфейса Executor Изменение реализаций или конфигурации экземпляра Executor гораздо менее агрессивный способ, чем изменение способа отправки задач; конфигурирование экземпляра Executor , как правило, является одноразовым действием и может быть легко введено в конфигурацию во время развертывания, в то время как код отправки задачи, как правило, разбросан по всей программе и, следовательно, изменить его значительно сложнее. Мы можем легко изменить поведение класса TaskExecutionWebServer так, чтобы он начал вести себя также, как класс ThreadPerTaskWebServer , просто заменив реализацию интерфейса Executor вариантом, создающим новый поток для каждого запроса. Написать такую реализацию интерфейса Executor тривиальная задача, как показано в классе ThreadPerTaskExecutor в листинге 6.5. public class ThreadPerTaskExecutor implements Executor { public void execute(Runnable r) { new Thread(r).start(); }; } Листинг 6.5 Реализация интерфейса Executor , запускающая для каждой задачи новый поток Точно так же легко написать реализацию интерфейса Executor , которая заставляла бы класс TaskExecutionWebServer вести себя как же, как однопоточная версия, синхронно выполняющая каждую задачу перед возвратом из метода execute , как показано в классе WithinThreadExecutor , в листинге 6.6. public class WithinThreadExecutor implements Executor { public void execute(Runnable r) { r.run(); }; } Листинг 6.6 Реализация интерфейса Executor , синхронно выполняющая задачи в вызывающем потоке 6.2.2 Политики выполнения Значение отделения отправки задачи от её выполнения заключается в том, что оно позволяет легко определять и впоследствии без особых трудностей изменять политику выполнения для данного класса задач. Политика выполнения определяет “что, где, когда и как" выполняет задачи, в том числе: • В каком потоке будет выполняться задача? • В каком порядке должны выполняться задачи (FIFO, LIFO, в порядке приоритета)? • Сколько задач может выполняться параллельно? • Сколько задач может быть поставлено в очередь ожидания выполнения? • Если задача должна быть отклонена из-за перегрузки системы, какая задача должна быть выбрана на роль жертвы и каким образом должно быть уведомлено приложение? • Какие действия следует предпринять до или после выполнения задачи? Политики выполнения представляют собой инструмент управления ресурсами, и оптимальная политика зависит от доступных вычислительных ресурсов и требований к качеству обслуживания. Ограничивая число параллельно выполняющихся задач, можно гарантировать, что приложение не потерпит крах в связи с исчерпанием доступных ресурсов или не столкнётся с проблемами с производительностью из-за конкуренции за доступ к дефицитным ресурсам 74 Разделение спецификации политики выполнения и механизма отправки задач позволяет выбрать политику выполнения во время развертывания, соответствующую доступному оборудованию. Всякий раз, когда вы видите код типа: new Thread(runnable).start() и вы думаете, что в какой-то момент вам может понадобиться более гибкая политика выполнения, серьезно подумайте о ее замене с использованием интерфейса Executor 6.2.3 Пулы потоков Пул потоков, как следует из названия, управляет однородным пулом рабочих потоков. Пул потоков тесно связан с рабочей очередью, содержащей задачи, ожидающие выполнения. Рабочие потоки живут достаточно просто: запрашивают следующую задачу из рабочей очереди, выполняют ее и возвращаются к ожиданию других задач. Выполнение задач в пуле потоков предлагает ряд преимуществ по сравнению с подходом "один поток на одну задачу". Повторное использование существующего потока вместо создания нового, амортизирует создание потока и снижает затраты, 74 Такое поведение соответствует одной из ролей монитора транзакций в корпоративном приложении: он может регулировать скорость, с которой транзакции могут выполняться, чтобы не исчерпывать или не перегружать ограниченные ресурсы. в случае обработки множества запросов. В качестве дополнительного бонуса, поскольку рабочий поток часто уже существует на момент получения запроса, затраты времени, связанные с созданием потока, не задерживают выполнение задачи, тем самым повышая отзывчивость приложения. Правильно настроив размер пула потоков, можно получить достаточно потоков, чтобы процессоры были загружены, но при этом не так много, чтобы приложение исчерпывало доступную память или терпело крах из-за конкуренции за ресурсы между потоками. Библиотека классов предоставляет гибкую реализацию пула потоков, наряду с некоторыми полезными предопределенными конфигурациями. Пул потоков можно создать, вызвав один из статических фабричных методов класса Executors : |