芯片高并发机制技术杂谈

芯片高并发机制技术杂谈

CPU控制并发原理CPU中断控制内核解析

介绍CPU角度的中断控制,CPU层面并行并发和中断控制的原理,现代CPU的缓存结构和架构图、CPU缓存一致性的源码原理,以及CPU如何通过编译器的屏障与指令实现系统屏障,经过内联汇编代码验证之后,证明上述所说的 Linux 内核用 volatile 关键字实现系统屏障(指令重排),加深对系统屏障的内核源码和原理的理解。

一、CPU的中断控制

1、并行和并发

1.1 并发

最初的计算机中是没有并发概念的。一台计算机只能运行一个任务,该任务没有完成,就不能运行下一个任务。

这有什么缺点呢?

想象一下,计算机只能运行一个任务,如果在听歌,那么计算机就无法进行其他事情。是不是很抓狂!让来想想如何让计算机同时运行多个任务,让在听歌时还能写文档、聊天等。这个在计算机中运行多个任务的操作就叫作并发,高并发当然就是同时有非常多的任务在计算机中执行。那么问题来了:大家都知道计算机中进行计算的是CPU,然而CPU个数有限,任务个数又远大于CPU个数,这时CPU又该如何运行这么多的任务的呢?

可换个思维来考虑,大家都学过数学,不妨回忆什么是函数,想象一个连续的曲线函数,用离散的多个小块来模拟曲线,当小块足够多时,那么就说这些小块就是组成连续函数的块。

同理,计算机也可以这么做。计算机中有很多任务,让这些任务交替在速度够快,人眼就感知不到任务间的切换,只要矩形足够多,就越能模拟出这个函数。下面对并发进行定义:在有限的COU中执行超过了CPU数量的任务,任务之间交替执行。

1.2 并行

并发也就是在不同时间执行多个任务,因为时间很短,所以人眼看不出来是在替执行。而并行呢,就是一种特殊的并发。并发是不同时间执行多个任务,并行其实就是同一时间执行多个任务。下面给出计算机中并行的定义:在有限的CPU中执行任务,任务的个数正好等同于CPU的个数,则称之为并行。

计算机什么时候用到并发,又什么时候用到并行呢?

这不仅仅是CPU个数正好等于任务的个数,还需要操作系统提供支持。例如,操作系统如果设定只用一个CPU,那么CPU个数再多也没有意义,所以具体的并发与并行问题需要放在实际情况中来考量。不过现在个人计算机或者服务器CPU个数都足够多,且操作系统如果不是特殊设置,也不会只用一个CPU。由于实际运行的任务多于CPU核心数,因此并行与并发是同时存在的。

2、CPU中断控制原理

本节从CPU来看看并发和并行下,硬件层是如何处理共享资源的。

如果只有一个CPU,肯定采用并发任务,因为没有多个CPU,所以无法并行,也就是分时复用CPU资源,即给每个任务分CPU时间片,当时间片到后切换下一个任务执行。想象一下,如想在这种场景下实现 P-V 原语,那么应怎么做呢?

众所周知,对于变量加 1 的操作分为3步,加载、修改、写回,如果在加载完成的时候,CPU 切换了任务,那么会发生什么?肯定就回到了之前说的共享资源导致并发的问题,肯定又要上锁,那么上锁就必须要有原子性操作。

怎么解决上述问题呢?

让先思考,是什么导致CPU下来看看任到没到达时间片呢?因为任务代码里是没有检测时间片的指令的,答案是中断那么什么是中断呢?想象一下,在写代码,当老板找时给打了个电话,于是停下手里的工作,接老板的电话,完毕后继续写代码,那么放下手头完成后继续写代码的过程就叫作中断恢复。

写代码时是电话把中断了,那么CPU执行指令时是谁中断了CPU?

答案是 CPU 一个针脚会检测中断,而这个中断信号在 intel上是由一个芯片叫作 8259A中断控制芯片 来做的。

 

8259A中断控制芯片

图中有两块 8259A 芯片,每块芯片可以管理8个中断源,通过使用多片级联的方式,那么最多可以管理64个不同的中断号(排列组合,芯片上只有8个中断源针脚,那么8个有8个中断源的8259A芯片是多少 8*8=64),图中采用了两块芯片,可以管理15级中断号(IR2连接了第2块的INT针脚,那么还剩7个,片2有RO-R7总共7个,7+8=15)。

这里将级联的芯片称为从芯片,而将直接跟 CPU 的 INTR 针脚(也就是 CPU Interrupt Request CPU中断请求针脚)相连的芯片称为主芯片。

从芯片的 INT 引脚连接到主芯片的 IR2 引脚上。主芯片的端口基地址为 0x20,从芯片的地址为 0xA0。可以在操作系统初始化时通过系统总线控制器,CPU用 IN或者OUT 命令对 8259A 操作。

完成编程后,首先芯片就开始工作,随时响应连接到IR0-IR15针脚的信号,再通过CPUINTR针脚通知给CPU。然后CPU 响应这个信号,通过数据总线 D0-D7 将通过编程设定的中断号读出,接着 CPU就可以知道是哪个中断了,最后根据这个中断号去调用响应的中断服务程序。

其实CPU就是通过检测 INTR 针脚信号来判断是否有中断,问题来了什么时候检测呢?在执行完一条指令,当开始执行下一个指令之前检测中断信号。

回顾CPU如何执行指令? IF(instruction fetch指令提取)、ID(instruction decode指令译码)、EX(execute执行阶段)、MEM(memory访存阶段)、WB(writeback写回阶段)、IE(interrupt execute中断处理阶段)。

下面来看看Linux内核是如何操作的。Linux 内核中对于中断的宏定义:

#define local irq_disable()_asm__volatile_("cli":::"memory") // 关中断 

#define local_irq_enable()__asm__volatile_("sti":::"memory") // 开中断

回到之前的问题上,如果在 CPU 上执行 P-V 原语呢?也就是如何让 CPU 不会切换任务,很简单,通过一种方式让 CPU 不响应INTR针脚的中断信号就行了,当执行完原子性的操作后,再让 CPU 响应INTR 信号即可,那么这两个过程就叫作:中断使能、关中断。对应着两个 CPU 指令,即 STI(set interrupt flag设置中断标志位)和 CLI(clear interrupt flag清除中断标志位)。

二、CPU的结构与缓存一致性

1、现代CPU架构

现代CPU架构除了在片上继承多个核以外,还有为了减少对内存访问增加了多级缓存来提高运行速度。通常是三级缓存,及L1(核内独享)L2(核内独享)L3(核外共享)

 

现代CPU架构

