性能优化 / 架构设计 / 草稿 - 编辑中 · 2023年10月2日

一文熟知程序执行 – 聊聊并发、数据结构&算法

1.前言

前面提到过,计算机系统本质上就是信息的传递、处理、存储,传递和存储前面已经聊过啦,本篇就来看下信息的高效处理,从并发、数据结构、算法几个角度来看信息的高效处理。

一文熟知程序执行 - 聊聊并发、数据结构&算法

我们都期待我们的程序执行非常的高,用极少的资源解决最大的问题,但现实往往不遂人愿,常见的原因有:CPU资源的不合理利用、使用了不合理的数据结构、低下的算法执行效率。

对应的解决方案也就是本文要说的重点,一些老生常谈的问题。

  • 编写合理的并发编程代码
  • 数据结构的设计&选择
  • 优化算法执行效率

我们的目标是:充分压榨CPU资源,在有限的资源内完成更多的计算,让整体的计算吞吐更高

2.从操作系统开始聊并发编程

2.1 先看看操作系统基础

从最底层说起,由CPU工作原理、CPU + 内存的配合模式、进程/线程、为什么需要多核(并行)、为什么需要并发、关于上下文切换的代价、如何实现“0拷贝”、并发编程的实现模式、Java并发编程思路、Go协程实现“同步编写、异步执行”、CPU跟程序的关系

最终来看,如何写相对优秀的并发编程代码

2.1.1 CPU

2.1.1.1 CPU 初步认知

计算机只感知01,但却向我们呈现了瑟赛斑斓的、各式各样的信息,并且我们能够对于这些信息进行存储、传输、处理。计算机世界中,我们把一切都使用01来表达。处理这些信息,本质上处理的是信息背后的各种01。

简单来说,要进行处理就需要一个“运算器”,然后计算过程需要有输入,这个输入的存储就是存储单元,CPU的执行需要对应协调控制,这个控制主要依赖于计数和具体指定的存储,这两者基本可以合并成为控制单元。

2.1.1.2 冯诺伊曼结构

上面描述的CPU结构本质就是冯诺伊曼结构,尘封的记忆又拂尘了。

运算器、控制器、存储器、输入设备、输出设备。

一文熟知程序执行 - 聊聊并发、数据结构&算法

冯诺依曼结构基本是现代计算机的原型,核心的运算、控制、存储,就是我们CPU要提供的能力。不过现代存储如果泛化一些,内存、磁盘层存储器也属于存储的部分,但严格来看CPU内的存储才属于存储器,内存、磁盘这些更应该看作输入。

2.1.1.3 CPU 执行过程

接下来围绕几个接口看一下CPU的执行过程,整体的阶段可以分为:取指令、指令译码、执行指令、访存取数、结果写回几大阶段。

取指令是将一条指令从主存放到指令寄存器的过程。程序计数器PC中的数值指示当前指令在主存中的位置。

指令译码是按照预定的指令格式对指令进行拆分和解释,识别出指令类型和对应取操作数的方法。

访存取数访问主存读取操作数,根据执行的地址码,得到操作数对应主存的地址,并读出。

指令执行完成具体的指令功能,比如说加法运算

结果写回把运算得出的结果,写回对应的存储介质,通常是CPU内部存储寄存器(也可能写回主存)

然后继续执行下一条指令

一文熟知程序执行 - 聊聊并发、数据结构&算法

2.1.1.4 CPU工作效率

上面说的是一个指令的指令周期,接下来看CPU的效率 — 时钟速度。时钟速度越高,CPU运行速度越快。时钟速度可以理解为一秒内多少次时钟周期(“时钟周期”是内部振荡器同步的脉冲)。CPU的多少GHz指的就是这个东西。

一文熟知程序执行 - 聊聊并发、数据结构&算法

