C# SpinWait 类 使用详解

发布于:2025-02-18 ⋅ 阅读:(138) ⋅ 点赞:(0)

总目录


前言

SpinWait 是 C# 中用于实现高效自旋等待的轻量级工具类,位于 System.Threading 命名空间下。它通过短暂的循环检查(自旋)来避免线程上下文切换的开销,适用于预期等待时间极短的多线程场景(如无锁算法、轻量级同步)。与传统阻塞等待(如 Thread.Sleep)相比,SpinWait 可以减少上下文切换的开销,在短时等待中性能更优,但需谨慎使用以避免 CPU 资源浪费。


一、相关知识

1. 三种常用等待

  • Thread.Sleep();
    • 会阻塞线程,使得线程交出时间片,然后处于休眠状态,直至被重新唤醒;
    • 适合用于长时间的等待;
  • Thread.SpinWait();
    • 使用了自旋等待,等待过程中会进行一些的运算,线程不会休眠
    • 用于微小的时间等待;长时间等待会影响性能;
  • Task.Delay();
    • 用于异步中的等待

2. 自旋和阻塞

线程等待有内核模式(Kernel Mode)和用户模式(User Model)。

只有操作系统才能控制线程的生命周期,因此使用 Thread.Sleep() 等方式阻塞线程,发生上下文切换,此种等待称为内核模式。

用户模式使线程等待,并不需要线程切换上下文,而是让线程通过执行一些无意义的运算,实现等待,也称为自旋。

我们来对比一下 Thread.Sleep(1) 和 Thread.SpinWait(1) 占用的时间。

    static void Main(string[] args)
    {
        Stopwatch stopwatch = new Stopwatch();
        stopwatch.Start();
        Thread.Sleep(1);
        Console.WriteLine(stopwatch.Elapsed.ToString());
        Console.ReadKey();
    }
    static void Main(string[] args)
    {
        Stopwatch stopwatch = new Stopwatch();
        stopwatch.Start();
        Thread.SpinWait(1);
        Console.WriteLine(stopwatch.Elapsed.ToString());
        Console.ReadKey();
    }

输出结果:

00:00:00.0083727
00:00:00.0000331

可以看到,自旋一次消耗的时间远远低于 1ms,并且 Thread.Sleep 会出现上下文切换,而 Thread.SpinWait 不会。Thread.SpinWait 适合等待短暂的任务,实现线程同步。

3. SpinWait

SpinWait 是结构体;Thread.SpinWait() 的原理就是 SpinWait 。

线程阻塞是会耗费上下文切换的,对于过短的线程等待,这种切换的代价会比较昂贵的。在我们前面的示例中,大量使用了 Thread.Sleep() 和各种类型的等待方法,这其实是不合理的。

SpinWait 则提供了更好的选择。

二、核心概念

SpinWait 的核心思想是通过自旋(即不断循环)来等待某个条件成立,而不是让线程进入阻塞状态。它适用于需要快速响应的低延迟场景,例如锁自旋、无锁数据结构等。

  • 自旋等待(或 忙等待)
    • 线程不释放 CPU 时间片,而是通过循环检查条件是否满足。
    • 通过循环检查条件来避免线程进入阻塞状态,适合于短时间等待。
  • 自旋策略(或 自适应等待)
    • 初始阶段纯自旋(不触发线程切换)。
    • 自旋次数超过阈值后,自动切换到 Thread.Sleep(1)Thread.Yield()
    • SpinWait 会根据系统负载自动调整其行为,最初进行忙等待,随后如果等待时间较长,则会切换到更高效的阻塞等待。
  • 适用场景
    • 高频率的短时间等待:例如,在等待某个标志位的变化时,使用忙等待可以减少上下文切换的开销。
    • 避免阻塞等待:在某些实时性要求较高的应用中,忙等待可以避免因阻塞等待导致的延迟。
    • 实现无锁数据结构(如自旋锁、队列)。
    • 替代 Thread.Sleep(0)Thread.Yield(),减少上下文切换。

三、主要方法和属性

1. 初始化 SpinWait

SpinWait spin = new SpinWait();

2. 关键方法

方法 / 属性 作用
SpinOnce() 执行一次自旋,可能触发上下文切换(根据自旋次数调整策略)。

让当前线程自旋一次。如果自旋次数达到一定阈值,线程会主动让出 CPU 时间片
SpinUntil(Func) 自旋直到指定的条件为真
NextSpinWillYield 指示下一次 SpinOnce() 是否会触发线程切换(true 表示即将切换)。