上图描述了现代 CPU 的架构,每个 CPU 独立有 L1和L2 缓存,而共享 L3 缓存。假如有两个任务A和B,它们对 counter变量的加1是这样的,任务 A和B 其中一个访存,通过总线总裁只有一个任务能够通过数据总线和地址总线,把counter变量从内存中加载放入 L3缓存 中,然后任务 A和B 分别把 counter 加载到自己的 L1、L2 缓存中,接着进行操作,操作完毕后,只需要把结果放入缓存中即可。

来考虑会出现什么问题?

如果任务A获得了锁,并且在修改了 counter后放到了自己的L1缓存中,同样任务B也如此,那么问题来了,当任务A释放了锁,这时任务B获得了锁,情况又当如何呢?

由于counter的最新值在任务A的L1缓存中,这可怎么办?任务B所在的 CPU 的缓存上是旧值,应如何保证程序的正确性?这就是缓存一致性协议。

2、CPU的缓存一致性

来看看intel的MESI缓存一致性协议的状态描述

 

intel的MESI缓存一致性协议的状态

上述状态的描述非常容易理解,L1、L2、L3 的缓存是一行一行排列的,称之为缓存行,那么4 种状态,即modified、exclusive、shared、invalid 就是在缓存行中保留了两个状态位分别可以表示4种状态。

那么分别代表什么意思呢?从上述描述中应该就能够大致猜出这是什么状态了。例如 CPU 1加载了 counter 值,那么 counter 在缓存行中的状态为E状态,当CPU2加载 counter 值时,CPU1窥探到总线上再到内存中加载 counter 值,那么就会把自己缓存行中的数据放入 CPU2 的缓存行中,且把自己状态修改为S状态,表明 CPU1 和 CPU2 共享 counter 变量。而当CPU1获得了锁并且修收了 counter 值时,CPU 1中的 counter 缓存行状态就变为 M 状态,并且 CPU 2 窥探到 counter 值已经改变,可将缓存行状态变为1状态,此时当CPU 2在操作 counter 值时,由于CPU2的缓存行无效,因此就会重新从CPU 1中将counter的最新值加载至其中。这就保证了不同 CPU 中缓存一致性。

总结下CPU缓存一致性

(1)一个处于 M 状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回内存中,或者将该值转发给需要这个值的 CPU,然后将状态修改为 S。

(2)一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。

(3)一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须将其缓存行状态设置为S,并且转发值给需需要的 CPU缓存行。

(4)当CPU需要读取数据时,如果其缓存行的状态是 I,则需要重新发起读取请求,并把自己状态变成 S;如果不是 I,则可以直接读取缓存中的值,但在此之前,必须要等待其他 CPU 的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存或者转发后,再次读取。

(5)当CPU需要写数据时,只有在其缓存行是 M或者 E 时才能执行,否则需要发出特殊的 RFO指令(read for ownership,这是一种总线事务),通知其他CPU置缓存无效(I),这种情况下性能开销是相对较大的。在写入完成后,修改其缓存状态为 M。

3、CPU总线与MESI协议

总线/缓存锁非常容易理解,回顾上图中描述的 CPU 架构图。

 

多个 core 也就是CPU核共享一个L3,然后L3后面就是内存,也就是使用同一个总线,来想想是不是可以对这根总线上锁,然后当CPU执行完一条指令后再解锁呢?当这个 CPU 在访存时不允许其他 CPU 再申请访问内存,如 CMPXCHG 指令,它是将寄存器的值和内存中的值比较并替换,那么如果直接写 CMPXCHGmemory,r,也就是将eax和memery地址的值比较,如果相同,则将r寄存器的内容放入 memory 地址中,将交换的值放入eax 中。想想这几个步骤,这将不会保证正在执行操作的CPU不会被其他CPU锁干扰,也就是这几个步骤不保证原子性。

那么如何做呢?如果写 LOCK CMPXCHG memory,r, 那么仅仅加一个 LOCK 前缀,但是这就够了,对这个操作上锁了,等这条指令操作完成后锁才释放,那么就能保证CPU这条操作满足原子性了。

知道 LOCK 前缀就是用来上锁的,那么想想对什么上锁,读者可能会说上述不是说了共享总线么,对总线上锁不就可以了

但是例如通过一根控制总线拉高电平,这时其他 CPU 将不能够访存这就可以了,可是就像上述操作一样,仅仅对一个变量的地址上锁

那么有没有办法来缩小锁的力度,查看上图,变量可是被加载到了CPU的高速缓存中,而且有 MESI协议呢?这就够了,可以看看如果变量在高速缓存中,那么通过 MESI协议 将其他CPU缓存的值变为 I 状态,这时其他 CPU 就无法再读取这个变量,于是它们就需要去访存,但是由于 MESI 规定了当有 CPU 在对这个变量操作的时候,其他 CPU 不能操作,直到 CPU 将值修改完成后置为 M 状态,因此可以通过 Forward 机制将修改的值转发给其他 CPU,这时状态变为 S,这也避免了其他 CPU 再去访问地址,因而极大地提高了效率。而且也将锁细粒度到了变量级别,就不用再去对总线上锁了。

三、CPU的系统屏障内核原理解析

由于汇编代码是由编译器产生的,而编译器是知道流水线的,因此编译器当然能够重排序汇编代码来更进一步的优化指令流水线,不仅是CPU可以乱序执行,而且编译器也可以重排序代码,据此,这里的屏障就被分为了两种,即编译器屏障和指令级屏障。

1、编译器屏障原理解析

上述讨论到,CPU为了高效执行代码 引用了多级流水线,而编译器也会面向CPU编译代码,所以也会导致指令重排序,先来看看以下代码和它的汇编代码。

 

代码声明了4个变量,即 a、b、c、d并初始化为 0, 然后在func_1函数体内修改a为1,并将d的值赋值给a,随后判断d是否为真(C语言非零即真), 如果为真,则输出c的值,同样 func_2

    int a = 0, b = 0, c = 0, d = 0;

    void func_1() {

        a = 1;

        b = d;

        if (d) {

            printf("%d", c);

        }

    };

    void func_2() {

        c = 1;

        d = a;

        if (a) {

            printf("%d", b);

        }

    } ;

也是如此。来看看func 1未经优化的汇编代码。

func_1:                    // func_1 函数

        push    rbp        // 保存 rbp

        mov     rbp, rsp   // 将 rsp 的值 赋值给 rbp

       

        mov     DWORD PTR a[rip], 1    // 将 1 赋值给变量 a

        mov     eax, DWORD PTR d[rip]  // 将 eax 中 d 赋值给 b

       

        mov     DWORD PTR b[rip], eax  // 将 eax 中的 d 赋值 b

        mov     eax, DWORD PTR d[rip]  // 将 d 放入 eax

        test    eax, eax    // eax 值取and 看是否为0

        je      .L3        

       

        mov     eax, DWORD PTR c[rip]   // 将 c 放入 eax 通过 rip来做相对偏移寻址,等于 %rip+c

        mov     esi, eax

        mov     edi, OFFSET FLAT:.LC0  // 将 %d 的地址放入 edi

        mov     eax, 0                 // 把调用好 0 方法 eax 中

        call    printf                 // 调用 printf 输出c

