Под катом находятся заметки, в которых расписано, как реализовать в Rust хитрые concurrency паттерны, которые я с легкостью пишу в Java, и в чем различие в подходах к concurrency у этих языков. Статья будет полезна и тем, кто переходит на Rust из C#, ведь у него аналогичная модель памяти.
 Java первый язык с моделью памяти (которая описывает синхронизацию операций чтения и записи в память), Rust наследует модель памяти C++11 в реализации LLVM. Так что с точки зрения терминологии есть много различий. По сути же в Java есть 3 механизма синхронизации разделяемой памяти: volatile переменные, блокировки (блоки synchronized, ReentrantLock и т.д.) и atomic. В Rust только блокировки (Mutex, Rwlock) и atomic. Volatile не нужен поскольку дублирует функционал atomic. Передачу сообщений через каналы опустим. Ещё отличия: Учтите так же, что я сам только учу Rust поэтому к коду на нем стоит отнестись скептически. Я его тестировал, но в контексте concurrency это ничего не гарантирует. В Java мире очень любят double-check-lock т.к. до Java 1.5 его невозможно было реализовать корректно. Напоминаю как это выглядит: Людей, привыкших к написанию concurrency кода, таким не удивить, однако если вдуматься тут есть масса опасных операций. Во-первых, чтение через гонку. Во-вторых, мы шарим между потоками изменяемый объект (метод get меняет внутреннее состояние объекта). Rust такого не любит. Он вообще злой. Тем не менее максимально близкий аналог в Rust выглядит так: Выглядит необычно, но stackoverflow считает, что так и надо. Кода тут намного больше. Не обращайте внимания на Mutex вокруг supp — он просто где-то должен быть. Из-за отсутствия null пришлось обернуть val в Option. А из-за сложностей в использовании AtomicPtr пришлось добавить AtomicBool флаг инициализации. Здесь обратите внимание на разницу в доказательстве корректности. Например в Java, если убрать volatile, то вы просто говорите, что нет happens-before из записи в чтение, и на этом анализ заканчивается. Искать конкретный вариант исполнения, при котором проблема реализуется, не нужно. В Rust же вы рискуете попасть в ловушку из-за разных вариантов барьеров памяти. Поэтому придется пробежать все варианты исполнения. Если вы прочитали в init true это значит вы видите запись в него и в val (по транзитивности). Поэтому все сработает нормально. Иначе вы берете блокировку, тогда чтение true там тоже гарантирует видимость записи в val. В последнем третьем случае если вы и есть тот поток, что записал значение, вы его тоже увидите т.к. другие потоки не пишут значение (и если бы вы не могли прочитать свою собственную запись — это сломало бы однониточные программы). В прошлом примере переменная supp не нужна после использования. Я встретил этот пример в докладе Шипилева о JMM. Код в Java: Тут все просто и знакомо: это почти double-check-lock, с той лишь разницей, что мы удаляем функцию, вычисляющую значение. Спецификация JMM позволяет нам увидеть supp == null до того как туда кто-то что-то положил, поэтому надо использовать EMPTY (подробнее в докладе Шипилева в самом конце, он просил не спрашивать это на собеседованиях). Volatile на supp нужен, что бы пробросить happens-before между операцией записи внутри synchronized и чтением вне его. Volatile на val не нужен, т.к. тот, кто видит запись в supp, видит и запись в val. Сразу стоит сказать, что этот пример может и не иметь смысла в Rust. У структур на стеке фиксированный размер, и если supp не указатель, то ничего не выиграть, если затрете его в None. Но если supp по какой-то причине Arc, тогда можно. Аналог в Rust: Как по мне вариант на Rust выглядит слишком сложно, что бы его использовать на практике. Однако, с точки зрения доказательства корректности ничего не поменялось т.к. все операции с функцией делаются под блокировкой. Ещё один хитрый прием, из того же доклада, позволяет написать ленивую инициализацию для небольшого числа случаев, вроде подсчета хеша объекта, которая не требует блокировок. Тут главный секрет в том что: Хотя код выглядит так будто мы не используем иных гарантий на операции, помимо гарантий для работы однониточных программ, это не так. Ведь Java гарантирует атомарность операций чтения и записи над примитивами (включая ссылки) кроме long и double, а без атомарности этот код не корректен. Также трюком с локальной переменной мы добавляем гарантию монотонности. Rust дает в точности эти же гарантии если сделаете Relaxed операцию.. Хешы считают в Rust хитрее чем в Java, поэтому пример ниже — абстрактный getter: В Rust в конце можно было бы вернуть вместо temp честный self.hash.load(Ordering::Relaxed), просто в этом нет смысла. Так же в nightly сборки Rust куча атомиков (даже 128 битные), а в Java сделать такой же фокус с 64-х битным long не получится и придется лепить volatile. Я услышал о такой идеи в докладе computer science ещё жива, где описывались хитрые алгоритмы используемые в IDEA. Мысль следующая: Read-write локи слишком медленные, при наличии тысячи чтецов и ровно одного писателя. Но можно ускориться если читающие потоки будут писать в ThreadLocal флажки и проверять атомарный флаг писателя. Иных подробностей не было, поэтому моя реализация будет совсем другой. Честно говоря описание задачи похоже на mvcc для бедных. Поясню. Если нет нужды в монотонности, то можно обойтись без блокировок в ситуации, когда имеется не более одного писателя и много чтецов. Чтецы читают из последней (на момент начала операции) копии данных, а писатель пишет в личную копию. Когда писатель заканчивает работу, он обновляет общую копию атомарно, и новые читатели будут брать уже её. Старые читатели будут работать со старой копией. Тут нарушается монотонность т.к. новый быстрый чтец может вернуть новое значение, а после него старый медленный чтец вернет старое значение. Однако заметить нарушение монотонности нельзя если получать значения в том же порядке, в котором создавались задачи или не ожидать завершения задач из других потоков. В реализации ниже это считается приемлемым. Однако в голову приходит по меньшей мере два способа починить монотонность: Код ниже не использует ThreadLocal (информации в докладе недостаточно что бы понять зачем он нужен, если можно обойтись без него). Этот код остается валидным даже если писателей больше одного. Но сломается если читатели начнут писать. Для разработчиков Rust может показаться дикостью, что приходится объявлять собственный интерфейс для копирования. В Java метод clone объявлен protected т.е. он видим только потомкам (где можно объявить его public) и даже если объявить тип T наследником Cloneable это не решит проблему. К слову в C# с этим все тоже не слава богу: интерфейс IСloneable не generic и возвращает Object, который ещё надо кастовать к нужному типу. Я сделал бенчмарк на JMH. Даже в случае 1 писателя и 1 читателя реализация на PoorMvcc намного быстрее реализации на ReentrantReadWriteLock. С увеличением числа писателей разрыв только растет. Я не знаю как написать аналогичный бенчмарк на Rust. Кажется там нет функционала для проверки того как методы влияют друг на друга исполняясь одновременно. Результат: Инкремент быстрее в 83 раза, а чтение в 51. Если читателей станет 100, тогда инкремент будет быстрее в 12 000 раз, а чтение в 17 000. В Rust есть Thread local, но он нам не понадобится тут тоже. Давайте сперва рассмотрим получение значения для чтения. Работать с AtomicPtr в Rust надо аккуратно что бы и память не утекла и не освободилась слишком рано. Я кладу внутрь Arc поскольку результат будет передан между потоками. Соответственно get_read_copy достает атомарно текущую Arc ссылку и возвращает её клон (так в Rust передаются Arc ссылки). Если больше ничего не сделать, то в AtomicPtr протухнет указатель, ведь считанная оттуда Arc ссылка освободится, поэтому я делаю into_raw, что бы этого не произошло. А return_read_copy принимает Arc ссылку, уничтожая эту копию. Получение значения на запись похитрее. Поскольку надо синхронизировать записи блокировкой, я завожу отдельную копию данных обернутую в Mutex. При вызове get_write_copy я просто возвращаю значение из Mutex (оно обернуто в MutexGuard что бы освободить потом блокировку — это полный аналог кода в Java). А в return_write_copy принимаю его назад, потом подменяю старое значение для чтения на новую копию. Тут есть хитрость с операцией swap, которую надо сделать атомарно, что бы потом освободить старую Arc ссылку (у других потоков может остаться копия). На сегодня всё. Поиск ошибок приветствуется. Разработчик