指示下一次自旋是否会让出 CPU 时间片
Reset() 重置自旋计数器(慎用,通常由系统自动管理)。
IsThreadOwnerRunning 获取一个值,指示当前线程是否是正在运行的线程。
SpinCount 获取当前的旋转计数,表示已经执行了多少次 SpinOnce()。

3. 原理

下面我们来通过案例来理解SpinWait的原理

class Program
{
    private static int sum = 0;
    // 标志位
    private static bool isCompleted = false;
    static void Main(string[] args)
    {
        new Thread(DoWork).Start();

        // 等待上面的线程完成工作
        MySleep();

        Console.WriteLine("sum = " + sum);
        Console.ReadKey();
    }

    private static void DoWork()
    {
        for (int i = 0; i < 1000_0000; i++)
        {
            sum++;
        }
        isCompleted = true;
    }


    private static void MySleep()
    {
        int i = 0;
        while (!isCompleted)
        {
            i++;
        }
    }
}

我们改进上面的示例,修改 MySleep 方法,改成:

        private static void MySleep()
        {
            SpinWait wait = new SpinWait();
            while (!isCompleted)
            {
                wait.SpinOnce();
            }
        }

或者改成

	    private static void MySleep()
        {
            SpinWait.SpinUntil(() => isCompleted);
        }
  • 自旋 通俗讲就是没事找事做。通过做一些简单的运算,来消耗时间,从而达到等待的目的。
  • SpinWait 实质上是(处理器)使用了非常紧密的循环,并使用 iterations 参数指定的循环计数。 SpinWait 等待时间取决于处理器的速度。

4. 等待时间对比

class Program
{
    private static int sum = 0;
    // 标志位
    private static bool isCompleted = false;
    static void Main(string[] args)
    {
        Stopwatch stopwatch = new Stopwatch();
        stopwatch.Start();
        new Thread(DoWork).Start();

        // 等待上面的线程完成工作
        MySleep();

        Console.WriteLine("sum = " + sum);
        Console.WriteLine(stopwatch.Elapsed.ToString());
        Console.ReadKey();
    }

    private static void DoWork()
    {
        for (int i = 0; i < 1000_0000; i++)
        {
            sum++;
        }
        isCompleted = true;
    }

    private static void MySleep()
    {
        SpinWait.SpinUntil(() => isCompleted);
    }
}

运行结果:

  • sum 均为 10000000
  • 使用i++ 的方式:处理时间为:00:00:00.2304807
  • 使用SpinOnce /SpinUntil 方式,处理时间为:00:00:00.0573504 / 00:00:00.0658191
  • 结果说明:如果需要等待的时间很短,那就最好使用 Thread.SpinWait,让线程继续占用短时间的 CPU 什么也不做,避免出现线程上下文切换。

四、示例

示例1:等待共享标志位

using System.Threading;

class Program
{
    static volatile bool _flag = false;

    static void Main()
    {
        new Thread(SetFlagAfterDelay).Start();

        SpinWait spin = new SpinWait();
        while (!_flag)
        {
            spin.SpinOnce(); // 自旋等待,直到标志位为 true
        }
        Console.WriteLine("标志位已设置为 true");
    }

    static void SetFlagAfterDelay()
    {
        Thread.Sleep(1000); // 模拟延迟
        _flag = true;
    }
}
using System;
using System.Threading;

class Program
{
    static bool _isWorkDone = false;
    static SpinWait _spinWait = new SpinWait();

    static void Main(string[] args)
    {
        // 启动工作线程
        Thread workerThread = new Thread(DoWork);
        workerThread.Start();

        // 主线程等待工作线程完成
        Console.WriteLine("主线程开始等待...");
        while (!_isWorkDone)
        {
            _spinWait.SpinOnce(); // 忙等待
        }

        Console.WriteLine("主线程继续执行");
    }

    static void DoWork()
    {
        Console.WriteLine("工作线程开始工作...");
        Thread.Sleep(3000); // 模拟一些工作
        Console.WriteLine("工作线程完成工作");

        _isWorkDone = true; // 标记工作完成
    }
}

代码解释

  • 初始化:定义了一个布尔变量 _isWorkDone 和一个 SpinWait 实例 _spinWait。
  • 启动工作线程:创建并启动了一个新线程 workerThread,该线程模拟了一些工作(通过 Thread.Sleep(3000))。
  • 主线程忙等待
    • 主线程进入一个 while 循环,不断调用 _spinWait.SpinOnce(),直到 _isWorkDone 变为 true。
    • 在每次调用 SpinOnce() 时,SpinWait 会执行一次忙等待循环,并根据系统负载决定是否让出当前线程的时间片。
  • 工作线程完成工作:工作线程完成模拟的工作后,将 _isWorkDone 设置为 true,通知主线程继续执行。

