Собственная потокобезопасная реализация Lazy<T>
От: RAza  
Дата: 26.12.18 12:11
Оценка:
Приветствую.

Хочу спроектировать тип аналогичный типу Lazy<T> или другими словами предоставляющий функционал аналогичный следующему сниппету:

private T? value = default;
 
public T Value
{
    get
    {
        if (value.HasValue)
            return value.Value;

        return (value = ...).Value;
    }
    set
    {
        this.value = value;
    }
}


Цели, которые я преследую:

    1. Поведение данного типа при доступе к экземпляру из нескольких конкурентных потоков аналогично поведению типа Lazy<T> при использовании режима LazyThreadSafetyMode.ExecutionAndPublication за исключением того, что исключения полученные при использовании фабричного метода НЕ будут кэшированы. Таким образом только один конкурентный поток попытается создать экземпляр указанного типа; при успешном создании все ожидающие потоки получат одинаковое значение; если во время создания возникает необработанное исключение, оно будет повторно создано для каждого ожидающего потока, но оно не будет кэшироваться, и последующие попытки получить доступ к значению повторят попытку создания и могут быть успешными.
    2. Тип является потокобезопасным

public class MyLazy<T>
{
    private readonly Func<T> getAction = null;

    private readonly object obj = new object();

    private readonly bool canBeReseted = false;

    private bool isExist = false;

    private T value = default;

    public bool HasValue => isExist;

    public T Value
    {
        get
        {
            if (Volatile.Read(ref isExist))
                return value;

            lock (obj)
            {
                if (Volatile.Read(ref isExist))
                    return value;

                value = getAction();

                Volatile.Write(ref isExist, true);
            }

            return value;
        }
        set
        {
            lock (obj)
            {
                this.value = value;

                Volatile.Write(ref isExist, true);
            }
        }
    }

    public MyLazy(Func<T> getAction, bool canBeReseted)
            : this(getAction)
        => this.canBeReseted = canBeReseted;

    public MyLazy(Func<T> getAction)
        => this.getAction = getAction;

    public void Reset()
    {
        if (canBeReseted == false)
            return;

        lock (obj)
        {
            Volatile.Write(ref this.isExist, false);
        }
    }
}


Вопросы:

    1. действительно ли данный тип является потокобезопасным?
    2. может ли быть race condition который я пропустил?
Отредактировано 26.12.2018 12:37 RAza . Предыдущая версия .
Re: Собственная потокобезопасная реализация Lazy<T>
От: Sharov Россия  
Дата: 26.12.18 12:51
Оценка:
Здравствуйте, RAza, Вы писали:

1)Для инициализации см. сюда -- https://en.wikipedia.org/wiki/Double-checked_locking.
2)Зачем Volatile.*, когда переменную можно объявить с модификатором volatile?
Кодом людям нужно помогать!
Re[2]: Собственная потокобезопасная реализация Lazy<T>
От: RAza  
Дата: 26.12.18 13:18
Оценка:
Здравствуйте, Sharov, Вы писали:

S>1)Для инициализации см. сюда -- https://en.wikipedia.org/wiki/Double-checked_locking.

На сколько я понимаю у меня инициализация как раз находится внутри конструкции DCL.

S>2)Зачем Volatile.*, когда переменную можно объявить с модификатором volatile?

Набор личных предубеждений. Вроде бы это ни на что не должно повлиять в данном случае?
Отредактировано 26.12.2018 13:19 RAza . Предыдущая версия .
Re[3]: Собственная потокобезопасная реализация Lazy<T>
От: Sharov Россия  
Дата: 26.12.18 14:08
Оценка:
Здравствуйте, RAza, Вы писали:

S>>2)Зачем Volatile.*, когда переменную можно объявить с модификатором volatile?

RA>Набор личных предубеждений. Вроде бы это ни на что не должно повлиять в данном случае?

А чем, кстати, Interlocked не подошел?
Кодом людям нужно помогать!
Re: Собственная потокобезопасная реализация Lazy<T>
От: RushDevion Россия  
Дата: 26.12.18 21:59
Оценка: +2
RA>1. действительно ли данный тип является потокобезопасным?
Да. Это стандартный double check lock.
RA>2. может ли быть race condition который я пропустил?
Я не увидел.

Не знаю, зачем тебе это, но если это production-код, то:
1. Нет проверки на null для getAction
2. Volatile.Read/Write из-под lock не имеют смысла, т.к. lock уже ставит все memory barriers. Можно просто читать/писать в переменную.
3. И да, можно переписать на Interlocked.CompareExchange и обойтись без lock.
Re: Собственная потокобезопасная реализация Lazy<T>
От: Vladek Россия Github
Дата: 26.12.18 23:00
Оценка:
Здравствуйте, RAza, Вы писали:

RA>Приветствую.