可以看到,未经优化编译器所生成的汇编代码是按照代码书写顺序来执行的,那么如果发生重排序也是CPU的指令重排序,而不是编译器的,因为这里编译器编译的代码和C代码的语义是一样的,同理func2的代码和func 1一样,这里就不粘贴出来了,只不过不同的是变量变为了c和d。那么来看看编译器优化过后的汇编代码(基于x86-64 gcc 12.2编译器)。

 

优化后编译出来的代码和写的代码有所不同。这就是编译器面向 CPU 编译,更合理地贴近于 CPU 的流水线架构了,不难发现当把 d 读取放入eax寄存器中时,由于赋值给a=1的指令和 b=d 的指令没有数据依赖,因此不如先把 d 读入,然后指令流水线就会在读入 d 时执行赋值操作

func_1:                    // func_1 函数

        mov     eax, DWORD PTR d[rip]  // 将 eax 中放入 d

        mov     DWORD PTR a[rip], 1    // 将 1 赋值给变量 a

        test    eax, eax               // eax 值取and 看是否为0

        mov     DWORD PTR b[rip], eax  // 将 eax 中的 d 赋值 b

        jne      .L4  

        rep ret  // 优化 CPU 分支预测器

有些人可能会认为这没什么关系,反正没有数据依赖,再排序就是为了快,最终结果没问题,但是想想,如果有两个任务同时进入两个 CPU 中,任务A执行了 func_1方法,任务B执行了 func_2 方法会发生什么?

从代码中可以看到本意是,当d=1时,c 应该为1。但是想想如果发生了重排序,那么 d 的值会优先被加载而 a 的赋值操作却是在 d 之后,任务B同时执行 func_2 方法,同理也发生了重排序,即d的值先被赋值为a的值,然后c才等于1,这就造成了与预期结果不符的现象。。如何让编译器禁止这种优化呢?答案是采用编译器屏障

    volatile int a = 0, b = 0, c = 0, d = 0;

    void func_1() {

        a = 1;

        b = d;

        if (d) {

            printf("%d", c);

        }

    };

重新编译后,与预期是一样的,先赋值 1,然后读入 d 赋值给 b ,确实起到了阻止编译器优化的作用。

接下来,来看看在 Linux 内核中是否可以用 volatile 关键字来实现阻止编译器优化呢?查看下列源码。

#define barrier()_asm__volatile_("":::"memory" )

可以看到就是通过内联汇编来做这个事的,核心代码是 "":::"memory" ,无汇编但是有 clobber 的指令,确切来说,它不包含汇编指令,所以不能叫作指令,只是起到提示编译器的作用。简而言之,这里的操作会影响内存,不可任意优化。下面来看看加 "":::"memory" 将是什么样的效果,先查看下列修改的代码。

    volatile int a = 0, b = 0, c = 0, d = 0;

    void func_1() {

        a = 1;

        _asm__volatile_("":::"memory" )

        b = d;

        if (d) {

            printf("%d", c);

        }

    };

可以看到,加了 _asm__volatile_("":::"memory") 是相同的效果。 可以通过 volatile 关键字和内联汇编 _asm__volatile_("":::"memory") 提示编器,不可任意排序。

2、指令级屏障剖析

在上述内容已详细介绍了发生指令集排序是由于 CPU 指令流水线造成的,那么有没有办法在处理器中禁止发生重排序呢?来看看 Linux 内核源码。

#define mb() alternative("lock;  addl $0,0(%%esp)","mfence", X86_FEATURE_XMM2)

#define rmb() alternative("lock; addl $0,0(%%esp)","lfence", X86_FEATURE_XMM2)

很简单,这里先解释 alternative 宏定义,这是一个选择宏,通过让 CPU 在运行时,根据自己支持的指令集选择并调用相应的指令,所以起到指令重排序作用的指令为 "lock; addl $0, 0(%%esp)", "mfence"。其中 "mfence" 为新的指令,因为在 intel 之前的 CPU 可以通过 lock 前缀对栈上指令加0操作来作为指令屏障,但后面新出了 mfence和lfence,其中sfence保证了全屏障、读屏障和写屏障的功能;而 "lock; addl S0,0(%%esp)" 指令对于任何 x86 平台都支持,所以这里通过 alternative 宏定义让 CPU 来选择执行哪个。

至于这里的 mfence、lfence、sfence 这里就不进行详述,因为屏障阻止的就是 loadload、storeload、storestore、loadstore 等重排序,这3个指令也是针对这些不同的场景来选择使用的。读者现在只需要记住能通过这几个指令提供 CPU指令集屏障即可,不用深究,否则容易陷入泥潭。

讲解CPU角度的中断控制,CPU层面并行并发和中断控制的原理,现代CPU的缓存结构和架构图、CPU缓存一致性的源码原理,以及CPU如何通过编译器的屏障与指令实现系统屏障,经过内联汇编代码验证之后,证明上述所说的 Linux 内核用 volatile 关键字实现系统屏障(指令重排),加深对系统屏障的内核源码和原理的理解。

高并发,真的了解吗?

介绍高并发系统的度量指标,讲述高并发系统的设计思路,再梳理高并发的关键技术,最后结合作者的经验做一些延伸探讨。

当前,数字化在给企业带来业务创新,推动企业高速发展的同时,也给企业的IT软件系统带来了严峻的挑战。面对流量高峰,不同的企业是如何通过技术手段解决高并发难题的呢?

0、引言

软件系统有三个追求:高性能、高并发、高可用,俗称三高。三者既有区别也有联系,门门道道很多,全面讨论需要三天三夜,本篇讨论高并发。

高并发(High Concurrency)。并发是操作系统领域的一个概念,指的是一段时间内多任务流交替执行的现象,后来这个概念被泛化,高并发用来指大流量、高请求的业务情景,比如春运抢票,电商双十一,秒杀大促等场景。

很多程序员每天忙着搬砖,平时接触不到高并发,哪天受不了跑去面试,还常常会被面试官犀利的高并发问题直接KO,其实吧,高并发系统也不高深,保证任何一个智商在线的看过这篇文章后,都能战胜恐惧,重拾生活的信心。

先介绍高并发系统的度量指标,然后讲述高并发系统的设计思路,再梳理高并发的关键技术,最后结合作者的经验做一些延伸探讨。

1、高并发的度量指标

既然是高并发系统,那并发一定要高,不然就名不副实。并发的指标一般有QPS、TPS、IOPS,这几个指标都是可归为系统吞吐率,QPS越高系统能hold住的请求数越多,但光关注这几个指标不够,还需要关注RT,即响应时间,也就是从发出request到收到response的时延,这个指标跟吞吐往往是此消彼长的,追求的是一定时延下的高吞吐。

