Страницы

Поиск по вопросам

суббота, 21 декабря 2019 г.

Расположение методов в многопоточном приложении

#java #многопоточность


При изучении многопоточности в Java столкнулся с непонятным для меня явлением при
перестановке двух методов местами внутри одного блока if.

Ниже пример, в котором запускаются 10 потоков (ссылка на пример кода в GitHub). Их
задача в порядке возрастания Id произвести некую работу, в данном случае вывести сообщение,
содержащее Id потока и значение переменной в вспомогательном классе. 

Пример содержит три класса:  

Класс Main.
В нем в цикле создается 10 экземпляров класса ThreadExample, которые, в свою очередь
являются элементами массива. Id первого экземпляра потока, до его старта заносится
в переменную вспомогательного класса. После этого поочередно в цикле вызывается метод
start всех потоков. 

package lan.example.thread_example;

public class Main {

    static HelperSingletonBillPugh INSTANCE_TEST_THREAD = HelperSingletonBillPugh.getInstance();    
    static ThreadExample[] myThreads = new ThreadExample[10];
    static final int COUNT = 10;

    public static void main(String[] args) { 

        for (int i = 0; i < COUNT; i++) {
            myThreads[i] = new ThreadExample();
             if (i == 0) {              
                INSTANCE_TEST_THREAD.setCurentThreadId(myThreads[i].getId());
             }
             myThreads[i].start();
        }
    }
}


Класс HelperSingletonBillPugh.
Это вспомогательный хелпер класс, реализованрый по типу синглетона способом Била
Пью. Вроде такой тип реализации синглетона считается потокобезопасным. Класс содержит
переменную curentThreadId типа long с Id потока и публичные методы setCurentThreadId(long
threadId), getCurentThreadId(), incremenCurentThreadId() для изменения, чтения и инкремента
значения этой переменной.  

package lan.example.thread_example;

public class HelperSingletonBillPugh {

    private HelperSingletonBillPugh() {
    }

    private static class SingletonHandler {
        private static final HelperSingletonBillPugh INSTANCE = new HelperSingletonBillPugh();
    }

    public static HelperSingletonBillPugh getInstance() {
        return SingletonHandler.INSTANCE;
    }

    private long curentThreadId = 0L;   // Id потока, который должен выполнить вывод
сообщения

    public long getCurentThreadId() {
        return this.curentThreadId;
    }

    public void setCurentThreadId(long threadId) {
        this.curentThreadId = threadId;
    }

    public void incremenCurentThreadId() {
        this.curentThreadId++;
    }
}


Класс ThreadExample.
Класс наследник Thread. В нем вечный цикл, с паузами и постоянным сравнением значения
getId() потока с переменной curentThreadId экземпляра вспомогательного класса HelperSingletonBillPugh.
Если равенство выполняется, то выводится сообщение, переменная curentThreadId увеличивается
на 1 и поток завершает работу.

package lan.example.thread_example;

public final class ThreadExample extends Thread {

    static HelperSingletonBillPugh INSTANCE_TEST_THREAD = HelperSingletonBillPugh.getInstance();

    @Override
    public void run() {

        while (true) {

            if (INSTANCE_TEST_THREAD.getCurentThreadId() == this.getId()) {

                // Раскомментируй следующую строку
                //INSTANCE_TEST_THREAD.incremenCurentThreadId();
                System.out.println("Print " + this.getName()
                        + " ### ID:" + this.getId()
                        + " ### getCurentThreadId: " + INSTANCE_TEST_THREAD.getCurentThreadId());

                // Закоментируй следующую строку
                INSTANCE_TEST_THREAD.incremenCurentThreadId();

                break;
            }
        }
    }
}


В данном примере кода сообщения выводятся последовательно и потоки завершают свою
работу, все стабильно от запуска к запуску, вот такой вывод:

Print Thread-1 ### ID:11 ### getCurentThreadId: 11
Print Thread-2 ### ID:12 ### getCurentThreadId: 12
Print Thread-3 ### ID:13 ### getCurentThreadId: 13
Print Thread-4 ### ID:14 ### getCurentThreadId: 14
Print Thread-5 ### ID:15 ### getCurentThreadId: 15
Print Thread-6 ### ID:16 ### getCurentThreadId: 16
Print Thread-7 ### ID:17 ### getCurentThreadId: 17
Print Thread-8 ### ID:18 ### getCurentThreadId: 18
Print Thread-9 ### ID:19 ### getCurentThreadId: 19
Print Thread-10 ### ID:20 ### getCurentThreadId: 20


Но стоит только переставить местами метод INSTANCE_TEST_THREAD.incremenCurentThreadId()
и метод System.out.println() (в коде помечено где убрать и добавить коммент) и результат
становится не стабильным и для меня не понятным, хотя перестановка осуществляется внутри
блока if. Если быть точнее, то он не всегда стабильный, то есть, несколько запусков
может пройти вполне корректно. Вот вывод одного из запусков после перестановки методов
местами:

Print Thread-1 ### ID:11 ### getCurentThreadId: 12
Print Thread-2 ### ID:12 ### getCurentThreadId: 15
Print Thread-6 ### ID:16 ### getCurentThreadId: 17
Print Thread-5 ### ID:15 ### getCurentThreadId: 16
Print Thread-4 ### ID:14 ### getCurentThreadId: 15
Print Thread-7 ### ID:17 ### getCurentThreadId: 21
Print Thread-3 ### ID:13 ### getCurentThreadId: 21
Print Thread-8 ### ID:18 ### getCurentThreadId: 21
Print Thread-9 ### ID:19 ### getCurentThreadId: 21
Print Thread-10 ### ID:20 ### getCurentThreadId: 21


Из примера видно, что потоки выводят сообщения хаотично и значение переменной getCurentThreadId
временами отличается от Id текущего потока более чем на 1. Что-же происходит при изменении
местами двух этих методов? Дело в том, что когда System.out.println() находится перед
INSTANCE_TEST_THREAD.incremenCurentThreadId(), то у меня нет ни одного непредсказуемого
результата. Можно ли считать это стабильной работой или все-таки эта стабильность обманчива
и при неких обстоятельствах пример отработает не корректно?

P/S. На всякий случай уточню. Я немного имею представление про Java Memory Model,
оператор volatile, блок synchronize, про кэши данных и атомарность, правда глубоких
знаний пока нет, но добиться гарантированной стабильной работы примера скорее всего
смогу. Интересует именно понимание того, какие-такие серьезные изменения происходят
при перестановки местами этих двух методов, так влияющие на результат работы примера.
И, как следствие, можно ли считать стабильной работу примера в первом случае и почему?
Проверял пример на разных операционных системах (Linux Mint 32 и 64 bit, Windows 10
64 bit), но правда только с Oracle JDK.
    


Ответы

Ответ 1



Дело в том, что операция инкремента не является атомарной, а состоит из двух операций: чтения текущего значения и записи увеличенного значения. Если развернуть ее, то цикл в первом сценарии будет выглядеть так: while (...) { <чтение id> // вывод на консоль <чтение id> // инкремент, шаг 1 <запись id> // инкремент, шаг 2 } Такое расположение операций вкупе с хитрым условием while по сути служит локом, дающим доступ к телу цикла только одному потоку. Сначала только первый поток заходит внутрь цикла, а остальные просто прокручивают его ("блокируются"). Когда первый поток записывает увеличенный id и выходит из цикла, то уже он начинает прокручивать цикл ("блокируется"), а второй поток заходит внутрь. И так по очереди для всех потоков. Именно за счет инкремента все потоки получают этот доступ последовательно, согласно своим номерам. Важным здесь является то, что запись id по сути освобождает "лок" текущего потока и "разрешает" выполнение другого потока. Пока она идет последней в теле цикла, проблем не возникает. Во втором сценарии цикл выглядит следующим образом: while (...) { <чтение id> // инкремент, шаг 1 <запись id> // инкремент, шаг 2 <чтение id> // вывод на консоль } Теперь мы видим, что "лок" освобождается чуть раньше. К чему это может привести? К тому, что между записью id и вторым чтением id могут начать исполняться другие потоки, поскольку "лок" свободен и доступ к телу цикла может получить другой поток. Т.е. возникает классическая гонка, в результате которой может происходить следующее: поток на втором чтении id может получить уже обновленное значение (это объясняет, например, почему Thread-2 выводит 15, а не 13) вывод в консоль может перепутаться из-за того, что другие потоки "встревают" сразу после записи id while (...) { <чтение id> // инкремент, шаг 1 <запись id> // инкремент, шаг 2 <возможное исполнение других потоков> <чтение id> // вывод на консоль (потенциально уже нового значения) } Сценарий выполнения для приведенного вами в вопросе лога может выглядеть так: все потоки начинают выполнение все потоки кроме потока 1 "блокируются" на условии while поток 1 читает id (=11) поток 1 увеличивает id (=12) поток 2 "разблокируется" поток 2 читает id (=12) поток 1 читает id и выводит на консоль (=12) поток 1 "засыпает" поток 2 увеличивает id (=13) поток 3 "разблокируется" поток 3 читает id (=13) поток 3 увеличивает id (=14) поток 4 "разблокируется" поток 4 читает id (=14) поток 4 увеличивает id (=15) поток 2 читает id и выводит на консоль (=15) поток 2 "засыпает" поток 5 "разблокируется" поток 5 читает id (=15) поток 5 увеличивает id (=16) поток 6 "разблокируется" поток 6 читает id (=16) поток 6 увеличивает id (=17) поток 6 читает id и выводит на консоль (=17) поток 6 "засыпает" ...и так далее

Ответ 2



На самом деле всё достаточно просто. Вы создаёте несколько потоков, а данный метод разрешает выполняться только 1 с нужным id. Этот id содержится в переменной INSTANCE_TEST_THREAD.getCurentThreadId(). Теперь вы меняете местами. Может быть такая ситуация: 1 поток зашёл, значение 1 1 поток увеличил значение до 2 2 поток зашёл значение 2 2 поток увеличил значение до 3 1 поток вывел 3 (!!) 2 поток вывел 3 (!!) 3 поток увеличил значение до 4 3 поток вывел 4. Думаю идею вы поняли. Поменяв местами мы делаем гонку потоков с непредсказуемым порядком выполнения, по сути сделав весь код синхронизации нерабочим.

Комментариев нет:

Отправить комментарий