【前言】
在日常开发工作中,我们经常要对变量进行操作,例如对一个int变量递增++。在单线程环境下是没有问题的,但是如果一个变量被多个线程操作,那就有可能出现结果和预期不一致的问题。
例如:
static void Main(string[] args)
{
var j = 0;
for (int i = 0; i < 100; i++)
{
j++;
}
Console.WriteLine(j);
//100
}
在单线程情况下执行,结果一定为100,那么在多线程情况下呢?
static void Main(string[] args)
{
var j = 0;
var t1 = Task.Run(() =>
{
for (int i = 0; i < 50000; i++)
{
j++;
}
});
var t2 = Task.Run(() =>
{
for (int i = 0; i < 50000; i++)
{
j++;
}
});
Task.WaitAll(t1, t2);
Console.WriteLine(j);
//82869 这个结果是随机的,和每个线程执行情况有关
}
我们可以看到,多线程情况下并不能保证执行正确,我们也将这种情况称为 “非线程安全”
这种情况下我们可以通过加锁来达到线程安全的目的
static void Main(string[] args)
{
var locker = new object();
var j = 0;
var t1 = Task.Run(() =>
{
for (int i = 0; i < 50000; i++)
{
lock (locker)
{
j++;
}
}
});
var t2 = Task.Run(() =>
{
for (int i = 0; i < 50000; i++)
{
lock (locker)
{
j++;
}
}
});
Task.WaitAll(t1, t2);
Console.WriteLine(j);
//100000 这里是一定的
}
加锁的确能解决上述问题,那么有没有一种更加轻量级,更加简洁的写法呢?
那么,今天我们就来认识一下 Interlocked 类
【Interlocked 类下的方法】
Increment(ref int location)
Increment 方法可以轻松实现线程安全的变量自增
/// <summary>
/// thread safe increament
/// </summary>
public static void Increament()
{
var j = 0;
Task.WaitAll(
Enumerable.Range(0, 50)
.Select(t =>
Task.Run(() =>
{
for (int i = 0; i < 2000; i++)
{
Interlocked.Increment(ref j);
}
}
))
.ToArray()
);
Console.WriteLine($"multi thread increament result={j}");
//result=100000
}
看到这里,我们一定好奇这个方法底层是怎么实现的?
我们通过ILSpy反编译查看源码:
首先看到 Increment
方法其实是通过调用 Add
方法来实现自增的
再往下看,Add
方法是通过 ExchangeAdd
方法来实现原子性的自增,因为该方法返回值是增加前的原值,因此返回时增加了本次新增的,结果便是相加的结果,当然 location1
变量已经递增成功了,这里只是为了友好地返回增加后的结果。
我们再往下看
这个方法用 [MethodImpl(MethodImplOptions.InternalCall)]
修饰,表明这里调用的是 CLR 内部代码,我们只能通过查看源码来继续学习。
我们打开 dotnetcore 源码:https://github.com/dotnet/corefx
找到 Interlocked
中的 ExchangeAdd
方法
可以看到,该方法用循环不断自旋赋值并检查是否赋值成功(CompareExchange返回的是修改前的值,如果返回结果和修改前结果是一致,则说明修改成功)
我们继续看内部实现
内部调用 InterlockedCompareExchange
函数,再往下就是直接调用的C++源码了
在这里将变量添加 volatile
修饰符,阻止寄存器缓存变量值(关于volatile不在此赘述),然后直接调用了C++底层内部函数 __sync_val_compare_and_swap
实现原子性的比较交换操作,这里直接用的是 CPU 指令进行原子性操作,性能非常高。
相同机制函数
和 Increment
函数机制类似,Interlocked
类下的大部分方法都是通过 CompareExchange
底层函数来操作的,因此这里不再赘述
- Add 添加值
- CompareExchange 比较交换
- Decrement 自减
- Exchange 交换
- And 按位与
- Or 按位或
- Read 读64位数值
public static long Read(ref long location)
Read 这个函数着重提一下
可以看到这个函数没有 32 位(int)类型的重载,为什么要单独为 64 位的 long/ulong 类型单独提供原子性读取操作符呢?
这是因为CPU有 32 位处理器和 64 位处理器,在 64 位处理器上,寄存器一次处理的数据宽度是 64 位,因此在 64 位处理器和 64 位操作系统上运行的程序,可以一次性读取 64 位数值。
但是在 32 位处理器和 32 位操作系统情况下,long/ulong 这种数值,则要分成两步操作来进行,分别读取 32 位数据后,再合并在一起,那显然就会出现多线程情况下的并发问题。
因此这里提供了原子性的方法来应对这种情况。
这里底层同样用了 CompareExchange
操作来保证原子性,参数这里就给了两个0,可以兼容如果原值是 0 则写入 0 ,如果原值非 0 则不写入,返回原值。
__sync_val_compare_and_swap 函数
在写入新值之前, 读出旧值, 当且仅当旧值与存储中的当前值一致时,才把新值写入存储
【关于性能】
多线程下实现原子性操作方式有很多种,我们一定会关心在不同场景下,不同方法间的性能问题,那么我们简单来对比下 Interlocked
类提供的方法和 lock
关键字的性能对比
我们同样用线程池调度50个Task(内部可能线程重用),分别执行 200000 次自增运算
public static void IncreamentPerformance()
{
//lock method
var locker = new object();
var stopwatch = new Stopwatch();
stopwatch.Start();
var j1 = 0;
Task.WaitAll(
Enumerable.Range(0, 50)
.Select(t =>
Task.Run(() =>
{
for (int i = 0; i < 200000; i++)
{
lock (locker)
{
j1++;
}
}
}
))
.ToArray()
);
Console.WriteLine($"Monitor lock,result={j1},elapsed={stopwatch.ElapsedMilliseconds}");
stopwatch.Restart();
//Increment method
var j2 = 0;
Task.WaitAll(
Enumerable.Range(0, 50)
.Select(t =>
Task.Run(() =>
{
for (int i = 0; i < 200000; i++)
{
Interlocked.Increment(ref j2);
}
}
))
.ToArray()
);
stopwatch.Stop();
Console.WriteLine($"Interlocked.Increment,result={j2},elapsed={stopwatch.ElapsedMilliseconds}");
}
运算结果
可以看到,采用 Interlocked
类中的自增函数,性能比 lock
方式要好一些
虽然这里看起来性能要好,但是不同的业务场景要针对性思考,采用恰当的编码方式,不要一味追求性能
我们简单分析下造成执行时间差异的原因
我们都知道,使用lock(底层是Monitor类),在上述代码中会阻塞线程执行,保证同一时刻只能有一个线程执行 j1++
操作,因此能保证操作的原子性,那么在多核CPU下,也只能有一个CPU核心在执行这段逻辑,其他核心都会等待或执行其他事件,线程阻塞后,并不会一直在这里傻等,而是由操作系统调度执行其他任务。由此带来的代价可能是频繁的线程上下文切换,并且CPU使用率不会太高,我们可以用分析工具来印证下。
Visual Studio 自带的分析工具,查看线程使用率
使用 Process Explorer 工具查看代码执行过程中上下文切换数
可以大概估计出,采用 lock(Monitor)同步自增方式,上下文切换 243
次
那么我们用同样的方式看下底层用 CAS
函数执行自增的开销
Visual Studio 自带的分析工具,查看线程使用率
使用 Process Explorer 工具查看代码执行过程中上下文切换数
可以大概估计出,采用 CAS
自增方式,上下文切换 220
次
可见,不论使用什么技术手段,线程创建太多都会带来大量的线程上下文切换
这个应该是和测试的代码相关
两者比较大的区别在CPU的使用率上,因为 lock 方式会造成线程阻塞,因此不会所有的CPU核心同时参与运算,CPU在当前进程上使用率不会太高,但 cas 方式CPU在自己的时间分片内并没有被阻塞或重新调度,而是不停地执行比较替换的动作(其实这种场景算是无用功,不必要的负开销),造成CPU使用率非常高。
【总结】
简单来说,Interlocked 类提供的方法给我们带来了方便快捷操作字段的方式,比起使用锁同步的编程方式来说,要轻量不少,执行效率也大大提高。但是该技术并非银弹,一定要考虑清楚使用的场景后再决定使用,比如服务器web应用下,多线程执行大量耗费CPU的运算,可能会严重影响应用吞吐量。虽然表面看起来执行这个单一的任务效率高一些(代价是CPU全部扑在这个任务上,无法响应其他任务),其实在我们的测试中,总共执行了 10000000 次运算,这种场景应该是比较极端的,而且在web应用场景下,用 lock 的方式响应时间也没有达到不能容忍的程度,但是用 lock 的好处是cpu可以处理其他用户请求的任务,极大提高了吞吐量。
我们建议在竞争较少的场景,或者不需要很高吞吐量的场景下(简单说是CPU时间不那么宝贵的场景下)我们可以用 Interlocked 类来保证操作的原子性,可以适当提升性能。而在竞争非常激烈的场景下,一定不要用 Interlocked 来处理原子性操作,改用 lock 方式会好很多。
【源码地址】
https://github.com/sevenTiny/CodeArts/blob/master/CSharp/ConsoleAppNet60/InterlockedTest.cs