Под катом находятся заметки, в которых расписано, как реализовать в Rust хитрые concurrency паттерны, которые я с легкостью пишу в Java, и в чем различие в подходах к concurrency у этих языков. Статья будет полезна и тем, кто переходит на Rust из C#, ведь у него аналогичная модель памяти.


Пара слов о разнице в подходах Java и Rust


Java первый язык с моделью памяти (которая описывает синхронизацию операций чтения и записи в память), Rust наследует модель памяти C++11 в реализации LLVM. Так что с точки зрения терминологии есть много различий. По сути же в Java есть 3 механизма синхронизации разделяемой памяти: volatile переменные, блокировки (блоки synchronized, ReentrantLock и т.д.) и atomic. В Rust только блокировки (Mutex, Rwlock) и atomic. Volatile не нужен поскольку дублирует функционал atomic. Передачу сообщений через каналы опустим.


Ещё отличия:


  1. В Rust нет сборщика мусора, поэтому придется класть расшаренные между потоками объекты в атомарный счетчик ссылок Arc. Более того, Rust трепетно относится к неизменяемым объектам, поэтому что бы изменить внутреннее состояние объекта без захвата mut ссылки придется обернуть его в Cell или аналог (RefCell, UnsafeCell).
  2. Операции над atomic в Rust используют барьеры четырех типов, но это не сильно меняет правила игры. Используйте Relaxed, когда чтение через гонку приемлемо (например под блокировкой), Acquire на чтение + Release на запись — когда не нужно что бы все потоки видели одинаковый порядок операций, и SeqCst — когда нужно. Напоминаю, что volatile в Java дает гарантии SeqCst. Для атомарных операций, которые делают за раз и чтение и запись (например swap), предусмотрена опция AcqRel (Acquire+Release).
  3. В Rust нет null и его нельзя использовать как индикатор. Для этих целей используйте Option, который вокруг указателя ничего не стоит. В Java порой использование null для индикатора тоже не лишено опасностей — см. второй пример.
  4. AtomicPtr в Rust не то же, что AtomicReferenc в Java. Это не очевидный момент, если смотреть только на семантику, но он становится очевидным при использовании — см. последний пример. Чаще проще использовать отдельный AtomicBool для синхронизации.
  5. В Rust блокировка (Mutex, RwLock) неотделима от объекта, вокруг которого она берется. И доступ к объекту нельзя получить иным способом, кроме как через захват блокировки. В Java тоже нужен объект синхронизации для монитора, но обращаться к нему можно без его захвата.
  6. Вы должны сознательно унаследовать Sync с помощью unsafe для класса, который хотите передавать между потоками.
  7. В отличие от сравнительно простой идеи happens-before, которая хороша общностью, идея барьеров намного более сложная для анализа кода. Думаю, вы увидите это на примерах.
  8. Всё таки Rust подразумевает, что вы не будете часто шарить изменяемые данные между потоками и организуете работу иначе.

Учтите так же, что я сам только учу Rust поэтому к коду на нем стоит отнестись скептически. Я его тестировал, но в контексте concurrency это ничего не гарантирует.


Double-check lock


В Java мире очень любят double-check-lock т.к. до Java 1.5 его невозможно было реализовать корректно. Напоминаю как это выглядит:


public class Lazy<T> {
    volatile T val;
    final Supplier<T> supp;

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

    public T get() {
        if (val == null) {
            synchronized (this) {
                if (val == null) {
                    val = supp.get();
                }
            }
        }
        return val;
    }
}

Людей, привыкших к написанию concurrency кода, таким не удивить, однако если вдуматься тут есть масса опасных операций. Во-первых, чтение через гонку. Во-вторых, мы шарим между потоками изменяемый объект (метод get меняет внутреннее состояние объекта). Rust такого не любит. Он вообще злой. Тем не менее максимально близкий аналог в Rust выглядит так:


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

