Некоторые паттерны стало возможно использовать на практике только благодаря безопасности Rust по памяти, а на C++ они слишком опасны. В статье приведён один такой пример.
Работая над внутренней библиотекой, написанной на Rust, я создал тип ошибок для парсера, у которых должна быть возможность сделать Clone
без дублирования внутренних данных. В Rust для этого требуется указатель с подсчётом ссылок (reference-counted pointer) наподобие Rc.
Поэтому я написал свой тип ошибок, использовал его как вариант ошибок fallible-функций, и продолжил двигаться дальше.
struct Error {
data: Rc<ExpensiveToCloneDirectly>,
}
pub type Response = Result<Output, Error>;
fn parse(input: Input) -> Response {
todo!()
}
Позже мы заметили, что при некоторых видах входных данных выполнение парсинга занимает долгое время, поэтому я решил, что буду отправлять входные данные на другой поток при помощи канала, а ответ стану получать через другой канал, чтобы эти долгие операции не блокировали основной поток.
enum Command {
Input(Input),
Exit,
}
pub enum RequestStatus {
Completed(Response),
Running,
}
pub struct Parser {
command_sender: Sender<Command>,
response_receiver: Receiver<(Input, Response)>,
cached_result: HashMap<Input, RequestStatus>,
}
impl Parser {
pub fn new() -> Self {
let (command_sender, command_receiver) = channel::<Command>();
let (response_sender, response_receiver) = channel::<(Input, Response)>();
std::thread::spawn(move || loop {
match command_receiver.recv() {
Ok(Command::Input(input)) => {
let response = parse(input);
let _ = response_sender.send((input, response));
}
Ok(Command::Exit) => break,
Err(_) => break,
}
});
Self {
command_sender,
response_receiver,
cached_result: HashMap::default(),
}
}
pub fn request_parsing(&mut self, input: Input) -> RequestStatus {
// накачиваем ранее полученные ответы
while let Ok((input, response)) = self.response.receiver.try_recv() {
self.cached_result
.insert(input, RequestStatus::Completed(response));
}
let response = match self.cached_result.entry(input) {
Entry::Vacant(entry) => {
self.command_sender
.send(Command::Input(entry.key()))
.unwrap();
entry.insert(RequestStatus::Running)
}
Entry::Occupied(entry) => entry.into_mut(),
};
response.clone()
}
}
Однако при внесении этого изменения я увидел следующую ошибку:
error[E0277]: `Rc<String>` cannot be sent between threads safely
--> src/main.rs:58:32
|
58 | std::thread::spawn(move || loop {
| _____________------------------_^
| | |
| | required by a bound introduced by this call
59 | | match command_receiver.recv() {
60 | | Ok(Command::Input(input)) => {
61 | | let response = maybe_make(input);
... |
68 | | }
69 | | });
| |_____________^ `Rc<String>` cannot be sent between threads safely
|
= help: within `(&'static str, Result<worker::Output, worker::Error>)`, the trait `Send` is not implemented for `Rc<String>`
note: required because it appears within the type `Error`
--> src/main.rs:17:16
|
17 | pub struct Error {
| ^^^^^
note: required because it appears within the type `Result<Output, Error>`
--> /home/dureuill/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/result.rs:502:10
|
502 | pub enum Result<T, E> {
| ^^^^^^
= note: required because it appears within the type `(&str, Result<Output, Error>)`
= note: required for `Sender<(&'static str, Result<worker::Output, worker::Error>)>` to implement `Send`
note: required because it's used within this closure
--> src/main.rs:58:32
|
58 | std::thread::spawn(move || loop {
| ^^^^^^^
note: required by a bound in `spawn`
--> /home/dureuill/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/thread/mod.rs:683:8
|
680 | pub fn spawn<F, T>(f: F) -> JoinHandle<T>
| ----- required by a bound in this function
...
683 | F: Send + 'static,
| ^^^^ required by this bound in `spawn`
Как объяснил компилятор, ошибка вызвана тем, что тип Rc
не поддерживает пересылку между потоками, потому что это вызовет гонку данных. И в самом деле, подсчёт ссылок в Rc
выполняется не атомарным образом, что было бы безопасно для потоков, а использует обычные целочисленные операции.
Чтобы обеспечить безопасный для потоков подсчёт ссылок, Rust имеет другой тип под названием Arc
, который использует атомарный подсчёт ссылок (atomic reference counting). Чтобы изменить код для использования Arc
, достаточно сделать следующее:
diff --git a/src/main.rs b/src/main.rs
index 04ec0d0..fd4b447 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,9 +3,9 @@ use std::{io::Write, time::Duration};
mod parse {
use std::{
collections::{hash_map::Entry, HashMap},
- rc::Rc,
sync::{
mpsc::{channel, Receiver, Sender},
+ Arc,
},
time::Duration,
};
@@ -15,13 +15,13 @@ mod parse {
#[derive(Clone, Debug)]
pub struct Error {
- data: Rc<ExpensiveToCloneDirectly>,
+ data: Arc<ExpensiveToCloneDirectly>,
}
impl Error {
fn new(data: ExpensiveToCloneDirectly) -> Self {
Self {
- data: Rc::new(data),
+ data: Arc::new(data),
}
}
}
(Протестировать этот код онлайн)
Пока мне не нужно было, чтобы подсчёт ссылок был атомарным, я мог пользоваться Rc
. Когда мне понадобилась безопасность по потокам, компилятор заставил меня перейти на Arc
и к атомарному подсчёту ссылок. Это иллюстрация старого принципа «не плати за то, чем не пользуешься».
Этот принцип очень близок и разработчикам на C++, однако, в отличие от Rust, C++ имеет в своей стандартной библиотеке только общие указатели с атомарным подсчётом ссылок, эквивалентные Arc
, а не Rc
. Мы всегда вынуждены платить за атомарность, даже если не пользуемся ею. Предоставление двух классов рассматривалось, но его отклонили, в частности, потому, что посчитали это слишком опасным («Код, написанный с несинхронизированным shared_ptr
, может быть использован в коде с потоками и вызвать сложные в отладке проблемы без отображения предупреждений»).
А так как Rust отслеживает их во время компиляции, они неопасны.
В некоторых реализациях стандартной библиотеки C++ были попытки вернуть потерянную производительность в некоторых ограниченных ситуациях (например, когда программа в целом не многопоточная), и это забавным образом повлияло на микробенчмарки.
Тем не менее, безопасность так и не обеспечена
К сожалению, предостережений, предпринятых C++ (постоянное использование атомарного подсчёта ссылок), всё равно недостаточно для того, чтобы shared_ptr
был безопасным в многопоточном контексте, потому что разработчикам по-прежнему приходится учитывать пару особенностей, позволяющих выстрелить себе в ногу.
shared_ptr безопасен по потокам при копировании, но не при присвоении
Эта проблема довольно тонкая, и, честно говоря, я не думаю, что когда-нибудь сталкивался с ней, но упомяну её для понятности, потому что иногда люди путают её со второй проблемой.
Можно взять shared_ptr
, сделать его копию, вызвав его конструктор копирования безопасным для потоков способом. Однако при этом нельзя сделать один экземпляр shared_ptr
общим для нескольких потоков. Представьте, что у вас есть struct, содержащая общий указатель, используемый в нескольких потоках, и метод у этой struct, переприсваивающий этот общий указатель. Если этот метод вызывается без синхронизации несколькими потоками, то это приведёт к неопределённому поведению.
Очевидно, эту проблему посчитали достаточно серьёзной, чтобы добавить в C++20 для std::atomic<std::shared_ptr>
частичную специализацию шаблонов. Однако я не советую ею пользоваться! Вместо этого ограничьте общий указатель одним потоком и по необходимости отправляйте копии другим потокам.
Так как для присвоения требуется исключающая ссылка или объект с владением, Rust статически запрещает присвоение Arc
, который используется несколькими потоками, позволяя избежать этой проблемы на этапе компиляции.
Объект, на который указывают, всё равно требует синхронизации
В shared_ptr
атомарным является только подсчёт ссылок, но для записи и чтения из разных потоков объекту, на который указывают, нужна собственная синхронизация. В этом есть опасность, ведь есть искушение сократить «shared_ptr
— это указатель с подсчетом безопасных ссылок на потоки» до «shared_ptr
— это потокобезопасный указатель с подсчётом ссылок», хотя справедливо только первое.
Хотя для опытных разработчиков это может казаться очевидным, на практике я часто встречался с этой проблемой, и, кажется, всегда у джуниор-разработчиков. Никогда не замечал, чтобы эту ошибку совершали сведущие разработчики, которые занимаются рефакторингом своего кода для добавления в него потоков.
Естественно, Rust предъявляет такие же требования к содержимому Arc
, но благодаря трейту Send
и трейту Sync
, а также тому, что счётчик Arc
предоставляет общую ссылку на своё содержимое, несинхронизированная запись и чтение адресуемого указателем объекта — это ошибка времени компиляции.
Rust добивается этого результата целиком благодаря borrow checker и своей системе типов. Это единственный из языков, с которыми я работаю, способный статически предотвращать гонки данных.
Автор:
PatientZero