CAS 的基本概念

CAS(Compare-and-Swap)是一种多线程并发编程中常用的原子操作,用于实现多线程间的同步和互斥访问。 它操作通常包含三个参数:一个内存地址(通常是一个共享变量的地址)、期望的旧值和新值。

CompareAndSwap(内存地址,期望的旧值,新值)

CAS 操作会比较内存地址处的值与期望的旧值是否相等,如果相等,则将新值写入该内存地址; 如果不相等,则不进行任何操作。这个比较和交换的操作是一个原子操作,不会被其他线程中断。

CAS 通常是通过硬件层面的CPU指令实现的,其原子性是由硬件保证的。具体的实现方式根据环境会有所不同。

CAS 操作通常会有一个返回值,用于表示操作是否成功。返回结果可能是true或false,也可能是内存地址处的旧值。

相比于传统的锁机制,CAS 有一些优势:

  • 原子性:CAS 操作是原子的,不需要额外的锁来保证多线程环境下的数据一致性,避免了锁带来的性能开销和竞争条件。

  • 无阻塞:CAS 操作是无阻塞的,不会因为资源被锁定而导致线程的阻塞和上下文切换,提高了系统的并发性和可伸缩性。

  • 适用性:CAS 操作可以应用于广泛的数据结构和算法,如自旋锁、计数器、队列等,使得它在实际应用中具有较大的灵活性和适用性。

C# 中如何使用 CAS

在 C# 中,我们可以使用 Interlocked 类来实现 CAS 操作。

Interlocked 类提供了一组 CompareExchange 的重载方法,用于实现不同类型的数据的 CAS 操作。

public static int CompareExchange(ref int location1, int value, int comparand);
public static long CompareExchange(ref long location1, long value, long comparand);
// ... 省略其他重载方法
public static object CompareExchange(ref object location1, object value, object comparand);
public static T CompareExchange<T>(ref T location1, T value, T comparand) where T : class;

CompareExchange 方法将 location1 内存地址处的值与 comparand 比较,如果相等,则将 value 写入 location1 内存地址处,否则不进行任何操作。

该方法返回 location1 内存地址处的值。

通过判断方法返回值与 comparand 是否相等,我们就可以知道 CompareExchange 方法是否执行成功。

算法示例

在使用 CAS 实现无锁算法时,通常我们不光是为了比较和更新一个数据,还需要在更新成功后进行下一步的操作。结合 while(true) 循环,我们可以不断地尝试更新数据,直到更新成功为止。

伪代码如下:

while (true)
{
// 读取数据
oldValue = ...;
// 计算新值
newValue = ...;
// CAS 更新数据
result = CompareExchange(ref location, newValue, oldValue);
// 判断 CAS 是否成功
if (result == oldValue)
{
// CAS 成功,执行后续操作
break;
}
}

在复杂的无锁算法中,因为每一步操作都是独立的,连续的操作并非原子,所以我们不光要借助 CAS,每一步操作前都应判断是否有其他线程已经修改了数据。

示例1:计数器

下面是一个简单的计数器类,它使用 CAS 实现了一个线程安全的自增操作。

public class Counter
{
private int _value; public int Increment()
{
while (true)
{
int oldValue = _value;
int newValue = oldValue + 1;
int result = Interlocked.CompareExchange(ref _value, newValue, oldValue);
if (result == oldValue)
{
return newValue;
}
}
}
}

CLR 底层源码中,我们也会经常看到这样的代码,比如 ThreadPool 增加线程时的计数器。

https://github.com/dotnet/runtime/blob/release/6.0/src/libraries/System.Private.CoreLib/src/System/Threading/ThreadPoolWorkQueue.cs#L446

internal void EnsureThreadRequested()
{
//
// If we have not yet requested #procs threads, then request a new thread.
//
// CoreCLR: Note that there is a separate count in the VM which has already been incremented
// by the VM by the time we reach this point.
//
int count = _separated.numOutstandingThreadRequests;
while (count < Environment.ProcessorCount)
{
int prev = Interlocked.CompareExchange(ref _separated.numOutstandingThreadRequests, count + 1, count);
if (prev == count)
{
ThreadPool.RequestWorkerThread();
break;
}
count = prev;
}
}

示例2:队列

下面是一个简单的队列类,它使用 CAS 实现了一个线程安全的入队和出队操作。相较于上面的计数器,这里的操作更加复杂,我们每一步都需要考虑是否有其他线程已经修改了数据。

这样的算法有点像薛定谔的猫,你不知道它是死是活,只有当你试图去观察它的时候,它才可能会变成死或者活。

public class ConcurrentQueue<T>
{
// _head 和 _tail 是两个伪节点,_head._next 指向队列的第一个节点,_tail 指向队列的最后一个节点。
// _head 和 _tail 会被多个线程修改和访问,所以要用 volatile 修饰。
private volatile Node _head;
private volatile Node _tail; public ConcurrentQueue()
{
_head = new Node(default);
// _tail 指向 _head 时,队列为空。
_tail = _head;
} public void Enqueue(T item)
{
var node = new Node(item);
while (true)
{
Node tail = _tail;
Node next = tail._next;
// 判断给 next 赋值的这段时间,是否有其他线程修改过 _tail
if (tail == _tail)
{
// 如果 next 为 null,则说明从给 tail 赋值到给 next 赋值这段时间,没有其他线程修改过 tail._next,
if (next == null)
{
// 如果 tail._next 为 null,则说明从给 tail 赋值到这里,没有其他线程修改过 tail._next,
// tail 依旧是队列的最后一个节点,我们就可以直接将 node 赋值给 tail._next。
if (Interlocked.CompareExchange(ref tail._next, node, null) == null)
{
// 如果_tail == tail,则说明从上一步 CAS 操作到这里,没有其他线程修改过 _tail,也就是没有其他线程执行过 Enqueue 操作。
// 那么当前线程 Enqueue 的 node 就是队列的最后一个节点,我们就可以直接将 node 赋值给 _tail。
Interlocked.CompareExchange(ref _tail, node, tail);
break;
}
}
// 如果 next 不为 null,则说明从给 tail 赋值到给 next 赋值这段时间,有其他线程修改过 tail._next,
else
{
// 如果没有其他线程修改过 _tail,那么 next 就是队列的最后一个节点,我们就可以直接将 next 赋值给 _tail。
Interlocked.CompareExchange(ref _tail, next, tail);
}
}
}
} public bool TryDequeue(out T item)
{
while (true)
{
Node head = _head;
Node tail = _tail;
Node next = head._next;
// 判断 _head 是否被修改过
// 如果没有被修改过,说明从给 head 赋值到给 next 赋值这段时间,没有其他线程执行过 Dequeue 操作。
if (head == _head)
{
// 如果 head == tail,说明队列为空
if (head == tail)
{
// 虽然上面已经判断过队列是否为空,但是在这里再判断一次
// 是为了防止在给 tail 赋值到给 next 赋值这段时间,有其他线程执行过 Enqueue 操作。
if (next == null)
{
item = default;
return false;
} // 如果 next 不为 null,则说明从给 tail 赋值到给 next 赋值这段时间,有其他线程修改过 tail._next,也就是有其他线程执行过 Enqueue 操作。
// 那么 next 就可能是队列的最后一个节点,我们尝试将 next 赋值给 _tail。
Interlocked.CompareExchange(ref _tail, next, tail);
}
// 如果 head != tail,说明队列不为空
else
{
item = next._item;
if (Interlocked.CompareExchange(ref _head, next, head) == head)
{
// 如果 _head 没有被修改过
// 说明从给 head 赋值到这里,没有其他线程执行过 Dequeue 操作,上面的 item 就是队列的第一个节点的值。
// 我们就可以直接返回。
break;
}
// 如果 _head 被修改过
// 说明从给 head 赋值到这里,有其他线程执行过 Dequeue 操作,上面的 item 就不是队列的第一个节点的值。
// 我们就需要重新执行 Dequeue 操作。
}
}
} return true;
} private class Node
{
public readonly T _item;
public Node _next; public Node(T item)
{
_item = item;
}
}
}