unsafe impl <T, F: Fn() -> T> Sync for Lazy<T, F> {}

impl <T, F: Fn() -> T> Lazy<T, F> {
    fn new(func: F) -> Self {
        Lazy{
            init: AtomicBool::new(false),
            val:  UnsafeCell::new(None),
            supp: Mutex::new(func)
        }
    }

    fn get<'a>(&'a self) -> &'a T {
        if !self.init.load(Ordering::Acquire) {
            let func = self.supp.lock().unwrap();

            if !self.init.load(Ordering::Relaxed) {
                let value = unsafe { &mut *self.val.get() };
                *value = Some(func());

                self.init.store(true, Ordering::Release);
            }
        }

        unsafe {
            (*self.val.get()).as_ref().unwrap()
        }
    }
}

Выглядит необычно, но stackoverflow считает, что так и надо.


Кода тут намного больше. Не обращайте внимания на Mutex вокруг supp — он просто где-то должен быть. Из-за отсутствия null пришлось обернуть val в Option. А из-за сложностей в использовании AtomicPtr пришлось добавить AtomicBool флаг инициализации.


Здесь обратите внимание на разницу в доказательстве корректности. Например в Java, если убрать volatile, то вы просто говорите, что нет happens-before из записи в чтение, и на этом анализ заканчивается. Искать конкретный вариант исполнения, при котором проблема реализуется, не нужно.


В Rust же вы рискуете попасть в ловушку из-за разных вариантов барьеров памяти. Поэтому придется пробежать все варианты исполнения. Если вы прочитали в init true это значит вы видите запись в него и в val (по транзитивности). Поэтому все сработает нормально. Иначе вы берете блокировку, тогда чтение true там тоже гарантирует видимость записи в val. В последнем третьем случае если вы и есть тот поток, что записал значение, вы его тоже увидите т.к. другие потоки не пишут значение (и если бы вы не могли прочитать свою собственную запись — это сломало бы однониточные программы).


Double-check lock с удалением Supplier


В прошлом примере переменная supp не нужна после использования. Я встретил этот пример в докладе Шипилева о JMM.


Код в Java:


public class LazyOpt<T> {
    volatile Supplier<T> supp;
    T val;

    final static Supplier EMPTY = new Supplier() {
        @Override public Object get() {
            return null;
        }
    };

    public LazyOpt(Supplier<T> supp) {
        this.supp = supp;
    }

    public T get() {
        if (supp != EMPTY) {
            synchronized (this) {
                if (val == null) {
                    val = supp.get();
                    supp = EMPTY;
                }
            }
        }
        return val;
    }
}

Тут все просто и знакомо: это почти double-check-lock, с той лишь разницей, что мы удаляем функцию, вычисляющую значение. Спецификация JMM позволяет нам увидеть supp == null до того как туда кто-то что-то положил, поэтому надо использовать EMPTY (подробнее в докладе Шипилева в самом конце, он просил не спрашивать это на собеседованиях). Volatile на supp нужен, что бы пробросить happens-before между операцией записи внутри synchronized и чтением вне его. Volatile на val не нужен, т.к. тот, кто видит запись в supp, видит и запись в val.


Сразу стоит сказать, что этот пример может и не иметь смысла в Rust. У структур на стеке фиксированный размер, и если supp не указатель, то ничего не выиграть, если затрете его в None. Но если supp по какой-то причине Arc, тогда можно.


Аналог в Rust:


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

unsafe impl <T, F: Fn() -> T> Sync for Lazy<T, F> {}

impl <T, F: Fn() -> T> Lazy<T, F> {
    fn new(func: F) -> Self {
        Lazy{
            init: AtomicBool::new(false),
            val:  UnsafeCell::new(None),
            supp: Mutex::new(UnsafeCell::new(Some(Arc::new(func)))),
        }
    }

