volatile和read应该是互斥的
本文关键字:read volatile | 更新日期: 2023-09-27 18:03:01
假设我正在设计一个线程安全的类来包装一个内部集合:
public class ThreadSafeQueue<T>
{
private readonly Queue<T> _queue = new Queue<T>();
public void Enqueue(T item)
{
lock (_queue)
{
_queue.Enqueue(item);
}
}
// ...
}
基于我的另一个问题,上面的实现是有bug的,因为当它的初始化与它的使用同时执行时,可能会出现竞争危险:
ThreadSafeQueue<int> tsqueue = null;
Parallel.Invoke(
() => tsqueue = new ThreadSafeQueue<int>(),
() => tsqueue?.Enqueue(5));
上面的代码是可以接受的不确定性:项可以排队,也可以不排队。然而,在当前的实现下,它也被打破了,并且可能产生不可预测的行为,例如抛出IndexOutOfRangeException
, NullReferenceException
,多次排队相同的项目,或者陷入无限循环。这是因为Enqueue
调用可能在新实例被分配给局部变量tsqueue
之后运行,但是在内部_queue
字段的初始化完成(或似乎完成)之前运行。
Per Jon Skeet:
Java内存模型不能确保在将对新对象的引用分配给实例之前构造函数完成。Java内存模型在1.5版进行了重做,但是在没有volatile变量(c# 中的)的情况下,双重检查锁定仍然被打破。
可以通过在构造函数中添加内存屏障来解决这个竞争危险:
public ThreadSafeQueue()
{
Thread.MemoryBarrier();
}
同样,可以通过将字段设置为volatile来更简洁地解决这个问题:
private volatile readonly Queue<T> _queue = new Queue<T>();
但是,后者是c#编译器禁止的:
'Program.ThreadSafeQueue<T>._queue': a field cannot be both volatile and readonly
考虑到以上似乎是volatile readonly
的合理用例,这个限制是语言设计中的缺陷吗?
我知道可以简单地删除readonly
,因为它不会影响类的公共接口。然而,这是无关紧要的,因为一般来说readonly
也是如此。我也意识到存在的问题"为什么只读和volatile修饰符是互斥的?";然而,这解决了一个不同的问题。
具体场景:这个问题似乎影响了。net框架类库本身的System.Collections.Concurrent
命名空间中的代码。ConcurrentQueue<T>.Segment
嵌套类有几个只在构造函数中分配的字段:m_array
、m_state
、m_index
和m_source
。其中,只有m_index
被声明为只读;其他的不能——尽管它们应该——因为它们需要被声明为volatile来满足线程安全的要求。
private class Segment
{
internal volatile T[] m_array; // should be readonly too
internal volatile VolatileBool[] m_state; // should be readonly too
private volatile Segment m_next;
internal readonly long m_index;
private volatile int m_low;
private volatile int m_high;
private volatile ConcurrentQueue<T> m_source; // should be readonly too
internal Segment(long index, ConcurrentQueue<T> source)
{
m_array = new T[SEGMENT_SIZE]; // field only assigned here
m_state = new VolatileBool[SEGMENT_SIZE]; // field only assigned here
m_high = -1;
m_index = index; // field only assigned here
m_source = source; // field only assigned here
}
internal void Grow()
{
// m_index and m_source need to be volatile since race hazards
// may otherwise arise if this method is called before
// initialization completes (or appears to complete)
Segment newSegment = new Segment(m_index + 1, m_source);
m_next = newSegment;
m_source.m_tail = m_next;
}
// ...
}
readonly
字段在构造函数体中是完全可写的。实际上,可以使用volatile
访问readonly
字段来导致内存屏障。你的情况,我认为,是一个很好的例子,这样做(这是由语言阻止)。
确实,在构造函数内部进行的写操作在函数完成后可能对其他线程不可见。它们甚至可以以任何顺序出现。这一点并不为人所知,因为它很少在实践中发挥作用。构造函数的末尾是,而不是——一个内存屏障(通常根据直觉假设)。
您可以使用以下解决方案:
class Program
{
readonly int x;
public Program()
{
Volatile.Write(ref x, 1);
}
}
我测试了它可以编译。我不确定是否允许形成ref
到readonly
字段,但它是。
为什么语言阻止readonly volatile
?我猜这是为了防止你犯错。大多数情况下,这将是一个错误。这就像在lock
中使用await
:有时这是完全安全的,但大多数情况下并非如此。
也许这应该是一个警告。
Volatile.Write
在c# 1.0时代还不存在,因此在1.0时代将其作为警告的情况更强。现在有了一个解决方法,这是一个错误的情况是很强的。
我不知道CLR是否禁止readonly volatile
。如果是,那可能是另一个原因。CLR有一种允许大多数合理实现的操作的风格。c#比CLR有更多的限制。所以我很确定(没有检查)CLR允许这个
ThreadSafeQueue<int> tsqueue = null; Parallel.Invoke( () => tsqueue = new ThreadSafeQueue<int>(), () => tsqueue?.Enqueue(5));
在您的示例中,问题是tsqueue
以非线程安全的方式发布。在这种情况下,在ARM等架构上获得部分构造的对象是完全可能的。因此,将tsqueue
标记为volatile
或使用Volatile.Write
方法赋值。
这个问题似乎影响了System.Collections.Concurrent中的代码. net框架类库本身的命名空间。的ConcurrentQueue。段嵌套类有几个字段只在构造函数中赋值:m_array, m_state, m_index,和m_source。其中,只有m_index被声明为只读;的另一些人则不能——尽管他们应该——因为他们需要声明为volatile,以满足线程安全的要求。
将字段标记为readonly
只是增加了一些编译器检查的约束,JIT可能稍后会使用这些约束进行优化(但是JIT足够聪明,即使在某些情况下没有该关键字也能找出该字段是readonly
)。但是由于并发性,将这些特定字段标记为volatile
更为重要。private
和internal
字段由该库的作者控制,因此完全可以省略readonly
。
首先,这似乎是语言强加的限制,而不是平台:
.field private initonly class SomeTypeDescription modreq ([mscorlib]System.Runtime.CompilerServices.IsVolatile) SomeFieldName
编译得很好,我找不到任何引用说明initonly (readonly)不能与modreq ([mscorlib]System.Runtime.CompilerServices.IsVolatile)
(volatile
)配对。
据我所知,所描述的情况可能来自低级指令交换。构造对象并将其放入字段的代码如下:
newobj instance void SomeClassDescription::.ctor()
stfld SomeFieldDescription
正如ECMA所说:
newobj指令分配一个与tor相关的类的新实例,并将新实例中的所有字段初始化为0(正确类型的)或适当的null。然后,它使用给定的参数和新创建的实例调用构造函数。在调用构造函数之后,现在初始化的对象引用被压入堆栈。
所以,据我所知,直到指令没有交换(这是不可能的,因为返回创建对象的地址和填充这个对象存储到不同的位置),你总是看到完全初始化的对象或null从另一个线程读取时。这可以通过使用volatile来保证。它会阻止交换:
newobj
volatile.
stfld
注。它本身并不是答案。我不知道为什么c#禁止readonly volatile
.