示例2:实现简单自旋锁

class SpinLockExample
{
    private int _lockState = 0; // 0=未锁定, 1=锁定
    private SpinWait _spin = new SpinWait();

    public void Enter()
    {
        while (Interlocked.CompareExchange(ref _lockState, 1, 0) != 0)
        {
            _spin.SpinOnce(); // 自旋等待锁释放
        }
    }

    public void Exit()
    {
        Volatile.Write(ref _lockState, 0); // 释放锁
    }
}

示例3:实现自旋锁

using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static bool _isReady = false;
    static SpinWait _spinWait = new SpinWait();

    static async Task Main(string[] args)
    {
        // 启动多个任务
        var tasks = new List<Task>();
        for (int i = 0; i < 5; i++)
        {
        	int j=i+1;
            tasks.Add(Task.Run(() => WorkerTask($"Worker {j}")));
        }

        // 模拟一段时间后标记任务准备就绪
        await Task.Delay(2000);
        _isReady = true;

        // 等待所有任务完成
        await Task.WhenAll(tasks);
        Console.WriteLine("所有任务已完成");
    }

    static void WorkerTask(string name)
    {
        Console.WriteLine($"{name} 开始等待...");
        while (!_isReady)
        {
            _spinWait.SpinOnce(); // 忙等待
            if (_spinWait.NextSpinWillYield)
            {
                Console.WriteLine($"{name} 将让出时间片");
            }
        }
        Console.WriteLine($"{name} 继续执行");
    }
}

代码解释

  • 初始化:定义了一个布尔变量 _isReady 和一个 SpinWait 实例 _spinWait。
  • 启动多个任务:创建并启动了5个任务,每个任务都会调用 WorkerTask 方法。
  • 任务忙等待
    • 每个任务进入一个 while 循环,不断调用 _spinWait.SpinOnce(),直到 _isReady 变为 true。
    • 在每次调用 SpinOnce() 时,SpinWait 会执行一次忙等待循环,并根据系统负载决定是否让出当前线程的时间片。
    • 如果 _spinWait.NextSpinWillYield 返回 true,则输出一条消息,表示即将让出时间片。
  • 主线程标记任务准备就绪:主线程模拟了一段时间后,将 _isReady 设置为 true,通知所有任务继续执行。

五、自旋策略详解

SpinWait 的内部自旋策略如下:

  1. 前 10 次自旋:纯 CPU 自旋(无上下文切换)。
  2. 第 10 次后:调用 Thread.SpinWait(100),增加自旋时间。
  3. 超过阈值(通常 20 次):触发 Thread.Sleep(1)Thread.Yield(),让出 CPU。

可通过 SpinOnce() 的返回值或 NextSpinWillYield 属性判断是否即将切换策略。

六、注意事项

  1. 避免长时间自旋

    • 自旋等待适用于极短延迟(如微秒级)。
    • 若条件长时间未满足,应改用 ManualResetEventSlimSemaphoreSlimTask.Delay
  2. 单核系统的优化

    • 单核 CPU 上自旋无意义,SpinWait 会自动切换到 Thread.Yield()
    • 代码示例:
      if (Environment.ProcessorCount == 1)
      {
          Thread.Yield(); // 单核直接让出 CPU
      }
      else
      {
          // 使用 SpinWait
      }
      
  3. Task 结合使用

    • 在异步编程中,优先使用 Task.DelayCancellationToken
    • 若需混合自旋和异步等待:
      async Task WaitWithSpinAsync()
      {
          SpinWait spin = new SpinWait();
          while (!CheckCondition())
          {
              if (spin.NextSpinWillYield)
              {
                  await Task.Delay(1);
                  spin.Reset();
              }
              else
              {
                  spin.SpinOnce();
              }
          }
      }
      

七、性能对比

方法 适用场景 CPU 开销 延迟
SpinWait 极短等待(<1μs) 极低
Thread.Sleep(0) 短等待(允许线程切换) 较高
Thread.Yield() 短等待(立即让出 CPU) 中等
ManualResetEvent 长等待(毫秒级及以上)

八、替代方案

  • ManualResetEventSlim:结合自旋和内核等待,适合中等时长等待。
  • CancellationToken:在异步编程中取消等待。
  • Interlocked:实现无锁原子操作,避免显式同步。

结语

回到目录页:C#/.NET 知识汇总
希望以上内容可以帮助到大家,如文中有不对之处,还请批评指正。


参考资料:
自旋


网站公告

今日签到

点亮在社区的每一天
去签到