RA>Хочу спроектировать тип аналогичный типу Lazy<T> или другими словами предоставляющий функционал аналогичный следующему сниппету:


Слабо понял, что нужно, но советую сначала почитать это https://stuartlang.uk/miscellaneous-csharp-async-tips/

Там упоминаются две либы, возможно ничего изобретать не придётся.

https://github.com/Microsoft/vs-threading
https://github.com/StephenCleary/AsyncEx
Re: Interlocked
От: RAza  
Дата: 27.12.18 12:23
Оценка:
S>А чем, кстати, Interlocked не подошел?
RD>3. И да, можно переписать на Interlocked.CompareExchange и обойтись без lock.

Никогда не использовал это на практике. Вас не затруднит показать реализацию? И в двух словах объяснить в чем плюсы по сравнению с DCL?
Re[2]: Собственная потокобезопасная реализация Lazy<T>
От: RAza  
Дата: 27.12.18 12:31
Оценка:
Здравствуйте, RushDevion, Вы писали:

RD>Не знаю, зачем тебе это, но...

Несколько потоков, работают с этим поставщиком данных. Логика у потоков различная (условно некоторые только читают, другие в ряде случаев могут обновть значение или "сбросить"). Но всем для работы нужно данное значение. Допустим, это значение можно получить через сеть, поэтому я хочу, чтобы только один из потоков создал запрос на его получение. Запрос может закончиться с исключением, и это означает, что все потоки должны попытаться получить его позже. Когда значение получено, некоторые потоки могут его изменить, а все остальные должны использовать обновленное на следующих итерациях (обращениях к поставщику).

RD>1. Нет проверки на null для getAction

Я использую Fody NullGuard. В примере убрал все "лишние".

RD>2. Volatile.Read/Write из-под lock не имеют смысла, т.к. lock уже ставит все memory barriers. Можно просто читать/писать в переменную.

Это может оказать влияние на производительность?
Re[2]: Собственная потокобезопасная реализация Lazy<T>
От: RAza  
Дата: 27.12.18 12:34
Оценка:
Здравствуйте, Vladek, Вы писали:

V>Слабо понял, что нужно, но советую сначала почитать это https://stuartlang.uk/miscellaneous-csharp-async-tips/

V>Там упоминаются две либы, возможно ничего изобретать не придётся.
V>https://github.com/Microsoft/vs-threading
V>https://github.com/StephenCleary/AsyncEx

Юз-кейс описал в ветке выше. За статью спасибо! С AsyncEx знаком. Во второй типа с необходимым мне поведением с виду нет.
Re[2]: Interlocked
От: Sharov Россия  
Дата: 27.12.18 13:35
Оценка:
Здравствуйте, RAza, Вы писали:


S>>А чем, кстати, Interlocked не подошел?

RD>>3. И да, можно переписать на Interlocked.CompareExchange и обойтись без lock.

RA>Никогда не использовал это на практике. Вас не затруднит показать реализацию? И в двух словах объяснить в чем плюсы по сравнению с DCL?


Я хотело вместо Volatile.Read посоветовать Interlocked.Read, но он только для int64. Смотрите сюда. C dcl все нормально, Volatile.* я бы убрал.
Кодом людям нужно помогать!
Re: Собственная потокобезопасная реализация Lazy<T>
От: pugv Россия  
Дата: 27.12.18 15:09
Оценка:
Здравствуйте, RAza, Вы писали:

RA> если во время создания возникает необработанное исключение, оно будет повторно создано для каждого ожидающего потока


Такого поведения тут нет. Для каждого будет вызываться getAction.
Re[2]: Собственная потокобезопасная реализация Lazy<T>
От: Sharov Россия  
Дата: 27.12.18 15:50
Оценка:
Здравствуйте, pugv, Вы писали:

P>Здравствуйте, RAza, Вы писали:


RA>> если во время создания возникает необработанное исключение, оно будет повторно создано для каждого ожидающего потока


P>Такого поведения тут нет. Для каждого будет вызываться getAction.


Если getAction отработал без ошибок, то он будет вызван только 1 раз.
Кодом людям нужно помогать!
Re[3]: Собственная потокобезопасная реализация Lazy<T>
От: pugv Россия  
Дата: 27.12.18 15:53
Оценка:
Здравствуйте, Sharov, Вы писали:

RA>>> если во время создания возникает необработанное исключение, оно будет повторно создано для каждого ожидающего потока


S>Если getAction отработал без ошибок, то он будет вызван только 1 раз.


Речь о необработанных исключениях и ожидающих в это время на локе потоках.
Re[2]: Interlocked
От: RushDevion Россия  
Дата: 28.12.18 10:07
Оценка: 6 (2) +1
Здравствуйте, RAza, Вы писали:

S>>А чем, кстати, Interlocked не подошел?

