【前言】

在日常开发工作中,我们经常要对变量进行操作,例如对一个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. 自定义spring boot starter 初尝试

    自定义简单spring boot starter 步骤 从几篇博客中了解了如何自定义starter,大概分为以下几个步骤: 1 引入相关依赖: 2 生成属性配置类: 3 生成核心服务类: 4 生成自动 ...

  2. kafka手动设置offset

    项目中经常有需求不是消费kafka队列全部的数据,取区间数据 查询kafka最大的offset: ./kafka-run-class.sh kafka.tools.GetOffsetShell --b ...

  3. 巧用 transition 实现短视频 APP 点赞动画

    在各种短视频界面上,我们经常会看到类似这样的点赞动画: 非常的有意思,有意思的交互会让用户更愿意进行互动. 那么,这么有趣的点赞动画,有没有可能使用纯 CSS 实现呢?那当然是必须的,本文,就将巧妙的 ...

  4. Jenkins+SpringCloud(多模块)+Vue项目详细配置

    一.Jenkins安装及所需插件安装 安装过程略. 我这用到工具包括JDK.Git.Maven.NodeJS:可以选择自行在服务器安装,也可以通过Jenkins自动安装,位置在系统管理 >全局工 ...

  5. KingbaseES V8R3 shared_buffer占用过多导致实例崩溃

    背景 有这样一个案例.客户备库意外宕机,从集群日志只看出发生了主备切换,备库一直持续恢复备库没有成功,从数据库日志看到如下报错: terminating connection because of c ...

  6. Kingbase_FDW 使用介绍

    与postgresql_fdw功能类似,KINGBASE_FDW  是一种外部访问接口,它可以被用来访问存储在外部的数据.想要使用fdw访问数据需要先确保:网络通,数据库访问配置(pg_hba,con ...

  7. KingbaseFlySync ddl变更流程

    关键字: KingbaseFlySync.Linux.x86_64.mips64el.aarch64.Java 一.ddl变更流程 1. 停掉客户业务,保证没有新数据产生 确认Oracle数据库上所有 ...

  8. Centos_yum使用

    安装应用 yum install -y xxx -y 表示自动yes 卸载应用 yum -y remove xxx -y 表示自动yes 查看已安装的应用 yum list installed

  9. AQS:Java 中悲观锁的底层实现机制

    介绍 AQS AQS(AbstractQueuedSynchronizer)是 Java 并发包中,实现各种同步组件的基础.比如 各种锁:ReentrantLock.ReadWriteLock.Sta ...

  10. 基于Containerd安装部署高可用Kubernetes集群

    转载自:https://blog.weiyigeek.top/2021/7-30-623.html 简述 Kubernetes(后续简称k8s)是 Google(2014年6月) 开源的一个容器编排引 ...