Как стать автором
Обновить

Комментарии 22

Double-check lock

АААААА МОИ ГЛАЗА!!!!11
Это совсем не упрек автору, наоборот, отличная демонстрация того, что старые паттерны из языков с беспорядочным доступом к переменным в Rust либо не работают совсем, либо выглядят как зебра в розовом смокинге.
Вот бы еще к примерам "как выглядит паттерн из Java в Rust" добавить "Как нужно писать". В случае синглтона это sync::Once, например.

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

Объясните, пожалуйста, для человека знакомого лишь с предпосылками, но не подробностями Rust: мне казалось, что основные идеи Rust — это "бесконтрольный доступ опасен, обложим всё compile-time проверками, максимально затруднив написание опасного кода". Не являются ли эти примеры попыткой выкосить все хорошие нововведения Раста, чтобы они не мешали писать код, как раньше. Я понимаю, что в низкоуровневых библиотеках будет что-то подобное, но, казалось бы, их должны один раз написать, очень тщательно проверить их интерфейсы на безопасность, а потом пользоваться этими интерфейсами (не реализуя их каждый раз у себя)?

Да, вы правы. Статья о довольно низкоуровневом программировании. Веб сервисы или консольные утилиты так писать не стоит.
Статья и так получилась большой, поэтому на раздел «как нужно» не хватило. Если все сложится удачно я напишу статью-продолжение раскрывающую эту тему.
Это будет очень полезно, на фоне полного отсутствия паттернов проектирования для Rust. Такое не грех и на буржуйский перевести, вам весь мир будет стоя аплодировать.
По чуть-чуть читаю Rust Book, сейчас в районе 8 главы. Куски Rust-кода в статье всё ещё выглядят как инопланетные.

Извините если я чего-то не понял, но у double check lock есть применения кроме глобальной инициализации?


Если нет — то первые 2 паттерна в Rust покрываются sync::Once, lazy_static, при необходимости — mut_static. Ещё есть scoped_tls. Советовать писать такую колбасу для задач глобальной или ленивой инициализации не надо.

В статье в начале указано, что идиоматический способ другой. Сейчас выделил пункт жирным — что бы избежать недопонимания. Double check lock используют для ленивой не обязательно глобальной инициализации, поэтому sync::Once, lazy_static немного не тоже самое. Я думаю, что могут существовать ситуации, например при написании быстрых concurrency библиотек, когда подобное (не обязательно идентичное) может пригодится. В любом случае моя цель была в том, что бы начать строить мостик между человеком, который пишет concurrency код на Java, и человеком, который делает это в Rust. Подходы в языках очень разные и мне кажется не лишено смысла нащупать сперва какие-то точки соприкосновения. Тем более, что задача эта не на одну статью в любом случае.
Если пользоваться услугами стандартной библиотеки, то точки соприкосновения начинаются с предотвращения дедлоков (лечение идентично в обоих языках) и дальше по учебнику Гётца. При этом, как мне кажется (лично не проверял, так как в 99% случаев хватает возможностей std), кастомные низкоуровневые синхронизационные примитивы стоит писать полностью в unsafe, ради производительности — то есть по примеру Си. Всё равно они будут тестироваться для всех случаев использования, не так ли?
кастомные низкоуровневые синхронизационные примитивы стоит писать полностью в unsafe, ради производительности

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

Я иду от обратного — unsafe не прибавляет скорости, но его отсутствие может скорость подрезать. В угоду безопасности, конечно же. Согласен, что все это требует замеров.
В стандартной библиотеке много реализованных примитивов, поэтому даже для низкоуровневых задач потребности в unsafe не абсолютны. Например в четвертом примере мне требуется отправить значение в другие потоки и нет никакого смысла писать свой велосипедный Arc. И хотя есть варианты использования unsafe для производительности (например UnsafeCell), все же мне кажется главная причина его использования это наличие валидного кода для которого компилятор не может доказать валидность. Например в четвертом примере мне нужно атомарное обновление ссылки, однако доказывать корректность операций с AtomicPtr Rust не может.
А зачем в первом примере так извращаться?
Было бы куда проще и безопаснее написать просто с final. И можно шарить объект между потоками. final нам будет гарантировать видимость. Если вызвали конструктор, то значит это кому-нибудь нужно :) Лучше один раз выполнить работу и просто возвращать значение чем писать опасный код с double check lock.