一个时钟周期内,可以完成多个指令;也可能多个时钟周期共同完成一个指令。线程执行最小的单位可以粗暴的理解为一个时钟周期。比如说多线程 i++,多线程循环加1,加100次(一个时钟周期内)和加10w次(多个时钟周期)的差异,如果单个线程的操作在一个时钟周期内,是不会发生并发问题的。

2.1.1.5 中断

CPU负责完成信息的处理,那在软件程序的处理过程中怎么完成输入输出设备的交互呢,比如怎么处理和网卡直接的交互动作。实现机制就是中断。

中断是软件、硬件交互的一种机制,操作系统整个架构都是靠中断来实现硬件事件驱动的。

CPU透过总线告诉外设需要执行那些动作(软 -> 硬),而外设完成执行后由中断通知CPU。就拿网络IO来说,CPU执行IO发送,总过地址总线告诉网络IO外设,IO执行工作,CPU转而执行其他程序,IO结束后发一个中断信号给CPU,CPU执行完当前指令周期后,检查中断信号(中断请求寄存器 – IRR)进行对应的处理,处理中写入服务中寄存器(ISR),处理完成写入(中断结束寄存器 – EOI)。同理接受网络链接时也是一样的过程。

一文熟知程序执行 - 聊聊并发、数据结构&算法

整个中断就是一个完全异步的逻辑。执行器是CPU,然后外设通过中断通知拉起新的处理。

还有一些中断为CPU自发的,比如说异常信息探测中断。

2.1.1.6 总线

前面提到了从内存中读取数据到寄存器中,从内存中读指令到寄存器中,写入中断信号。这些都是通过什么进行传输的呢?总线。

CPU的协同交互,比如说寻址、数据、控制等,都是依赖于总线进行落地的,核心就是地址总线、控制总线、数据总线

很显然这几条总线的“吞吐”是除主频以外,限制CPU的效率的主要因素,其中:

  • 地址总线的宽度决定了 CPU的寻址能力;
  • 数据总线的宽度决定了 CPU 与其他器件进行数据传送时的一次数据传送量;
  • 控制总线(通常是若干调控制链路)的宽度决定了 CPU对系统中其他器件的控制能力.
一文熟知程序执行 - 聊聊并发、数据结构&算法

2.1.2 内存

CPU内部用于存储数据、指令的存储器通常是寄存器,日常存储数据用硬盘,机械硬盘、固态硬盘,但是寄存器和磁盘的速度差异可谓是天差地别,直接跟磁盘交互肯定是不现实的,会被拖慢若干数量级,所以需要一种相对便宜、速度尚可的介质来进行存储,内存就出现了。

相对于寄存器(基于D触发器实现,跟CPU计算完全同频),内存通常是DRAM(动态随机存储,一个电容+一个MOS组成,电容充放电存储数据),这种结构差异也就导致了两者速度的差异,而DRAM的实现方式保证了其集成度高、相对廉价等特点。

2.1.2.1 CPU多级缓存

使用内存之后发现还不够,速度差仅仅被缓解了一部分,DRAM的速度仍然跟不上CPU的速度,怎么办?加缓存。

于是L1、L2、L3这些缓存就出现了。这些缓存也是一种RAM结构,但与内存不同,这些高速缓存通常是SRAM结构,由多个晶体管实现,使用交叉耦合的反相器对,用来锁存数据,而不像DRAM那样依赖于电容存数,也不需要担心保存丢失。

一文熟知程序执行 - 聊聊并发、数据结构&算法

集成度相对DRAM要差一下,但比寄存器要高,价格适中,所以CPU缓存的存储介质通常是这样。

为了进一步降低速度GAP并且兼顾成本,按照对应的数据类型进行了缓存拆分,不同的缓存大小不同、速度有差异: L1(1级)高速缓存是计算机系统中存在的最快内存,L1缓存具有CPU在完成特定任务时最可能需要的数据。(256KB – 1MB)