比如有100万次请求,99万次请求都在10毫秒内响应,其他次数10秒才响应,平均时延不高,但时延高的用户受不了,所以,就有了TP90/TP99指标,这个指标不是求平均,而是把时延从小到大排序,取排名90%/99%的时延,这个指标越大,对慢请求越敏感。

除此之外,有时候,也会关注可用性指标,这可归到稳定性。

一般而言,用户感知友好的高并发系统,时延应该控制在250毫秒以内。

什么样的系统才能称为高并发?这个不好回答,因为它取决于系统或者业务的类型。不过可以告诉一些众所周知的指标,这样能帮助下次在跟人扯淡的时候稍微靠点儿谱,不至于贻笑大方。

通常,数据库单机每秒也就能抗住几千这个量级,而做逻辑处理的服务单台每秒抗几万、甚至几十万都有可能,而消息队列等中间件单机每秒处理个几万没问题,所以经常听到每秒处理数百万、数千万的消息中间件集群,而像阿某的API网关,每日百亿请求也有可能。

2、高并发的设计思路

高并发的设计思路有两个方向:

  1. 垂直方向扩展,也叫竖向扩展
  2. 水平方向扩展,也叫横向扩展

垂直方向:提升单机能力

提升单机处理能力又可分为硬件和软件两个方面:

  • 硬件方向,很好理解,花钱升级机器,更多核更高主频更大存储空间更多带宽
  • 软件方向,包括用各快的数据结构,改进架构,应用多线程、协程,以及上性能优化各种手段,但这玩意儿天花板低,就像提升个人产出一样,996、007、最多24 X 7。

水平方向:分布式集群

为了解决分布式系统的复杂性问题,一般会用到架构分层和服务拆分,通过分层做隔离,通过微服务解耦

这个理论上没有上限,只要做好层次和服务划分,加机器扩容就能满足需求,但实际上并非如此,一方面分布式会增加系统复杂性,另一方面集群规模上去之后,也会引入一堆AIOps、服务发现、服务治理的新问题。

因为垂直向的限制,所以,通常更关注水平扩展,高并发系统的实施也主要围绕水平方向展开。

3、高并发的关键技术

玩具式的网络服务程序,用户可以直连服务器,甚至不需要数据库,直接写磁盘文件。但春运购票系统显然不能这么做,它肯定扛不住这个压力,那一般的高并发系统是怎么做呢?比如某宝这样的正经系统是怎么处理高并发的呢?

其实大的思路都差不多,层次划分 + 功能划分。可以把层次划分理解为水平方向的划分,而功能划分理解为垂直方向的划分。

首先,用户不能直连服务器,要做分布式就要解决“分”的问题,有多个服务实例就需要做负载均衡,有不同服务类型就需要服务发现。

集群化:负载均衡

负载均衡就是把负载(request)均衡分配到不同的服务实例,利用集群的能力去对抗高并发,负载均衡是服务集群化的实施要素,它分3种:

  1. DNS负载均衡,客户端通过URL发起网络服务请求的时候,会去DNS服务器做域名解释,DNS会按一定的策略(比如就近策略)把URL转换成IP地址,同一个URL会被解释成不同的IP地址,这便是DNS负载均衡,它是一种粗粒度的负载均衡,它只用URL前半部分,因为DNS负载均衡一般采用就近原则,所以通常能降低时延,但DNS有cache,所以也会更新不及时的问题。
  2. 硬件负载均衡,通过布置特殊的负载均衡设备到机房做负载均衡,比如F5,这种设备贵,性能高,可以支撑每秒百万并发,还能做一些安全防护,比如防火墙。
  3. 软件负载均衡,根据工作在ISO 7层网络模型的层次,可分为四层负载均衡(比如章文嵩博士的LVS)和七层负载均衡(NGINX),软件负载均衡配置灵活,扩展性强,阿某云的SLB作为服务对外售卖,Nginx可以对URL的后半部做解释承担API网关的职责。

所以,完整的负载均衡链路是 client <-> DNS负载均衡 -> F5 -> LVS/SLB -> NGINX

不管选择哪种LB策略,或者组合LB策略,逻辑上,都可以视为负载均衡层,通过添加负载均衡层,将负载均匀分散到了后面的服务集群,具备基础的高并发能力,但这只是万里长征第一步。

数据库层面:分库分表+读写分离

前面通过负载均衡解决了无状态服务的水平扩展问题,但系统不全是无状态的,后面通常还有有状态的数据库,所以解决了前面的问题,存储有可能成为系统的瓶颈,需要对有状态存储做分片路由

数据库的单机QPS一般不高,也就几千,显然满足不了高并发的要求。

所以,需要做分库分表 + 读写分离。

就是把一个库分成多个库,部署在多个数据库服务上,主库承载写请求,从库承载读请求。从库可以挂载多个,因为很多场景写的请求远少于读的请求,这样就把对单个库的压力降下来了。

如果写的请求上升就继续分库分表,如果读的请求上升就挂更多的从库,但数据库天生不是很适合高并发,而且数据库对机器配置的要求一般很高,导致单位服务成本高,所以,这样加机器抗压力成本太高,还得另外想招。

读多写少:缓存

缓存的理论依据是局部性原理

一般系统的写入请求远少于读请求,针对写少读多的场景,很适合引入缓存集群。

在写数据库的时候同时写一份数据到缓存集群里,然后用缓存集群来承载大部分的读请求,因为缓存集群很容易做到高性能,所以,这样的话,通过缓存集群,就可以用更少的机器资源承载更高的并发。

缓存的命中率一般能做到很高,而且速度很快,处理能力也强(单机很容易做到几万并发),是理想的解决方案。

CDN本质上就是缓存,被用户大量访问的静态资源缓存在CDN中是目前的通用做法。

缓存也有很多需要谨慎处理的问题:

  1. 一致性问题:(a)更新db成功+更新cache失败 -> 不一致 (b)更新db失败+更新cache成功 -> 不一致 ©更新db成功+淘汰缓存失败 -> 不一致
  2. 缓存穿透:查询一定不存在的数据,会穿透缓存直接压到数据库,从而导致缓存失去作用,如果有人利用这个漏洞,大量查询一定不存在的数据,会对数据库造成压力,甚至打挂数据库。解决方案:布隆过滤器 或者 简单的方案,查询不存在的key,也把空结果写入缓存(设置较短的过期淘汰时间),从而降低命失
  3. 缓存雪崩:如果大量缓存在一个时刻同时失效,则请求会转到DB,则对DB形成压迫,导致雪崩。简单的解决方案是为缓存失效时间添加随机值,降低同一时间点失效淘汰缓存数,避免集体失效事件发生

