concurrency

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

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

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



Shared State



Работу с разделяемым состоянием я покажу на примере вычисления чисел фибоначчи.
Формула для вычисления выглядит так:

f(0) = 0
f(1) = 1
f(n) = f(n - 1) + f(n - 2) , n >= 2


В первую очередь определим интерфейс:

public interface FibonacciGenerator<T> {
    /**
     * Следующее сгенерированное значение
     */
    T next();

    /**
     * Текущее значение в генераторе
     */
    public T val();
}


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

Locking



Первый способ сделать класс корректно работающим в многопоточной среде — это использовать блокировки и объявить все методы synchronized. Примером может служить класс Vector.

public class IntrinsicLock implements FibonacciGenerator<BigInteger> {

    private BigInteger curr = BigInteger.ONE;
    private BigInteger next = BigInteger.ONE;

    @Override
    public synchronized BigInteger next() {
        BigInteger result = curr;
        curr = next;
        next = result.add(next);
        return result;
    }

    @Override
    public synchronized BigInteger val() {
        return curr;
    }

}


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

Fine-Grained locking



Следующий способ — разбить наши структуры на части и оградить локами только те секции, в которых происходит работа с разделяемым состоянием. Примером такого подхода является СoncurrentHashMap. В нем все данные разбиваются на несколько частей. При доступе блокируется только та часть, изменение которой происходит в текущий момент. Также есть вариант использовать более функциональные локи, такие как: StampedLock (java 8), ReadWriteLock. При корректных алгоритме и реализации мы получаем более высокий уровень параллелизма. Пример с использованием ReadWriteLock:

public class FineGrainedLock implements FibonacciGenerator<BigInteger> {

    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    private BigInteger curr = BigInteger.ONE;
    private BigInteger next = BigInteger.ONE;

    @Override
    public BigInteger next() {
        BigInteger result;
        lock.writeLock().lock();
        try {
            // Вход другим потокам запрещен
            result = curr;
            curr = next;
            next = result.add(next);
            return result;
        } finally {
            lock.writeLock().unlock();
        }
    }

    @Override
    public BigInteger val() {
        lock.readLock().lock();
        try {
            // При отпущенном write lock
            // Допуст`им вход множества потоков
            return curr;
        } finally {
            lock.readLock().unlock();
        }
    }
}


Мы усовершенствовали класс и добились более высокой пропускной способности на чтение. У такой реализации есть все минусы локов. К тому же, из минусов добавилось то, что алгоритм усложнился (добавилось шума, не связанного с логикой работы) и вероятность некорректной реализации возросла.

Lock-free algorithms



Использование локов влечет массу проблем с производительностью и корректностью. Существует альтернатива в виде неблокирующих алгоритмов. Такие алгоритмы построены на атомарных операциях, предоставляемых процессорами. Примером служит метод get в ConcurrentHashMap. Чтобы писать неблокирующие алгоритмы, имеет смысл воспользоваться существующими неблокирующими классами: ConcurrentLinkedQueue, ConcurrentHashMap и т.п. Напишем неблокирующую реализацию нашего класса.

public class LockFree implements FibonacciGenerator<BigInteger> {

    // Инкапсулируем состояние генератора в класс
    private static class State {
        final BigInteger next;
        final BigInteger curr;

        public State(BigInteger curr, BigInteger next) {
            this.next = next;
            this.curr = curr;
        }
    }

    // Сделаем состояние класса атомарным
    private final AtomicReference<State> atomicState = new AtomicReference<>(
            new State(BigInteger.ONE, BigInteger.ONE));

    public BigInteger next() {
        BigInteger value = null;
        while (true) { 
            // сохраняем состояние класса 
            State state = atomicState.get();
            // то что возвращаем если удалось изменить состояние класса
            value = state.curr; 
            // конструируем новое состояние
            State newState = new State(state.next, state.curr.add(state.next));
            // если за время пока мы конструировали новое состояние
            // оно осталось прежним, то заменяем состояние на новое,
            // иначе пробуем сконструировать еще раз
            if (atomicState.compareAndSet(state, newState)) {break;}
        }
        return value;
    }

    @Override
    public BigInteger val() {
        return atomicState.get().curr;
    }
}


Из плюсов такого подхода следует отметить увеличение производительности, по сравнению с блокирующими алгоритмами. А также, что не менее важно, избавление от недостатков локов. Минус в том, что неблокирующий алгоритм придумать гораздо сложнее.

Software Transactional Memory



Альтернативой неблокирующим алгоритмам является применение программной транзакционной памяти. Её использование похоже на использование транзакций при работе с базами данных. Концепция довольно таки новая (1995) и среди популярных языков, нативная поддержка есть только в Clojure. Поддержка STM на уровне библиотек есть во многих языках, в том числе и Java. Я буду использовать STM, реализованный в рамках проекта Akka.

public class STM implements FibonacciGenerator<BigInteger> {

    // Оборачиваем переменные в транзакционные ссылки
    // система будет отслеживать изменения этих переменных внутри транзакции
    private final Ref<BigInteger> curr = new Ref<>(BigInteger.ONE);
    private final Ref<BigInteger> next = new Ref<>(BigInteger.ONE);

    @Override
    public BigInteger next() {
        // Создаем транзакцию
        return new Atomic<BigInteger>() {
            // Изменения внутри метода 
            // будут обладают АСI (https://en.wikipedia.org/wiki/ACID)
            @Override
            public BigInteger atomically() {
                // Все значения отслеживаемых переменных согласованы
                BigInteger result = curr.get();
                // Изменения не видны другим потокам
                curr.set(next.get());
                next.set(result.add(next.get()));
                // Проверяется были ли изменения над отслеживаемыми
                // переменными. Если да, то нас опередили, но мы
                // оптимистичны и повторяем транзакцию еще раз.
                // Если мы первые, то атомарно записываем новые значения.
                return result;
            }
        // и выполняем ее
        }.execute();
    }

    @Override
    public BigInteger val() {
        // Транзакция создается неявно
        return curr.get();
    }

}


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

Immutability



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

public class Immutable {

    private final BigInteger next;
    // Текущее значение
    public final BigInteger val;

    private Immutable(BigInteger next, BigInteger val) {
        this.next = next;
        this.val = val;
    }

    public Immutable next() {
        return new Immutable(val.add(next), next);
    }

    public static Immutable first() {
        return new Immutable(BigInteger.ONE, BigInteger.ONE);
    }

}


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

Isolated mutability



Идея изолированной изменяемости объектов состоит в том, что доступ к ним ограничен всегда одним потоком. Следовательно у нас не возникнет проблем, характерных для многопоточных программ. Такой подход использует модель акторов. Акторы — это сущности похожие на объекты, у которых есть изменяемое состояние и поведение. Взаимодействие акторов происходит через асинхронную передачу сообщений. Сообщения неизменяемы и актор обрабатывает одно сообщение за раз. Результатом обработки сообщения является изменение поведения, состояния и выполнение других действий. Пример использования акторов будет приведен в следующей статье.

Итог



У каждого подхода есть свои минусы и плюсы и нельзя дать универсального совета.
Комбинация fine-grained локов и неблокирующих алгоритмов, наиболее часто используемый подход в Java. В Clojure же напротив — транзакционная память и неизменяемые структуры данных. Транзакционная память, на мой взгляд, многообещающий инструмент (предлагаю читателю самостоятельно провести аналогию со сборкой мусора). Надеюсь, что при следующем проектировании сис��емы, модуля или класса, вы вспомните подходы, описанные в данной статье, и выберите подходящий именно вам.

Спасибо за внимание. Жду ваших замечаний и предложений.