使用闭包而不是共享状态锁的优缺点是什么?

What are the pros and cons of using closures instead of locks for shared state?

我正在尝试评估在单一作者、单一 reader 场景中共享状态的最快解决方案是什么,其中 reader 仅使用 writer 分配的状态变量的最新值。共享状态可以是任何托管类型(即引用或值类型)。

理想情况下,同步解决方案的运行速度应尽可能快,因为该方法将用于单线程和多线程场景,可能会使用数千次。

read/writes 的顺序无关紧要,只要 reader 在某个时间范围内收到最新值(即 reader 只会读取,永远不会修改,所以更新时间并不重要,只要它没有在旧值之前收到未来值...)

天真的解决方案,没有锁定:

var memory = default(int);
var reader = Task.Run(() =>
{
    while (true)
    {
        func(memory);
    }
});

var writer = Task.Run(() =>
{
    while (true)
    {
        memory = DateTime.Now.Ticks;
    }
});

天真的解决方案的真正问题是什么?到目前为止,我已经想出了这些:

  1. 不保证reader看到最新值(无内存barrier/volatile)
  2. 如果共享变量的类型不是原始类型或引用类型(例如复合值类型),reader 使用的值可能无效。

直接的解决方案是锁定:

var gate = new object();
var memory = default(int);
var reader = Task.Run(() =>
{
    while (true)
    {
        int local;
        lock(gate) { local = memory; }
        func(local);
    }
});

var writer = Task.Run(() =>
{
    while (true)
    {
        lock(gate)
        {
            memory = DateTime.Now.Ticks;
        }
    }
});

这当然有效,但在单线程情况下会产生price of locking (~50ns),当然在多线程情况下会产生context-switching/contention的代价。

这对于大多数情况来说完全可以忽略不计,但在我的情况下很重要,因为该方法将在可能需要尽可能及时的数千个循环中全面使用 运行 数以万计每秒次数。

我想到的最后一个解决方案是使用不可变状态闭包来读取共享状态:

Func<int> memory = () => default(int);
var reader = Task.Run(() =>
{
    while (true)
    {
        func(memory());
    }
});

var writer = Task.Run(() =>
{
    while (true)
    {
        var state = DateTime.Now.Ticks;
        memory = () => state;
    }
});

现在这有什么潜在的问题?我自己的性能基准报告此解决方案与单线程情况下的锁定相比约为 10ns。这似乎是一个不错的收获,但一些注意事项包括:

  1. 仍然没有记忆 barrier/volatile 所以 reader 不能保证看到最新的关闭(这实际上有多常见?很高兴知道。 ..)
  2. 原子性问题解决:由于闭包是引用类型,reading/writing已经按照标准保证了原子性
  3. 装箱成本:基本上使用闭包意味着以某种方式在堆上分配内存,这在每次迭代中都会发生。不清楚这样做的真正成本是多少,但它似乎比锁定更快...

还有什么我想念的吗?在您的程序中,您通常会考虑将这种用法用于闭包而不是锁吗?也很高兴知道 single-reader/single-writer 共享状态的其他可能的快速解决方案。

正如您已经指出的,您的第一个和第三个示例都无法确保 reader 任务看到 writer 任务分配的最新值。一个缓解因素是,在 x86 硬件上,所有内存访问本质上都是易变的,但是您的问题没有任何内容将上下文限制为 x86 硬件,并且在任何情况下都假设 JIT 编译器未优化写入或读取。

Marc Gravell 有 an excellent demonstration 个未受保护的危害 write/read,其中写入的值只是 never 被读取线程观察到。底线是,如果您不显式同步访问,您的代码就会出错。

所以,继续第二个例子,这是唯一一个真正正确的例子。


顺便说一句,就使用闭包来包装一个值而言,我会说那没有意义。您可以直接将一些值集合有效地包装在一个对象中,而不是让编译器为您生成 class,并将该对象的引用用作 reader 和编写器的共享值。在对象引用上使用 Thread.VolatileWrite()Thread.VolatileRead() 解决了跨线程可见性问题(我假设您在这里使用的是捕获的本地......当然,如果共享变量是一个字段,您可以只标记它 volatile).

值可以在 Tuple 中,或者您可以编写自己的自定义 class(您希望使其不可变,例如 Tuple,以防止意外错误).

当然,在您的第一个示例中,如果您确实使用了易失性语义,则可以解决 int 等类型的可见性问题,其中可以自动完成写入和读取。