При реализации класса, который должен быть потокобезопасным, должен ли я включать барьер памяти в конце его конструктора, чтобы гарантировать, что все внутренние структуры будут инициализированы до того, как к ним можно будет получить доступ? Или это ответственность потребителя - вставить барьер памяти перед тем, как сделать экземпляр доступным для других потоков?
Упрощенный вопрос:
Есть ли в приведенном ниже коде опасность гонки, которая может привести к ошибочному поведению из-за отсутствия барьера памяти между инициализацией и доступом к потокобезопасному классу? Или сам поточно-ориентированный класс должен защищать от этого?
ConcurrentQueue<int> queue = null;
Parallel.Invoke(
() => queue = new ConcurrentQueue<int>(),
() => queue?.Enqueue(5));
Обратите внимание, что для программы допустимо ничего не ставить в очередь, как это могло бы случиться, если бы второй делегат выполнялся раньше первого. (Нулевой условный оператор ?.
защищает от NullReferenceException
здесь.) Однако для программы не должно быть приемлемым выдавать IndexOutOfRangeException
, NullReferenceException
, ставить в очередь 5
несколько раз, застревать в бесконечном цикле или выполнять какие-либо другие странные действия. вещи, вызванные расовыми опасностями на внутренних конструкциях.
Подробный вопрос:
Конкретно представьте, что я реализую простую потокобезопасную оболочку для очереди. (Я знаю, что .NET уже предоставляет ConcurrentQueue<T>
< / a>; это просто пример.) Я мог бы написать:
public class ThreadSafeQueue<T>
{
private readonly Queue<T> _queue;
public ThreadSafeQueue()
{
_queue = new Queue<T>();
// Thread.MemoryBarrier(); // Is this line required?
}
public void Enqueue(T item)
{
lock (_queue)
{
_queue.Enqueue(item);
}
}
public bool TryDequeue(out T item)
{
lock (_queue)
{
if (_queue.Count == 0)
{
item = default(T);
return false;
}
item = _queue.Dequeue();
return true;
}
}
}
После инициализации эта реализация является поточно-ориентированной. Однако, если сама инициализация выполняется другим потоком-потребителем, может возникнуть опасность гонки, в результате чего последний поток получит доступ к экземпляру до того, как будет инициализирован внутренний Queue<T>
. В качестве надуманного примера:
ThreadSafeQueue<int> queue = null;
Parallel.For(0, 10000, i =>
{
if (i == 0)
queue = new ThreadSafeQueue<int>();
else if (i % 2 == 0)
queue?.Enqueue(i);
else
{
int item = -1;
if (queue?.TryDequeue(out item) == true)
Console.WriteLine(item);
}
});
Допускается пропуск некоторых чисел в приведенном выше коде; однако без барьера памяти он также может получить NullReferenceException
(или другой странный результат) из-за того, что внутренний Queue<T>
не был инициализирован к моменту вызова Enqueue
или TryDequeue
.
Обязан ли потокобезопасный класс включать барьер памяти в конце своего конструктора, или это потребитель должен включить барьер памяти между созданием экземпляра класса и его видимостью для других потоков? Каково соглашение .NET Framework для классов, помеченных как потокобезопасные?
Изменить: это сложная тема для обсуждения, поэтому я понимаю путаницу в некоторых комментариях. Экземпляр может выглядеть недоделанным, если к нему обращаются из других потоков без надлежащей синхронизации. Эта тема широко обсуждается в контексте блокировки с двойной проверкой, которая нарушается в соответствии со спецификацией интерфейса командной строки ECMA без использования барьеров памяти (например, через volatile
). Согласно Джону Скиту:
Модель памяти Java не гарантирует, что конструктор завершит работу до того, как ссылка на новый объект будет присвоена экземпляру. Модель памяти Java подверглась переработке для версии 1.5, но после этого блокировка с двойной проверкой все еще не работает без изменчивой переменной (как в C #).
Без каких-либо барьеров памяти, он также сломан в спецификации ECMA CLI. Возможно, что в рамках модели памяти .NET 2.0 (которая сильнее, чем спецификация ECMA) это безопасно, но я бы не стал полагаться на эту более сильную семантику, особенно если есть какие-либо сомнения в безопасности.
ConcurrentQueue<T>
, который вы упомянули, не имеет никакой защиты в своем конструкторе. Сделайте из этого что хочешь. referenceource.microsoft.com/#mscorlib/system/Collections.com/#mscorlib/system/Collections - person Bradley Uffner   schedule 10.08.2016this
. - person Ivan Stoev   schedule 10.08.2016Lazy<T>
или должен ли потокобезопасный класс обеспечивать эту защиту сам. - person Douglas   schedule 10.08.2016static
конструкторами (или конструкторами, которые разделяютstatic
состояние). Конструкторы экземпляров обычно кажутся безопасными. - person Bradley Uffner   schedule 10.08.2016_queue = new Queue<T>();
, модель компилятора / архитектуры / памяти может привести к тому, что область памяти нового объекта будет казаться назначенной_queue
до завершения конструктораQueue<T>()
. - person Douglas   schedule 10.08.2016lock
или высокоуровневые фреймворки, такие как TPL. - person Douglas   schedule 10.08.2016_queue
. На ARM64 все ставки сняты. Я предполагаю, что JIT-компилятор вставит барьер, чтобы избежать нарушения существующей программы, но, к сожалению, я не знаю, как это проверить (у меня нет процессора ARM64, а Sharplab поддерживает только x86) - person Kevin Gosse   schedule 03.01.2021