L2(级别2)缓存比L1缓存慢,但是大小更大。它的大小通常在256KB到8MB之间,尽管较新的,功能强大的CPU往往会超过它。L2缓存保存下一步可能由CPU访问的数据。在大多数现代CPU中,L1和L2高速缓存存在于CPU内核本身,每个内核都有自己的高速缓存。

L3(Level 3)缓存是最大的缓存单元,也是最慢的缓存单元。它的范围在4MB到50MB之间。现代CPU在CPU裸片上有专用空间用于L3缓存,占用了大量空间。

2.1.2.2 虚拟内存

前面提到了为了减少存储带来的速度差,引入了内存、缓存等等,对于内存我们并不直接操作硬件,同磁盘的管理一样,操作系统提供了一种虚拟内存机制来进行内存的管理。我们程序操作的是虚拟地址,透过MMU映射为物理地址。

虚拟内存的引入大大减少了直接操作内存带来的内存碎片,并且虚拟内存背后的介质并不一定非得是内存,可以使用部分磁盘当作“内存”来存储那些不常用的数据,内存存储量直线攀升;受益于虚拟存的引入,不同程序对于内存的使用可以忽略互相抢占&篡改的问题。

一文熟知程序执行 - 聊聊并发、数据结构&算法

最初遵从程序执行的逻辑视角,把程序执行的内存占用分成了堆、栈、数据、代码等,也就形成了最初了堆段、栈段、数据段、代码段等等,把内存按照使用上的逻辑属性进行了拆封,也就是最早的段式管理,段表(段选择子做索引) + 段内偏移量来定位具体的数据,这种方式比较容易形成内存碎片,并且内存段较大,交换效率较低。

由于段式管理的弱点,出现了后来的内存分页,把虚拟空间、物理内存分成大小固定的页,然后进行映射,这样就避免了产生细小夹缝的内存碎片,由于单页较小,交换效率就上来了。具体内存页的索引由页表来进行管控,页表(页号来索引) + 页内偏移就定位到了具体的数据。

如果虚拟内存非常大,页表无疑是非常庞大的,于是多级页表就出现了,适当的牺牲了查找时的效率,但是降低了“巨大页表”的发生,后来在CPU中引入了TLB,负责缓存最近常被访问的页表项,减缓了多级页表带来的劣化。

当访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。对应的置换动作,常见的算法有FIFO、LRU、OPT、Clock、LFU、PBA等等。置换算法决定了在缺页中断时的处理效率,其中FIFO效率最差,OPT最理想。

我们日常用的linux主要是分页管理,但由于历史原因,系统无法避免分段管理。于是 Linux 就把所有段的基地址设为 0,也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了 CPU 逻辑地址的概念,所以段只被用于访问控制和内存保护。段页式地址变换中要得到物理地址须经过三次内存访问:
1: 访问段表,得到页表起始地址;
2: 访问页表,得到物理页号;
3: 将物理页号与页内位移组合,得到物理地址。

linux使用的置换算法的原型是LRU,具体的实现方式也要复杂的多,会有几个守护进程在做这事儿,毕竟缺页中断及带来的置换动作性能损耗很大。实际操作中尽可能少的发生缺页中断才是王道,内存也没那么贵。

2.1.3 内核态&用户态

我们的程序执行过程,对于操作系统而言是有两个状态的,内核态和用户态,对应的是程序执行的内核空间、用户空间,更严谨来说,是对于处理器的两种工作模式,实际处理器的工作模式要更多,本质上是一样的,并且可以笼统的归位这一类。

两种状态的出现是出于对指令的权限限定,在用户态能执行的指令集是有限的,执行在用户态的进行“不犯大错”,保护系统的稳定性和扩展性,执行在内核态的程序几乎可以认为是“为所欲为”,通常我们的程序是执行在内核态的,如果需要使用内核态中被限定的指令,则通过异常、外设中断、系统调用(也算是中断)的方式“陷入内核态”。

一文熟知程序执行 - 聊聊并发、数据结构&算法