    fn get<'a>(&'a self) -> &'a T {
        if !self.init.load(Ordering::Acquire) {
            let supp = self.supp.lock().unwrap();

            if !self.init.load(Ordering::Relaxed) {
                let value = unsafe { &mut *self.val.get() };
                let func = unsafe { & *supp.get() };

                if let Some(ref f) = *func {
                    *value = Some(f());
                }

                let func = unsafe { &mut *supp.get() };

                *func = None;

                self.init.store(true, Ordering::Release);
            }
        }

        unsafe {
            (*self.val.get()).as_ref().unwrap()
        }
    }
}

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


Безопасная гонка (benign races)


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


public class LazyHash {
    int hash;

    @Override public int hashCode() {
        int temp = hash;
        if (temp == 0) {
            temp = supp();
            hash = temp;
        }

        return temp;
    }

    public int supp() {
        return 4;
    }
}

Тут главный секрет в том что:


  1. Нас не парит нетранзитивность видимости полей объекта если: их нет, они final, либо если это не класс, а примитив (за исключением long и double). В остальных случаях она парит, т.к. её нельзя исправить задним числом.
  2. Если supp вызвать много раз, он вернёт одно и тоже значение (не равное по ссылке).
  3. Нас не парит, что объекты могут быть разными по ссылке, если в остальном они одинаковые.
  4. Мы сохраняем объект в локальную копию, что бы избежать возврата null при двойном чтении.
  5. Оптимизация в этом месте необходима (пункт срезающий большинство ситуаций).

Хотя код выглядит так будто мы не используем иных гарантий на операции, помимо гарантий для работы однониточных программ, это не так. Ведь Java гарантирует атомарность операций чтения и записи над примитивами (включая ссылки) кроме long и double, а без атомарности этот код не корректен. Также трюком с локальной переменной мы добавляем гарантию монотонности. Rust дает в точности эти же гарантии если сделаете Relaxed операцию..


Хешы считают в Rust хитрее чем в Java, поэтому пример ниже — абстрактный getter:


pub struct LazyHash {
    hash: AtomicIsize
}

impl LazyHash {
    fn new() -> Self {
        LazyHash{hash: AtomicIsize::new(0)}
    }

    fn get(&self) -> isize {
        let mut temp = self.hash.load(Ordering::Relaxed);

        if temp == 0 {
            temp = self.supp();
            self.hash.store(temp, Ordering::Relaxed);
        }

        temp
    }

    fn supp(&self) ->isize {
        4
    }
}

В Rust в конце можно было бы вернуть вместо temp честный self.hash.load(Ordering::Relaxed), просто в этом нет смысла. Так же в nightly сборки Rust куча атомиков (даже 128 битные), а в Java сделать такой же фокус с 64-х битным long не получится и придется лепить volatile.


Read-write структура для одного писателя


Я услышал о такой идеи в докладе computer science ещё жива, где описывались хитрые алгоритмы используемые в IDEA.


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


Честно говоря описание задачи похоже на mvcc для бедных. Поясню. Если нет нужды в монотонности, то можно обойтись без блокировок в ситуации, когда имеется не более одного писателя и много чтецов. Чтецы читают из последней (на момент начала операции) копии данных, а писатель пишет в личную копию. Когда писатель заканчивает работу, он обновляет общую копию атомарно, и новые читатели будут брать уже её. Старые читатели будут работать со старой копией. Тут нарушается монотонность т.к. новый быстрый чтец может вернуть новое значение, а после него старый медленный чтец вернет старое значение. Однако заметить нарушение монотонности нельзя если получать значения в том же порядке, в котором создавались задачи или не ожидать завершения задач из других потоков. В реализации ниже это считается приемлемым.


