C# SpinWait 高性能同步

前言

以前做多线程同步的时候,非常喜欢用无锁队列 ConcurrentQueue<T> ,随着对多线程原理了解的深入,决定放弃这种一知半解的形式,打算把以前写的一个程序重构一下。 在重构到多线程的时候,有这样一个场景:

有一个 int 变量 i,初始值为 1, 
有一个线程1 ,以极限频率访问 i,如果 i 等于 0,则线程结束,
有一个按钮 button1,按下 button1 就把 i 的值修改为 0 .

代码如下:

int i = 1;

private void Form1_Load(object sender, EventArgs e)
{
    Task.Factory.StartNew(() => {
    while (i != 0);
    MessageBox.Show("out");
    });
}

private void button1_Click(object sender, EventArgs e)
{
    i = 0;
}

代码运行结果:按下 button1,线程跳出循环,界面打印出 “out”。由此可见,对变量的访问是原子的,不会干扰到其他线程对变量的赋值。
注意:由于线程处于无限死循环,没有做任何措施,导致 CPU 急速空转,占用率达到了25%(我的电脑是6核心的)。如果这样的线程多来几个,CPU 资源就会消耗殆尽!

下面,在线程1 中对 i 增加 “写” 操作,代码如下:

int i = 1;

private void Form1_Load(object sender, EventArgs e)
{
    Task.Factory.StartNew(() => {
    while (i != 0)
    {
       i++;
    }
    MessageBox.Show("out");
    });
}

private void button1_Click(object sender, EventArgs e)
{
    i = 0;
}

代码运行结果:无论以多块的 “手速” 点击 button1,线程都无法跳出,打印不出 “out”,这就意味着,多线程同时 “写”,就会产生 “脏数据”。那这种情况应该怎么办呢:

1,使用 lock.

2,加延时 Thread.Sleep();

3,spinWait.SpinOnce();

在这种极限速度下,很显然 lock 的效率显得有些低下,所以下面直接实验方案 2 和方案 3。

int i = 1;

private void Form1_Load(object sender, EventArgs e)
{
    Task.Factory.StartNew(() => {
    while (i != 0)
    {
        i++;

        //方案2.0
        Thread.Sleep(0); //可以跳出循环, CPU 占用率高(23%)。
        //方案2.1
        Thread.Sleep(1); //可以跳出循环,CPU 占用率极低,接近 0%。
        //方案3
        spinWait.SpinOnce(); //可以跳出循环,CPU 占用率极低,接近 0%。
    }
    MessageBox.Show("out");
    });
}

private void button1_Click(object sender, EventArgs e)
{
    i = 0;
}

很显然,方案 2 和方案 3 都可以实现多线程数据同步。

Thread.Sleep(0) 比 Thread.Sleep(1) 消耗更多的 CPU,可以参考这篇文章:https://www.cnblogs.com/stg609/p/3857242.html

这里主要是记录一下方案 3,“自旋” 操作,比方案 2 靠谱,方案 2 只是让出了 CPU 时间片段,刚好被我们按下 button1 的时候完成了对 i 的修改。方案 3 的速度更快,没有上下文切换,只是让 CPU 空转了一下。SpinWait.SpinOnce() 是一个智能的方法,当 SpinOnce() 超过 10 次后,就切换到内核模式,跟 Sleep() 一样节省 CPU 资源,所以我们看到方案 3 几乎不消耗 CPU。如果在循环内,使用 SpinWait.Reset() 重置自旋计数,则会看到 CPU 占用几乎和 Thread.Sleep(0) 相同。

而 SpinWait.SpinUntil() ,用法如:

SpinWait.SpinUntil(()=> 
{
     Console.WriteLine(w.NextSpinWillYield);
     return false;
},2000);

在超时时间前,一直自旋,当自旋次数超过 10 次时,会切换到内核模式。
注意:虽然 SpinWait 旨在在并发应用程序中使用,但它并不是从多个线程同时使用而设计的。 SpinWait 成员不是线程安全的。 如果多个线程必须旋转,每个线程都应该使用其自己的 SpinWait 实例。

最后

记一个有意思的事情,如果把 i++; 的位置移动一下,效果又大不相同,代码如下:

int i = 1;

private void Form1_Load(object sender, EventArgs e)
{
    Task.Factory.StartNew(() => {
    while (i != 0)
    {
        //方案2.0
        Thread.Sleep(0); //无法跳出循环, CPU 占用率高(23%)。
        //方案2.1
        Thread.Sleep(1); //无法跳出循环,CPU 占用率极低,接近 0%。 
        //方案2.2
        Thread.Sleep(4000); //无法跳出循环,CPU 占用率极低,接近 0%。 
        //方案3
        spinWait.SpinOnce(); //无法跳出循环,CPU 占用率极低,接近 0%。
        
        i++;
    }
    MessageBox.Show("out");
    });
}

private void button1_Click(object sender, EventArgs e)
{
    i = 0;
}

此时,无论哪种方案,即使延时10秒钟,点击 button1,仍旧无法把 i 的值修改为 0。其实原因很简单,因为绝大多的时间片段该循环都处于 Sleep 或者自旋状态,此时修改 i 为0,但是线程接下来增加了 i 的值,为 1 了?。

3 thoughts on “C# SpinWait 高性能同步

  1. 匿名

    学长,写的不错!

  2. 匿名

    代码运行结果:无论以多块的 “手速” 点击 button1,线程都无法跳出,打印不出 “out”,这就意味着,多线程同时 “写”,就会产生 “脏数据”。那这种情况应该怎么办呢: ———你这里有问题,不是因为多线程写就会产生脏数据,写没问题,写int类型i是原子操作。 但是i++不是原子操作。

    • Bluesummer

      谢谢指点,也就是说两个线程同时写,原子操作直接赋值,是没问题的,不会产生脏数据,对读不影响

发表评论

Powered by WordPress | Theme Revised from Doo

苏ICP备18047621号

Copyright © 2017-2024 追光者博客