由于程序内核态执行、用户态执行,对应的程序栈是不同的,状态的切换(用户态 -> 内核态 -> 用户态),并非简简单单只是一个状态变更,通常意味着需要执行:

1:现场的保存(上下文、寄存器、用户栈)

2:数据的复制(用户态参数、内核态结果)

3:现场恢复

4:校验检查(内核态执行相对于用户态,由于对于用户态的不信任,还会有额外的检验检查,开销也就比较高)

2.1.3.1 举个例子来看用户态、内核态

再举个完整的例子,串一下前面提到的知识点,Socket服务器来看(比如Redis:单进程-单线程处理多链接)

1:程序在用户态执行,先执行getaddrinfo(),然后创建监听socket(被动socket),bind()绑定地址,后listen(), 这个过程会多次从用户态陷入内核态再回到用户态,之后就是不断的accept()了

2:调用epoll_create,陷入内核态开辟一块内存区,创建一个epoll对象的数据结构(红黑树 + 双链表)

3:将监听socket(listenfd)加到epoll里面(epoll_ctl),然后将监听事件的读事件的回调注册为accept()拿到clientfd(客户端socket连接),并加到epoll里。同样的每次系统调用,都会发生内核态、用户态的切换。

4:调用epoll_wait陷入内核态,然后开始不断循环检查(event-loop)

5:当接受新连接时,发生中断,会通过读事件的回调拿到clientfd,加入到epoll里

6:epoll_wait拿到活跃连接之后返回对应fd,进入用户态,进行对应的动作(回调函数:redis就是读数据,执行命令了)

歪个楼

ps1:相对于select机制,差异就在于只 仅关注活跃连接 + 读写数据和连接处理的处理拆分 + 更少的数据复制

再歪一下,accpet和epoll_wait的过程会有一个经典的劣化现象,叫做“惊群问题”,泛指多线程或多进程在等待某种事件时的激烈竞争。

ps2:accept惊群是指,多个线程/进程同时等待接受连接的场景,sokect有事件时被同时唤醒,可以通过SO_REUSEPORT避免

ps3:epoll惊群是指,多个线程/进程同时监听一个fd的读写事件,可以使用边缘触发(只通知一个)、EPOLLONESHOT限定一个进程、降低监听线程的数量

2.2 并发编程模型

前面的都是硬件和操作系统基础,接下来进入并发的正文,来看下如何编写高效率的程序。

2.2.1 复习一下概念

先来复习一堆概念:并发、并行、同步、异步、阻塞、非阻塞,之前聊网络的时候,已经详解过了。

并发是指:在一段时间内多个程序都得到执行,背后可以是交替执行、也可以是同时执行。

并行是指:在每个时刻,多个程序均在同时执行。可以认为,CPU的数量就是并行的宽度。

阻塞是指:阻塞/非阻塞参考对象是执行对象,执行对象被挂起等待就是阻塞的。

非阻塞是指:执行对象没被挂起等待,继续向下了,那就是非阻塞的。

同步是指:参考对象是操作的执行者,看执行者是否切换,如果是自身去检查继续就是同步的。

异步是指:操作存在执行者切换,比如依赖被动回调,新拉一个执行者执行,就是异步的。

对于那一堆IO可参照一文熟知网络-1.4.2.1 网络IO

2.2.1.1 细说并行

首先是Rpb Pike的经典描述:
并发是同一时间应对(dealing with)多件事情的能力。
并行是同一时间动手做(doing)多件事情的能力。

