Теория и практика Java: Пулы потоков и очередь действий (исходники)

Брайан Гетц

Почему поток пулов?

Работа многих серверных приложений, таких как Web-серверы, серверы базы данных, серверы файлов или почтовые серверы, связана с совершением большого количества коротких задач, поступающих от какого-либо удаленного источника. Запрос прибывает на сервер определенным образом, например, через сетевые протоколы (такие как HTTP, FTP или POP), через очередь JMS, или, возможно, путем опроса базы данных. Независимо от того, как запрос поступает, в серверных приложениях часто бывает, что обработка каждой индивидуальной задачи кратковременна, а количество запросов большое.

Одной из упрощенных моделей для построения серверных приложений является создание нового потока каждый раз, когда запрос прибывает и обслуживание запроса в этом новом потоке. Этот подход в действительности хорош для разработки прототипа, но имеет значительные недостатки, что стало бы очевидным, если бы вам понадобилось развернуть серверное приложение, работающее таким образом. Один из недостатков подхода "поток-на-запрос" состоит в том, что системные издержки создания нового потока для каждого запроса значительны; a сервер, создавший новый поток для каждого запроса, будет тратить больше времени и потреблять больше системных ресурсов, создавая и разрушая потоки, чем он бы тратил, обрабатывая фактические пользовательские запросы.

В дополнение к издержкам создания и разрушения потоков, активные потоки потребляют системные ресурсы. Создание слишком большого количества потоков в одной JVM (виртуальной Java-машине) может привести к нехватке системной памяти или пробуксовке из-за чрезмерного потребления памяти. Для предотвращения пробуксовки ресурсов, серверным приложениям нужны некоторые меры по ограничению количества запросов, обрабатываемых в заданное время.

Поток пулов предлагает решение и проблемы издержек жизненного цикла потока, и проблемы пробуксовки ресурсов. При многократном использовании потоков для решения многочисленных задач, издержки создания потока распространяются на многие задачи. В качестве бонуса, поскольку поток уже существует, когда прибывает запрос, задержка, произошедшая из-за создания потока, устраняется. Таким образом, запрос может быть обработан немедленно, что делает приложение более быстрореагирующим. Более того, правильно настроив количество потоков в пуле потоков, вы можете предотвратить пробуксовку ресурсов, заставив любые запросы, если их количество выходит за определенные пределы, ждать до тех пор, пока поток не станет доступным, чтобы его обработать.

Альтернативы пулов потоков

Пулы потока - это далеко не единственный способ использовать множественные потоки в серверном приложении. Как упоминалось ранее, иногда довольно разумно генерировать новый поток для каждой новой задачи. Однако, если частота создания задач высока, а их средняя продолжительность низка, порождение нового потока для каждой задачи приведет к проблемам с производительностью.

Другая распространенная модель организации поточной обработки - наличие единого фонового потока и очереди задач для задач определенного типа. AWT (набор инструментальных средств для абстрактных окон) и Swing используют эту модель, в которой есть поток событий GUI (графического интерфейса пользователя), и вся работа, вызывающая изменения в пользовательском интерфейсе, должна выполняться в этом потоке. Однако, поскольку существует только один AWT-поток, нежелательно выполнять задачи в потоке AWT, завершение которого может занять значительное количество времени. В результате, приложения Swing часто требуют дополнительных потоков "исполнителя" для решения долгосрочных, связанных с пользовательским интерфейсом (UI) задач.

Подходы "поток-на-задачу" и "единый фоновый поток" могут довольно хорошо функционировать в определенных ситуациях. Подход "поток-на-задачу" хорошо работаeт с небольшим количеством долгосрочных задач. Подход "единый фоновый поток" функционирует довольно хорошо, если не важна предсказуемость распределения (scheduling predictability), как в случае низкоприоритетных фоновых (low-priority background) задач. Однако большая часть серверных приложений ориентированы на обработку большого количества краткосрочных задач или подзадач, и нужно иметь механизм для эффективного осуществления этих задач с небольшими издержками, а также какую-либо меру управления ресурсами и предсказуемостью времени выполнения. Пулы потока дают следующие преимущества.

Очереди действий

С точки зрения того, как применяются пулы потоков, термин "пул потоков" несколько обманчив, "очевидное" применение пула потоков в большинстве случаев дает не тот результат, который мы хотели бы. Термин "пул потоков" связан с платформой Java и, возможно, является артефактом менее объектно-ориентированного подхода. Однако термин по-прежнему широко используется.

