Хочу спроектировать тип аналогичный типу 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 который я пропустил?
Здравствуйте, Sharov, Вы писали:
S>1)Для инициализации см. сюда -- https://en.wikipedia.org/wiki/Double-checked_locking.
На сколько я понимаю у меня инициализация как раз находится внутри конструкции DCL.
S>2)Зачем Volatile.*, когда переменную можно объявить с модификатором volatile?
Набор личных предубеждений. Вроде бы это ни на что не должно повлиять в данном случае?
Здравствуйте, RAza, Вы писали:
S>>2)Зачем Volatile.*, когда переменную можно объявить с модификатором volatile? RA>Набор личных предубеждений. Вроде бы это ни на что не должно повлиять в данном случае?
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.
Здравствуйте, RAza, Вы писали:
RA>Приветствую.
RA>Хочу спроектировать тип аналогичный типу Lazy<T> или другими словами предоставляющий функционал аналогичный следующему сниппету:
Здравствуйте, RushDevion, Вы писали:
RD>Не знаю, зачем тебе это, но...
Несколько потоков, работают с этим поставщиком данных. Логика у потоков различная (условно некоторые только читают, другие в ряде случаев могут обновть значение или "сбросить"). Но всем для работы нужно данное значение. Допустим, это значение можно получить через сеть, поэтому я хочу, чтобы только один из потоков создал запрос на его получение. Запрос может закончиться с исключением, и это означает, что все потоки должны попытаться получить его позже. Когда значение получено, некоторые потоки могут его изменить, а все остальные должны использовать обновленное на следующих итерациях (обращениях к поставщику).
RD>1. Нет проверки на null для getAction
Я использую Fody NullGuard. В примере убрал все "лишние".
RD>2. Volatile.Read/Write из-под lock не имеют смысла, т.к. lock уже ставит все memory barriers. Можно просто читать/писать в переменную.
Это может оказать влияние на производительность?
S>>А чем, кстати, Interlocked не подошел? RD>>3. И да, можно переписать на Interlocked.CompareExchange и обойтись без lock.
RA>Никогда не использовал это на практике. Вас не затруднит показать реализацию? И в двух словах объяснить в чем плюсы по сравнению с DCL?
Я хотело вместо Volatile.Read посоветовать Interlocked.Read, но он только для int64. Смотрите сюда. C dcl все нормально, Volatile.* я бы убрал.
Здравствуйте, RAza, Вы писали:
RA> если во время создания возникает необработанное исключение, оно будет повторно создано для каждого ожидающего потока
Такого поведения тут нет. Для каждого будет вызываться getAction.
Здравствуйте, pugv, Вы писали:
P>Здравствуйте, RAza, Вы писали:
RA>> если во время создания возникает необработанное исключение, оно будет повторно создано для каждого ожидающего потока
P>Такого поведения тут нет. Для каждого будет вызываться getAction.
Если getAction отработал без ошибок, то он будет вызван только 1 раз.
Здравствуйте, Sharov, Вы писали:
RA>>> если во время создания возникает необработанное исключение, оно будет повторно создано для каждого ожидающего потока
S>Если getAction отработал без ошибок, то он будет вызван только 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 выглядит более чем сомнительным.
Так что рассматривай это скорее как академический пример.
Здравствуйте, RushDevion, Вы писали:
RD>Теперь по реализации. Покажу на примере getter'a для Value RD>А если добавить обработку ошибок инициализации, то код будет еще сложнее. При этом профит от lock-free выглядит более чем сомнительным. RD>Так что рассматривай это скорее как академический пример.
Большое спасибо за пример. Основную идею я понял. В посте ниже справедливо указали, на то, что моя реализация не отвечает заявленным критериям, а именно при возникновении ошибки при получении значения ожидающие потоки не получат тот же объект исключения. Сейчас набросал небольшой тест кейс и думаю как модифицировать мою реализацию. Пока безуспешно.
На первый взгляд в вашей реализации добавить код для обработки ошибок инициализации значительно проще. Достаточно в цикле бросить исключение для всех ожидающих потоков и изменить механизм Sleep-wait.
Но меня не устраивает ограничение:
RD>
RD>public class Lazy<T> where T : class
RD>...
RD>
На сколько понимаю это связано из-за ограничения методов Volatile.* Это можно как то обойти?
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;
}
}
}
Здравствуйте, 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
Здравствуйте, 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 не будет считаться законченным, пока не закончаться его потомки. Как-то так...(могу ошибаться)
S>>А чем, кстати, Interlocked не подошел? RD>>3. И да, можно переписать на Interlocked.CompareExchange и обойтись без lock.
RA>Никогда не использовал это на практике. Вас не затруднит показать реализацию? И в двух словах объяснить в чем плюсы по сравнению с DCL?
Для справки:
volatile — указание компилятору что к этой переменной может быть обращение с другого потока, потому значение должно сохраняться в ОЗУ и компилятор не должен переупорядочивать команды
interlocked — это указание компилятору о защищенной команде, что выливается на уровне ассемблера в генерацию префикса lock для текущей команды, например при инкрементации значения с возвратом lock xadd xxx,xxx Чрезвычайно быстрая хрень.
lock — блокирование по примерному алгоритму, сначало проверяется через interlocked команды, что делается очень быстро. Если заблокировано, то блокируем через объект ядра (что не так быстро по сравнению с interlocked командами).