Однако в голову приходит по меньшей мере два способа починить монотонность:


  1. Для случая когда нам важен throughput можно добавить блокировку: новые чтецы и писатель не сообщат о завершении своей работы пока все старые чтецы этого не сделают. Это не тоже самое что ReadWrite блокировка поскольку задачи физически выполняются одновременно и мы просто синхронизируем видимость: после снятия блокировки все готовые результаты станут видны мгновенно. Можно отслеживать последнего, кто взял текущую копию для чтения, и все, кто взял следующую копию, должны будут подождать пока он её отпустит, прежде чем отпустить свою.
  2. Если же мы предполагаем, что писатель у нас в приоритете и нам важна его latency и есть возможность отменять и перезапускать задачи чтецов, то можно перезапустить все задачи, которые не успели выполниться к моменту, когда писатель обновил значение — наверное это больше подходит для use case из доклада.

Код ниже не использует ThreadLocal (информации в докладе недостаточно что бы понять зачем он нужен, если можно обойтись без него). Этот код остается валидным даже если писателей больше одного. Но сломается если читатели начнут писать.


public class PoorMvcc<T extends PoorMvcc.Copyable<T>> {
    volatile T currentValue;

    final ReentrantLock writersSync = new ReentrantLock();

    public PoorMvcc(T val) {
        currentValue = val;
    }

    public T getReadCopy() {
        return currentValue;
    }

    public T getWriteCopy() {
        writersSync.lock();
        // Copy shoud not throw any exception.
        return currentValue.copy();
    }

    public void returnReadCopy(T oldValue) {
        // No-op because we don't care about monotonic.
    }

    public void returnWriteCopy(T newValue) {
        currentValue = newValue;
        writersSync.unlock();
    }

    // Because Cloneable isn't generic and method clone is protected.
    public static interface Copyable<T> {
        T copy();
    }
}

Для разработчиков Rust может показаться дикостью, что приходится объявлять собственный интерфейс для копирования. В Java метод clone объявлен protected т.е. он видим только потомкам (где можно объявить его public) и даже если объявить тип T наследником Cloneable это не решит проблему. К слову в C# с этим все тоже не слава богу: интерфейс IСloneable не generic и возвращает Object, который ещё надо кастовать к нужному типу.


Я сделал бенчмарк на JMH. Даже в случае 1 писателя и 1 читателя реализация на PoorMvcc намного быстрее реализации на ReentrantReadWriteLock. С увеличением числа писателей разрыв только растет. Я не знаю как написать аналогичный бенчмарк на Rust. Кажется там нет функционала для проверки того как методы влияют друг на друга исполняясь одновременно.


Бенчмарк 1 писателя и 10 читателей и его результаты
    public static class LongCopy implements PoorMvcc.Copyable<LongCopy> {
        public long val;
        public LongCopy(long val) {
            this.val = val;
        }

        @Override public LongCopy copy() {
            return new LongCopy(val);
        }
    }

    public final PoorMvcc<LongCopy> poorMvcc = new PoorMvcc<>(new LongCopy(1));

    @Benchmark
    @Group("g")
    @GroupThreads(1)
    public long inc() {
        LongCopy copy = poorMvcc.getWriteCopy();
        try {
            copy.val++;
            return copy.val;
        } finally {
            poorMvcc.returnWriteCopy(copy);
        }
    }

    @Benchmark
    @Group("g")
    @GroupThreads(10)
    public long read() {
        LongCopy copy = poorMvcc.getReadCopy();
        try {
            return copy.val;
        } finally {
            poorMvcc.returnReadCopy(copy);
        }
    }

    final LongCopy longCopy = new LongCopy(1);
    final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    @Benchmark
    @Group("g2")
    @GroupThreads(1)
    public long inc2() {
        ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
        writeLock.lock();
        try {
            longCopy.val++;
            return longCopy.val;
        } finally {
            writeLock.unlock();
        }
    }

    @Benchmark
    @Group("g2")
    @GroupThreads(10)
    public long read2() {
        ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
        readLock.lock();
        try {
            return longCopy.val;
        } finally {
            readLock.unlock();
        }
    }

Результат:


Benchmark      Mode  Cnt      Score       Error  Units
Main.g         avgt   20     60,452 ±     2,956  ns/op
Main.g:inc     avgt   20    344,700 ±    33,870  ns/op
Main.g:read    avgt   20     32,027 ±     0,935  ns/op
Main.g2        avgt   20   4093,235 ±  3403,373  ns/op
Main.g2:inc2   avgt   20  28628,403 ± 37297,944  ns/op
Main.g2:read2  avgt   20   1639,718 ±    52,243  ns/op

Инкремент быстрее в 83 раза, а чтение в 51. Если читателей станет 100, тогда инкремент будет быстрее в 12 000 раз, а чтение в 17 000.


В Rust есть Thread local, но он нам не понадобится тут тоже.


struct PoorMvcc<T: Clone> {
    current_value: AtomicPtr<T>,
    write_copy: Mutex<T>
}

unsafe impl <T: Clone> Sync for PoorMvcc<T> {}

impl <T: Clone> PoorMvcc<T> {
    fn new(val: T) -> Self {
        PoorMvcc {
            write_copy: Mutex::new(val.clone()),
            current_value: AtomicPtr::new(Arc::into_raw(Arc::new(val)) as *mut _)
        }
    }

    fn get_read_copy(&self) -> Arc<T> {
        let val = unsafe {Arc::from_raw(self.current_value.load(Ordering::Acquire))};

        let copy = Arc::clone(&val);

        Arc::into_raw(val);

        copy
    }

    fn return_read_copy(&self, val: Arc<T>) {
        // Eliminate a link
    }

    fn get_write_copy(&self) -> MutexGuard<T> {
        self.write_copy.lock().unwrap()
    }

    fn return_write_copy(&self, val: MutexGuard<T>) {
        let new_val = Arc::new(val.clone());

        let old_val = self.current_value.swap(Arc::into_raw(new_val) as *mut _, Ordering::AcqRel);

        //To avoid a memory leak we have to eliminate the old value.
        unsafe {Arc::from_raw(old_val);}
    }
}

Давайте сперва рассмотрим получение значения для чтения. Работать с AtomicPtr в Rust надо аккуратно что бы и память не утекла и не освободилась слишком рано. Я кладу внутрь Arc поскольку результат будет передан между потоками. Соответственно get_read_copy достает атомарно текущую Arc ссылку и возвращает её клон (так в Rust передаются Arc ссылки). Если больше ничего не сделать, то в AtomicPtr протухнет указатель, ведь считанная оттуда Arc ссылка освободится, поэтому я делаю into_raw, что бы этого не произошло. А return_read_copy принимает Arc ссылку, уничтожая эту копию.


Получение значения на запись похитрее. Поскольку надо синхронизировать записи блокировкой, я завожу отдельную копию данных обернутую в Mutex. При вызове get_write_copy я просто возвращаю значение из Mutex (оно обернуто в MutexGuard что бы освободить потом блокировку — это полный аналог кода в Java). А в return_write_copy принимаю его назад, потом подменяю старое значение для чтения на новую копию. Тут есть хитрость с операцией swap, которую надо сделать атомарно, что бы потом освободить старую Arc ссылку (у других потоков может остаться копия).


На сегодня всё. Поиск ошибок приветствуется.

