Понимание и реализация смарт-указателя Arc и мьютекса на Rust.
Rust — язык системного программирования с акцентом на безопасности, многопоточности, производительности. В этом руководстве рассмотрим два примитива многопоточности Rust: Arc и Mutex.
При написании многопоточного Rust рано или поздно встречаются типы Arc и Mutex. Mutex применяется во многих языках, а вот Arc вряд ли найдется где-то еще, кроме Rust. Нельзя полностью понять эти концепции, не связав их с моделью владения Rust. Эта статья — мой подход к
Понимание и реализация смарт-указателя Arc и мьютекса на Rust...
Rust — язык системного программирования с акцентом на безопасности, многопоточности, производительности. В этом руководстве рассмотрим два примитива многопоточности Rust: Arc и Mutex.
При написании многопоточного Rust рано или поздно встречаются типы Arc и Mutex. Mutex применяется во многих языках, а вот Arc вряд ли найдется где-то еще, кроме Rust. Нельзя полностью понять эти концепции, не связав их с моделью владения Rust. Эта статья — мой подход к пониманию Arc и Mutex в Rust.
Когда в многопоточной среде обмениваются данными, обычно передают их как сообщения или совместно используют память. В условиях многопоточности передача сообщений, например, по каналам предпочтительнее, но из-за модели владения различия в безопасности и корректности в Rust не так велики, как в других языках. То есть в безопасном Rust гонки данных невозможны. Поэтому основной критерий при выборе между передачей сообщений и совместным использованием памяти на Rust — удобство, а не безопасность.
Если выбрать для обмена данными совместное использование памяти, быстро обнаруживается, что без Arc и Mutex здесь мало что делается. Arc — умный указатель для совместного, безопасного использования потоками значения. Mutex — обертка над другим типом для безопасной изменяемости в потоках. Чтобы полностью понять эти концепции, рассмотрим модель владения.
Владение на Rust
Вот характеристики модели владения Rust:
- у значения имеется только один владелец;
- общих неизменяемых ссылок на значение может быть несколько;
- изменяемая ссылка на значение может быть только одна.
use std::thread::spawn;
#[derive(Debug)]
struct User {
name: String
}
fn main() {
let user = User { name: "sam".to_string() };
spawn(move || {
println!("Hello from the first thread {}", user.name);
}).join().unwrap();
}
Пока все хорошо, программа компилируется с выводом сообщения. Добавим второй поток, также с доступом к экземпляру user:
fn main() {
let user = User { name: "sam".to_string() };
let t1 = spawn(move || {
println!("Hello from the first thread {}", user.name);
});
let t2 = spawn(move || {
println!("Hello from the second thread {}", user.name);
});
t1.join().unwrap();
t2.join().unwrap();
} С этим кодом получаем такую ошибку:
error[E0382]: use of moved value: `user.name`
--> src/main.rs:15:20
|
11 | let t1 = spawn(move || {
| ------- value moved into closure here
12 | println!("Hello from the first thread {}", user.name);
| --------- variable moved due to use in closure
...
15 | let t2 = spawn(move || {
| ^^^^^^^ value used here after move
16 | println!("Hello from the second thread {}", user.name);
| --------- use occurs due to use in closure
|
= note: move occurs because `user.name` has type `String`, which does not implement the `Copy` trait
Что нужно компилятору? Ошибка здесь такая: use of moved value («Использование перемещенного значения user.name»). Компилятором даже указываются конкретные места, где возникает проблема. Сначала перемещаем значение в первый поток, затем пытаемся во второй.
Если посмотреть на правила владения, в этом нет ничего удивительного. У значения имеется только один владелец. В текущей версии кода нужно с помощью move переместить значение, которое планируется использовать, в первый поток, поэтому в другой поток значение переместить нельзя. Владение им уже поменялось. Но мы же не меняем данные, поэтому может быть несколько общих ссылок:
fn main() {
let user = User { name: "sam".to_string() };
let t1 = spawn(|| {
println!("Hello from the first thread {}", &user.name);
});
let t2 = spawn(|| {
println!("Hello from the second thread {}", &user.name);
});
t1.join().unwrap();
t2.join().unwrap();
} Здесь в замыканиях потоков удалили ключевое слово move, потоками неизменяемо заимствуется значение user. То есть получается общая ссылка, представленная амперсандом. С этим кодом получаем:
error[E0373]: closure may outlive the current function, but it borrows `user.name`, which is owned by the current function
--> src/main.rs:15:20
|
15 | let t2 = spawn(|| {
| ^^ may outlive borrowed value `user.name`
16 | println!("Hello from the first thread {}", &user.name);
| --------- `user.name` is borrowed here
|
note: function requires argument type to outlive `'static`
--> src/main.rs:15:14
|
15 | let t2 = spawn(|| {
| ______________^
16 | | println!("Hello from the second thread {}", &user.name);
17 | | });
| |______^
help: to force the closure to take ownership of `user.name` (and any other referenced variables), use the `move` keyword
|
15 | let t2 = spawn(move || {
| ++++
Теперь ошибкой указывается на то, что замыкание способно «пережить» функцию. Иными словами, компилятором Rust не гарантируется, что замыкание в потоке завершится до функции main().
Структура user заимствуется потоками, но владение ею остается за функцией main. В этом сценарии, если функция main завершается, структура user выходит из области видимости, и память удаляется. Поэтому, если таким образом делиться значением с потоками, поток попытается считать освобожденную память. Это неопределенное поведение, и оно, конечно, нежелательно.
В примечании также говорится: чтобы избежать заимствования, переменную user можно переместить в поток, но из этого сценария мы и идем, нет смысла в него возвращаться. Здесь имеется два простых решения, одно из них — Arc, но рассмотрим сначала другое.
Потоки области видимости
Потоки области видимости — это функционал, доступный из отличного крейта crossbeam или как экспериментальная ночная функция на Rust. Воспользуемся crossbeam, но API обеих версий очень похожи.
Добавив crossbeam = "0.8" в зависимости Cargo.toml, получаем беспроблемный рабочий код:
use crossbeam::scope;
#[derive(Debug)]
struct User {
name: String,
}
fn main() {
let user = User {
name: "sam".to_string(),
};
scope(|s| {
s.spawn(|_| {
println!("Hello from the first thread {}", &user.name);
});
s.spawn(|_| {
println!("Hello from the second thread {}", &user.name);
});
})
.unwrap();
}
Все потоки, созданные в области видимости, гарантированно завершаются до завершения замыкания scope. То есть, прежде чем замыкание выйдет из области видимости, потоки объединяются в ожидании завершения. Благодаря этому компилятор «знает», что ни одно из заимствований не «переживет» владельца.
Интересно, что для человека обе эти программы допустимы: в версии, отвергаемой Rust, мы объединяем оба потока до завершения функции main(), поэтому делиться значением user с потоками на самом деле безопасно. Такое в Rust случается. Невозможно написать компилятор для приема всех допустимых программ, альтернатива — суперстрогий компилятор, которым отклоняются все недопустимые. Потоки в области видимости созданы специально для этого — писать код, принимаемый компилятором.
Но, как бы ни были хороши потоки области видимости, использовать их не всегда возможно. Например, при написании асинхронного кода. Вернемся к первому решению.
Arc в помощь
Arc — это умный указатель для обмена данными между потоками, расшифровывается как atomic reference counter, то есть атомарный подсчет ссылок.
Фактически задача Arc — обернуть значение, которым мы пытаемся поделиться, и быть указателем на него. Этим Arc отслеживаются все копии указателя, и по выходе последнего указателя из области видимости безопасно освобождается память.
Вот как Arc решается описанная выше проблема:
use std::thread::spawn;
use std::sync::Arc;
#[derive(Debug)]
struct User {
name: String
}
fn main() {
let user_original = Arc::new(User { name: "sam".to_string() });
let user = user_original.clone();
let t1 = spawn(move || {
println!("Hello from the first thread {}", user.name);
});
let user = user_original.clone();
let t2 = spawn(move || {
println!("Hello from the first thread {}", user.name);
});
t1.join().unwrap();
t2.join().unwrap();
}
Рассмотрим подробнее.
Сначала создаем значение user и оборачиваем его в Arc. Теперь оно сохраняется в памяти, а Arc всего лишь указатель.
При каждом клонировании клонируется не значение user, а только ссылка. Клонируя Arc, мы перемещаем в каждый из потоков копию указателя. Благодаря Arc данные обмениваются независимо от времен жизни.
В этом примере создается три указателя на значение user: один при создании Arc, второй клонированием перед запуском первого потока, в который он и перемещается, а третий клонированием перед запуском второго потока — тоже перемещается в первый поток.
Пока хоть один указатель активен, память в Rust не освободится. Когда же завершаются потоки и функция main, все указатели Arc выходят из области видимости и удаляются. С последним из них удаляется и значение user.
Send и Sync
Копнем глубже. Согласно документации, типажи Send и Sync реализуются в Arc, только если реализуются и оборачиваемым типом. Чтобы разобраться, начнем с определения Send и Sync.
В Rustonomicon Send и Sync определяются так:
- Если тип безопасно отправляется в другой поток, это Send.
- Если тип безопасно обменивается между потоками, это Sync; T является Sync, только когда &T является Send.
Подробнее об этих типажах — в Rustonomicon, но попробуем разобраться самостоятельно. Send и Sync — типажи-маркеры без реализованных методов, им ничего не требуется реализовывать. Компилятор уведомляется ими о возможности обмениваться типом или отправлять тип между потоками.
Начнем с Send, он попроще. Нельзя отправить в другой поток тип !Send, то есть не Send: нельзя отправить его по каналу или переместить в поток. Например, этот код не скомпилируется:
#![feature(negative_impls)]
#[derive(Debug)]
struct Foo {}
impl !Send for Foo {}
fn main() {
let foo = Foo {};
spawn(move || {
dbg!(foo);
});
}
Send и Sync выводятся автоматически. Например, если все атрибуты типа являются Send, этот тип будет тоже Send. В коде экспериментальным функционалом negative_impls компилятору сообщается о намерении явно обозначить этот тип как !Send.
В итоге появляется ошибка:
`Foo` cannot be sent between threads safely
То же происходит при создании канала для отправки foo в поток. С Arc таким же образом вылетает та же ошибка. И то же справедливо для типа !Sync, поскольку Arc нужны оба типажа:
#![feature(negative_impls)]
#[derive(Debug)]
struct Foo {}
impl !Send for Foo {}
fn main() {
let foo = Arc::new(Foo {});
spawn(move || {
dbg!(foo);
});
}
Но разве Arc не должен обернуть тип и предоставить больше возможностей? Верно, но с Arc тип не делается потокобезопасным как по волшебству. Почему? Покажем в подробном примере в конце статьи, а пока продолжим изучать применение этих типов.
Мы знаем теперь, что благодаря Arc потоки независимо от времен жизни обмениваются ссылками на типы, которые являются Send + Sync. Ведь это не обычная ссылка, а умный указатель.
Изменение данных с помощью Mutex
Мьютексы во многих языках рассматриваются как семафоры. Создавая мьютексный объект, мы защищаем с помощью mutex конкретную часть или части кода. Так что защищаемое место единовременно доступно только одному потоку.
В Rust Mutex — это скорее обертка. Доступ к базовому значению предоставляется ею только после блокировки мьютекса. Обмен значения между потоками упрощается этим Mutex с помощью Arc.
Вот пример:
use std::time::Duration;
use std::{thread, thread::sleep};
use std::sync::{Arc, Mutex};
struct User {
name: String
}
fn main() {
let user_original = Arc::new(Mutex::new(User { name: String::from("sam") }));
let user = user_original.clone();
let t1 = thread::spawn(move || {
let mut locked_user = user.lock().unwrap();
locked_user.name = String::from("sam");
// После того как «locked_user» выйдет из области видимости, мьютекс снова разблокируется.
// Чтобы разблокировать его явно, применяется
// «drop(locked_user)».
});
let user = user_original.clone();
let t2 = thread::spawn(move || {
sleep(Duration::from_millis(10));
// Выведется «Hello sam»
println!("Hello {}", user.lock().unwrap().name);
});
t1.join().unwrap();
t2.join().unwrap();
}
Разберем этот код. В первой строке функции main() создается экземпляр структуры User, оборачиваемый в Mutex и Arc. С Arc указатель легко клонируется, поэтому мьютекс задействуется потоками совместно. После того как мьютекс блокируется, базовое значение используется исключительно этим потоком. В следующей строке это значение меняется. Мьютекс разблокируется, как только защищенная, блокированная часть кода выходит из области видимости, или удаляется вручную с помощью drop(locked_user).
Во втором потоке через 10 мс ожидания выводится название, обновленное в первом потоке. На этот раз блокировка выполняется в одной строке, поэтому мьютекс удаляется в том же операторе.
Необходимо упомянуть также о методе unwrap(), вызываемом после lock(). В Mutex стандартной библиотеки заложено понятие об отравлении. Если поток «паникует» при заблокированном мьютексе, нельзя определить, остается ли значение внутри Mutex валидным. Поэтому поведение по умолчанию — возвращение ошибки, а не защищенной части кода. Причем этим Mutex возвращается вариант Ok() с обернутым значением в качестве аргумента либо ошибка. Подробнее об этом — в документации.
В целом оставлять методы unrwap() в производственном коде не рекомендуется, но в случае с Mutex это рабочая стратегия: если мьютекс отравлен, состояние приложения бывает недопустимым, тогда работа приложения аварийно завершается.
Интересно и вот что: пока тип внутри Mutex является Send, мьютекс будет также и Sync. Ведь мьютексом обеспечивается доступ к базовому значению только для одного потока, поэтому совместное использование Mutex безопасно для потоков.
Mutex: добавление Sync к типу Send
Напомним: чтобы Arc стал Send + Sync, ему нужен базовый тип Send + Sync. А вот, чтобы Mutex стал Send, требуется только базовый тип Send. То есть с Mutex тип !Sync становится Sync, обменивается между потоками, а также изменяется.
Mutex без Arc
Что, если использовать Mutex без Arc? Подумайте, что означает то, что Mutex — это Send + Sync для типов Send?
Очень похоже на то, что это означает для типа Arc. Если применять что-то вроде потоков области видимости, Mutex обходится без Arc:
use crossbeam::scope;
use std::{sync::Mutex, thread::sleep, time::Duration};
#[derive(Debug)]
struct User {
name: String,
}
fn main() {
let user = Mutex::new(User {
name: "sam".to_string(),
});
scope(|s| {
s.spawn(|_| {
user.lock().unwrap().name = String::from("psaa");
});
s.spawn(|_| {
sleep(Duration::from_millis(10));
// выводится «Hello psaa»
println!("Hello {}", user.lock().unwrap().name);
});
})
.unwrap();
}
В этой программе достигается та же цель: доступ к значению позади мьютекса получается в двух отдельных потоках, но мьютексы используются ими совместно по ссылке, и без Arc. Опять же, это не всегда возможно, например, в асинхронном коде, поэтому Mutex очень часто применяется вместе с Arc.
Заключение
Мы изучили типы Arc и Mutex в Rust. Arc, как правило, используется при невозможности обмена данными между потоками с помощью обычных ссылок. Для изменения данных, которыми обмениваются потоки, применяется Mutex. Если же при этом мьютекс не разделяется посредством ссылок, используется Arc<Mutex<...>>.
Бонус: почему Arc нужен тип Sync?
Вернемся к вопросу о том, почему Arc нужно, чтобы базовый тип являлся и Send, и Sync, помечался как Send и Sync. Концовку статьи можете пропустить, поскольку Arc и Mutex в коде не особо востребованы. Но для понимания типажей-маркеров она придется кстати.
Возьмем в качестве примера Cell, которым обертывается другой тип и обеспечивается внутренняя изменяемость, то есть возможность изменять значение внутри неизменяемой структуры. Тип Cell — Send, но это !Sync.
Вот пример:
use std::cell::Cell;
struct User {
age: Cell<usize>
}
fn main() {
let user = User { age: Cell::new(30) };
user.age.set(36);
// выведется «Age: 36»
println!("Age: {}", user.age.get());
}
Cell полезен в некоторых ситуациях, но не потокобезопасен, то есть это !Sync. Если значение, обернутое в cell, каким-то образом обменивается между потоками, то же место в памяти изменяется из двух потоков:
// этот пример не скомпилируется, «Cell» — это «!Sync», поэтому
// «Arc» будет «!Sync» и «!Send»
use std::cell::Cell;
struct User {
age: Cell<usize>
}
fn main() {
let user_original = Arc::new(User { age: Cell::new(30) });
let user = user_original.clone();
std::thread::spawn(move || {
user.age.set(2);
});
let user = user_original.clone();
std::thread::spawn(move || {
user.age.set(3);
});
}
Такой код чреват неопределенным поведением. Поэтому Arc не рабочий с любыми типами, кроме Send и Sync. Но Cell — это Send, поэтому отправляется между потоками. Дело в том, что отправкой или перемещением значение не делается доступным более чем из одного потока, такой поток всегда только один. Как только значение перемещается в другой, предыдущему потоку оно уже не принадлежит. Учитывая это, мы всегда можем изменить Cell локально.
Бонус: зачем Arc нужен тип
А нет ли у Arc типажа Send и для !Send? Rc — один из типов Rust, который является !Send. В отличие от Arc, он не атомарный Rc, расширяется лишь до счетчика ссылок и при практически той же роли Arc используется только в одном потоке. Он не обменивается и даже не перемещается между потоками, посмотрим почему:
// этот код не скомпилируется, Rc является «!Send» и «!Sync»
use std::rc::Rc;
fn main() {
let foo = Rc::new(1);
let foo_clone = foo.clone();
std::thread::spawn(move || {
dbg!(foo_clone);
});
let foo_clone = foo.clone();
std::thread::spawn(move || {
dbg!(foo_clone);
});
}
Этот пример не компилируется, потому что Rc — !Sync + !Send. Его внутренний счетчик не атомарный, поэтому обмен им между потоками чреват неточным подсчетом ссылок.
Если же в Arc типы !Send сделаются Send:
use std::rc::Rc;
use std::sync::Arc;
#[derive(Debug)]
struct User {
name: Rc<String>,
}
unsafe impl Send for User {}
unsafe impl Sync for User {}
fn main() {
let foo = Arc::new(User {
name: Rc::new(String::from("drogus")),
});
let foo_clone = foo.clone();
std::thread::spawn(move || {
let name = foo_clone.name.clone();
});
let foo_clone = foo.clone();
std::thread::spawn(move || {
let name = foo_clone.name.clone();
});
}
Теперь пример компилируется, только не делайте так в коде. Здесь определяется структура User с Rc внутри. Поскольку Send и Sync выводятся автоматически, а Rc — !Send + !Sync, структура User тоже !Send + !Sync, но компилятору явно указывается обозначить ее по-другому, в данном случае Send + Sync с синтаксисом unsafe impl.
Теперь видно, что пойдет не так, если разрешить в Arc перемещение типов !Send между потоками. В примере клоны Arc перемещаются в отдельные потоки, после чего ничто не мешает клонировать тип Rc. А поскольку тип Rc не потокобезопасный, это чревато неточным подсчетом ссылок. Следовательно, память освободится слишком рано либо не освободится вовсе, хотя и должна.