likes
comments
collection
share

JUC(一) AQS的前世今生(上)之前为了面试背过JUC、AQS八股文,到现在工作几年了,依旧知其然而不知其所以然,所

作者站长头像
站长
· 阅读数 6

前言

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种代码实现:

  1. FIFO队列版
  2. 优先级队列版
  3. 在NUMA架构上的FIFO队列版
  4. 在NUMA架构上的优先级队列版

并给出了CLH的其他功能扩展:

  1. 利用堆维护优先级队列(logn),提升选择下一个优先节点的速度
  2. 在不额外增加节点的情况下支持嵌套锁
  3. 允许超时节点移出队列
  4. 支持Conditional Lock
  5. 调度程序需要知道正在抢占的进程正在等待某个锁,并对其执行超时操作,以免未运行的waiter拿到锁。后面将进程重新调度到CPU上时,需要重新激活该请求或重新排队

CLH锁发展史

CLH锁的出现不是一蹴而就的,在其设计中可以明显看到有其他锁的影子。其实在其之前已经有前人做过同步算法的研究,从早期的数组队列锁发展到MCS链表队列锁,Craig 再通过借鉴前人的经验将其升级为CLH队列锁,后人又为CLH锁扩展出诸多版本(比如分层CLH锁,简称HCLH),直到 Doug Lea 设计了AQS。以下是部分论文发布时间线:

作者年份论文名称
Anderson1990《The Performance of Spin Lock Alternatives for Shared-Memory Multiprocessors》
Graunke、Thakkar1990《Synchronization Algorithms for Shared-Memory Multiprocessors》
Mellor-Crummey 、Scott1991《Algorithms for Scalable Synchronization on Shared-Memory Multiprocessors》
Craig1993《Building FIFO and priority-queueing spin locks from atomic swap》
1993《Queuing Spin Lock Algorithms to Support Timing Predictability》
Landin 、Hagersten1994《Efficient Software Synchronization on Large Cache Coherent Multiprocessors》
1994《Queue Locks on Cache Coherent Multiprocessors》
Scott、Scherer2001《Scalable Queue-Based Spin Locks with Timeout》
Luchangco、Nussbaum、Shavi2001《A Hierarchical CLH Queue Lock》
Doug Lea2005《The java.util.concurrent Synchronizer Framework》

有趣的是,CLH锁由不同的作者独立完成,且相互间隔了近一年的时间,大概是这么个过程:

  1. Craig 于1993年发表论文《Building FIFO and priority-queueing spin locks from atomic swap》,其中详细描述了一种支持FIFO、优先级和NUMA架构的队列自旋锁,并简要讨论了该锁的扩展,如锁的重入、超时、条件锁、CPU抢占等。并于1993发布的《Queuing Spin Lock Algorithms to Support Timing Predictability》中再次描述了超时的实现思路。
  2. 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进行操作且仅只有地址这一个入参,相比FetchAndStorecompareAndSwap,需要的寄存器与耗时更少。

使用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实现,由于语言的限制,不得不对原语义做出一些改动,或省略一些冗余代码,如:

  1. Java中并没有testAndSet/fetchAnd*指令,需要用while(compareAndSwapInt)模拟,应明确两者的差别
  2. 锁的状态本只有2个:lockedunlocked,用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的效果,确保公平,而且需要的原子操作很少,不会出现窗口空闲便一拥而上的情况。思路就是每个人先取号,然后一直检查窗口显示的号码与自己的相同就可以办理业务了。变相地把竞争锁变成了竞争取号。细节如下:

  1. 创建2个计数器:
    • requestCount:表示请求锁的次数,
    • releaseCount:表示释放锁的次数
  2. 加锁:每个线程加锁时使用FAA(FetchAndAdd)requestCount+1,此时该值就是当前线程的ticket,轮询直到ticket=releaseCount则加锁成功
  3. 解锁:将releaseCount+1
