JUC(一) AQS的前世今生(上)之前为了面试背过JUC、AQS八股文,到现在工作几年了,依旧知其然而不知其所以然,所
前言
JUC(java.util.concurrent
)中提供了大量的并发工具类,其中大部分的同步器(例如锁,屏障等等)都依赖于一个公共基础组件——AQS(AbstractQueuedSynchronizer
)。简单来说,AQS完成了锁的主要工作(比如线程排队、阻塞/解除阻塞、管理同步状态),并对外提供了大量扩展功能,好比是锁的一个基础核心组件,后续同步器的具体实现类只需继承AQS类便可省略大量工作。可以说AQS就是JUC中锁的基石, 掌握AQS的设计原理对后续JUC的学习至关重要。
整个框架的关键在于如何管理阻塞线程的队列,而无锁同步队列主要有2种选择:MCS和CLH。由于CLH锁能更容易地实现取消和超时功能,因此AQS选择将CLH锁作为实现的基础。AQS的实现参考了CLH锁并做了大量改动,算是CLH锁的变种。换句话说,CLH锁就是AQS的基石,想要深入理解AQS就需要先深入理解CLH锁。那么CLH锁又是什么?AQS借鉴了哪些,又做了哪些改变呢?本文主要讨论CLH锁的实现与发展史,旨在通过对CLH锁的学习,为后续掌握AQS打下基础。如有错误欢迎指正。
CLH锁简介
CLH锁是一种支持FIFO、优先级、超时、可适配NUMA架构的队列自旋锁,以作者Craig、Landin、Hagersten名字命名。
Craig 给出了基础版CLH锁的4种代码实现:
- FIFO队列版
- 优先级队列版
- 在NUMA架构上的FIFO队列版
- 在NUMA架构上的优先级队列版
并给出了CLH的其他功能扩展:
- 利用堆维护优先级队列(logn),提升选择下一个优先节点的速度
- 在不额外增加节点的情况下支持嵌套锁
- 允许超时节点移出队列
- 支持
Conditional Lock
- 调度程序需要知道正在抢占的进程正在等待某个锁,并对其执行超时操作,以免未运行的waiter拿到锁。后面将进程重新调度到CPU上时,需要重新激活该请求或重新排队
CLH锁发展史
CLH锁的出现不是一蹴而就的,在其设计中可以明显看到有其他锁的影子。其实在其之前已经有前人做过同步算法的研究,从早期的数组队列锁发展到MCS链表队列锁,Craig 再通过借鉴前人的经验将其升级为CLH队列锁,后人又为CLH锁扩展出诸多版本(比如分层CLH锁,简称HCLH),直到 Doug Lea 设计了AQS。以下是部分论文发布时间线:
作者 | 年份 | 论文名称 |
---|---|---|
Anderson | 1990 | 《The Performance of Spin Lock Alternatives for Shared-Memory Multiprocessors》 |
Graunke、Thakkar | 1990 | 《Synchronization Algorithms for Shared-Memory Multiprocessors》 |
Mellor-Crummey 、Scott | 1991 | 《Algorithms for Scalable Synchronization on Shared-Memory Multiprocessors》 |
Craig | 1993 | 《Building FIFO and priority-queueing spin locks from atomic swap》 |
1993 | 《Queuing Spin Lock Algorithms to Support Timing Predictability》 | |
Landin 、Hagersten | 1994 | 《Efficient Software Synchronization on Large Cache Coherent Multiprocessors》 |
1994 | 《Queue Locks on Cache Coherent Multiprocessors》 | |
Scott、Scherer | 2001 | 《Scalable Queue-Based Spin Locks with Timeout》 |
Luchangco、Nussbaum、Shavi | 2001 | 《A Hierarchical CLH Queue Lock》 |
Doug Lea | 2005 | 《The java.util.concurrent Synchronizer Framework》 |
有趣的是,CLH锁由不同的作者独立完成,且相互间隔了近一年的时间,大概是这么个过程:
- Craig 于1993年发表论文《Building FIFO and priority-queueing spin locks from atomic swap》,其中详细描述了一种支持FIFO、优先级和NUMA架构的队列自旋锁,并简要讨论了该锁的扩展,如锁的重入、超时、条件锁、CPU抢占等。并于1993发布的《Queuing Spin Lock Algorithms to Support Timing Predictability》中再次描述了超时的实现思路。
- Landin 和 Hagersten 于1994年共同发表《Efficient Software Synchronization on Large Cache Coherent Multiprocessors》和《Queue Locks on Cache Coherent Multiprocessors》, 提出了LH锁, 本质上和 Craig 提出的锁相同。
接下来,会基于以上提到的论文,详细说明各种同步算法的实现。
基于TestAndSet指令的锁
首先要介绍的是TAS(testAndSet)
指令锁。TAS
是对指定内存地址写入1并返回旧值的原子指令。返回值是旧值,并不是指令执行成功与否。由于是对bit进行操作且仅只有地址这一个入参,相比FetchAndStore
、compareAndSwap
,需要的寄存器与耗时更少。
使用TAS
指令实现锁非常简单,每次指令完成后检查旧值,若为true表示锁仍占用。但由于所有线程通过总线来和内存通信,且每个等待的线程为了争夺锁,都会尽可能频繁地访问同一个共享标志位,各线程需通过总线获取新值,这使得解锁线程、自旋线程,甚至其他线程,都会因此延迟。就好比一群人在银行办理业务,但只有一个办理窗口,所有人都挤到了窗口。因此可以通过在轮询之间增加固定间隔时间来减少竞争,若竞争失败则休息一段时间,或更复杂的方案,如指数退避:每次加锁失败都会将下次延迟时间翻倍。当然,延迟时间可以设置上限。
不过,退避依旧会出现性能问题。当第一次抢锁失败时,所有线程会延迟一个较短的时间,随后继续争夺,持续一段较长的时间,直到平均延迟增加到足以避免冲突。随着任务执行,这种冲突导致的性能下降会逐渐消失。然而,随着越来越多的线程获得锁并执行完成,等待的线程减少,可此时的延迟已经增加得过长,这会使得后续锁的传递往后延迟。
代码实现如下:
public class TASLock {
private volatile int state = 0;
public void lock() {
long delay = 1L;
while (testAndSetState() == 1) {
Thread.sleep(delay);
delay *= 2;
}
}
public void unlock() {
state = 0;
}
}
注意:本文代码都是Java实现,由于语言的限制,不得不对原语义做出一些改动,或省略一些冗余代码,如:
- Java中并没有
testAndSet/fetchAnd*
指令,需要用while(compareAndSwapInt)
模拟,应明确两者的差别- 锁的状态本只有2个:
locked
或unlocked
,用boolean
即可表示。但为了在CAS
时可省略一些代码,此处换成int
了解原理即可,不必纠结具体语言的实现
各原子指令区别:
原子指令 | 描述 |
---|---|
TestAndSet | 向指定地址写入1,并返回旧值,是对bit操作 |
FetchAndStore | 向指定地址写入指定值,并返回旧值。不限于bit,比如可以是32位 |
FetchAndAdd | 对指定地址执行增加1操作 |
compareAndSwap | 将指定地址的值与指定值进行比较,相同则将指定地址的值替换为新值 |
public int testAndSetState() { // 用CAS模拟TAS
int oldState;
do {
oldState = state;
} while (!unsafe.compareAndSwapInt(this, stateOffset, oldState, 1));
return oldState;
}
public int fetchAndAddIndex(int delta) { // 用CAS模拟FAA
int oldIndex;
do {
oldIndex = index;
} while (!unsafe.compareAndSwapInt(this, indexOffset, oldIndex, oldIndex + delta));
return oldIndex;
}
基于TAS指令的Test-TestAndSet锁
若使用testAndSet
指令实现锁,除了可以通过增加轮询间隔时间减少竞争,还可以在TAS
前先检测锁是否已经占用,已占用则继续轮询。各线程在轮询锁状态时,直到发现锁可用,才会执行TAS
指令。好处是,在锁占用期间不会有尝试加锁导致的内存竞争。但锁释放的瞬间,各线程竞争依旧激烈。
同样拿银行办理业务的例子,当看到窗口有人便继续等待,等到窗口空闲就一拥而上,拥挤的同时还会有倒霉蛋一直抢不到锁。
代码实现如下:
public class TTASLock {
private volatile int state = 0;
public void lock() {
while (state = 1 || testAndSetState(1) == 1);
}
public void unlock() {
state = 0;
}
}
TicketLock
TTAS
的实际执行指令数量相比于TAS
少了很多,但仍有较大的竞争的可能性。比如每个线程都正好在锁可用的时候进行TAS
,但最后只有1个线程能成功抢到锁,可能会有饥饿现象。
而TicketLock能按照线程请求锁的顺序进行加锁,不使用队列就达到FIFO的效果,确保公平,而且需要的原子操作很少,不会出现窗口空闲便一拥而上的情况。思路就是每个人先取号,然后一直检查窗口显示的号码与自己的相同就可以办理业务了。变相地把竞争锁变成了竞争取号。细节如下:
- 创建2个计数器:
- requestCount:表示请求锁的次数,
- releaseCount:表示释放锁的次数
- 加锁:每个线程加锁时使用
FAA(FetchAndAdd)
将requestCount+1
,此时该值就是当前线程的ticket
,轮询直到ticket=releaseCount
则加锁成功 - 解锁:将
releaseCount+1