public class Lazy<T> {
    private final T val;

    public Lazy(Supplier<T> supp) {
        this.val = supp.get();
    }

    public T get() {
        return val;
    }
}
Ровно затем, зачем нужна ленивость. Я согласен, что в большинстве случаев она не нужна. В большинстве случаев разработчику вообще не нужно ничего по этой теме. Но мне, например, по работе приходится писать довольно много асинхронной лапши.
В моем примере по сути тоже ленивость. Вызвали конструктор значит значение потребовалось. Мой пример не делает ничего заранее. Ленивость примера из статьи в том, что мы вызвали конструктор, но не пользуемся значением? Это тогда не ленивость, а просто забыли заюзать значение.
Ты неверно понимаешь смысл ленивости. Ленивость это когда у тебя есть код, результаты вычисления которого может быть понадобятся, а может быть и нет, а может быть и несколько раз. Ты пакуешь такой код в Lazy и гарантируешь, что он будет вызван не более одного раза. Любой ленивый код можно переписать в нелинивую версию. Но при создании лямбды может быть не очевидно кто и как её будет использовать, и тогда ленивая версия будет проще.
Например пусть supp достает данные из БД, а таких Lazy много и их кушает оптимизационный алгоритм, который нетривиально ходит по ним асинхронно и с возвращением. Проще написать алгоритм так, будто все данные находятся в памяти, в тоже время эффективнее читать из БД только то, что нужно. И тут тебя выручит ленивость.

Ну и зачем нужен такой класс Lazy?

Это ленивый класс. Как только он потребуется, тогда и узнаем — зачем
Дичайше плюсую все комментарии про критику первой реализации double lock, но нет кармы для голосования.

Вот из-за таких вот статей по всему интернету у нас до сих пор ничерта надежно в многопоточном режиме не работает.
Rust получился страшным, сложным, но мне он нравится тем что data races на нем быстро вылезают там, где даже на второй взгляд абсолютно нормальный код (из личного опыта).

Тем не менее, я считаю Rust слишком усложненным.

p.s. double lock из примера на самом деле никакой не дабл лок, а пародия на lock free с двумя проверками, и должен выглядеть как пример с «безопасной гонкой» далее по тексту.

Наткнулся я тут снова на старый пост, и поскольку теперь я знаю Rust лучше чем раньше — не могу удержаться от запоздалой критики.


По первому паттерну — Double-check lock


Когда мы используем UnsafeCell<Option<T>> — мы храним внутри признак инициализированности значения. Но он же самый хранится в поле init! Чтобы не хранить два раза одно и то же — лучше вместо Option использовать MaybeUninit (да, я в курсе что на момент написания поста этой штуки ещё не было):


pub struct Lazy<T, F: Fn() -> T> {
    init: AtomicBool,
    val: UnsafeCell<MaybeUninit<T>>,
    supp: Mutex<F>
}



Теперь про второй вариант, Double-check lock с удалением Supplier


Помимо того же самого соображения про MaybeUninit, как-то странно выглядит тип Mutex<UnsafeCell<Option<Arc<F>>>>. Во-первых, смысл дропать замыкание не только в занятой им памяти — но и в занятых им ресурсах! Во-вторых, Mutex уже является контейнером с внутренней мутабельностью и ему не требуется UnsafeCell. В-третьих, тут прямо-таки напрашивается использование FnOnce.


Так что структура должна выглядеть как-то так:


pub struct Lazy<T, F: FnOnce() -> T> {
    init: AtomicBool,
    val:  UnsafeCell<MaybeUninit<T>>,
    supp: Mutex<Option<F>>,
}



Наконец, ваш PoorMvcc попросту небезопасен, поскольку содержит гонку! Если один поток, исполняя get_read_copy, застрянет между self.current_value.load и Arc::clone, в то время как другой поток сделает return_write_copy — у вас будет повреждение кучи.

Хах! Приятно что к моим статьям возвращаются :-)

На самом деле я с тех пор перевел свою статью на английский и там код уже по чище.

Но про MaybeUninit я не знал конечно.

На самом деле в итоге я забил на изучение Раста.

Зарегистрируйтесь на Хабре , чтобы оставить комментарий

Публикации

Истории