[Перевод] Это слишком опасно для C++

b7a537fdcad660401876ca12febd5f34.jpg

Некоторые паттерны стало возможно использовать на практике только благодаря безопасности Rust по памяти, а на C++ они слишком опасны. В статье приведён один такой пример.

Работая над внутренней библиотекой, написанной на Rust, я создал тип ошибок для парсера, у которых должна быть возможность сделать Clone без дублирования внутренних данных. В Rust для этого требуется указатель с подсчётом ссылок (reference-counted pointer) наподобие Rc.

Поэтому я написал свой тип ошибок, использовал его как вариант ошибок fallible-функций, и продолжил двигаться дальше.

struct Error {
    data: Rc,
}

pub type Response = Result;

fn parse(input: Input) -> Response {
    todo!()
}

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

enum Command {
    Input(Input),
    Exit,
}

pub enum RequestStatus {
    Completed(Response),
    Running,
}

pub struct Parser {
    command_sender: Sender,
    response_receiver: Receiver<(Input, Response)>,
    cached_result: HashMap,
}

impl Parser {
    pub fn new() -> Self {
        let (command_sender, command_receiver) = channel::();
        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` 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` cannot be sent between threads safely
    |
    = help: within `(&'static str, Result)`, the trait `Send` is not implemented for `Rc`
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`
   --> /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 {
    |          ^^^^^^
    = note: required because it appears within the type `(&str, Result)`
    = note: required for `Sender<(&'static str, Result)>` 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: F) -> JoinHandle
    |        ----- 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,
+        data: Arc,
     }

     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 частичную специализацию шаблонов. Однако я не советую ею пользоваться! Вместо этого ограничьте общий указатель одним потоком и по необходимости отправляйте копии другим потокам.

Так как для присвоения требуется исключающая ссылка или объект с владением, Rust статически запрещает присвоение Arc, который используется несколькими потоками, позволяя избежать этой проблемы на этапе компиляции.

Объект, на который указывают, всё равно требует синхронизации

В shared_ptr атомарным является только подсчёт ссылок, но для записи и чтения из разных потоков объекту, на который указывают, нужна собственная синхронизация. В этом есть опасность, ведь есть искушение сократить »shared_ptr — это указатель с подсчетом безопасных ссылок на потоки» до »shared_ptr — это потокобезопасный указатель с подсчётом ссылок», хотя справедливо только первое.

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

Естественно, Rust предъявляет такие же требования к содержимому Arc, но благодаря трейту Send и трейту Sync , а также тому, что счётчик Arc предоставляет общую ссылку на своё содержимое, несинхронизированная запись и чтение адресуемого указателем объекта — это ошибка времени компиляции.

Rust добивается этого результата целиком благодаря borrow checker и своей системе типов. Это единственный из языков, с которыми я работаю, способный статически предотвращать гонки данных.

Habrahabr.ru прочитано 12094 раза