但缓存是针对读,如果写的压力很大,怎么办?

高写入:消息中间件

同理,通过跟主库加机器,耗费的机器资源是很大的,这个就是数据库系统的特点所决定的。

相同的资源下,数据库系统太重太复杂,所以并发承载能力就在几千/s的量级,所以此时需要引入别的一些技术。

比如说消息中间件技术,也就是MQ集群,它是非常好的做写请求异步化处理,实现削峰填谷的效果。

消息队列能做解耦,在只需要最终一致性的场景下,很适合用来配合做流控。

假如说,每秒是1万次写请求,其中比如5千次请求是必须请求过来立马写入数据库中的,但是另外5千次写请求是可以允许异步化等待个几十秒,甚至几分钟后才落入数据库内的。

那么此时完全可以引入消息中间件集群,把允许异步化的每秒5千次请求写入MQ,然后基于MQ做一个削峰填谷。比如就以平稳的1000/s的速度消费出来然后落入数据库中即可,此时就会大幅度降低数据库的写入压力。

业界有很多著名的消息中间件,比如ZeroMQ,rabbitMQ,kafka等。

消息队列本身也跟缓存系统一样,可以用很少的资源支撑很高的并发请求,用它来支撑部分允许异步化的高并发写入是很合适的,比使用数据库直接支撑那部分高并发请求要减少很多的机器使用量。

避免挤兑:流控

再强大的系统,也怕流量短事件内集中爆发,就像银行怕挤兑一样,所以,高并发另一个必不可少的模块就是流控。

流控的关键是流控算法,有4种常见的流控算法。

  1. 计数器算法(固定窗口):计数器算法是使用计数器在周期内累加访问次数,当达到设定的限流值时,触发限流策略,下一个周期开始时,进行清零,重新计数,实现简单。计数器算法方式限流对于周期比较长的限流,存在很大的弊端,有严重的临界问题。
  2. 滑动窗口算法:将时间周期分为N个小周期,分别记录每个小周期内访问次数,并且根据时间滑动删除过期的小周期,当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。此算法可以很好的解决固定窗口算法的临界问题。
  3. 漏桶算法:访问请求到达时直接放入漏桶,如当前容量已达到上限(限流值),则进行丢弃(触发限流策略)。漏桶以固定的速率进行释放访问请求(即请求通过),直到漏桶为空。分布式环境下实施难度高。
  4. 令牌桶算法:程序以r(r=时间周期/限流值)的速度向令牌桶中增加令牌,直到令牌桶满,请求到达时向令牌桶请求令牌,如获取到令牌则通过请求,否则触发限流策略。分布式环境下实施难度高。

4、高并发的实践经验

接入-逻辑-存储是经典的互联网后端分层,但随着业务规模的提高,逻辑层的复杂度也上升了,所以,针对逻辑层的架构设计也出现很多新的技术和思路,常见的做法包括系统拆分,微服务。

除此之外,也有很多业界的优秀实践,包括某信服务器通过协程(无侵入,已开源libco)改造,极大的提高了系统的并发度和稳定性,另外,缓存预热,预计算,批量读写(减少IO),池技术等也广泛应用在实践中,有效的提升了系统并发能力。

为了提升并发能力,逻辑后端对请求的处理,一般会用到生产者-消费者多线程模型,即I/O线程负责网络IO,协议编解码,网络字节流被解码后产生的协议对象,会被包装成task投入到task queue,然后worker线程会从该队列取出task执行,有些系统会用多进程而非多线程,通过共享存储,维护2个方向的shm queue,一个input q,一个output q,为了提高并发度,有时候会引入协程,协程是用户线程态的多执行流,它的切换成本更低,通常有更好的调度效率。

另外,构建漏斗型业务或者系统,从客户端请求到接入层,到逻辑层,到DB层,层层递减,过滤掉请求,Fail Fast(尽早发现尽早过滤),哈哈。

漏斗型系统不仅仅是一个技术模型,它也可以是一个产品思维,配合产品的用户分流,逻辑分离,可以构建全方位的立体模型。

5、小结

莫让浮云遮望眼,除去繁华识真颜。不能掌握了大方案,吹完了牛皮,而忽视了编程最本质的东西,掌握最基本最核心的编程能力,比如数据架构和算法,设计,惯用法,培养技术的审美,也是很重要的,既要致高远,又要尽精微

计算机原理:CPU、并发、并行、多核、多线程、多进程

计算机由五大部分组成:控制器、运算器、存储器、输入设备、输出设备。

(1)控制器和运算器集成在CPU上,后面重点介绍介绍。

(2)存储器是计算机记忆或暂存数据的部件。计算机中的全部信息,包括原始的输入数据。经过初步加工的中间数据以及最后处理完成的有用信息都存放在存储器中。

存储器分为内存储器(简称内存或主存,RAM)、外存储器(简称外存或辅存,如硬盘,ROM)。

(3)输入设备:键盘、鼠标、扫描仪、光笔等

(4)输出设备:扫描仪、打印机、显示器等

0.1 CPU(Central Processing Unit)

CPU又称中央处理器,通常被称为计算机的大脑。CPU由三大单元构成:运算单元、控制单元、存储单元。

需要指出的是,此处的存储单元和上文的存储器不同(即和RAM和ROM不同)

(1)控制单元

控制单元是整个CPU的指挥控制中心,指挥全机中各个部件自动协调工作。在控制器的控制下,计算机能够自动按照程序设定的步骤进行一系列操作。负责把内存上的指令、数据等读入寄存器,并根据指令的执行结果来控制整个计算机。

控制单元的组成:指令寄存器IR(Instruction Register)、指令译码器ID(Instruction Decoder)和操作控制器OC(Operation Controller)

(2)运算单元

运算单元是运算器的核心。可以执行算术运算(包括加减乘数等基本运算及其附加运算)和逻辑运算(包括移位、逻辑测试或两个值比较)。相对控制单元而言,运算器接受控制单元的命令而进行动作,即运算单元所进行的全部操作都是由控制单元发出的控制信号来指挥的,所以它是执行部件。

运算器负责运算从内存读入寄存器的数据。

运算单元的核心部件是算术逻辑部件ALU。ALU 主要完成对二进制信息的定点算术运算、逻辑运算和各种移位操作。ALU 是一种功能较强的组合逻辑电路,有时被称为多功能发生器。

(3)存储单元:

存储单元包括CPU片内缓存(CPU Cache)和寄存器组(Register),是CPU中暂时存放数据的地方,里面保存着那些等待处理的数据,或已经处理过的数据。采用缓存和寄存器,可以减少CPU访问内存的次数,从而提高了CPU的工作速度。

按与CPU远近来分,离得最近的是寄存器,然后缓存,最后内存。