Конечно, мы могли легко применять класс пула потоков, в котором класс клиентов ожидал бы доступного потока, передавал бы задачу этому потоку для исполнения и затем возвращал бы поток к пулу, когда все окончено; но этот подход имеет несколько потенциально нежелательных эффектов. Что, например, если пул пуст? Любая вызывающая сторона, которая предприняла попытку передать задачу потоку пулов, обнаружила бы, что пул пуст, и ее поток заблокировался бы, ожидая доступного потока пулов. Часто одной из причин, по которой нам бы хотелось использовать фоновые потоки, является необходимость предотвращения блокировки подающего (submitting) потока. Проталкивание блокировки к вызывающей стороне, что происходит в случае с "очевидным" применением пула потоков, может закончиться возникновением таких же проблем, какие мы пытались решить.

То, что нам обычно нужно - это рабочая очередь в сочетании с фиксированной группой рабочих потоков, в которой используются wait (ожидание)() и notify (уведомление)(), чтобы сигнализировать ожидающим потокам, что прибыла новая работа. Очередь действий главным образом применяется как связанный список с присоединенным объектом монитора. Листинг 1 показывает пример простой, объединенной в пул очереди. Эта модель, используя очередь Runnable (работоспособных) объектов, является обычной для планировщиков и очередей действий, хотя нет особой необходимости из-за Thread API использовать интерфейс Runnable.

public class WorkQueue
{
    private final int nThreads;
    private final PoolWorker[] threads;
    private final LinkedList queue;

    public WorkQueue(int nThreads)
    {
        this.nThreads = nThreads;
        queue = new LinkedList();
        threads = new PoolWorker[nThreads];

        for (int i=0; i<nThreads; i++) {
            threads[i] = new PoolWorker();
            threads[i].start();
        }
    }

    public void execute(Runnable r) {
        synchronized(queue) {
            queue.addLast(r);
            queue.notify();
        }
    }

    private class PoolWorker extends Thread {
        public void run() {
            Runnable r;

            while (true) {
                synchronized(queue) {
                    while (queue.isEmpty()) {
                        try
                        {
                            queue.wait();
                        }
                        catch (InterruptedException ignored)
                        {
                        }
                    }

                    r = (Runnable) queue.removeFirst();
                }

                // If we don't catch RuntimeException, 
                // the pool could leak threads
                try {
                    r.run();
                }
                catch (RuntimeException e) {
                    // You might want to log something here
                }
            }
        }
    }
}

Возможно, вы заметили, что в реализации задач в Листинге 1 используется notify() вместо notifyAll(). Большинство экспертов советуют использовать notifyAll() вместо notify(), и не напрасно: есть некоторые моменты риска, ассоциирующиеся с notify(), его использование является верным в определенных специфических условиях. С другой стороны, в случае надлежащего использования, notify() имеет более желательные рабочие характеристики, чем notifyAll() ; в особенности то, что notify() вызывает гораздо меньше переключений процессов, что является важным в работе серверных приложений.

Пример рабочей очереди в Листинге 1 соответствует требованиям безопасного использования notify(). Поэтому продолжайте использовать его в своей программе, но соблюдайте большую осторожность, используя notify() в других ситуациях.

Возможный риск при использовании пулов потоков

Хотя пул потоков - мощный механизм для структурирования многопоточных приложений, он связан с определенным риском. Приложения, построенные при помощи пулов потоков, подвержены всем тем параллельным рискам, что и любое другое многопоточное приложение, как, например, ошибки синхронизации и взаимоблокировка, и также нескольким другим рискам, специфических также для пулов потоков, таких, как зависимая от пулов взаимоблокировка, пробуксовка ресурсов и рассеяние потока.

Взаимоблокировка

В любом многопоточном приложении есть риск взаимоблокировки. Говорят, что набор процессов или потоков взаимоблокирован , когда каждый ожидает события, которое может быть вызвано другим процессом. Простейший случай взаимоблокировки - когда поток A полностью блокирует объект X и ожидает блокировки объекта Y, в то время как поток B полностью блокирует объект Y и ожидает блокировки объекта X. И если нет какого-либо способа вырваться из ожидания блокирования (что блокирующее устройство Java не поддерживает), взаимоблокированные потоки будут ожидать вечно.