显然,加锁时的轮询会有和TAS
同样的问题:对相同内存的竞争。比如,当等待线程数量较多时,每次releaseCount
修改都会使得各线程缓存失效,需要通过总线刷新本地缓存,此时便会发生拥堵。因此,每次轮询同样需要引入延迟时间,但使用TAS
中的指数退避并不是一个好办法,因为这会导致后面的所有线程被推迟。延迟时间首先应和前面排队线程数(ticket-releaseCount
)有关,再将其乘以某个系数(如最小执行时间/平均执行时间)得到可能的延迟时间。其中的系数选择最小执行时间更合适,因为平均执行时间会导致后面的线程往后推迟。
代码实现如下:
public class TicketLock {
private volatile int requestCount = 0;
private volatile int releaseCount = 0;
public void lock() {
int myTicket = fetchAndAdd(requestCount); // return 旧值
while(myTicket != releaseCount) {
Thread.sleep(myTicket - releaseCount); // 线程在临界区耗时不确定,因此延迟时间应视具体情况定
}
}
public void unlock() {
releaseCount++;
}
}
基于数组的队列锁
自旋会增加处理器-内存的负载,影响其他处理器的进度。况且,由于线程在临界区耗时的不确定性,TicketLock的性能也是不确定的。于是 Anderson 提出了一种新的方案,即基于数组的FIFO公平队列锁:构建一个长度为最大核心数的bool
数组,每个线程通过fetchAndAdd
指令获取自己在数组中对应的index
,并在该位置自旋。
与前面的TAS
指令锁相比,队列化的好处是,由于自旋的线程在其标志位被设置true时会自动直接获得临界区的控制权,因此,从上一个线程释放锁到下一个线程进入临界区之间的时间得以减少。从某种意义上说,这利用了并行性,即:自旋的线程在上一个线程执行临界区时就完成了耗时的原子操作fetchAndAdd
,从而减少了抢夺锁所需的串行工作量。因此,当临界区耗时较长且竞争激烈时,吞吐量相比TAS
会更高。