a、寄存器

寄存器是最贴近CPU的,而且CPU只与寄存器中进行存取。(寄存的意思是,暂时存放数据,不中每次从内存中取,它就是一个临时放数据的空间,火车站寄存处就是这个意思)而寄存器的数据又来源于内存。于是 CPU<—>寄存器<----->内存 这就是它们之间的信息交换。

寄存器是CPU内部用来存放数据(且存储的是二进制的数据)的一些小型存储区域,用来暂时存放参与运算的数据和运算结果。一个CPU内部会有20~100个寄存器。

寄存器组可分为专用寄存器和通用寄存器。专用寄存器的作用是固定的,分别寄存相应的数据。而通用寄存器用途广泛并可由程序员规定其用途。

b、Cache,那为什么有缓存呢?

因为如果老是操作内存中的同一址地的数据,就会影响速度。于是就在寄存器与内存之间设置一个缓存。缓存就把从内存提取的数据暂时保存在里面,如果寄存器要取内存中同一位置的东西,就不用老远巴巴地跑到内存中去取,直接从缓存中提取。因为从缓存提取的速度远高于内存。当然缓存的价格肯定远远高于内存,不然的话,机器里就没有内存的存在,只有缓存的存在了,但如果全是缓存,相信没有几个人买 得起计算机了。

CPU缓存:是用于减少处理器访问内存所需平均时间的部件。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近处理器的频率。

当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(命中),则不经访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。

缓存之所以有效,主要是因为程序运行时对内存的访问呈现局部性

在处理器看来,缓存是一个透明部件。因此,程序员通常无法直接干预对缓存的操作。但是,确实可以根据缓存的特点对程序代码实施特定优化,从而更好地利用缓存

c、总结

由此可以看出,从远近来看: CPU〈------〉寄存器〈---->缓存<----->内存。

注意一下,寄存器并不每次数据都可以从缓存中取得数据,万一不是同一个内存地址中的数据,那寄存器还必须直接绕过缓存从内存中取数据。所以并不每次都得到缓存中取数据,这就是缓存的命中率,从缓存中取就命中,不从缓存中取从内存中取,就没命中。当然关于缓存命中率又是一门学问,哪些留在缓存中,哪些不留在缓存中,都是命中的算法。

0.2 计算机总线(Bus)

主板(Mother Board)是一座城市,那么总线就像是城市里的公共汽车(bus)。总线是一种内部结构,它是cpu、内存、输入、输出设备传递信息的公用通道,主机的各个部件通过总线相连接,外部设备通过相应的接口电路再与总线相连接,从而形成了计算机硬件系统。在计算机系统中,各个部件之间传送信息的公共通路叫总线,微型计算机是以总线结构来连接各个功能部件的。

 

计算机的总线可以划分为数据总线DB(Data Bus)、地址总线AB(Address Bus)和控制总线CB(Control Bus),分别用来传输数据、数据地址和控制信号。这三条总线也统称为系统总线

0.3计算机工作流程

先放图片,下面将结合图片说明。

 

虚线为分割线,虚线上方是处理部分,包括CPU,IO操作系统,软件系统;下方是输入输出系统。

其流程如下:

1、输入系统结合软件编写helloWorld.py文件

2、py文件运行:控制器发出命令,将硬盘中的文件存到Cache中,Cache中的数据在寄存器中变为二进制数据

3、ALU完成计算,将结果返回到寄存器中

4、寄存器中的数据再次返回到输出系统中,完成运行

备注:所有的数据传输都是通过总线完成的。

其工作流程图如下:

 

1、线程和进程

线程和进程的区别与联系

需要注意的是,在此处的线程和进程和编程语言中的API接口对应的进程/线程是有差异的,需要区别开来。

 

1.0 前提了解

一个最最基础的事实:

CPU(此处的CPU指的是控制器+ALU)太快,太快,太快了,寄存器仅仅能够追的上他的脚步,RAM和别的挂在各总线上的设备则难以企及。当多个任务执行时,不管谁的优先级高,在CPU看来就是轮流着来。而且因为速度差异,CPU实际的执行时间和等待执行的时间是数量级的差异。比如工作1秒钟,休息一个月。所以多个任务,轮流着来,让CPU不那么无聊,给流逝的时间增加再多一点点的意义。这些任务,在外在表现上就仿佛是同时在执行。

一个必须知道的事实:

执行一段程序代码,实现一个功能的过程之前 ,当用到CPU的时候,相关的资源必须也已经就位,就是万事俱备只欠CPU这个东风。所有这些任务都处于就绪队列,然后由操作系统的调度算法,选出某个任务,让CPU来执行。然后就是PC指针指向该任务的代码开始,由CPU开始取指令,然后执行。

1.1进程(process):

什么是进程:进程是指在系统中正在运行的一个应用程序,是系统资源分配的基本单位,在内存中有其完备的数据空间和代码空间,拥有完整的虚拟空间地址。一个进程所拥有的数据和变量只属于它自己。

为什么要有进程(程序和进程的关系):程序并不能单独执行,只有将程序加载到内存中,系统为他分配资源后才能够执行,这种执行的程序称之为进程,也就是说进程是系统进行资源分配和调度的一个独立单位,每个进程都有自己单独的地址空间。所以说程序与进程的区别在于,程序是指令的集合,是进程运行的静态描述文本,而进程则是程序在系统上顺序执行时的动态活动。

下图为一条进程:

 

但是进程存在着很多缺陷,主要集中在两点:

(1).进程只能在同一时间干一件事情,如果想同时干两件事或多件事情,进程就无能为力了。

(2).进程在执行的过程中如果由于某种原因阻塞了,例如等待输入,整个进程就会挂起,其他与输入无关的工作也必须等待输入结束后才能顺序执行。

为了解决上述两点缺陷,引入了线程这个概念。

1.2 线程(thread)

线程是进程内相对独立的可执行单元,所以也被称为轻量进程(lightweight processes);是操作系统进行任务调度的基本单元。它与父进程的其它线程共享该进程所拥有的全部代码空间和全局变量,但拥有独立的堆栈(即局部变量对于线程来说是私有的)。

线程是进程的一个实体,也是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位,有时又被称为轻权进程或轻量级进程,相对进程而言,线程是一个更加接近于执行体的概念,进程在执行过程中拥有独立的内存单元,而线程自己基本上不拥有系统资源,也没有自己的地址空间,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),线程的改变只代表了 CPU 执行过程的改变,而没有发生进程所拥有的资源变化。除了CPU 之外,计算机内的软硬件资源的分配与线程无关,但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。

1.3 进程和线程的区别与联系

联系

(1)线程和进程的关系,可以理解为线程是进程的一部分。

(2)一个进程至少拥有一个线程——主线程,也可以拥有多个线程。

线程是进程的一部分,一个线程必须有一个父进程。