RD>>3. И да, можно переписать на Interlocked.CompareExchange и обойтись без lock.
RA>Никогда не использовал это на практике. Вас не затруднит показать реализацию? И в двух словах объяснить в чем плюсы по сравнению с DCL?

Для начала, зачем все это надо.
1. Иногда код алгоритма на Interlocked-инструкциях получается чище, чем с применением примитивов синхронизации (lock, производные WaitHandle и т.п.)
2. Мы экономим объект (тот, на котором делается lock).
3. Interlocked-инструкции быстрее (ns против ms у других примитивов). Поэтому в высококонкурентных lock-free алгоритмах используют именно их.

И сразу оговорюсь, что рядовому разработчику корпоративного софта (ну типа меня ) исчезающе редко (т.е. практически никогда)
приходится писать код, где разница в производительности между Interlocked/не-Interlocked ощутимо влияет на performance.

Теперь по реализации. Покажу на примере getter'a для Value
public class Lazy<T> where T : class
{
    private readonly Func<T> m_Factory;
    private int m_Initializing = 0;
    private T m_Value;

    public Lazy(Func<T> factory) { m_Factory = factory; }

    public T Value
    {
        get
        {
            var curVal = Volatile.Read(ref m_Value);
            if (curVal != default(T)) return curVal;
        
            // Самый простой и красивый вариант. 
            // Когда я предлагал Interlocked, то думал именно о нем.
            // Атомарно проверяем, что m_Value == null, если да - инициализируем.
            // К сожалениею, он не обеспечивает требование exact-once инициализации.
            Interlocked.CompareExchange(ref m_Value, m_Factory(), default(T));
            return Volatile.Read(ref m_Value);


            // Вариант с exact-once инициализацией (без обработки ошибок)
            if (Interlocked.CompareExchange(ref m_Initializing, 1, 0) == 0)
            {
                // Захватили право на инициализацию, инициируем
                var val = m_Factory();
                Volatile.Write(ref m_Value, val);
                return val;
            }

            // Если мы попали сюда, то право на инициализацию захватил кто-то другой, будем ждать, пока он проинициализирует
            while (true)
            {
                var val = Volatile.Read(ref m_Value);
                if (val != default(T)) return val;

                // Это так называемый Sleep-wait, 
                // В lock-free часто применяют SpinWait/Thread.Yield либо их комбинации
                Thread.Sleep(TimeSpan.FromMilliseconds(5));
            }
        }
    }
}


А если добавить обработку ошибок инициализации, то код будет еще сложнее. При этом профит от lock-free выглядит более чем сомнительным.
Так что рассматривай это скорее как академический пример.
Re[3]: Interlocked
От: RAza  
Дата: 29.12.18 09:37
Оценка:
Здравствуйте, RushDevion, Вы писали:

RD>Теперь по реализации. Покажу на примере getter'a для Value

RD>А если добавить обработку ошибок инициализации, то код будет еще сложнее. При этом профит от lock-free выглядит более чем сомнительным.
RD>Так что рассматривай это скорее как академический пример.

Большое спасибо за пример. Основную идею я понял. В посте ниже справедливо указали, на то, что моя реализация не отвечает заявленным критериям, а именно при возникновении ошибки при получении значения ожидающие потоки не получат тот же объект исключения. Сейчас набросал небольшой тест кейс и думаю как модифицировать мою реализацию. Пока безуспешно.

На первый взгляд в вашей реализации добавить код для обработки ошибок инициализации значительно проще. Достаточно в цикле бросить исключение для всех ожидающих потоков и изменить механизм Sleep-wait.
Но меня не устраивает ограничение:

RD>
RD>public class Lazy<T> where T : class
RD>...
RD>


На сколько понимаю это связано из-за ограничения методов Volatile.* Это можно как то обойти?
Re[4]: Interlocked
От: RushDevion Россия  
Дата: 29.12.18 10:32
Оценка: 3 (1)
RA>Большое спасибо за пример. Основную идею я понял. В посте ниже справедливо указали, на то, что моя реализация не отвечает заявленным критериям, а именно при возникновении ошибки при получении значения ожидающие потоки не получат тот же объект исключения. Сейчас набросал небольшой тест кейс и думаю как модифицировать мою реализацию. Пока безуспешно.
Как идея: можно возвращать Task. Тогда проброс ошибок будет автоматическим.
public class Lazy<T>
{
    private Task<T> m_Value;
    private readonly Func<Task<T>> m_Factory;

    public Lazy(Func<Task<T>> factory) { m_Factory = factory; }

    public Task<T> GetValueAsync()
    {
        // Запускаем инициализацию
        Interlocked.CompareExchange(ref m_Value, m_Factory(), null);
        var task = Volatile.Read(ref m_Value);
        
        // Ошибка инициализации? Сбросим текущую таску в null, чтобы перезапуститься при след. обращении
        task.ContinueWith(_ =>
                Interlocked.CompareExchange(ref m_Value, null, task),
            TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously);

        return task;
    }
}