不过队列化也有一些缺点:
- 每个线程必须增加一个计数,检查某一个槽位的值,将该位置置零然后设置下一个位置,这些操作会增加锁的延迟;而在
TAS
指令锁中,当没有竞争时,第一个TAS
指令就能获取锁。简单来说:当存在竞争时,更需要排队维护秩序,队列化会更好;当没有竞争时,简单自旋或退避更好。 - 难以应对多临界区的场景。随着核心数量的增加,集中的单个资源极易成为瓶颈。为了提高吞吐量,需要将资源划分到多个临界区中,自旋的线程可以分别进入不同的临界区进行工作,若抢夺某个锁失败,则延迟一段时间再去尝试另一个锁。然而队列化之后,线程只能在一个队列中排队等待,而其他临界资源可能没有线程在使用。
代码实现如下:
public class ArrayBasedQueuingLock {
private int size = 10; // size是最大并行线程数
private volatile int[] slots = new int[size];
private volatile int index = 0;
public ArrayBasedQueuingLock() {
slots[0] = true; // 初始化时将第一个置为true
}
public int lock() {
int myIndex = fetchAndAddIndex(1) % size;
while (slots[myIndex] == 0);
return myIndex;
}
public void unlock(int myIndex) {
slots[myIndex] = 0;
slots[(myIndex + 1) % size] = 1; // 将下一个槽位上锁
}
}
MCS锁
1991年1月,Mellor-Crummey 和 Scott 提出了一种基于链表的队列锁,用名字的首字母命名为MCS锁。每个申请锁的线程都将在队列中插入一个节点,节点中仅有next
指针和state
标识,每个线程都只在自己可访问的本地变量上自旋。队列锁本身只有一个指向队尾节点的指针(若队列空则指向null)。当队列中的节点需释放锁时,通过next
指针通知下一个节点(修改其wait=false
)使其恢复执行。在释放锁时通过CAS指令确定自己是否是队列内的唯一节点,并且正确地移除自己。

