自旋🔒

2020/10/21 41

多线程

Interlocked 类中的每个方法都执行一次原子读取以及写入操作。此外,Interlocked 的所有方法都建立了完整的内存栅栏(memory fence)。换言之,调用某个 Interlocked 方法之前的任何变量写入都在这个 Interlocked 方法调用之前执行;而这个调用之后的任何变量读取都在这个调用之后读取。

Interlocked 的方法很好用,但主要用于操作 int 类型。如果需要原子性地操作类对象中的一组字段,又该怎么办呢?在这种情况下,需要采用一个办法阻止所有线程。只允许其中一个进入对字段进行操作的代码区域。可以使用 Interlocked 地方法构造一个线程同步块:

internal struct SimpleSpinLock
{
    private int resourceInUse;

    public void Enter()
    {
        while (true)
        {
            // 总是将资源设为“正在使用”(1),
            // 只有“从未使用”变成“正在使用”才会返回
            if (Interlocked.Exchange(ref resourceInUse, 1) == 0)
                return;
            // 在这里添加“黑科技”
        }
    }

    // 将资源标记为“未使用”
    public void Leave() => Volatile.Write(ref resourceInUse, 0);
}

下面这个类展示了如何使用 SimpleSpinLock:

sealed class SomeResource
{
    SimpleSpinLock sl = new SimpleSpinLock();
    public void AccessResource()
    {
        sl.Enter();
        // 一次只有一个线程才能进入这里访问资源
        sl.Leave();
    }
}

SimpleSpinLock 的实现很简单。如果两个线程同时调用 Enter(),那么 Interlocked.Exchange 会确保一个线程将 resourceInUse 从 0 变成 1,并发现 resourceInUse 为 0,然后这个线程从 Enter() 返回,使它能继续执行 AccessResource 方法中地代码。另一个线程会将 resourceInUse 从 1 变成 1。由于不是从 0 变成 1,所以会不停地调用 Exchange() 进行“自旋”,直到第一个线程调用 Leave()

第一个线程完成对 SomeResource 对象地字段处理之后会调用 Leave()Leave() 内部调用 Volatile.Write(),将 resourceInUse 更改回 0。这造成正在“自旋”的线程能够将 resourceInUse 从 0 变成 1,所以终于能从 Enter() 返回,终于可以开始访问 SomeResource 对象的字段。

这就是线程同步🔒的一个简单实现。这种所最大的问题在于,在存在对🔒的竞争的前提下,会造成线程“自旋”。这个“自旋”会浪费宝贵的 CPU 时间,阻止 CPU 做其他更有用的工作。因此,自旋🔒只应该用于保护那些会执行得非常快的代码区域。

自旋🔒一般不要再单 CPU 机器上使用,因为在这种机器上,一方面是希望获得锁的线程自旋,一方面是占有🔒的线程不能快速释放🔒。如果占有🔒的线程优先级低于想要获取🔒的线程(自旋线程),局面还会变得更糟,因为占有🔒的线程可能根本没有机会运行。这会造成“活锁”情形。Windows 有时会短时间地动态提升一个线程的优先级。因此,对于正在使用自旋🔒的线程,应该禁止像这样的优先级提升;请参考 System.Diagnostics.Process 和 System.Diagnostics.ProcessThread 的 PriorityBoostEnabled 属性。超线程机器同样存在自旋🔒的问题。为了解决这些问题,许多自旋🔒内部都有一些额外的逻辑;我将这些额外的逻辑称为“黑科技”(Black Magic)。这里不打算过多讲解其中的细节,因为随着越来越多的人开始研究🔒及其性能,这些逻辑也可能发生变化。但我可以告诉你的是:FCL 提供了一个名为 System.Threading.SpinWait 的结构,它封装了人们关于这种“黑科技”的最新研究成果。