(3)多个进程可以并发执行;一个线程可以创建和撤销另一个线程;同一个进程中的多个线程之间可以并发执行。

区别

(1)系统开销:进程和线程的主要差别在于操作系统并没有将多个线程看作多个独立的应用,来实现进程的调度和管理以及资源分配。在创建或撤消进程时,由于系统都要为之分配和回收资源,导致系统的开销明显大于创建或撤消线程时的开销。

(2)资源管理:进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。

(3)通信方式:进程间通信主要包括管道、系统IPC(包括消息队列,信号量,共享存储)、SOCKET,具体说明参考linux进程间通信方式。进程间通信其实是指分属于不同进程的线程之间的通讯,所以进程间的通信方法同样适用于线程间的通信。但对应归于同一进程的不同线程来说,使用全局变量进行通信效率会更高。

1.4多线程和多进程

多线程和多进程

操作系统的设计,可以归结为三点:

(1)以多进程形式,允许多个任务同时运行;

(2)以多线程形式,允许单个任务分成不同的部分运行;

(3)提供协调机制,一方面防止进程之间和线程之间产生冲突,另一方面允许进程之间和线程之间共享资源。

一、什么是多线程?

(1)、多线程的概念?

  进程可以简单的理解为一个可以独立运行的程序单位。它是线程的集合,进程就是有一个或多个线程构成的,每一个线程都是进程中的一条执行路径。

  那么多线程就很容易理解:多线程就是指一个进程中同时有多个执行路径(线程)正在执行。

(2)、为什么要是用多线程?

  1.在一个程序中,有很多的操作是非常耗时的,如数据库读写操作,IO操作等,如果使用单线程,那么程序就必须等待这些操作执行完成之后才能执行其他操作。使用多线程,可以在将耗时任务放在后台继续执行的同时,同时执行其他操作。

  2.可以提高程序的效率。

  3.在一些等待的任务上,如用户输入,文件读取等,多线程就非常有用了。

  缺点:

  1.使用太多线程,是很耗系统资源,因为线程需要开辟内存。更多线程需要更多内存。

  2.影响系统性能,因为操作系统需要在线程之间来回切换。

  3.需要考虑线程操作对程序的影响,如线程挂起,中止等操作对程序的影响。

  4.线程使用不当会发生很多问题。

  总结:多线程是异步的,但这不代表多线程真的是几个线程是在同时进行,实际上是系统不断地在各个线程之间来回的切换(因为系统切换的速度非常的快,所以给在同时运行的错觉)。

二、多进程

进程是程序在计算机上的一次执行活动。当运行一个程序,就启动了一个进程。凡是用于完成操作系统的各种功能的进程就是系统进程,而所有由启动的进程都是用户进程。

同理,多进程就是指计算机同时执行多个进程,一般是同时运行多个软件。

三、多线程与多进程,选择谁?

知乎上的一个答案:

单进程单线程:一个人在一个桌子上吃菜。

单进程多线程:多个人在同一个桌子上一起吃菜。

多进程单线程:多个人每个人在自己的桌子上吃菜。

多线程的问题是多个人同时吃一道菜的时候容易发生争抢,例如两个人同时夹一个菜,一个人刚伸出筷子,结果伸到的时候已经被夹走菜了。。。此时就必须等一个人夹一口之后,在还给另外一个人夹菜,也就是说资源共享就会发生冲突争抢。

1.对于 Windows 系统来说,【开桌子】的开销很大,因此 Windows 鼓励大家在一个桌子上吃菜。因此 Windows 多线程学习重点是要大量面对资源争抢与同步方面的问题。

 

2.对于 Linux 系统来说,【开桌子】的开销很小,因此 Linux 鼓励大家尽量每个人都开自己的桌子吃菜。这带来新的问题是:坐在两张不同的桌子上,说话不方便。因此,Linux 下的学习重点大家要学习进程间通讯的方法。

 

2、 多核,高并发,并行

介绍完之前的东西,就该重头戏了。了解了CPU的工作原理,了解了线程、进程,下面开始介绍多核,高并发以及并行,旨在通过此次学习能够提升代码的运行效率,在有限的计算资源的情况下,充分利用计算资源,达到最高的效率。

2.1 多核

多核CPU和多CPU之间的关系

2.1.1 物理CPU多核

双核 CPU 是整合两颗物理 CPU 核心做在一个 CPU 上

四核 CPU 是整合四颗物理 CPU 核心做在一个CPU上

由此可见,多核就是整合多个物理CPU核心做在一个CPU上。

 

看了上图,有人会有疑问,什么是逻辑处理器。

2.1.2 逻辑处理器

内核是指物理CPU,而虚拟处理器则是通过在一枚处理器上整合两个逻辑处理器(注:是处理器而不是运算单元)单元,使得具有这种技术的新型 CPU 具有能同时执行多个线程的能力。这就是所说的:超线程。

超线程技术为了避免 CPU 处理资源冲突,负责处理第二个线程的那个逻辑处理器,其使用的是仅是运行第一个线程时被暂时闲置的处理单元。所以虽然采用超线程技术能同时执行多个线程,但它并不象两个真正的 CPU 那样,每各 CPU 都具有独立的资源。当两个线程都同时需要某一个资源时,其中一个要暂时停止,并让出资源,直到这些资源闲置后才能继续。因此超线程的性能并不等于两颗 CPU 的性能。

 

所以,虚拟逻辑处理器指的就是支持超线程技术的处理器,在一个单核心的 CPU 内,利用其中空闲的执行单元,模拟出另外一个核心,使整个 CPU 有两个逻辑核心,从而提高整个 CPU 的工作效率。

 

注意:一个核并不代表只能有一个或两个逻辑 CPU,也可以有 4 个逻辑 CPU 或者更多。两个 CPU,可能都是四核的。如果一个在设备管理器或任务管理器中显示有 4 个,另一个在设备管理器或任务管理器中显示有 8 个,则说明其中一个每个核含有两个逻辑 CPU。

 

2.2 并发和并行

并发和并行的理解

并发(concurrency),并行(parallellism)都是完成多任务更加有效率的方式,但还是有一些区别的。并发和并行都可以处理“多任务”,二者的主要区别在于是否是“同时进行”多个的任务。

并发性,又称共行性,是指能处理多个同时处理多个任务的能力;并行是指同时发生的两个并发事件,具有并发的含义,而并发则不一定并行,也亦是说并发事件之间不一定要同一时刻发生。

 

并发:交替做不同事情的能力,不同的代码块交替执行。通过CPU调度算法,让用户看上去同时执行,实际上CPU操作层面不是真正的同时。

并发的问题:并发时如果操作了公用资源,可能产生线程安全问题。线程安全:多个线程操作公用资源,有可能产生安全问题。

 