Поскольку взаимоблокировка - риск в любой многопоточной программе, пулы потоков предполагают другую возможность взаимоблокировки, где все потоки пулов осуществляют задачи, которые блокируются в ожидании результатов другой задачи в очереди, но эта задача не может запуститься, поскольку нет доступного незанятого потока. Это может случиться, когда пулы потоков используются для проведения имитационных экспериментов, включающих большое количество взаимодействующих объектов, имитационные объекты могут посылать запросы друг другу, которые потом выполняются как задачи из очереди, и запрашиваемый объект синхронно ожидает ответа.

Пробуксовка ресурсов

Одно из преимуществ пулов потоков состоит в том, что они обычно хорошо выполняют операции, имеющие отношение к альтернативным распределяющим механизмам, некоторые из которых мы уже обсудили. Но это верно только в том случае, если размер пула потоков настроен правильно. Потоки потребляют многочисленные ресурсы, включая память и другие системные ресурсы. Кроме памяти, требующейся для объекта Thread, каждый поток требует двух списков вызовов выполнения, которые могут быть большими. В дополнение к этому, JVM, возможно, создаст "родной" поток для каждого Java-потока, что связано с потреблением дополнительных системных ресурсов. Наконец, поскольку распределяющиеся издержки переключения между потоками малы, для многих потоков переключение процессов может стать значительным замедлением в работе программы.

Если пул потоков слишком велик, ресурсы, потребляемые этими потоками, могут в значительной степени повлиять на работу системы. Время будет напрасно потрачено на переключение между потоками, и если у вас потоков больше, чем необходимо, это может вызвать проблемы ресурсного голодания, так кака потоки пулов потребляют ресурсы, которые могли бы быть более эффективно использованы другими задачами. В дополнение к ресурсам, использующимися самими потоками, работа, выполняемая по обслуживанию запросов, может также требовать дополнительных ресурсов, таких как соединения JDBC, сокеты, или файлы. Эти ресурсы также ограничены, и слишком много параллельных запросов могут вызвать сбои, такие как невозможность определении места JDBC-соединения.

Параллельные ошибки

Пулы потоков и другие механизмы ведения очередей опираются на методы wait() и notify(), которые могут быть коварны. Уведомления, если их неправильно закодировать, могут быть утеряны, в результате потоки остаются в состоянии бездействия, даже если в очереди есть работа, которая должна быть выполнена. При использовании этих средств необходимо соблюдать большую осторожность; даже эксперты делают ошибки при работе с ними. Еще лучше использовать проверенную в работе реализацию, например пакет util.concurrent, который обсуждается в разделе Нет необходимости писать свое.

Утечка потока

Существенный риск в самых разных пулах потоков заключается в утечке потока, которая случается, когда поток удаляется из пула для выполнения задачи, но не возвращается в пул, когда задача выполнена. Во-первых, это происходит, когда задача выдает RuntimeException или Error. Если класс пула их не воспринимает, тогда поток просто прекращается и размер пула потоков сокращается на один. Когда это произойдет достаточное количество раз, пул потоков окажется пустым, и система заблокируется, потому, что нет потоков, доступных для осуществления задач.

Задачи, которые постоянно блокируются, например, те, что потенциально ждут ресурсов, которые могут и не стать доступными, или ждут ввода со стороны пользователя, который, возможно, ушел домой, могут также вызвать эффект, эквивалентный утечке потока. Если поток постоянно занимается такой задачей, он фактически удален из пула. Таким задачам следует либо выделять собственный поток, либо ограничить время ожидания.

Перегрузка запросами

Бывает, что сервер просто переполнен запросами. В этом случае мы, возможно, не захотим, чтобы каждый входящий запрос помещался в нашу рабочую очередь, потому, что задачи, ожидающие выполнения, могут потреблять слишком много системных ресурсов и вызвать ресурсное голодание. Вам решать, что делать в таком случае; в некоторых ситуациях вы, возможно, можете просто выбросить запрос, надеясь, что протоколы более высокого уровня повторят запрос позже, или, возможно, вы захотите отказаться от запроса, сообщив, что сервер временно занят.

Руководство по эффективному использованию пулов потоков

