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

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

Механизмы блокировки и ожидания. С тегами java, параллелизм, многопоточность.

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

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

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()) {
            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;
    }
}

Эта версия на самом деле довольно ужасна. Мы никогда не сможем оправдать то, что делаем что-то подобное в нашем коде:

while(!mustPlay);

Занятое ожидание

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

Я расскажу вам забавную историю об этом. Когда я реализовал пример кода для этой серии, я оставил свою СТОРОНУ открытой с запущенным приложением (и напряженным ожиданием, конечно). В результате моя батарея, которая обычно работает от 6 до 8 часов, разрядилась менее чем за 2 часа. Давайте подумаем о последствиях такого рода неправильного дизайна в серьезных корпоративных приложениях!

Запирающийся

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

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

Версия 3: Встроенный Запирающийся

Самый старый механизм в Java для создания критических разделов известен как Внутренняя блокировка или Блокировка монитора. Каждый объект, созданный в Java, имеет связанную блокировку (встроенную блокировку или блокировку монитора), которую можно использовать для целей исключения в наших потоках с помощью ключевого слова synchronized :

//...
Object myObject = new Object();
//...
synchronized(myObject) {
    //critical section
}

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

В Интернете полно примеров использования synchronized , поэтому я не буду вдаваться в подробности здесь в отношении лучших практик. Я просто добавлю несколько моментов для рассмотрения:

  • Довольно обычно синхронизировать это ( синхронизировано(это) ), поэтому собственный экземпляр использует себя в качестве блокировки для защиты своих клиентов от проблем параллелизма. Однако мы должны быть очень осторожны, если мы делаем это, потому что наши клиенты могут синхронизироваться в одном и том же экземпляре, вызывая взаимоблокировку
  • Лучшей практикой было бы использовать частную блокировку (например, ту, которую мы использовали в приведенном выше фрагменте кода). Таким образом, мы не раскрываем используемый механизм блокировки внешнему миру, потому что он инкапсулирован в собственном классе
  • синхронизированный имеет еще одну цель, помимо исключения, и это видимость . Таким же образом, что ключевое слово изменчивый обеспечивает немедленную видимость измененной переменной, синхронизированный обеспечивает видимость состояния объекта, используемого в качестве блокировки (поэтому область действия, если она больше). Эта видимость гарантируется моделью памяти Java

Механизмы ожидания

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

Каждый объект предоставляет метод, wait() . Когда этот метод вызывается потоком, он заставляет Планировщик потоков приостанавливать его, изменяя его состояние на “Ожидание”, т. Е.:

//the thread state at this point is Running
i++
lock.wait(); // => thread state changes to Waiting

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