RA>На сколько понимаю это связано из-за ограничения методов Volatile.* Это можно как то обойти?

Да, Volatile работает с очень ограниченным набором типов.
Ну можно использовать вложенный класс, типа такого:
public class Lazy<T>
{
    private ValueHolder m_ValueHolder;
    private Func<T> m_Factory;

    private  class ValueHolder
    {
        public T Value;
    }

    private T Value
    {
        get
        {
            Interlocked.CompareExchange(ref m_ValueHolder, new ValueHolder {Value = m_Factory()}, null);
            var holder = Volatile.Read(ref m_ValueHolder);
            return holder.Value;
        }
    }
}
Re[5]: Interlocked
От: Sharov Россия  
Дата: 29.12.18 10:46
Оценка:
Здравствуйте, RushDevion, Вы писали:

RD> public Task<T> GetValueAsync()

RD> {

RD> // Ошибка инициализации? Сбросим текущую таску в null, чтобы перезапуститься при след. обращении

RD> task.ContinueWith(_ =>
RD> Interlocked.CompareExchange(ref m_Value, null, task),
RD> TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously);

RD> return task;

RD> }

А тут rc не будет случаем? Может continuation прицепить к родителю (task)?
Кодом людям нужно помогать!
Re[6]: Interlocked
От: RushDevion Россия  
Дата: 29.12.18 11:19
Оценка:
Здравствуйте, Sharov, Вы писали:
S>Здравствуйте, RushDevion, Вы писали:
RD>> public Task<T> GetValueAsync()
RD>> {

RD>> // Ошибка инициализации? Сбросим текущую таску в null, чтобы перезапуститься при след. обращении

RD>> task.ContinueWith(_ =>
RD>> Interlocked.CompareExchange(ref m_Value, null, task),
RD>> TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously);

RD>> return task;

RD>> }

S>А тут rc не будет случаем? Может continuation прицепить к родителю (task)?

Так оно и цепляется к task
Я возможностей для rc не вижу.

Но тут может быть множественная инициализация.
Если два и более потоков придут одновременно, то каждый из них запустит инициализацию.
Но востребованной будет только одна. Невостребованные либо тихо умрут, либо (худший случай) зафейлятся и улетят в Unobseved Task Exception handler.
Я же сказал, что это идея, а не production code
Re[7]: Interlocked
От: Sharov Россия  
Дата: 29.12.18 11:32
Оценка: +1
Здравствуйте, RushDevion, Вы писали:

RD>Здравствуйте, Sharov, Вы писали:

S>>Здравствуйте, RushDevion, Вы писали:
RD>>> public Task<T> GetValueAsync()
RD>>> {

RD>>> // Ошибка инициализации? Сбросим текущую таску в null, чтобы перезапуститься при след. обращении

RD>>> task.ContinueWith(_ =>
RD>>> Interlocked.CompareExchange(ref m_Value, null, task),
RD>>> TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously);

RD>>> return task;

RD>>> }

S>>А тут rc не будет случаем? Может continuation прицепить к родителю (task)?

RD>Так оно и цепляется к task
RD>Я возможностей для rc не вижу.

Пользователь может воспользовать знач. task, до того как оно сброситься в null.

Уточню, что прицепив его AttachToParent, task не будет считаться законченным, пока не закончаться его потомки. Как-то так...(могу ошибаться)
Кодом людям нужно помогать!
Отредактировано 29.12.2018 11:43 Sharov . Предыдущая версия .
Re[2]: Interlocked
От: GlebZ Россия  
Дата: 29.12.18 11:59
Оценка:
Здравствуйте, RAza, Вы писали:


S>>А чем, кстати, Interlocked не подошел?

RD>>3. И да, можно переписать на Interlocked.CompareExchange и обойтись без lock.

RA>Никогда не использовал это на практике. Вас не затруднит показать реализацию? И в двух словах объяснить в чем плюсы по сравнению с DCL?

Для справки:
volatile — указание компилятору что к этой переменной может быть обращение с другого потока, потому значение должно сохраняться в ОЗУ и компилятор не должен переупорядочивать команды
interlocked — это указание компилятору о защищенной команде, что выливается на уровне ассемблера в генерацию префикса lock для текущей команды, например при инкрементации значения с возвратом lock xadd xxx,xxx Чрезвычайно быстрая хрень.
lock — блокирование по примерному алгоритму, сначало проверяется через interlocked команды, что делается очень быстро. Если заблокировано, то блокируем через объект ядра (что не так быстро по сравнению с interlocked командами).

Но это все для задротов.
Подождите ...
Wait...
Пока на собственное сообщение не было ответов, его можно удалить.