Рубрики
Без рубрики

Многопоточность на Java для чайников (часть 1)

Потоки, видимость и прерывание. Помеченный как java, параллелизм, многопоточность.

В этой серии мы рассмотрим несколько основных концепций для расширения возможностей параллельного программирования на Java. Я знаю, Интернет полон ссылок, руководств, статей … по этой теме, но мое намерение здесь состоит в том, чтобы использовать другую точку зрения. Вместо того, чтобы подробно объяснять теорию и/или использовать чрезмерно сложные примеры, я попытаюсь использовать практический подход, разработав простое приложение с нуля, которое будет развиваться, одновременно внедряя различные концепции и API, которые нам необходимо знать для реализации параллельных приложений на Java. Надеюсь, я достигну своей цели:)

Применение: Настольный теннис

Вот так просто. Наше Java-приложение будет отображать в стандартном выводе и попеременно тексты “ping”/”pong”, а также верхний и нижний колонтитулы для начала и завершения игры:

Game starting...!
ping
pong
ping
pong
//....
Game finished!

Там будут два игрока (или актера), которые будут печатать тексты “пинг” и “понг”. Актер “пинг” сыграет первым.

Нулевая версия: один поток

Первая версия будет выполняться в одном потоке выполнения, так что здесь не будет никакого параллельного программирования:). Первые версии, которые мы внедрим, завершатся после того, как оба игрока примут участие фиксированное количество раз, скажем, 10 (настройте в константе MAX_TURNS ).

Это код, реализующий класс Player для нашей первой версии:

public class Player {

    private final String text;

    private int turns = Game.MAX_TURNS;

    private Player nextPlayer;

    public Player(String text) {
        this.text = text;
    }

    public void play() {
        if (!gameFinished()) {
            System.out.println(text);
            turns--;
            nextPlayer.play();
        }
    }

    private boolean gameFinished() {
        return turns == 0;
    }

    public void setNextPlayer(Player nextPlayer) {
        this.nextPlayer = nextPlayer;
    }

}

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

public class Game {

    public static final int MAX_TURNS = 10;

    public static void main(String[] args) {

        Player player1 = new Player("ping");
        Player player2 = new Player("pong");

        player1.setNextPlayer(player2);
        player2.setNextPlayer(player1);

        System.out.println("Game starting...!");

        player1.play();

        System.out.println("Game finished!");
    }
}

Мы видим, что в конструкторе Player нет параметра для другого игрока. Это связано с тем, что у нас есть циклическая ссылка, поэтому второй проигрыватель, возможно, еще не создан, и мы должны ввести его через setter.

Атрибут текст в Игрок объявляется финальным |/. В параллельных приложениях (на самом деле во всех приложениях) рекомендуется объявлять атрибуты класса как final если мы знаем, что они не будут изменены. Мало того, что наш код будет более надежным, это гарантирует видимость наших атрибутов между потоками, концепция, известная как "Безопасная публикация". Вы можете прочитать хорошую дискуссию о безопасной публикации здесь . Идя немного дальше, мы всегда должны стараться проектировать наши классы как неизменяемые , даже если в нашем примере это невозможно.

Версия первая: Игроки как потоки

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

Во-первых, давайте сделаем так, чтобы наши классы Player реализовали Запускаемый (дополнительная информация здесь ):

public class Player implements Runnable {

    private final String text;

    private int turns = Game.MAX_TURNS;

    private Player nextPlayer;

    private boolean mustPlay = false;

    public Player(String text) {
        this.text = text;
    }

    @Override
    public void run() {
        while(!gameFinished()) {

            while (!mustPlay); //Busy Waiting

            System.out.println(text);
            turns--;

            this.mustPlay = false;
            nextPlayer.mustPlay = true;

        }
    }

    private boolean gameFinished() {
        return turns == 0;
    }

    public void setNextPlayer(Player nextPlayer) {
        this.nextPlayer = nextPlayer;
    }

    public void setMustPlay(boolean mustPlay) {
        this.mustPlay = mustPlay;
    }
}

Важным методом является run , он содержит цикл, который повторяется до тех пор, пока количество ходов для игрока не закончится. Кроме того, после каждой итерации появляется Занят ожиданием пока не настанет очередь игрока действовать. Когда это происходит, игрок печатает текст, он устанавливает для своего собственного must Play значение false и сообщает другому игроку играть.