并行在操作系统中是指,一组程序按独立异步的速度执行,不等于时间上的重叠(同一个时刻发生)。

 

并行:同时做不同事情的能力,不同的代码块同时执行。多个CPU实例或多台机器同时执行一段处理逻辑,是真正的同时。

 

并发是指:在同一个时间段内,两个或多个程序执行,有时间上的重叠(宏观上是同时,微观上仍是顺序执行)。

并发的实质是一个物理CPU(也可以多个物理CPU) 在若干道程序之间多路复用,并发性是对有限物理资源强制行使多用户共享以提高效率。

 

2.3多线程和并发的关系

2.多线程与高并发的联系。

  高并发:高并发指的是是一种系统运行过程中遇到的一种“短时间内遇到大量操作请求”的情况,主要发生在web系统集中大量访问或者socket端口集中性收到大量请求(例如:12306的抢票情况;天猫双十一活动)。该情况的发生会导致系统在这段时间内执行大量操作,例如对资源的请求,数据库的操作等。如果高并发处理不好,不仅仅降低了用户的体验度(请求响应时间过长),同时可能导致系统宕机,严重的甚至导致OOM异常,系统停止工作等。如果要想系统能够适应高并发状态,则需要从各个方面进行系统优化,包括,硬件、网络、系统架构、开发语言的选取、数据结构的运用、算法优化、数据库优化……。

 

而多线程只是在同/异步角度上解决高并发问题的其中的一个方法手段,是在同一时刻利用计算机闲置资源的一种方式。

  多线程在高并发问题中的作用就是充分利用计算机资源,使计算机的资源在每一时刻都能达到最大的利用率,不至于浪费计算机资源使其闲置。

  可能会有人疑问,既然并发是同一块cpu处理不同的事情,那么并发还能提升处理效率么?答案是肯定的。上文讲到,cpu具有强大的计算能力,它在任务之间的切换可以快到无法察觉,这样并发就能提升计算效率了。

 

同步就是整个处理过程顺序执行,当各个过程都执行完毕,并返回结果。是一种线性执行的方式,执行的流程不能跨越。一般用于流程性比较强的程序,比如用户登录,需要对用户验证完成后才能登录系统。

异步则是只是发送了调用的指令,调用者无需等待被调用的方法完全执行完毕;而是继续执行下面的流程。是一种并行处理的方式,不必等待一个程序执行完,可以执行其它的任务,比如页面数据加载过程,不需要等所有数据获取后再显示页面。

他们的区别就在于一个需要等待,一个不需要等待,在部分情况下,项目开发中都会优先选择不需要等待的异步交互方式,比如日志记录就可以使用异步方式进行保存。

 

2.4 多线程和并行之前的关系

(1)如果是单核多线程,那么多线程之间就不是并行,而是并发,在这种情况下多线程和并行之间毫无关系。

在上述情况下,为了均衡负载,cpu调度器会不断的在单核上切换不同的线程执行,但是说过,一个核只能运行一个线程,所以并发虽然让看起来不同线程之间的任务是并行执行的,但是实际上却由于增加了线程切换的开销使得代价更大了。

 

(2)如果是多核多线程,且计算机中的总的线程数量小于等于核数,那线程就可以并行运算在不同的核中。

 

(3)如果是多核多线程,且线程数量大于核数,其中有些线程就会不断切换,并发执行,但实际上最大的并行数量还是当前这个进程中的核的数量,所以盲目增加线程数不仅不会让程序更快,反而会给程序增加额外的开销。

 

3 如何做并发、并行编程,提高代码的运行效率

3.1并发编程

并发一定能提升效率么

并发并不一定能提升效率。因为一个核只能运行一个线程,所以并发虽然让看起来不同线程之间的任务是并行执行的,但是实际上却由于增加了线程切换的开销使得代价更大了。

 

那么什么任务下可以通过并发来提升计算效率呢?

对于I/O密集型任务,是可以在这样的系统中使用多线程的,因为在I/O等待的过程中,CPU是空闲的,其他的线程依然可以使用CPU来处理问题。

 

什么任务下不需要通过并发来提升计算效率呢?

当程序是计算密集型任务时,多线程的效果并不明显甚至不如单线程,因为创建线程本身就需要花时间,而这个时间可以计算许多的数字,因为计算数字对于CPU来讲完全是小儿科。

 

任务可以分为计算密集型和IO密集型。

假设现在使用一个进程来完成这个任务,对计算密集型任务(例如在一个大数组中求最大值,最小值,平均值),可以使用【核心数】个线程,就可以占满cpu资源,进而可以充分利用cpu,如果再多,就会造成额外的开销;

对于IO密集型任务(涉及到网络、磁盘IO的任务都是IO密集型任务),线程由于被IO阻塞,如果仍然用【核心数】个线程,cpu是跑不满的,于是可以使用更多个线程来提高cpu使用率。

 

3.2并行计算

并行计算一般可以提升计算效率。但盲目的并行计算也不一定能提升效率。

 

实现并行计算有三种方式,多线程,多进程,多进程+多线程。

 

使用并行计算需要注意的几个问题:

(1)当用多线程从一个公用硬盘I/O中读取大量的数据时,希望已此方式来提升I/O读取的性能,基本上很难(除非是SSD硬盘可以考虑),因为硬盘I/O本身就是硬件中最慢的设备,并行的效果只会导致更多的争用。

(2)当然若果发现做完I/O读操作后,处理数据的时间占比较大,这个时间浪费了不值得,因此有的小伙伴可能会说,可以将文件分解为多个片段,多个线程分开读取每个片段,不过这需要磁盘的IOPS能力足够强才行,要知道普通的机械磁盘是串行的。另外,需要考虑这个文件是否足够大,需要去分段。

 

(3)CPU 是多核时是支持多个线程同时执行。但在 Python 中,无论是单核还是多核,一个进程同时只能由一个线程在执行。其根源是 GIL 的存在。GIL 的全称是 Global Interpreter Lock(全局解释器锁),来源是 Python 设计之初的考虑,为了数据安全所做的决定。某个线程想要执行,必须先拿到 GIL,可以把 GIL 看作是“通行证”,并且在一个 Python 进程中,GIL 只有一个。拿不到通行证的线程,就不允许进入 CPU 执行。所以多线程在python中很鸡肋。

 

参考文献链接

https://blog.csdn.net/FMC_WBL/article/details/126575914

https://zhuanlan.zhihu.com/p/187336277?utm_id=0

https://blog.csdn.net/rs_gis/article/details/115860381

 

 

 

 

 

posted @   吴建明wujianming  阅读(87)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
历史上的今天:
2023-01-18 LLD-LLVM链接器,ELF、COFF与Wasm Linkers
2022-01-18 ADAS技术市场总结展望(2021年-2022年)
点击右上角即可分享
微信分享提示