我们可以通过以下代码来进行测试

using System.Collections.Concurrent;

var queue = new ConcurrentQueue<int>();
var results = new ConcurrentBag<int>();
int dequeueRetryCount = 0; var enqueueTask = Task.Run(() =>
{
// 确保 Enqueue 前 dequeueTask 已经开始运行
Thread.Sleep(10);
Console.WriteLine("Enqueue start");
Parallel.For(0, 100000, i => queue.Enqueue(i));
Console.WriteLine("Enqueue done");
}); var dequeueTask = Task.Run(() =>
{
Thread.Sleep(10);
Console.WriteLine("Dequeue start");
Parallel.For(0, 100000, i =>
{
while (true)
{
if (queue.TryDequeue(out int result))
{
results.Add(result);
break;
} Interlocked.Increment(ref dequeueRetryCount);
}
});
Console.WriteLine("Dequeue done");
}); await Task.WhenAll(enqueueTask, dequeueTask);
Console.WriteLine(
$"Enqueue and dequeue done, total data count: {results.Count}, dequeue retry count: {dequeueRetryCount}"); var hashSet = results.ToHashSet();
for (int i = 0; i < 100000; i++)
{
if (!hashSet.Contains(i))
{
Console.WriteLine("Error, missing " + i);
break;
}
} Console.WriteLine("Done");

输出结果:

Dequeue start
Enqueue start
Enqueue done
Dequeue done
Enqueue and dequeue done, total data count: 100000, dequeue retry count: 10586
Done

上述的 retry count 为 797,说明在 100000 次的 Dequeue 操作中,有 10586 次的 Dequeue 操作需要重试,那是因为在 Dequeue 操作中,可能暂时没有数据可供 Dequeue,需要等待其他线程执行 Enqueue 操作。

当然这个 retry count 是不稳定的,因为在多线程环境下,每次执行的结果都可能不一样。

总结

CAS 操作是一种乐观锁,它假设没有其他线程修改过数据,如果没有修改过,那么就直接修改数据,如果修改过,那么就重新获取数据,再次尝试修改。

在借助 CAS 实现较为复杂的数据结构时,我们不光要依靠 CAS 操作,还需要注意每次操作的数据是否被其他线程修改过,考虑各个可能的分支,以及在不同的分支中,如何处理数据。

欢迎关注个人技术公众号

C#中使用CAS实现无锁算法的更多相关文章

  1. 非阻塞同步算法与CAS(Compare and Swap)无锁算法

    锁(lock)的代价 锁是用来做并发最简单的方式,当然其代价也是最高的.内核态的锁的时候需要操作系统进行一次上下文切换,加锁.释放锁会导致比较多的上下文切换和调度延时,等待锁的线程会被挂起直至锁释放. ...

  2. CAS无锁算法与ConcurrentLinkedQueue

    CAS:Compare and Swap 比较并交换 java.util.concurrent包完全建立在CAS之上的,没有CAS就没有并发包.并发包借助了CAS无锁算法实现了区别于synchroni ...

  3. 无锁算法CAS 概述

    无锁算法CAS 概述 JDK5.0以后的版本都引入了高级并发特性,大多数的特性在java.util.concurrent包中,是专门用于多线并发编程的,充分利用了现代多处理器和多核心系统的功能以编写大 ...

  4. 具体CAS操作实现(无锁算法)

    具体CAS操作 上一篇讲述了CAS机制,这篇讲解CAS具体操作. 什么是悲观锁.乐观锁?在java语言里,总有一些名词看语义跟本不明白是啥玩意儿,也就总有部分面试官拿着这样的词来忽悠面试者,以此来找优 ...

  5. 【Java并发编程】9、非阻塞同步算法与CAS(Compare and Swap)无锁算法

    转自:http://www.cnblogs.com/Mainz/p/3546347.html?utm_source=tuicool&utm_medium=referral 锁(lock)的代价 ...

  6. java并发:AtomicInteger 以及CAS无锁算法【转载】

    1 AtomicInteger解析 众所周知,在多线程并发的情况下,对于成员变量,可能是线程不安全的: 一个很简单的例子,假设我存在两个线程,让一个整数自增1000次,那么最终的值应该是1000:但是 ...

  7. CAS(Compare and Swap)无锁算法-学习笔记

    非阻塞同步算法与CAS(Compare and Swap)无锁算法 这篇问题对java的CAS讲的非常透彻! 锁的代价 1. 内核态的锁的时候需要操作系统进行一次上下文切换,加锁.释放锁会导致比较多的 ...

  8. 【实战Java高并发程序设计6】挑战无锁算法:无锁的Vector实现

    [实战Java高并发程序设计 1]Java中的指针:Unsafe类 [实战Java高并发程序设计 2]无锁的对象引用:AtomicReference [实战Java高并发程序设计 3]带有时间戳的对象 ...

  9. 使用CAS实现无锁的SkipList

    无锁 并发环境下最常用的同步手段是互斥锁和读写锁,例如pthread_mutex和pthread_readwrite_lock,常用的范式为: void ConcurrencyOperation() ...

  10. 基于CAS实现无锁结构

    杨乾成 2017310500302 一.题目要求 基于CAS(Compare and Swap)实现一个无锁结构,可考虑queue,stack,hashmap,freelist等. 能够支持多个线程同 ...

