【前言】

在日常开发工作中,我们经常要对变量进行操作,例如对一个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

C# Interlocked 类的更多相关文章

  1. C#线程同步技术(二) Interlocked 类

    接昨天谈及的线程同步问题,今天介绍一个比较简单的类,Interlocked.它提供了以线程安全的方式递增.递减.交换和读取值的方法. 它的特点是: 1.相对于其他线程同步技术,速度会快很多. 2.只能 ...

  2. 原子性: Interlocked 类

    public class CounterNoLock:CountBase { private int _count; public int Count { get { return _count; } ...

  3. 转载 三、并行编程 - Task同步机制。TreadLocal类、Lock、Interlocked、Synchronization、ConcurrentQueue以及Barrier等

    随笔 - 353, 文章 - 1, 评论 - 5, 引用 - 0 三.并行编程 - Task同步机制.TreadLocal类.Lock.Interlocked.Synchronization.Conc ...

  4. 三、并行编程 - Task同步机制。TreadLocal类、Lock、Interlocked、Synchronization、ConcurrentQueue以及Barrier等

    在并行计算中,不可避免的会碰到多个任务共享变量,实例,集合.虽然task自带了两个方法:task.ContinueWith()和Task.Factory.ContinueWhenAll()来实现任务串 ...

  5. [转载]C#使用Interlocked进行原子操作

    原文链接:王旭博客 » C# 使用Interlocked进行原子操作 什么是原子操作? 原子(atom)本意是"不能被进一步分割的最小粒子",而原子操作(atomic operat ...

  6. C# Interlocked 笔记

    无锁代码下,在读写字段时使用内存屏障往往是不够的.在 64 位字段上进行加.减操作需要使用Interlocked工具类这样更加重型的方式.Interlocked也提供了Exchange和Compare ...

  7. C#必须掌握的系统类

    系统类  Type类,Object类,String类, Arrary类,Console类, Exception类,GC类, MarshalByRefObject类, Math类. DateTime结构 ...

  8. C#多线程编程(6)--线程安全2 互锁构造Interlocked

    在线程安全1中,我介绍了线程同步的意义和一种实现线程同步的方法:volatile.volatile关键字属于原子操作的一种,若对一个关键字使用volatile,很多时候会显得很"浪费&quo ...

  9. 线程系列07,使用lock语句块或Interlocked类型方法保证自增变量的数据同步

    假设多个线程共享一个静态变量,如果让每个线程都执行相同的方法每次让静态变量自增1,这样的做法线程安全吗?能保证自增变量数据同步吗?本篇体验使用lock语句块和Interlocked类型方法保证自增变量 ...

随机推荐

  1. 经典01背包问题(C++)--详解

    二维数组解决01背包问题 题目: 有 N 件物品和一个容量是 V 的背包.每件物品只能使用一次. 第 i 件物品的体积是 vi,价值是 wi. 求解将哪些物品装入背包,可使这些物品的总体积不超过背包容 ...

  2. 一些有用的数学知识(Updating)

    文章目录 拉格朗日插值公式 微分中值定理 费马引理 拉格朗日中值定理 柯西中值定理 洛必达法则 连分数(NOI2021 D2T2 考点) 定义 结论 定理1 定理2 定理3 定理4 定理5 欧拉公式 ...

  3. 随机视频API

    首先打开服务器创建一个html文件也可以不创建 代码如下 点击查看代码 <!DOCTYPE html> <html lang="zh-CN"> <he ...

  4. Linux安装Jenkins及配置svn使用

    目录 1. 下载 2. 创建文件夹 3. 安装 4. 修改端口,不用这步 5. 安装插件提速 6. 启动 7. 页面访问 8. 新建用户 9. 安装Subversion插件 10. 安装maven插件 ...

  5. 牛客小白月赛51-C-E

    C-零一题 题意: 每次可以选择两个相邻且相同的字符,将他们删除,在无数次操作后,字符串的长度变为n,问能否构造出原来的字符串,不能输出-1 题解: 很明显,最后无法再操作时,这个字符串一定是01相交 ...

  6. HDFS 高可用分布式环境搭建

    HDFS 高可用分布式环境搭建 作者:Grey 原文地址: 博客园:HDFS 高可用分布式环境搭建 CSDN:HDFS 高可用分布式环境搭建 首先,一定要先完成分布式环境搭建 并验证成功 然后在 no ...

  7. centos7设置时间和上海时区并进行同步

    1.设置时区(同步时间前先设置) timedatectl set-timezone Asia/Shanghai 2.安装组件 yum -y install ntp systemctl enable n ...

  8. Dapr 的 gRPC组件 (又叫可插拔组件) 的提案

    Dapr 在1.9 版本中的提案,计划在 Dapr Runtime 中组件采用 外部 gRPC 组件: https://github.com/dapr/dapr/issues/3787 ,针对这个 g ...

  9. 【2022-09-09】Django框架(九)

    Django框架(九) cookie与session简介 网址的发展史: 1.起初网站都没有保存用户功能的需求,所有用户访问返回的结果都是一样的. 比如:新闻网页,博客网页,小说... (这些网页是不 ...

  10. Unity接入微信支付SDK 2022年版安卓篇

    最近1年转了UE开发,博客更新的比较少,技术栈宽了不少,以后有空尽量多更新,也方便总结记忆 Unity接入微信支付整个过程坑比较多,网上之前的教程要么比较老,要么比较零碎,只能东拼西凑摸索,跑通后还是 ...