FCL 还包含一个 System.Threading.SpinLock 结构,它和前面展示的 SimpleSpinLock 类相似,只是使用了 SpinWait 结构来增强性能。SpinLock 结构还提供了超时支持。很有趣的一点是,我的 SimpleSpinLock 和 FCL 的 SpinLock 都是值类型。这意味着它们是轻量级的、内存友好的对象。例如,如果需要将一个🔒同集合中的每一项关联,SpinLock 就是很好的选择。但一定不要传递 SpinLock 实例,否则它们会被复制,而你会失去所有同步。虽然可以定义实例 SpinLock 字段,但不要将字段标记为 readonly,因为在操作🔒的时候,它的内部状态必须改变。


在线程处理中引入延迟

“黑科技”旨在让希望获得资源的线程暂停执行,使当前拥有资源的线程能执行它的代码并让出资源。为此,SpinWait 结构内部调用 Thread 的静态 Sleep,Yield 和 SpainWait 方法。在这里的补充内容中,我想简单解释一下这些方法。

线程可以告诉系统它在指定时间内不想被调度。这是调用 Thread 的静态 Sleep 方法来实现的。

这个方法导致线程在指定时间内挂起。调用 Sleep 允许线程自愿放弃它的时间片的剩余部分。系统会使线程在大致指定的时间内不被调度。没有错——如果告诉系统你希望一个线程睡眠 100ms,那么会睡眠大致那么长的时间,但也有可能会多睡眠几秒、甚至几分钟的时间。记住,Windows 不是实时操作系统。你的线程可能在正确的时间还行,但具体是否这样,要取决于系统中正在发生的别的事情。

可以调用 Sleep,并传递参数 System.Threading.Timeout.Infinite 值(定义为-1)。这告诉系统永远不调度线程,这样做没什么意义。更好的做法是让线程退出,回收它的栈和内核对象。可以向 Sleep 传递 0,告诉系统调用线程放弃了它当前时间片剩余的部分,强迫系统调度另一个线程。但系统可能重新调度刚才调用了 Sleep 的线程(如果没有相同或更高优先级的其他可调度线程,就会发生这种情况)。

线程可要求 Windows 在当前 CPU 上调度另一个线程,这是通过 Thread 的 Yield 方法来实现的。

如果 Windows 发现有另一个线程准备好在当前处理器上运行,Yield 就会返回 true,调用 Yield 的线程会提前结束它的时间片,所选的线程得以运行一个时间片。然后,调用 Yield 的线程被再次调度,开始用一个全新的时间片运行。如果 Windows 发现没有其他线程准备在当前处理器上运行,Yield 就会返回 false,调用 Yield 的线程继续运行它的时间片。

Yield 方法旨在使“饥饿”状态的、具有相等或更低优先级的线程有机会运行。如果一个线程希望获得当前由另一个线程拥有的资源,就会调用这个方法。如果运气好,Windows 会调度当前拥有资源的线程,而那个线程会让出资源。然后当调用 Yield 的线程再次运行时就会拿到资源。

调用 Yield 的效果介于调用 Thread.Sleep(0)Thread.Sleep(1) 之间。Thread.Sleep(0) 不允许较低优先级的线程运行,而 Thread.Sleep(1) 总是强迫进行上下文切换,而由于内部系统计时器的解析度的问题,Windows 总是强迫线程睡眠超过 1ms 的时间。

事实上,超线程 CPU 一次只允许一个线程运行。所以,在这些 CPU 上执行“自旋”循环时,需要强迫当前线程暂停,使 CPU 有机会切换到另一个线程并允许它运行。线程可调用 Thread 的 SpinWait 方法强迫它自身暂停,允许超线程 CPU 切换到另一个线程。

调用这个方法实际上会执行一个特殊的 CPU 指令;它不告诉 Windows 做任何事情(因为 Windows 已经认为它在 CPU 上调度了两个线程)。在非超线程 CPU 上,这个特殊 CPU 指令会被忽略。

要更多的了解这些方法,请参见它们的 Win32 等价函数:Sleep,SwitchToThread 和 YieldProcessor。另外,要想进一步了解如何调整系统计时器的解析度,请参见 Win32 timeBeginPeriod 和 timeEndPeriod 函数。

CLR via C#