Семинар 3 (27.02.2017)

Многопоточное (параллельное) программирование.

Язык Java позволяет создавать несколько потоков управления в рамках одной программы. Эти потоки (threads) могут исполняться параллельно (на разных ядрах процессора) или "квази-параллельно" - на одном ядре процессора за счёт переключения контекста процессов.

Рассмотрим для примера задачу, в которой 2 потока одновременно увеличивают значение общего счётчика. В примере используется класс Thread, который позволяет запускать код, определенный внутри метода run() в отдельном потоке.

// Вариант 1.

#ckage voronkov.increment;

public class Main {
    public static void main(String[] args) {
        Counter c = new Counter();
        Thread t1 = new IncThread(c);
        Thread t2 = new IncThread(c);
        // "запускаем" потоки
        t1.start();
        t2.start();
        try {
            // дожидаемся их завершения
            t1.join();
            t2.join();
            System.out.printf("count = %d", c.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

// Класс для хранения счётчика
class Counter {
    private int value;
    public Counter() {}
    public int get() {
        return value;
    }
    public void set(int v) {
        value = v;
    }
}

// Класс-поток увеличивающий значение счётчика 100000 раз
class IncThread extends Thread {
    private Counter counter;
    public IncThread(Counter n) {
        counter = n;
    }
    public void run() {
        for (int i=0; i<times; i++) {
            int value = counter.get();
            counter.set(value + 1);
        }
    }
    public static final int times = 100000000;
}

Запуская такую программу, мы можем ожидать, что итоговое значение счётчика будет равно 100000000 (10000000 + 10000000), но в реальности получится что-то вроде:

count = 174285374

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

thread1: int value = counter.get(); // value = 0
thread1: counter.set(value + 1); // counter = 1
thread2: int value = counter.get(); // value = 1
thread2: counter.set(value + 1); // counter = 2

когда счётчик увеличивается как ожидается. Но возможна и такая ситуация:

thread1: int value = counter.get(); // value = 0
thread2: int value = counter.get(); // value = 0
thread1: counter.set(value + 1); // counter = 1
thread2: counter.set(value + 1); // counter = 1

В этом случае итоговое значение счетчика равно 1, а не двум. Ошибки такого рода называют "состояние гонки" ("race condition").

Попробуем это исправить. Добавим вместо метода set "атомарный" метод увеличения счётчика addOne:

class Counter {
    private int value;
    public Counter() {}
    public int get() {
        return value;
    }
    public void addOne() {
        value++;
    }
}

class IncThread extends Thread {
    private Counter counter;
    public IncThread(Counter n) {
        counter = n;
    }
    public void run() {
        for (int i=0; i<times; i++) {
            counter.addOne();
        }
    }
    public static final int times = 10000000;
}

Кажется, что всё должно работать, но проблема заключается в том, что value++ это не атомарная операция. С точки зрения Java это эквиватентно int newvalue = value + 1; value = newvalue;, т.е. две операции, а не одна, и между ними возможно переключение контекста потоков.

Попробуем сделать метод addOne действительно атомарным. Для этого необходимо добавить synchronized в объявление метода:

    public synchronized void addOne() {
        value++;
    }

Только один synchronized метод объекта может исполняться в каждый момент времени. Если один поток пытается вызвать synchronized метод объекта в то время, как некоторый synchronized метод уже исполняется другим потоком - первый поток блокируется (останавливается) до окончания выполнения этого метода в другом потоке. На этот раз итоговое значение счётчика правильное:

count = 10000000

Другой метод синхронизации в многопоточной программе: блокировка на уровне объектов. Перед тем, как вызвать метод объекта, мы пытаемся захватить "монитор" объекта при помощи synchronized-блока. (если монитор уже захвачен, то выполнение потока блокируется до освобождения монитора):

class Counter {
    // ...
    public void addOne() {
        value++;
    }
}

class IncThread extends Thread {
    // ...
    public void run() {
        for (int i=0; i<times; i++) {
            synchronized (counter) { // блокирование объекта counter
                counter.addOne(); // безопасная работа с counter
            } // освобождение counter при выходе из блока
        }
    }
    public static final int times = 10000000;
}

Проблема "Производителя-потребителя"

В многопоточных системах часто встречается т.н. проблема производителя и потребителя ("Producer-consumer problem"): один поток "производит" некоторый ресурс, который необходим другому потому, при этом второй поток не может продолжать свою работу, если ресурс недоступен.

В Java эту проблему можно решить при помощи методов wait и notify, которые позволяют "подождать", если ресурс недоступен и "сообщить" остальным потокам, что сотояние ресурса изменилось. (примеры кода можно найти в интернете).