虽然前面说,在日常编程的视角来看编程的视角,并行就是多核。但是更严谨来说,并发的粒度有很多:

  • 位级并行:同时进行多位的运算,比如说64位可以并行执行2次32位的动作。 指令级并行:单处理器同时执行多个指令,单核中的多计算单元同时介入,并行那些没有前后依赖关系的执行,比如说Dynamic schedule: Tomasulo 指令调度算法。之前看到过一篇不错的文章理解现代处理器:指令级并行篇
  • 数据级并行:单指令多数据,同样是单处理器,执行相同指令,但不同的数据。
  • 任务级并行:就是我们常说的多核并行模式,基于内存共享的并行模式(或者是基于网络通信的分布式内存共享模式),分布式出现的原因是内存的瓶颈问题,总线风暴很多时候就是这么来的。
  • MapReduce 模型:MapReduce 模型是一种用于分布式数据处理的模型,它将数据分成多个块,然后使用 Map 和 Reduce 阶段来处理数据。这种模型在大规模数据处理中非常有用。

有了并发、并行的理论基础,接下来我们来看一下常见的并发编程模型。

2.2.2 目标

回顾下我们的目标:充分压榨CPU资源,在有限的资源内完成更多的计算,让整体的计算吞吐更高

简单来看: 1: 让CPU一直在忙碌 – 需要并发,在CPU空闲时让CPU忙起来。

2: 让每个CPU都在工作,充分并行。

3: 围绕操作系统之上程序的执行原理,较少不必要的代价,更小成本执行。

2.2.3 并发编程模型

对于并发编程模型描述,边界都相对模糊,大家理解背后的思想,了解我们可以通过哪些方式实现并发就好,不纠结概念。有兴趣可以每个点都去研究下。

  • 多进程并发 -进程拥有自己独立的堆和栈,既不共享堆,亦不共享栈,进程由操作系统调度。多进程是在操作系统层面进行并发的基本模式。同时也是开销最大的模式。
  • 多线程/协程并发
    • 基于共享内存通信
      • 基于锁(悲观策略),比如说Java原生编发编程
      • STM(乐观机制,MVCC多版本并发控制),比如说各类数据库MVCC实现
    • 基于消息传递
      • CSP,通信顺序进程,go就是这种思路,基于通道来共享数据,关注通道
      • actor,同样基于通信共享数据,但关注通信实体,比如说Vert.x
  • 基于事件驱动实现异步操作-避开创建大量线程/协程,事件驱动模型基于事件和回调机制,程序响应事件的发生并执行相应的操作。这种模型通常用于构建非阻塞的、高性能的应用程序,如网络服务器。

2.2.4 异步编程范式

我们实现并发编程可以是同步编程的方式实现,也可以是异步编程的方式来实现。

同步编程基本就是大家常写的业务代码的形式,程序具有顺序性(线性)显式阻塞动作等特点,大白话就是以执行顺序的思路去写代码,需要等就等、需要锁就锁。同步编程的方式,对于编码的角度来看通常是易于理解的。

异步编程的代码读起来通常难一点,异步编程有天然的并发性,允许多个任务在同一时间内执行,不必等待前一个任务完成,很显然异步编程也是非阻塞的(或者说异步编程中没有阻塞/非阻塞这个概念更合适)

通常来说异步编程由回调(Callbacks)Promise & Future协程等机制实现异步动作,背后基本都是事件驱动+回调机制,这里协程相对特殊一点,后面会具体来说。

异步编程机制天然的并发性,通常用于编写高并发、响应速度要求高的程序,选择这样的编程范式通常是以性能为目标导向,但是其中复杂的编写的难度、高昂的维护成本是很恐怖的。比如说大量的回调复杂的错误处理,使用不当时回调函数的创建和管理成本及异步操作的上下文切换成本,程序执行的不可预见性等等

异步编程和同步编程适用于不同的场景,如果不确定,有不好辨别,看看旁边的同事都在用哪种编程范式,他们的编程方式通常就是你所适合的。

2.3 进程 & 线程 & 协程

2.3.1 进程

我们把程序的一次执行定义为一个进程,进程会有自己的虚拟内存空间,主要存放三部分信息:进程控制块PCB、数据段、程序段,其中数据断存放运行过程中的各种数据、程序段就是一系列的代码背后的指令序列、而进程控制块PCB则是给操作系统使用的,主要包含进程的描述信息、进程控制和管理信息、资源的分配清淡、CPU信息等等。

进程是一种实现并发的主要手段,在一段时间内,是可以存在多个进程并发执行的,多个进程共享CPU资源,充分压榨CPU资源,这就意味着进程间会有切换动作,从进程A切换至进程B。

具体进程的切换是内核围绕PCB进行的,很显然进程的上下文切换一定发生在内核态。

围绕进程间切换、进程的生命周期,进程可以大致分为:新建态、就绪态、运行态、阻塞态、结束态。

一文熟知程序执行 - 聊聊并发、数据结构&算法

2.3.1.1 进程的创建&终止&阻塞&唤醒

操作系统初始化、启动时,会创建资源分配、控制管理的一系列系统进程,同时还会创建一个所有用户进程的祖先,当一个程序执行时,创建对应的用户进程。

操作系统允许一个进程创建另一个进程,这两个进程之间就形成了所谓的父子关系,紫禁城能够继承父进程所用有的资源,子进程终止时需要归还从父进程所继承的资源。终止父进程会终止所有的子进程。

一个进程通常有三种结束时机:正常结束(进程主动退出)、异常结束(被操作系统结束)、外界干预(被杀死)

正常运行状态的进程,如果需要等待某个事件则会陷入阻塞(比如说IO动作,会触发一个阻塞原语主动阻塞自己),而时间片用完则会变更到就绪态。

被阻塞的进程依赖于唤醒原语这是一个异步的状态,操作系统收到中断之后,会通过进程调度唤醒对应的进程,被唤醒的进程进入就绪态,通常来说阻塞原语、唤醒原语是成对出现的。

进程的创建、销毁、调度代价总体来说都相对较高,并且进程间通信也比较麻烦,于是一个更轻量的“进程”出现了 — 线程。

2.3.2 线程

线程是一种更轻量的程序执行的载体,线程被包含在进程之中,一个线程指的是进程中的一个单一顺序控制流,一个进程中可以并发多个线程,这些线程任务独立,但共享进程的部分内存区域(解决了进程并发时的通讯效率低下问题),一个进程至少包含一个线程(主线程)

线程是操作系统调度的最小单位(要是这个视角,进程就是资源分配的最小单位),一个线程通常有:TCB(线程控制块) + 私有线程栈 + 共享数据区(可以粗暴的理解为堆区)+ 共享代码区

线程是进程中的一个单一顺序控制流,一个线程的执行,内部也会涉及用户态和内核态。对于线程来说 通常有用户态线程 & 内核态线程,用户态线程负责程序在用户态下的执行,内核态线程负责程序在内核态下的执行。在linux中默认两者是一一对应的(PThread)。

一文熟知程序执行 - 聊聊并发、数据结构&算法

比如你的Java代码new Thread.start() 会创建一个用户态线程和内核态线程,然后两者对应一下。

2.3.2.1 关于调度

线程是最小的调度单位,进程里面至少有一个线程存在,CPU本质是围绕线程进行CPU的分配,这个是操作系统最核心的功能之一,以实现公平性、响应性和高效性。CPU调度的主要目标是最大化系统性能、资源利用率和用户体验。

这里看一下一些调度算法和概念,早年学操作系统的时候一定背过吧,不苛求记忆,本质上就是让线程、让作业,更合理的分配和使用CPU,也就是所谓的公平、响应、吞吐:

抢占式和非抢占式调度

  • 抢占式调度允许操作系统随时中断当前运行的进程,将CPU分配给另一个进程。非抢占式调度只在当前进程主动释放CPU时才会进行调度。

进程优先级

  • 操作系统通常为每个进程分配一个优先级,以确定它在CPU调度中的相对权重。高优先级进程更有可能被调度执行。

时间片轮转

  • 时间片轮转是一种抢占式调度算法,每个进程被分配一个小的时间片,当时间片用完时,操作系统会将CPU分配给下一个进程。这种算法确保公平分配CPU时间。

优先级调度

  • 优先级调度将CPU时间分配给具有最高优先级的进程。这种调度算法可以用于实现实时系统或按用户需求调整进程的优先级。

多级反馈队列

  • 多级反馈队列是一种动态调度算法,根据进程的行为将其放入不同的队列。队列之间有不同的优先级和时间片大小。这种算法允许操作系统根据进程的性质进行动态调整。

最短作业优先

  • SJF调度算法将CPU分配给估计执行时间最短的进程。它通常用于最小化平均等待时间。

实时调度

  • 实时调度算法用于实时系统,它们确保任务在规定时间内完成。常见的实时调度算法包括最早截止时间优先(Earliest Deadline First,EDF)和固定优先级调度。

2.3.3 协程

前面提到过协程是一种相对特殊的存在,协程可以粗暴地理解为“轻量级用户态线程”,本质上协程就是协程,它谁也不是,实现机制可以时线程、进程、用户态线程等,协程更像一个逻辑概念。

对于协程我们可以完全按照多线程并发的方式去编写程序,使用锁、基于管道实现协同。

对于协程我们可以享受顺序编程的低开发&维护成本,又可以享受异步编程的高响应机制,特别适合简单业务逻辑的高性能应用,比如接入层密集IO类的处理,比如Go语言、比如redis 实现、比如es实现机制。

但是就协程的实现思路来看,协程本身就是在语言层面实现的一种“调度机制”,围绕这种机制,我们可以以顺序编程的方式实现异步编程。

粗暴点我们可以把协程理解成一个“调度任务”,协程由线程去进行动作的执行,一个线程可以完成多个协程的动作执行,需要上下文切换时内核线程去执行对应的动作,当发生阻塞时,线程继续执行其他“调度任务”,当“阻塞动作”完成时,调度一个新的线程去执行后续的动作。

一文熟知程序执行 - 聊聊并发、数据结构&算法

相对于同步编程的多线程并发模型来说,少了线程的阻塞、线程间切换的成本,甚至不用考虑所谓的线程池打满问题。协程背后的线程不阻塞,协程的调度仅仅是个状态的变更(3个寄存器),而非线程的上下文切换:16个寄存器、内核态/用户态。

协程帮助我们封装了异步编程中:“少量线程来服务大量IO动作、线程间切换、线程服务对象切换”的编码部分,并提供了一个相对聪明的调度模型,协程就是一种“同步编程”的异步编程范式。

2.3.3.1 有栈和无栈

协程按照有栈和无栈可以大致分为两种,它们在协程的管理和资源分配方面有一些区别。

栈协程(Stackful Coroutine)

  • 有自己的堆栈:栈协程拥有自己的执行堆栈,类似于线程。这意味着它们可以在调用栈上保存局部变量、函数调用信息等。
  • 较重:由于拥有自己的堆栈,栈协程的开销通常较大,因此不能创建太多栈协程以避免耗尽内存。但其实还好。
  • 更灵活:栈协程可以进行长时间的阻塞操作,因为它们不会影响其他栈协程的执行。
  • 复杂管理:管理栈协程的状态和切换通常较为复杂,需要显式的挂起和恢复。

无栈协程(Stackless Coroutine)

  • 共享堆栈:无栈协程不拥有自己的堆栈,它们通常共享与主线程或其他协程相同的堆栈。这使得它们的资源开销较低。
  • 轻量级:由于不需要自己的堆栈,无栈协程通常更轻量级,可以创建大量的协程。
  • 不能进行长时间阻塞:无栈协程通常用于执行轻量级任务,不适用于长时间的阻塞操作,因为它们共享堆栈,一个长时间阻塞的协程可能会影响其他协程的执行。
  • 简单管理:管理无栈协程的状态和切换通常较为简单,因为它们共享堆栈,上下文切换时不需要保存和恢复整个堆栈。