JUC(一) AQS的前世今生(上)之前为了面试背过JUC、AQS八股文,到现在工作几年了,依旧知其然而不知其所以然,所

显然,加锁时的轮询会有和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会更高。

JUC(一) AQS的前世今生(上)之前为了面试背过JUC、AQS八股文,到现在工作几年了,依旧知其然而不知其所以然,所

不过队列化也有一些缺点:

  1. 每个线程必须增加一个计数,检查某一个槽位的值,将该位置置零然后设置下一个位置,这些操作会增加锁的延迟;而在TAS指令锁中,当没有竞争时,第一个TAS指令就能获取锁。简单来说:当存在竞争时,更需要排队维护秩序,队列化会更好;当没有竞争时,简单自旋或退避更好。
  2. 难以应对多临界区的场景。随着核心数量的增加,集中的单个资源极易成为瓶颈。为了提高吞吐量,需要将资源划分到多个临界区中,自旋的线程可以分别进入不同的临界区进行工作,若抢夺某个锁失败,则延迟一段时间再去尝试另一个锁。然而队列化之后,线程只能在一个队列中排队等待,而其他临界资源可能没有线程在使用。

代码实现如下:

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指令确定自己是否是队列内的唯一节点,并且正确地移除自己。

JUC(一) AQS的前世今生(上)之前为了面试背过JUC、AQS八股文,到现在工作几年了,依旧知其然而不知其所以然,所

MCS锁使用到的原子指令是FAS,在竞争激烈时性能会比TAS锁更好(并非指令本身的好坏,准确说是使用场景更佳),因为队列化使得竞争点从临界资源转移到队尾,MCS锁竞争队列尾是不需要自旋的,而TAS锁需自旋不断进行TAS指令,这点与数组队列锁相似。

MCS锁带来的新问题:使用next指针构建的单链表是无法原子插入队尾的,因为在新节点FAS成功更换尾指针后才能设置前一个节点的next指针,在这两步之间,可能会有其他线程读取到脏数据,比如前一个节点恰好是持有锁的线程,恰好在释放锁,发现next节点为空就无法通知下一个节点获得锁

线程1(持有锁的线程)线程2(新进入队列的线程)
时刻1FAS更新队尾
时刻2释放锁,发现无next节点
时刻3将前节点的next指向自己

为了应对这种情况,线程1在释放锁时若发现没有next节点,尝试先CAS将队尾设置为null,此处会和线程2的FAS更新队尾发生竞争,若成功则解锁成功,若失败表示线程2入队第一步成功,作为补偿,线程1需要自旋直到线程2入队操作结束,随后将线程2的状态置为true后才算解锁完成。

可前面提到的数组队列锁在解锁时并没有使用CAS指令,为什么不会发生这种情况?原因是每个槽位都一定有next槽位,即使next槽位此刻没有新线程使用,当前线程也能先将next槽位置为true,当前线程解锁和未来新线程加锁可分开进行。

总结MCS锁的优点:

  1. 保证按FIFO顺序获取锁(同TicketLock和ArrayBasedQueuingLock)
  2. 在本地变量上自旋
  3. 每个锁只需要很小的固定空间(同TASLock和TicketLock)
  4. 释放锁只会使next节点的缓存失效

缺点如下:

  1. 释放锁可能需要自旋等待
  2. 需要更多原子指令如CAS。若不支持CAS,作者在论文中也给出了无CAS指令的版本,但实现较复杂且无法保证FIFO排序,引入饥饿的可能性,此处不做讨论
  3. 实现虽然简单,但功能也较少,没有优先级、超时等功能。直至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锁中每个等待线程都有自己的节点,每个节点状态为booleantrue表示正持有锁或等待锁,称之为busy以便理解,反之表示已释放锁,即free。锁本身只有一个tail指针指向尾节点,除了在抢夺队尾时的FAS之外,不需要其他任何原子指令。每个线程自旋检查前节点状态,由于第一个节点没有前节点,为了逻辑更加清晰且统一,需在队列初始化时创建头节点,默认为free状态。当前线程释放锁后,需指向前节点,原节点便成为新的头节点。结构如图所示:

JUC(一) AQS的前世今生(上)之前为了面试背过JUC、AQS八股文,到现在工作几年了,依旧知其然而不知其所以然,所

为什么线程1解锁时需要指向前节点?考虑一下这种情况:线程1释放锁后再次尝试获取锁,会形成循环,如下图所示:

JUC(一) AQS的前世今生(上)之前为了面试背过JUC、AQS八股文,到现在工作几年了,依旧知其然而不知其所以然,所

若一开始线程1解锁时便丢掉其节点1,而且使用原节点0,便能避免这种情况

JUC(一) AQS的前世今生(上)之前为了面试背过JUC、AQS八股文,到现在工作几年了,依旧知其然而不知其所以然,所

代码实现如下:

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》,从名字可以看出这种队列锁具有优先级功能,可队列中已有固定顺序,如何再按优先级抢锁?

实现思路很简单:

  1. Lock中增加head指针
  2. NodeProcess组成双向链表
  3. 释放锁时,遍历整个队列找到优先级最高的节点授予锁,然后将自己移除

具体流程如下:

  1. 假设某一时刻只有2个线程,线程1(优先级6)和 线程2(优先级8),因此线程2已经获得锁
  2. 线程2准备解锁,从head开始遍历找到优先级最高的线程4(优先级16),将其前节点3的状态变更为free,并将线程1和节点2互连,线程2和节点1互连,使得线程2和节点1出队列

JUC(一) AQS的前世今生(上)之前为了面试背过JUC、AQS八股文,到现在工作几年了,依旧知其然而不知其所以然,所

为什么需要额外增加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交换队尾指针
时刻2a. 修改节点状态为free
时刻3b. 无后节点,无法通知
时刻4a. 将前节点指向后节点
时刻5b. 获取前节点状态为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的区别在核心到内存是否对等

JUC(一) AQS的前世今生(上)之前为了面试背过JUC、AQS八股文,到现在工作几年了,依旧知其然而不知其所以然,所
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资源。

JUC(一) AQS的前世今生(上)之前为了面试背过JUC、AQS八股文,到现在工作几年了,依旧知其然而不知其所以然,所
NUMA(Non-Uniform Memory Access)

但当频率和CPU核数提升到一定程度,之前的UMA架构面临总线性能瓶颈。为了减少CPU访问内存的竞争和延迟,可将内存控制器从北桥放进CPU,且内存在物理上分开,各CPU访问自己的内存,但访问其他CPU节点内存较慢,访问速度与节点的距离有关,即非均匀内存访问,也有翻译为非一致性内存访问的,但这样的翻译可能会和MESI(缓存一致性协议)中的一致性搞混。NUMA将CPU和内存划分为多个节点,每个node有多个CPU核心、专用总线和自己独立的内存空间。各个node之间通过QPI(QuickPath Interconnect)总线进行通信,因此每个节点都能访问整个系统的内存。但需要尽量减少访问非当前节点的内存

NUMA的几个概念如下:

  1. socket:对应主板上物理CPU插槽。Socket和Node并不完全对应,比如1个Socket可以对应2个Node
  2. Node:Node是逻辑上的概念,是NUMA架构中的基本单元。每个节点包含自己的内存和多个cpu。各个Node之间通过QPI通信
  3. Core:满足执行单个程序的一个基本计算单元,当支持如Intel的超线程时也可以运行多个程序。每个核心都有自己的L1缓和L2缓,但L3缓为节点共享
  4. Thread:每个core中使用超线程虚拟出的逻辑执行单元
JUC(一) AQS的前世今生(上)之前为了面试背过JUC、AQS八股文,到现在工作几年了,依旧知其然而不知其所以然,所
转载自:https://juejin.cn/post/7417374536671395855
评论
请登录