Комментарии (20)


  1. snuk182
    05.09.2017 23:42
    +8

    Double-check lock

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


    1. vitvakatu
      06.09.2017 00:02
      +6

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


    1. atrosinenko
      06.09.2017 13:43

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


      1. SharplEr Автор
        06.09.2017 14:08
        +2

        Да, вы правы. Статья о довольно низкоуровневом программировании. Веб сервисы или консольные утилиты так писать не стоит.


    1. SharplEr Автор
      06.09.2017 14:11
      +7

      Статья и так получилась большой, поэтому на раздел «как нужно» не хватило. Если все сложится удачно я напишу статью-продолжение раскрывающую эту тему.


      1. snuk182
        07.09.2017 18:35
        +2

        Это будет очень полезно, на фоне полного отсутствия паттернов проектирования для Rust. Такое не грех и на буржуйский перевести, вам весь мир будет стоя аплодировать.


  1. amarao
    06.09.2017 11:31
    +2

    По чуть-чуть читаю Rust Book, сейчас в районе 8 главы. Куски Rust-кода в статье всё ещё выглядят как инопланетные.


  1. mkpankov
    06.09.2017 12:31
    +4

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


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


    1. SharplEr Автор
      06.09.2017 14:01
      +2

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


      1. snuk182
        07.09.2017 13:06
        +1

        Если пользоваться услугами стандартной библиотеки, то точки соприкосновения начинаются с предотвращения дедлоков (лечение идентично в обоих языках) и дальше по учебнику Гётца. При этом, как мне кажется (лично не проверял, так как в 99% случаев хватает возможностей std), кастомные низкоуровневые синхронизационные примитивы стоит писать полностью в unsafe, ради производительности — то есть по примеру Си. Всё равно они будут тестироваться для всех случаев использования, не так ли?


        1. DarkEld3r
          07.09.2017 14:54
          +2

          кастомные низкоуровневые синхронизационные примитивы стоит писать полностью в unsafe, ради производительности

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


          1. snuk182
            07.09.2017 15:01
            +2

            Я иду от обратного — unsafe не прибавляет скорости, но его отсутствие может скорость подрезать. В угоду безопасности, конечно же. Согласен, что все это требует замеров.


            1. SharplEr Автор
              07.09.2017 17:02
              +1

              В стандартной библиотеке много реализованных примитивов, поэтому даже для низкоуровневых задач потребности в unsafe не абсолютны. Например в четвертом примере мне требуется отправить значение в другие потоки и нет никакого смысла писать свой велосипедный Arc. И хотя есть варианты использования unsafe для производительности (например UnsafeCell), все же мне кажется главная причина его использования это наличие валидного кода для которого компилятор не может доказать валидность. Например в четвертом примере мне нужно атомарное обновление ссылки, однако доказывать корректность операций с AtomicPtr Rust не может.


  1. lukdiman
    06.09.2017 13:57
    -4

    А зачем в первом примере так извращаться?
    Было бы куда проще и безопаснее написать просто с 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;
        }
    }
    


    1. SharplEr Автор
      06.09.2017 14:06
      +2

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


      1. lukdiman
        06.09.2017 14:44
        -4

        В моем примере по сути тоже ленивость. Вызвали конструктор значит значение потребовалось. Мой пример не делает ничего заранее. Ленивость примера из статьи в том, что мы вызвали конструктор, но не пользуемся значением? Это тогда не ленивость, а просто забыли заюзать значение.


        1. SharplEr Автор
          06.09.2017 15:10
          +6

          Ты неверно понимаешь смысл ленивости. Ленивость это когда у тебя есть код, результаты вычисления которого может быть понадобятся, а может быть и нет, а может быть и несколько раз. Ты пакуешь такой код в Lazy и гарантируешь, что он будет вызван не более одного раза. Любой ленивый код можно переписать в нелинивую версию. Но при создании лямбды может быть не очевидно кто и как её будет использовать, и тогда ленивая версия будет проще.
          Например пусть supp достает данные из БД, а таких Lazy много и их кушает оптимизационный алгоритм, который нетривиально ходит по ним асинхронно и с возвращением. Проще написать алгоритм так, будто все данные находятся в памяти, в тоже время эффективнее читать из БД только то, что нужно. И тут тебя выручит ленивость.


    1. mayorovp
      06.09.2017 14:43
      +3

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


      1. olegchir
        07.09.2017 22:10
        +1

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


  1. ngreduce
    08.09.2017 12:59
    -1

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

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

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

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