synchronized (lock) {
    try {
        while (!condition)
            lock.wait();

        //Execute code after waiting for condition

    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }

В коде мы видим, что:

  1. Необходимо получить блокировку объекта, который мы хотим вызвать ждать
  2. Это ожидание подразумевает, что мы ждем “чего-то”. Это что-то является условием (предикатом условия), которое может быть истинным до того, как придется ждать. Поэтому мы проверяем это условие перед вызовом ждать
  3. Ожидание выполняется в цикле, а не в предложении if по нескольким причинам. Самый важный из них известен как “ложные пробуждения”. Из его названия легко понять, что это такое, иногда поток выходит из состояния “Ожидания”, и никто не просит его об этом, поэтому может случиться так, что условие еще не выполнено и это должно снова подождать
  4. И последнее, но не менее важное: подождите бросает Исключение InterruptedException , которое мы обрабатываем, как обсуждалось в первой части этой серии

Увидев это, мы видим, что поток переходит в состояние “Ожидания”, ожидая, что условие будет истинным, но кто-то должен сообщить, что один или несколько потоков должны проснуться… Ну, это делается с помощью методов уведомлять и Уведомлять всех , который, как вы, вероятно, поняли, запрашивает один или все потоки, ожидающие блокировки, чтобы проснуться и проверить состояние. Идиома такова:

synchronized(lock) {
    //....
    condition = true;
    lock.notifyAll(); //or lock.notify();
}

Опять же, нам нужно обладать блокировкой, чтобы вызывать методы на объекте. Написано множество статей об использовании уведомлять и Уведомлять всех , это зависит от намерения каждого приложения. Точно, использование notifyAll является одной из причин, по которой ожидание выполнения условия выполняется в цикле, в случаях, когда только один поток из всех ожидающих потоков может выполняться, когда условие выполняется.

Давайте наконец посмотрим, как будет выглядеть наше приложение для пинг-понга после применения всех концепций, которые мы видели:

public class Player implements Runnable {

    private final String text;

    private final Object lock;

    private Player nextPlayer;

    private volatile boolean play = false;

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

    @Override
    public void run() {
        while(!Thread.interrupted()) {
            synchronized (lock) {
                try {
                    while (!play)
                        lock.wait();

                    System.out.println(text);

                    this.play = false;
                    nextPlayer.play = true;

                    lock.notifyAll();

                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        }
    }

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

    public void setPlay(boolean play) {
        this.play = play;
    }
}

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

Основной класс не слишком сильно отличается от последней версии первого сообщения в серии:

public class Game {

    public static void main(String[] args) {

        Object lock = new Object();

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

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

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

        player1.setPlay(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!");
    }

}

Версия 4: Явные блокировки и условия

Java предоставляет в своем API параллелизма интерфейс, Блокировка , которая позволяет нам реализовать те же механизмы исключения, которые мы видели до использования встроенных блокировок, но с помощью другого подхода.

Основная реализация Замок является повторной блокировкой . Он имеет такое название, потому что блокировки в Java являются реентерабельными. Это означает, что, как только поток получает блокировку, если тот же поток пытается получить ту же блокировку, он преуспевает. Мы собираемся реализовать те же примеры, что и выше, используя этот API.

Критические разделы

Lock lock = new ReentrantLock();
//...
lock.lock();
try {
    //critical section...
} finally {
    lock.unlock();
}

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

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

  • tryLock() : мы пытаемся получить блокировку, но поток не блокируется, если это не удается
  • справедливость: мы можем создать блокировку как “справедливую”. По умолчанию блокировки в Java несправедливы, поэтому для получения блокировки можно выбрать ожидающий поток, даже если он прибыл последним. При честной блокировке будет реализована блокировка FIFO

Я бы посоветовал вам взглянуть на API для получения дополнительной информации.

Механизмы ожидания

Реализация этих механизмов осуществляется с помощью класса Условие . Создание Условия экземпляра должно быть выполнено из Блокировка :

Condition condition = lock.newCondition();

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

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

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

Давайте посмотрим на аспект наших приложений после добавления Заблокировать и Условие API:

public class Player implements Runnable {

    private final String text;

    private final Lock lock;
    private final Condition myTurn;
    private Condition nextTurn;

    private Player nextPlayer;

    private volatile boolean play = false;

    public Player(String text,
                  Lock lock) {
        this.text = text;
        this.lock = lock;
        this.myTurn = lock.newCondition();
    }

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

            try {
                while (!play)
                    myTurn.awaitUninterruptibly();

                System.out.println(text);

                this.play = false;
                nextPlayer.play = true;

                nextTurn.signal();
            } finally {
                lock.unlock();
            }
        }
    }

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

    public void setPlay(boolean play) {
        this.play = play;
    }
}

Мы видим, что использование Условия делает код более читабельным. Мы использовали метод Ожидание прерывания , это гарантирует, что оба игрока сыграют последний ход, когда основной поток прерывает потоки (ПРИМЕЧАНИЕ: Есть проблема с этим подходом, которая обсуждается в разделе комментариев).

public class Game {

    public static void main(String[] args) {
        Lock lock = new ReentrantLock();

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

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

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

        player1.setPlay(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!");
    }
}

Бонус, масштабируемый до N игроков

Давайте посмотрим, насколько легко масштабировать игру для многих игроков, чтобы они передавали мяч между собой в определенном порядке (это больше не было бы пинг-понгом:)). Результат приложения будет примерно таким:

Game starting...!
player0
player1
player2
player3
player4
player5
...
Game finished!

Оказывается, нам не нужно изменять класс Игрок вообще! Действительно, каждый игрок должен знать только о следующем игроке в игре, поэтому единственные изменения должны быть сделаны в классе Игра :

public class GameScale {

    public static final int NUM_PLAYERS = 6;

    public static void main(String[] args) {
        Lock lock = new ReentrantLock();

        int length = NUM_PLAYERS;

        Player[] players = new Player[length];

        for (int i=0; i < length; i++) {
            Player player = new Player("player"+i, lock);
            players[i] = player;
        }

        for (int i=0; i < length - 1; i++) {
            players[i].setNextPlayer(players[i+1]);
        }
        players[length - 1].setNextPlayer(players[0]);

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

        players[0].setPlay(true);

        //Threads creation
        Thread[] threads = new Thread[length];
        for (int i=0; i < length; i++) {
            Thread thread = new Thread(players[i]);
            threads[i] = thread;
            thread.start();
        }

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

        //Tell the players to stop
        for (Thread thread : threads) {
            thread.interrupt();
        }

        //Don't progress main thread until all players have finished
        try {
            for (Thread thread : threads) {
                thread.join();
            }
        }  catch (InterruptedException e) {
            e.printStackTrace();
        }

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

}

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

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

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

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