不同编程语言和协程库可能选择使用栈协程或无栈协程,具体选择取决于应用程序的需求和性能考虑。栈协程通常更适合执行长时间阻塞的任务,而无栈协程更适合执行大量轻量级任务。比如,Python的生成器就是无栈协程的一种实现,goroutine的协程就是有栈协程,nginx lua 的协程是有栈协程。

2.4 如何高质量的进行并发编程

2.4.1 常见并发问题

在基于上述原理看建议之前,先看一下并发可能会存在的问题:

竞态条件

  • 当多个线程同时访问共享资源,并且其中一个线程在修改该资源时,其他线程也试图读取或修改该资源,可能导致竞态条件(读后写)。竞态条件可能导致不一致的数据状态。

死锁

  • 死锁发生在多个线程或进程之间,每个线程都在等待其他线程释放它们持有的资源,导致所有线程都无法继续执行。这是一种常见的并发问题。

饥饿

  • 饥饿指的是某个线程或进程由于其他线程或进程的占用资源,而无法获得所需的资源以继续执行。饥饿可能导致某些线程永远无法完成其任务。

资源泄漏

  • 在并发环境中,资源泄漏可能是一个问题,特别是在使用资源池(如数据库连接池或线程池)时。如果不正确管理资源的生命周期,会导致资源泄漏。

锁粒度问题

  • 锁粒度指的是锁定的范围大小。如果锁的粒度过粗,可能会导致并发性能下降;如果锁的粒度过细,可能会引入更多的锁竞争,导致性能问题。

内存模型问题

  • 并发环境下,内存模型和内存访问顺序可能会引入一些问题。这包括内存可见性、指令重排序等问题。

并发修改下,“数据的事务性问题”

  • 脏读是指读出了其他事务正在修改的中间值,而不是最终值
  • 不可重复读,一个事务内多次读取数值不同,会有新提交的事务结果被读到。
  • 幻读,其他事务新插入的数据被读到了。
  • 一类丢失更新,事务撤销时,会导致之后执行的事务被覆盖,导致丢失。(可以理解为版本控制的指定快照回滚)
  • 二类丢失更新,两个事务同时开启,但提交延迟较大,较早提交的事务,会被之后提交的事务给覆盖。(经典的并发下的竟态条件)

2.4.2 一点建议

  • 首先要根据自己的场景判断是否需要并发,如果需要并发选择适合自己的并发编程模型,多线程、多进程、基于事件驱动等
  • 精炼需要并发的代码,足够少,足够简单,通常编程难度:同步编程 < 异步编程,通常性能:同步编程 < 异步编程,试试协程
  • 尽可能避免出现共享,出现冲向就意味着冲突,冲突就意味着需要保护
  • 尽可能减少可变状态,算是规避共享的一种方式吧
  • 非要共享,选择合适的同步机制,使用锁、信号量、读写锁、无锁数据结构等等,如果能实现无锁和乐观锁更好,但要衡量复杂度换来的性能,除此之外不妨试试actor、CSP机制
  • 想要并发安全,尽可能少的使用锁,更要尽可能少的持有锁
  • 擅长利用硬件,比如说CPU绑核、CPU某些天然的并行特性等
  • 讲真的,并发编程的代码,注释更容易骗人,注释更难懂,最好留张图,或者说把代码写清晰
  • 切记:一定要压测、一定要不同数据压测、一定要不同数据长时间压测
  • 异步编程和同步编程适用于不同的场景,如果不确定,又不好辨别,看看旁边的同事都在用哪种编程范式,他们的编程方式通常就是你所适合的。
  • 正确和安全永远是前提条件,如果无法编写高质量的并发代码,就别去写。
  • 事务相关的问题可参照:3.3.5.1 并发修改下的问题

3.以Redis为例聊聊数据结构

持续更新中…