Пулы потоков могут быть чрезвычайно эффективным способом структурирования серверных приложений, при условии, что вы следуете некоторым простым правилам:

  • Не ставьте в очередь задачи, которые одновременно ожидают результатов других задач. Это может вызвать взаимоблокировку описанной выше формы, где все потоки заняты задачами, которые в свою очередь ожидают результатов от задач в очереди, не выполняющихся, поскольку все потоки заняты.
  • Будьте осторожны, используя объединенные в пулы потоки для потенциально продолжительных операций. Если программа должна ждать ресурс, такой как осуществление I/O (ввода - вывода), укажите максимальное время ожидания, а затем выведите или возвратите в очередь задачу для выполнения в более позднее время. Это гарантирует, что некоторый прогресс будет достигнут путем освобождения потока для задач, которые могли бы успешно осуществиться.
  • Разберитесь в своих задачах. Чтобы эффективно настроить размер пула потоков, вам нужно понять, что за задачи в очереди, и что они выполняют. Ограничены ли они возможностями процессора? Есть ли ограничения ввода/вывода? От ваших ответов зависит, как вы настроите свое приложение. Если у вас есть разные классы задач с радикально отличающимися характеристиками, возможно, имеет смысл иметь несколько рабочих очередей для разных типов задач, так, чтобы каждый пул можно было соответственно настроить.

Настройка размера пула

Настраивая размер пула потоков, важно избежать двух ошибок: слишком мало потоков или слишком много потоков. К счастью, для большинства приложений спектр между слишком большим и слишком малым количеством потоков довольно широк.

Если вы помните, есть два основных преимущества в организации поточной обработки сообщений в приложениях: возможность продолжения процесса во время ожидания медленных операций, таких, как I/O (ввод - вывод), и использование возможностей нескольких процессоров. В приложениях с ограничением по скорости вычислений, функционирующих на N-процессорной машине, добавление дополнительных потоков может улучшить пропускную способность, по мере того как количество потоков подходит к N, но добавление дополнительных потоков свыше N не оправдано. Действительно, слишком много потоков разрушают качество функционирования из-за дополнительных издержек переключения процессов

Оптимальный размер пула потоков зависит от количества доступных процессоров и природы задач в рабочей очереди. На N-процессорной системе для рабочей очереди, которая будет выполнять исключительно задачи с ограничением по скорости вычислений, вы достигните максимального использования CPU с пулом потоков, в котором содержится N или N+1 поток.

Для задач, которые могут ждать осуществления I/O (ввода - вывода) -- например, задачи, считывающей HTTP-запрос из сокета - вам может понадобиться увеличение размера пула свыше количества доступных процессоров, потому, что не все потоки будут работать все время. Используя профилирование, вы можете оценить отношение времени ожидания (WT) ко времени обработки (ST) для типичного запроса. Если назвать это соотношение WT/ST, для N-процессорной системе вам понадобится примерно N*(1+WT/ST) потоков для полной загруженности процессоров.

Использование процессора - не единственный фактор, важный при настройке размера пула потоков. По мере возрастания пула потоков, можно столкнуться с ограничениями планировщика, доступной памяти, или других системных ресурсов, таких, как количество сокетов, дескрипторы открытого файла, или каналы связи базы данных

Нет необходимости писать свое

Даг Ли создал отличную открытую библиотеку утилит параллельности, util.concurrent, которая включает объекты-мьютексы, семафоры, коллекции, такие как очереди и хэш-таблицы, хорошо работающие при параллельном доступе, и несколько реализаций рабочей очереди. Класс PooledExecutor из этого пакета - эффективная, широко использующаяся, правильная реализация пула потоков, основанного на рабочей очереди. Прежде чем пытаться писать собственное программное обеспечение, которое вполне может оказаться неправильным, вы можете рассмотреть использование некоторых утилит в util.concurrent.

Библиотека util.concurrent также служит вдохновителем для JSR 166, рабочей группы Java Community Process (JCP), которая будет производить набор параллельных утилит для включения в библиотеку классов Java в пакете java.util.concurrent, и которая готовит выпуск Java Development Kit 1.5.

Заключение

Пул потока - полезный инструмент для организации серверов приложений. Он довольно простой по сути, но есть некоторые моменты, с которыми следует быть осторожными во время применения и использования, такие как взаимоблокировка, пробуксовка ресурсов, и сложности, связанные с wait() и notify(). Если вам потребуется пул потоков для вашего приложения, рассмотрите использование одного из классов Executor из util.concurrent, такой как PooledExecutor, вместо создания нового с нуля. Если вам нужно создать потоки для решения краткосрочных задач, вам определенно следует рассмотреть использование вместо этого пула потоков.


Страница сайта http://test.interface.ru
Оригинал находится по адресу http://test.interface.ru/home.asp?artId=9759