Главная » Java, Советы » Не попадайте в зависимость от планировщика потоков

0

 

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

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

Основной прием, позволяющий сократить количество запущенных потоков, заключается в том, что каждый поток должен выполнять небольшую порцию работы, а затем ждать наступления некоего условия (используя Object.wait) либо истечения не которого интервала времени (используя Thгead.sleep). Потоки не должны находиться в состоянии активного ожидания (busy-wait), регулярно проверяя структуру данных и ожидая, пока с теми что-то произойдет. Помимо того, что программа при этом становится чувствительной к причудам планировщика, активное ожидание может значительно повысить нагрузку на процессор, соответственно уменьшая количество полезной работы, которую на той же машине могли бы выполнить остальные процессы.

Указанным рекомендациям отвечает пример с очередью заданий (статья 49): если предоставляемый клиентом метод pгocessItem имеет правильное поведение, то поток, обрабатывающий очередь, большую часть своего времени, пока очередь пуста, будет проводить в ожидании монитора. В качестве яркого примера того, как поступать не следует, рассмотрим еще одну неправильную реализацию класса WoгkQueue, в которой вместо работы с монитором используется активное ожидание:

 

 //Ужасная программа: использует активное ожидание

// вместо метода Object.wait!

public abstract class WoгkQueue {

private final List queue = new LinkedList();

private boolean stopped = false;

 

import java.util.*;

public abstract class WorkQueue {

    private final List queue = new LinkedList();

    private boolean stopped = false;

protected WorkQueue() { new WorkerThread().start(); }

    public final void enqueue(Object workItem) {

        synchronized (queue) { queue.add(workItem); }

    }

    public final void stop()  {

        synchronized (queue) { stopped = true; }

   }

    protected abstract void processItem(Object workItem)

        throws InterruptedException;

    private class WorkerThread extends Thread {

        public void run() {

            final Object QUEUE_IS_EMPTY = new Object();

            while (true) {  // Главный цикл

                Object workItem = QUEUE_IS_EMPTY;

                synchronized (queue) {

                    if (stopped)

                        return;

                    if (!queue.isEmpty())

                        workItem = queue.remove(0);

                }

                    if (workItem != QUEUE_IS_EMPTY) {

                    try {

                        processItem(workItem);

                    } catch (InterruptedException e) {

                        return;

                    }

                }

            }

        }

    }

}

 

Чтобы дать некоторое представление о цене, которую вам придется платить за такую реализацию, рассмотрим микротест, в котором создаются две очереди заданий и затем некое задание передается между ними в ту и другую сторону. (Запись о задании, передаваемая из одной очереди в другую,- это ссылка на первую очередь, которая служит адресом возврата.) Перед началом измерений программа выполняется десять секунд, ‘чтобы система "разогрелась"; в течение следующих десяти секунд подсчитывается количество циклических переходов из очереди в очередь. На моей

 

машине окончательный вариант WorkQueue (статья 49) показал 23 000 циклических переходов в секунду, тогда как представленная выше некорректная реализация демонстрирует 17 переходов в секунду.

 

class PingPongQueue extends WorkQueue {

    volatile int count = 0;

 

    protected void processItem(final Object sender) {

        count++;

        WorkQueue recipient = (WorkQueue) sender;

        recipient.enqueue(this);

    }

}

public class WaitQueuePerf {

    public static void main(String[] args) {

        PingPongQueue q1 = new PingPongQueue();

        PingPongQueue q2 = new PingPongQueue();

        q1.enqueue(q2); // Запускаем систему

 

        // Дадим системе 10 с на прогрев

        try {

            Thread.sleep(10000);

        } catch (InterruptedException e) {

        }

 

        // Подсчитаем количество переходов за 10 с

        int count = q1.count;

        try {

            Thread.sleep(10000);

        } catch (InterruptedException e) {

        }

        System.out.println(q1.count – count);

 

        q1.stop();

        q2.stop();

    }

}

 

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

 

 

Столкнувшись с тем, что программа едва работает из-за того, что некоторые потоки, по сравнению с остальными, не получают достаточно процессорного времени, не поддайтесь искушению  “исправить" программу, добавив в нее вызовы Thread.yield. Вы можете заставить программу работать, однако полученное приложение не будет переносимым с точки зрения производительности. Вызовы yield, улучшающие производительность в одной реализации JVM, в другой ее ухудшают, а в третьей не оказывают никакого влияния. У Thread.yield нет строгой семантики. Лучше измените структуру приложения таким образом, чтобы сократить количество параллельно выполняемых потоков.

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

Метод Thread.yield следует использовать для того, чтобы искусственно увеличить степень распараллеливания программы на время тестирования. Благодаря просмотру большей части пространства состояний программы, это помогает; найти ошибки и удостовериться в правильности системы. Этот прием доказал свою высокую эффективность в выявлении скрытых ошибок многопоточной обработки.

Подведем итоги. Ваше приложение не должно зависеть от планировщика потоков. Иначе оно не будет ни устойчивым, ни переносимым. Как следствие, лучше не связывайтесь с методом Thread.yield и приоритетами. Эти функции предназначены единственно для планировщика. Их можно дозировано при менять для улучшения качества сервиса в уже работающей реализации, но ими нельзя пользоваться для "исправления" программы, которая едва работает.

 

Источник: Джошуа Блох, Java TM Эффективное программирование, Издательство «Лори»

По теме:

  • Комментарии