MCS锁使用到的原子指令是FAS
,在竞争激烈时性能会比TAS
锁更好(并非指令本身的好坏,准确说是使用场景更佳),因为队列化使得竞争点从临界资源转移到队尾,MCS锁竞争队列尾是不需要自旋的,而TAS
锁需自旋不断进行TAS
指令,这点与数组队列锁相似。
MCS锁带来的新问题:使用next
指针构建的单链表是无法原子插入队尾的,因为在新节点FAS
成功更换尾指针后才能设置前一个节点的next
指针,在这两步之间,可能会有其他线程读取到脏数据,比如前一个节点恰好是持有锁的线程,恰好在释放锁,发现next
节点为空就无法通知下一个节点获得锁
线程1(持有锁的线程) | 线程2(新进入队列的线程) | |
---|---|---|
时刻1 | FAS 更新队尾 | |
时刻2 | 释放锁,发现无next 节点 | |
时刻3 | 将前节点的next 指向自己 |
为了应对这种情况,线程1在释放锁时若发现没有next
节点,尝试先CAS
将队尾设置为null
,此处会和线程2的FAS
更新队尾发生竞争,若成功则解锁成功,若失败表示线程2入队第一步成功,作为补偿,线程1需要自旋直到线程2入队操作结束,随后将线程2的状态置为true
后才算解锁完成。
可前面提到的数组队列锁在解锁时并没有使用CAS
指令,为什么不会发生这种情况?原因是每个槽位都一定有next
槽位,即使next
槽位此刻没有新线程使用,当前线程也能先将next
槽位置为true
,当前线程解锁和未来新线程加锁可分开进行。
总结MCS锁的优点:
- 保证按FIFO顺序获取锁(同TicketLock和ArrayBasedQueuingLock)
- 在本地变量上自旋
- 每个锁只需要很小的固定空间(同TASLock和TicketLock)
- 释放锁只会使
next
节点的缓存失效
缺点如下:
- 释放锁可能需要自旋等待
- 需要更多原子指令如
CAS
。若不支持CAS
,作者在论文中也给出了无CAS
指令的版本,但实现较复杂且无法保证FIFO排序,引入饥饿的可能性,此处不做讨论 - 实现虽然简单,但功能也较少,没有优先级、超时等功能。直至2001年Scott、Scherer才在《Scalable Queue-Based Spin Locks with Timeout》中描述了MCS锁的超时版本
MCS锁代码实现如下
public class MCSLock {
private class Node {
public volatile Boolean wait;
public volatile Node next;
}
private Node tail;
public void lock(Node node) {
Node pred = fetchAndStoreTail(node);
if (pred != null) {
node.wait = true;
pred.next = node;
while (node.wait) {
}
}
}
public void unlock(Node node) {
if (node.next == null) {
if (compareAndSwapTail(null)) { // 无next节点,所以需要尝试原子更新tail为null
return;
}
while (node.next == null) { // casTail失败,说明新节点入队,等待next线程执行pred.next = node
}
}
node.next.wait = false;
}
}
CLH锁
Craig 借鉴了前人的经验,在MCS锁和数组队列锁的基础上加以改进,提出了4种更实用的新算法,并给出多种扩展功能。
CLH锁-FIFO版
CLH锁中每个等待线程都有自己的节点,每个节点状态为boolean
,true
表示正持有锁或等待锁,称之为busy
以便理解,反之表示已释放锁,即free
。锁本身只有一个tail
指针指向尾节点,除了在抢夺队尾时的FAS
之外,不需要其他任何原子指令。每个线程自旋检查前节点状态,由于第一个节点没有前节点,为了逻辑更加清晰且统一,需在队列初始化时创建头节点,默认为free
状态。当前线程释放锁后,需指向前节点,原节点便成为新的头节点。结构如图所示:
为什么线程1解锁时需要指向前节点?考虑一下这种情况:线程1释放锁后再次尝试获取锁,会形成循环,如下图所示:
若一开始线程1解锁时便丢掉其节点1,而且使用原节点0,便能避免这种情况
代码实现如下:
public class CLHLock {
private static class Node {
public volatile Boolean busy; // true表示持有锁或等待锁
}
private static class Process {
public volatile Node predNode;
public volatile Node node;
}
private Node tail;
public CLHLock() {
Node n = new Node();
node.busy = false;
tail = n;
}
public void lock(Process p) {
p.node.busy = true;
p.predNode = (fetchAndStoreTail(p.node));
while (p.predNode.busy) {
}
}
public void unlock(Process p) {
p.node.busy = false;
p.node = p.predNode;
}
}
lock()
中插入队尾时没有使用CAS
保证原子性,而是先使用FAS
原子设置队尾,再赋值给predNode
,虽然非原子操作,但前节点只有当前节点能访问,所以不影响。
CLH锁-优先级队列版
CLH的第一篇论文名为《Building FIFO and Priority-Queuing Spin Locks from Atomic Swap》,从名字可以看出这种队列锁具有优先级功能,可队列中已有固定顺序,如何再按优先级抢锁?
实现思路很简单:
Lock
中增加head
指针Node
和Process
组成双向链表- 释放锁时,遍历整个队列找到优先级最高的节点授予锁,然后将自己移除
具体流程如下:
- 假设某一时刻只有2个线程,线程1(优先级6)和 线程2(优先级8),因此线程2已经获得锁
- 线程2准备解锁,从
head
开始遍历找到优先级最高的线程4(优先级16),将其前节点3的状态变更为free
,并将线程1和节点2互连,线程2和节点1互连,使得线程2和节点1出队列
为什么需要额外增加head和next指针并且从前往后遍历呢?不能通过只增加process
指针直接从tail
往前遍历吗?原因是CLH锁使用的不是CAS
而是FAS
指令,而FAS
指令执行完成后才会返回旧tail
,此时新tail
还未来得及连接至前面的旧tail
,所以无法遍历整个队列。
代码实现如下:
public class CLHPrioLock {
private static class Node {
public volatile Boolean busy;
public volatile Process nextProcess;
public volatile Process process;
}
private static class Process {
public volatile Node predNode;
public volatile Node node;
public volatile Integer prio;
}
private Node tail;
private Node head;
public void lock(Process p) {
p.node.busy = true;
p.predNode = (fetchAndStoreTail(p.node));
p.predNode.nextProcess = p;
while (p.predNode.busy) {
}
}
public void unlock(Process p) {
p.node.process = p.predNode.process;
if (p.node.process != null) {
p.node.process.node = p.node;
} else {
head = p.node;
}
Integer highPrio = 0;
Node highNode = head;
Process currP = head.nextProcess;
while(currP != null) { // 从head开始遍历,找到优先级最高的节点
if (currP.prio > highPrio) {
highPrio = currP.prio;
highNode = currP.predNode;
}
currP = currP.node.nextProcess;
}
highNode.busy = false; // 将优先级最高节点的前节点状态改为free
p.node = p.predNode;
p.node.process = p;
}
}
CLH锁-NUMA版
CLH锁在前节点自旋,可在NUMA架构中,各处理器节点都有自己的本地内存,且访问本地内存要快于访问其他节点的内存,如果各处理器在其他节点的内存位置自旋会有性能问题,因此需要尽可能在本地内存进行自旋。CLH锁的设计参考了MCS
锁,增加next
指针使得前节点能通知后节点,但对于节点状态,CLH锁的实现稍微复杂了些,既保留了基础版CLH锁中判断前节点状态的逻辑,又增加了MCS锁中自旋判断本节点状态的逻辑:先判断前节点状态是否为free
,是则直接获得锁。否则自旋本地状态,直到前节点通知。这样的好处之一是不需要CAS
指令,仅用TAS
就能实现,且前节点不用自旋等待后节点。原因在于前节点可以将解锁信息放到节点状态字段,后节点获取前节点的状态,相当于前节点变相通知了后节点,而不需要像MCS锁那样CAS
失败后自旋,如下表,即使前节点无法通知后节点,也能通过查询前节点状态获取信息。
线程1(持有锁的线程) | 线程2(入队等待锁的线程) | |
---|---|---|
时刻1 | 交换队尾指针 | |
时刻2 | a. 修改节点状态为free | |
时刻3 | b. 无后节点,无法通知 | |
时刻4 | a. 将前节点指向后节点 | |
时刻5 | b. 获取前节点状态为free ,表示可以获得锁 |
这种做法其实需要保证代码按特定顺序执行,即 [1a
早于2b
] 或 [2a
早于1b
](1a
表示线程1修改节点状态,以此类推)。比如,线程1和线程2代码都按先a后b的顺序编写,即使执行时两线程是乱序的,也一定能满足要求。
而CLH
锁的实际实现更为复杂,它要求我们当前是按偶数字节对齐的,比如4字节对齐(即指针是4的倍数),那么指针低2位一定为0,低2位就可以用来保存数据,取最低位用来保存节点状态(boolean)
,因此用原子指令更新next
就相当于同时修改了指向和状态,这使得逻辑更清晰。这种思路源自G-T Lock
(Graunke 和 Thakkar 的数组队列锁)。
伪代码如下:
public class CLHNUMALock {
private static class Process {
public volatile Boolean localBusy;
public volatile Node predNode;
public volatile Node node;
}
private static class Node {
public volatile {Process, Boolean} NextProcess_busy;
}
private Node tail;
private Node head;
public void lock(Process p) {
fatchAndStoreNode({null, true}),
p.node.busy = true;
p.predNode = fatchAndStoreTail(p.node);
{ignore, predNodeBusy} = fatchAndStorePredNode({&p, true}),
if (predNodeBusy) {
while (p.localBusy) {
}
}
}
public void unlock(Process p) {
{nextProcess, ignore} = fatchAndStoreNode({null, false}),
if (nextProcess != null) {
nextProcess.localBusy = false;
}
p.node = p.predNode;
}
}
CLH锁与MCS对比
比较项 | MCS锁(CAS) | MCS锁(FAS only) | CLH锁 | |
---|---|---|---|---|
内存模型 | NUMA | √ | √ | √ |
原子指令 | CAS | √ | ||
FAS | √ | √ | √ | |
加锁顺序 | FIFO | √ | √ | |
priority | √ | |||
嵌套锁所需空间 | L + P | √ | ||
L + P * D | √ | √ |
NUMA架构
CLH锁给出了NUMA架构的扩展版本。这里说的NUMA又是什么呢?NUMA(Non-Uniform Memory Access)即非均匀内存访问,与之对应的是UMA(Uniform Memory Access)。而UMA和NUMA架构都属于SMP架构。与SMP相对的则是AMP架构。SMP和AMP的区别在于CPU是否对等,UMA和NUMA的区别在核心到内存是否对等

SMP(Symmetric Multi-Processor)
即对称多处理器架构,是目前最常见的多核架构。各CPU对称工作,无主次或从属关系,平等地访问内存、外设等。操作系统平等为各个内核分配工作,各核心并行工作。对SMP架构进行扩展的方式包括:使用更快的CPU/内存 、增加CPU/内存 、扩充 I/O以及添加磁盘等
AMP(Asymmetric Multi-processing)
即非对称多处理器。每个处理器可能是同构或是异构的,但大多情况是异构多处理器,拥有自己的独立资源,运行一个独立操作系统实例。各操作系统有自己的专用内存。用户需参与系统资源的分配,每个核心可针对不同的需求设定,独立运行不同的任务,有自己的内存空间(同时会有共享的内存空间)。但也需要一个主核心,来控制整个系统和其它核心。
UMA(Uniform Memory Access)
即均匀内存访问。每个CPU通过相同的内存总线访问相同的内存资源,所以所需时间在同一数量级。早期计算机中,CPU通过前端总线-北桥中的内存控制器(现在内存控制器已从北桥放入CPU中)来访问内存,要提高性能就得提高数量(CPU数量,核心数量,内存通道数量)和工作/数据传输频率(如CPU,总线,内存的频率),可随着CPU数量的增加,内存访问冲突将迅速增加,最终会降低CPU性能,浪费CPU资源。

NUMA(Non-Uniform Memory Access)
但当频率和CPU核数提升到一定程度,之前的UMA架构面临总线性能瓶颈。为了减少CPU访问内存的竞争和延迟,可将内存控制器从北桥放进CPU,且内存在物理上分开,各CPU访问自己的内存,但访问其他CPU节点内存较慢,访问速度与节点的距离有关,即非均匀内存访问,也有翻译为非一致性内存访问的,但这样的翻译可能会和MESI(缓存一致性协议)中的一致性搞混。NUMA将CPU和内存划分为多个节点,每个node有多个CPU核心、专用总线和自己独立的内存空间。各个node之间通过QPI(QuickPath Interconnect)总线进行通信,因此每个节点都能访问整个系统的内存。但需要尽量减少访问非当前节点的内存
NUMA的几个概念如下:
- socket:对应主板上物理CPU插槽。Socket和Node并不完全对应,比如1个Socket可以对应2个Node
- Node:Node是逻辑上的概念,是NUMA架构中的基本单元。每个节点包含自己的内存和多个cpu。各个Node之间通过QPI通信
- Core:满足执行单个程序的一个基本计算单元,当支持如Intel的超线程时也可以运行多个程序。每个核心都有自己的L1缓和L2缓,但L3缓为节点共享
- Thread:每个core中使用超线程虚拟出的逻辑执行单元

转载自:https://juejin.cn/post/7417374536671395855