C# Interlocked 类

【前言】

在日常开发工作中,我们经常要对变量进行操作,例如对一个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 源码://github.com/dotnet/corefx

找到 Interlocked 中的 ExchangeAdd 方法

image

可以看到,该方法用循环不断自旋赋值并检查是否赋值成功(CompareExchange返回的是修改前的值,如果返回结果和修改前结果是一致,则说明修改成功)

我们继续看内部实现

image

image

内部调用 InterlockedCompareExchange 函数,再往下就是直接调用的C++源码了

image

image

在这里将变量添加 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 这个函数着重提一下

image

可以看到这个函数没有 32 位(int)类型的重载,为什么要单独为 64 位的 long/ulong 类型单独提供原子性读取操作符呢?

这是因为CPU有 32 位处理器和 64 位处理器,在 64 位处理器上,寄存器一次处理的数据宽度是 64 位,因此在 64 位处理器和 64 位操作系统上运行的程序,可以一次性读取 64 位数值。

但是在 32 位处理器和 32 位操作系统情况下,long/ulong 这种数值,则要分成两步操作来进行,分别读取 32 位数据后,再合并在一起,那显然就会出现多线程情况下的并发问题。

因此这里提供了原子性的方法来应对这种情况。

image

这里底层同样用了 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 方式会好很多。

【源码地址】

//github.com/sevenTiny/CodeArts/blob/master/CSharp/ConsoleAppNet60/InterlockedTest.cs

Tags: