总目录
前言
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
的内部自旋策略如下:
- 前 10 次自旋:纯 CPU 自旋(无上下文切换)。
- 第 10 次后:调用
Thread.SpinWait(100)
,增加自旋时间。 - 超过阈值(通常 20 次):触发
Thread.Sleep(1)
或Thread.Yield()
,让出 CPU。
可通过 SpinOnce()
的返回值或 NextSpinWillYield
属性判断是否即将切换策略。
六、注意事项
避免长时间自旋:
- 自旋等待适用于极短延迟(如微秒级)。
- 若条件长时间未满足,应改用
ManualResetEventSlim
、SemaphoreSlim
或Task.Delay
。
单核系统的优化:
- 单核 CPU 上自旋无意义,
SpinWait
会自动切换到Thread.Yield()
。 - 代码示例:
if (Environment.ProcessorCount == 1) { Thread.Yield(); // 单核直接让出 CPU } else { // 使用 SpinWait }
- 单核 CPU 上自旋无意义,
与
Task
结合使用:- 在异步编程中,优先使用
Task.Delay
或CancellationToken
。 - 若需混合自旋和异步等待:
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 知识汇总
希望以上内容可以帮助到大家,如文中有不对之处,还请批评指正。
参考资料:
自旋