- PVSM.RU - https://www.pvsm.ru -

Это слишком опасно для C++

Это слишком опасно для C++ - 1

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

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

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

struct Error {
    data: Rc<ExpensiveToCloneDirectly>,
}

pub type Response = Result<Output, Error>;

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

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

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 имеет другой тип под названием   [3]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),
             }
         }
     }

(Протестировать этот код онлайн [4])

Пока мне не нужно было, чтобы подсчёт ссылок был атомарным, я мог пользоваться Rc. Когда мне понадобилась безопасность по потокам, компилятор заставил меня перейти на Arc и к атомарному подсчёту ссылок. Это иллюстрация старого принципа «не плати за то, чем не пользуешься».

Этот принцип очень близок и разработчикам на C++, однако, в отличие от Rust, C++ имеет в своей стандартной библиотеке только общие указатели с атомарным подсчётом ссылок, эквивалентные Arc, а не Rc. Мы всегда вынуждены платить за атомарность, даже если не пользуемся ею. Предоставление двух классов рассматривалось, но его отклонили, в частности, потому, что посчитали это слишком опасным [5] («Код, написанный с несинхронизированным shared_ptr , может быть использован в коде с потоками и вызвать сложные в отладке проблемы без отображения предупреждений»).

А так как Rust отслеживает их во время компиляции, они неопасны.

В некоторых реализациях стандартной библиотеки C++ были попытки вернуть потерянную производительность в некоторых ограниченных ситуациях (например, когда программа в целом не многопоточная), и это забавным образом повлияло на микробенчмарки [6].

Тем не менее, безопасность так и не обеспечена

К сожалению, предостережений, предпринятых C++ (постоянное использование атомарного подсчёта ссылок), всё равно недостаточно для того, чтобы shared_ptr был безопасным в многопоточном контексте, потому что разработчикам по-прежнему приходится учитывать пару особенностей, позволяющих выстрелить себе в ногу.

shared_ptr безопасен по потокам при копировании, но не при присвоении

Эта проблема довольно тонкая, и, честно говоря, я не думаю, что когда-нибудь сталкивался с ней, но упомяну её для понятности, потому что иногда люди путают её со второй проблемой.

Можно взять shared_ptr, сделать его копию, вызвав его конструктор копирования безопасным для потоков способом. Однако при этом нельзя сделать один экземпляр shared_ptr общим для нескольких потоков. Представьте, что у вас есть struct, содержащая общий указатель, используемый в нескольких потоках, и метод у этой struct, переприсваивающий этот общий указатель. Если этот метод вызывается без синхронизации несколькими потоками, то это приведёт к неопределённому поведению.

Очевидно, эту проблему посчитали достаточно серьёзной, чтобы добавить в C++20 для std::atomic<std::shared_ptr> частичную специализацию шаблонов [7]. Однако я не советую ею пользоваться! Вместо этого ограничьте общий указатель одним потоком и по необходимости отправляйте копии другим потокам.

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

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

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

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

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

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

Автор:
PatientZero

Источник [13]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/kompilyatsiya/390396

Ссылки в тексте:

[1] Rc: https://doc.rust-lang.org/std/rc/struct.Rc.html

[2] канала: https://doc.rust-lang.org/std/sync/mpsc/fn.channel.html

[3] другой тип под названием  : https://doc.rust-lang.org/std/sync/struct.Arc.html

[4] Протестировать этот код онлайн: https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=b1f40129f7a6c9baf77fde13a4156889

[5] посчитали это слишком опасным: https://stackoverflow.com/a/15140227/1614219

[6] забавным образом повлияло на микробенчмарки: http://snf.github.io/2019/02/13/shared-ptr-optimization/

[7] частичную специализацию шаблонов: https://en.cppreference.com/w/cpp/memory/shared_ptr/atomic2

[8] предъявляет такие же требования: https://doc.rust-lang.org/std/sync/struct.Arc.html#thread-safety

[9] благодаря: https://doc.rust-lang.org/std/sync/struct.Arc.html#impl-Send-for-Arc%3CT,+A%3E

[10] трейту : https://doc.rust-lang.org/std/marker/trait.Send.html

[11] трейту : https://doc.rust-lang.org/std/marker/trait.Sync.html

[12] предотвращать гонки данных: https://doc.rust-lang.org/nomicon/races.html

[13] Источник: https://habr.com/ru/articles/793868/?utm_source=habrahabr&utm_medium=rss&utm_campaign=793868