随机推荐

  1. SpringBoot+MybatisPlus 多数据源问题

    SpringBoot+Mybatis 多数据源报错 使用了2个数据源 @Bean("dataSource") @ConfigurationProperties(prefix = & ...

  2. IT工具知识-13: 如何编辑SVG图像文件并转换为ICO图标文件

    使用背景 最近做了个小软件, 但是桌面快捷方式图标不好看, 于是想着找个好看点的图标, 但是网上搜了一圈, 发现好看的几乎都要钱, 常用的话, 付费倒也不反感, 但是, 仅仅只用那么一两次, 为这个付 ...

  3. Rfost的自我介绍+软工五问

    自我介绍+软工五问 问题 解答 这个作业属于哪个课程 网工1934-软件工程 这个作业要求在哪里 作业要求 这个作业的目标 让学生对软件工程有个初步的了解,同时掌握基础的markdown语法和博客园的 ...

  4. 持续集成环境(4)-Jenkins凭证管理

    凭据可以用来存储需要密文保护的数据库密码.Gitlab密码信息.Docker私有仓库密码等,以便 Jenkins可以和这些第三方的应用进行交互. 安装Credentials Binding插件 要在J ...

  5. @click使用三元运算符

    @click="scope.row.status == 1 ? '' :  blockUp(scope.row) "

  6. CC协议的诞生背景

    CC协议的诞生背景 在当今世界绝大部分国家的法律法规中,作品的版权一般都保留于创造者或拥有人手中,在没有特殊声明的情况下,任何人想要获取或使用该作品,都要事先取得版权所有者的授权,才可以进行合法的获取 ...

  7. 关与python面向对象的认识

    面向对象编程 类:从一堆对象中以抽象的方式把相同的特征归类得到. 抽象类 类 实列 子类抽象为父类,子类继承父类特征. 类实例化为实例,实例抽象为类. class Human(object): cen ...

  8. 微信小程序中如何设置跳转页面

    修改project.config.json内容 "cloudfunctionRoot":"cloud", //配置云开发的路径 更改app.js文件内容 App ...

  9. 01.html大致主体格式

    <!DOCTYPE html> 不是HTML标签,就是文档声明标签 告诉浏览器使用哪种html版本来显示网页,其必须在文档中的最前面位置,要放在<html>标签之前, < ...

  10. IntelliJ IDEA常用插件

    Mybatis Log Plugin安装好插件后,在Tools工具栏中可以看到安装好的插件,点击即可打开相应窗口,在Debug时,相应的Sql语句即可输出到此窗口,方便查看.此插件相当好用,提升开发效 ...