Большая разница между этой версией и предыдущей заключается в том, что в первой версии один игрок сказал другому играть, используя прямой вызов метода (т.Е. play ), в то время как здесь это происходит, изменяя значение флага ( mustPlay ), который каждый игрок проверяет индивидуально и постоянно.

Давайте посмотрим, как наш класс Игра выглядела бы так, как сейчас:

public class Game {

    public static final int MAX_TURNS = 10;

    public static void main(String[] args) {

        Player player1 = new Player("ping");
        Player player2 = new Player("pong");

        player1.setNextPlayer(player2);
        player2.setNextPlayer(player1);

        System.out.println("Game starting...!");

        player1.setMustPlay(true);  //Plays first!!!

        Thread thread2 = new Thread(player2);
        thread2.start();
        Thread thread1 = new Thread(player1);
        thread1.start();

        System.out.println("Game finished!");
    }

}

Самое большое отличие заключается в том, что потоки запускаются отдельно, и мы отвечаем только за установку флага должен играть адекватно. На самом деле, я специально запустил поток player2 первым, чтобы подтвердить, что даже в этом случае первое напечатанное сообщение – “ping”.

Давайте посмотрим, что произойдет, когда мы запустим приложение:

Game starting...!
ping
Game finished!

Это не то, чего мы ожидали…что случилось? Теперь в нашем приложении есть три потока:

  • Основной поток ( Game.main )
  • поток игрока 1
  • поток игрока 2

Проблема в том, что основной поток завершается, как только запускаются остальные потоки, поэтому, хотя остальные потоки продолжают свое выполнение и завершаются правильно, наша среда IDE не отражает выходные данные, сгенерированные в этих двух дополнительных потоках, создавая двойную путаницу, потому что мы видим сообщение “Игра завершена!”. Чтобы избежать этого, есть простое решение, используя метод join :

public class Game {

    public static final int MAX_TURNS = 10;

    public static void main(String[] args) {

        Player player1 = new Player("ping");
        Player player2 = new Player("pong");

        player1.setNextPlayer(player2);
        player2.setNextPlayer(player1);

        System.out.println("Game starting...!");

        player1.setMustPlay(true);

        Thread thread2 = new Thread(player2);
        thread2.start();
        Thread thread1 = new Thread(player1);
        thread1.start();

        //Wait until both threads finish
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Game finished!");
    }

}

Этот метод определяет ход выполнения потока до завершения потока, в котором он вызывает метод join . join – это блокирующий метод (т.Е. он блокирует поток, который его вызывает), и методы блокировки могут быть прерваны, поэтому он выдает исключение ckecked/| InterruptedException . Мы подробнее поговорим об осложнениях прерывания потоков позже в этом посте.

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

Game starting...!
ping

Наше приложение заблокировано и вообще не работает!

Что случилось? Одной из главных проблем параллельного программирования является “видимость”. Java гарантирует видимость атрибутов между потоками только в том случае, если мы следуем набору рекомендаций, регулируемых моделью памяти Java , конкретно с помощью отношения “происходит до”. Согласно Википедии, в Java это соотношение гласит, что:

В частности, в Java отношение “происходит до” является гарантией того, что память, записанная оператором A, видна оператору B, то есть этому оператору A завершает свою запись до того, как оператор B начнет свое чтение

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

Чтобы устранить эту проблему, нам придется ввести модификатор volatile . Этот модификатор указывает JVM, что атрибут, скорее всего, будет совместно использоваться потоками, поэтому операции чтения никоим образом не должны кэшироваться, во всех случаях доступ к основной памяти. Кроме того, операции записи должны выполняться атомарно и сразу же становиться видимыми.

Таким образом, наш код должен быть изменен таким образом:

public class Player implements Runnable {
    //...
    private volatile boolean mustPlay = false;
    //....
}

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

Версия 2: Бесконечная игра

Вместо того, чтобы играть фиксированное количество ходов, мы собираемся заставить обоих игроков играть вечно. Или лучше сказать, до тех пор, пока основной поток не захочет. Чтобы достичь этого, нам придется использовать одну из функций, предлагаемых Java, для прерывания потока. Давайте посмотрим, как будет выглядеть наш класс Game :

public class Game {

    public static void main(String[] args) {

        Player player1 = new Player("ping");
        Player player2 = new Player("pong");

        player1.setNextPlayer(player2);
        player2.setNextPlayer(player1);

        System.out.println("Game starting...!");

        player1.setMustPlay(true);

        Thread thread2 = new Thread(player2);
        thread2.start();
        Thread thread1 = new Thread(player1);
        thread1.start();

        //Let the players play!
        try {
            Thread.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        //Tell the players to stop
        thread1.interrupt();
        thread2.interrupt();

        //Wait until players finish
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Game finished!");
    }

}

Мы видим, что как только запускаются оба потока проигрывателя, основной класс на некоторое время переходит в спящий режим (2 мс), и как только он просыпается (фактически возвращается в состояние “running”), он запрашивает завершение обоих потоков проигрывателя.

Я повторяю, он запрашивает . Единственное, что происходит, когда метод interrupt вызывается в потоке, – это то, что флаг interrupted устанавливается в значение true в этом потоке. Собственный поток несет ответственность за то, чтобы действовать, когда он сочтет это необходимым, выполнять задачи по очистке и завершать работу. Но это вполне допустимо, если поток решает ничего не делать и продолжает свое выполнение (хотя, конечно, это было бы не очень правильно). Способ узнать значение этого флага – использовать метод Thread.interrupted() , поэтому наш класс Игрок изменится на:

public class Player implements Runnable {

    private final String text;

    private Player nextPlayer;

    private volatile boolean mustPlay = false;

    public Player(String text) {
        this.text = text;
    }

    @Override
    public void run() {
        while(!Thread.interrupted()) {  //Was I interrupted?

            while (!mustPlay);

            System.out.println(text);

            this.mustPlay = false;
            nextPlayer.mustPlay = true;

        }
    }

    public void setNextPlayer(Player nextPlayer) {
        this.nextPlayer = nextPlayer;
    }

    public void setMustPlay(boolean mustPlay) {
        this.mustPlay = mustPlay;
    }
}

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

Версия 2b: Подробнее о прерывании

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

В некоторых случаях мы видели, что некоторые методы в классе Поток ( присоединиться , спать ,…) бросить InterruptedException . Это происходит, когда поток прерывается, находясь в заблокированном состоянии после вызова любого из этих методов (например, another Thread.join() ). В этом случае происходит то, что метод ( join , чтобы следовать примеру) устанавливает флаг interrupted в значение false в потоке и выдает InterruptedException . Я не большой поклонник проверяемых исключений, но я думаю, что это один из немногих случаев, когда их использование более чем оправдано.

Давайте изменим класс Player немного, поэтому, как только наступает его очередь играть, он переходит в спящий режим на 1 мс перед печатью текста:

public class Player implements Runnable {
    //...
    @Override
    public void run() {
        while(!Thread.interrupted()) {
            while (!mustPlay);

            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace(); //1
            }

            System.out.println(text);

            this.mustPlay = false;
            nextPlayer.mustPlay = true;

        }
    }
    //...
}

Если мы продолжим использовать одну и ту же версию класса Game , очень вероятно, что игра будет длиться вечно. Почему? Потому что, если прерывание происходит, когда поток проигрывателя находится в спящем режиме, метод sleep проглотит состояние “прервано” перед выдачей исключения, и, учитывая, что мы печатаем ошибку только в//1, цикл не обнаруживает прерванное состояние и продолжается вечно.

Решением этой проблемы является восстановление состояния потока до прерванного:

@Override
public void run() {
    while(!Thread.interrupted()) {
        while (!mustPlay);

        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            //I was interrupted, propagate the state  
            Thread.currentThread().interrupt();
        }

        System.out.println(text);

        this.mustPlay = false;
        nextPlayer.mustPlay = true;

    }
}

В общем, мы должны быть очень осторожны, когда обращаемся с InterruptedException . Другой рекомендуемой стратегией, которая подразумевала бы изменение логики в нашем методе run , является повторное создание исключения, чтобы оно обрабатывалось где-то в другом месте. Мы не должны никогда проглотите исключение.

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

(Весь код можно найти в этом репозитории GitHub )

Оригинал: “https://dev.to/raulavila/multithreading-in-java-for-dummies-part-1”