为了提高服务器的并发处理能力,编程模式从原来串行模式升级到并发模型,并发模型主要有IO多路复用、多进程、多线程等。为什么会演化出协程?比多线程又有那些优势?它们之间又有那些区别?我们将带着这些问题深入了解其背景。
一、串行、并行、并发、同步、异步
串行(serial):任务依次顺序执行与并行相对应,优点:由于任务在一个线程执行即不存在线程不安全情况,也就不存在临界区的问题。缺点:不能很好的利用多CPU的资源。
并发(Concurrent):同一个时间段应对多个任务;任务之间是互相抢占资源的
并行(Parallel):同一时间点处理多个任务;仅在多CPU情况下;多个任务之间是不互相抢占资源的,真正意义上的『同时进行』
同步与异步:同步不能开启新的线程,异步可以。同步是顺序执行,需等待、协调运行,异步是彼此独立执行,不需等待。线程是实现异步的一个方式,并不是一个同等关系。
这里我们主要深入理解并发和并行。
并发和并行最早是操作系统中的概念,表示的是CPU执行多个任务的方式。这两个概念很容易混淆。
在操作系统上我们可以"同时"执行多个应用的,比如:一边听歌,一边打游戏,所谓的"同时",在操作系统底层可能并不是真正的意义上的"同时"。
事实上,对于单CPU来说,在CPU中,同一时间是只能处理一个任务的。为了看起来像是"同时干多件事",操作系统会把CPU的时间划分成长短基本相同的时间区间,即"时间片",通过操作系统来管理,把这些时间片依次轮流(分时调度)地分配给各个应用使用,即"时间片切换"。
这样从用户使用角度来看是"同时"进行的,实际上CPU是不停的在进程之间来回切换执行的。
CPU的时间片使用是有规则的,某个作业在时间片结束之前,整个任务还没有完成,那么该作业就被暂停下来,等待下一次循环再继续,此时CPU会分配给另一个作业使用。
由于CPU的处理速度很快,只要时间片的间隔取得适当,那么时间片切换间的"停顿",用户基本是察觉不出来的。
因此,在单CPU的系统中,同时进行多个任务,其实是通过CPU时间片技术,并发完成的。
示意图:
所以,并发是同一时段内宏观上多个任务同时运行。并行是同一个时刻多个任务确实真的在同时运行。
注意:只有在多CPU的情况下,才会并行执行的。否则看似同时执行,其实是并发执行的(伪并行)。如图:
二、进程、线程、协程
进程:
进程是系统进行资源分配的基本单位。
有独立的内存空间。
进程是线程的容器。
进程与程序区别:
程序是数据和指令的集合, 是一个静态的概念, 就是一堆代码, 可以长时间的保存在系统中。
进程是程序运行的过程, 是一个动态的概念, 进程存在着生命周期, 也就是说进程会随着程序的终止而销毁, 不会永久存在系统中。
线程:
进程与线程区别:
协程:
线程上下文切换机制:由于中断处理、多任务处理、用户态切换等原因会导致 CPU 从一个线程切换到另一个线程,切换过程需要保存当前进程的状态并恢复到另一个进程的状态。上下文切换的代价比较高,因为交换线程会花费很多时间。上下文切换的延迟取决于不同的因素,大概在在 50 到 100 纳秒之间。考虑到硬件平均在每个核心上每纳秒执行 12 条指令,那么一次上下文切换可能会花费 600 到 1200 条指令的延迟时间。实际上上下文切换占用了大量程序执行指令的时间。如果存在跨核上下文切换(Cross-Core Context Switch),可能会导致 CPU 缓存失效(CPU 从缓存访问数据的成本大约 3 到 40 个时钟周期,从主存访问数据的成本大约 100 到 300 个时钟周期),这种场景的切换成本会更高。
三、线程模型
操作系统按照特权等级,把进程的运行空间分为内核空间和用户空间,一次系统调用可以实现用户态和内核态的切换,线程是调度的基本单位(两种不同方法来提供线程支持:用户层的用户线程和内核层的内核线程),而进程则是资源拥有的基本单位。
线程模型主要有3种:内核级线程模型、用户级线程模型、混合型线程模型。它们之间最大的区别在于线程与内核调度实体KSE(Kernel Scheduling Entity)之间的对应关系。所谓的内核调度实体KSE就是指可以被操作系统内核调度器调度的对象实体,也称其为内核级线程(也就是操作系统内核的最小调度单元)。
用户级线程(user-level threads):
运行于用户态的线程,不被内核感知。
单个进程中可有多个线程共享进程虚拟地址及全局变量。
用户线程也有私有数据,如:栈和寄存器等,在上下文切换时也是需要保存的。
用户态线程上线文切换只在同一进程中切换当前线程寄存器状态与栈等,并不涉及进程上下文切换。
同一个进程中的用户态线程只能运行在同一CPU核上,做不到真正意义上的并行加速。
用户态线程需要绑定内核态线程。
内核级线程(kernel-level threads):
用户态线程需要绑定内核态线程,就决定了,每次创建用户线程需要执行一次系统调用,更新内核中的线程表信息(如:执行一次std::thread)
内核线程可以放在多个CPU核心运行,但是一次内核线程调用成本高,为了执行一次系统调用,CPU寄存器首先保存用户态指令位置,然后更新为内核态指令的新位置,最后才是跳转到内核态运行内核任务,系统调用结束后,CPU寄存器恢复原保存的用户态指令位置,然后再切换到用户空间,一次系统调用的过程,发生了两次CPU上下文切换。
PS:内核调度实体(Kernel Scheduling Entity),即内核分配CPU的对象单位。
1、1对1(即1个用户线程对应1个内核线程):一个线程阻塞时,能够允许另一个线程继续执行,所以它提供了比多对一模型更好的并发功能;也允许多个线程并行运行在多处理器系统上。这种模型的唯一缺点是线程的扩展是有限的,因内核线程对系统性能影响较大。
2、多对1(即多个用户线程对应1个内核线程):用户线程可扩展,但对于需使用内核线程的操作(IO频繁操作)会导致正在执行IO操作的用户线程因内核线程没释放从而阻塞住同一进程中的其他线程,同时多个用户线程对于内核是无法感知的(任一时间只有一个线程可以访问内核),故做不到真正意义上的并行调用(多个线程不能并行运行),而无法充分利用多核。
3、多对多(即多个用户线程对应多个内核线程):线程的调度需由内核态和用户态一起来实现,如:线程间同步需要用户态和内核态共同实现。用户态和内核态的分工合作导致实现该模型较复杂。(Linux多线程模型曾也想使用该模型,但太过于复杂,要对内核进行大范围改动,所以还是采用了一对一的模型。)
PS1:多对多模型是多路复用即多个用户级线程到同样数量或更少数量的内核线程,内核线程的数量可与特定应用或主机有关(应用程序在多处理器上比在单处理器上可能分配到更多数量的线程)。
PS2:多对多模型的另一种变种仍然是多路复用,但允许绑定某个用户线程到一个内核线程,被称为双层模型。
四、协程的理解
协程(Coroutine)是一种轻量级的用户级线程,实现的是非抢占式的调度,即由当前协程切换到其他协程由当前协程来控制。协程框架一般都是设计成 1:N 模式。所谓 1:N 就是一个线程作为一个容器里面放置多个协程。那么谁来适时的切换这些协程?答案是有协程自己主动让出 CPU,也就是每个协程池里面有一个调度器,这个调度器是被动调度的。意思就是他不会主动调度。而且当一个协程发现自己执行不下去了(比如异步等待网络的数据回来,但是当前还没有数据到),这个时候就可以由这个协程通知调度器,这个时候执行到调度器的代码,调度器根据事先设计好的调度算法找到当前最需要 CPU 的协程。切换这个协程的 CPU 上下文把 CPU 的运行权交个这个协程,直到这个协程出现执行不下去需要等等的情况或者它调用主动让出 CPU,触发下一次调度。
协程是用户态线程的一种调度方式,实现了用户态的上下文切换,配合一个用户态调度器与相应协程队列实现的。在行为逻辑上和线程、进程类似,都是实现不同逻辑流的切换和调度。但要明确的是协程(Coroutine)是编译器级的,进程(Process)和线程(Thread)是操作系统级的。
1、引入协程的目的:
在没有协程的时代,为了应对 IO 操作,主要有三种模型:
同步编程:应用程序等待IO结果(如等待打开一个大文件或者等待远端服务器的响应),阻塞当前线程。
优点:符合常规思维,易于理解逻辑简单。
缺点:成本高昂效率太低,其他与IO无关的业务也要等待IO的响应。
异步多线程/进程:将IO操作频繁的逻辑或者单纯的IO操作独立到一个或多个线程中,业务线程与IO线程间靠通信全局变量来共享数据。
优点:充分利用CPU资源,防止阻塞资源。
缺点:线程切换代价相对较高,异步逻辑代码复杂。
异步消息+回调函数:设计一个消息循环处理器,接收外部消息(包括系统通知和网络报文等),收到消息时调用注册的回调函数。
优点:充分利用CPU资源,防止阻塞资源。
缺点:代码逻辑复杂。
协程的概念,从一定程度来讲,可以说是“用同步的语义解决异步问题”,即业务逻辑看起来是同步的,但实际上并不阻塞当前线程(一般是靠事件循环处理来分发消息)。协程就是用来解决异步逻辑的编程复杂度问题的。
优点:
缺点
适用场景:
高性能计算,牺牲公平性换取吞吐。
IO 密集型
Generator 式的流式计算
2、上下文切换概念
保存好当前的寄存器状态与栈(协程状态,如:可以把寄存器值压到栈里然后栈和PC、SP存起来),恢复的时就恢复寄存器值以及PC、SP,创建的时就给个新的栈空间,运行完就销毁对应的栈空间。(PS:除汇编外,需要语言本身支持类似的上下文切换操作,如:Python的Generator)
运行流程:
在需切换协程的地方保存当前上下文、恢复调度器协程的上下文
调度器协程选择下一个可以运行的协程(协程队列)
调度器协程保存自己的上下文,恢复目标协程的上下文
程序运行入口注册初始要运行的协程(加到可运行队列里),然后启动调度器协程
随后配合epoll之类的event loop,给协程不同的状态(运行中/可运行/阻塞,很类似线程/进程的状态),调度器根据这些状态选择可运行的协程去运行了。
以上的理解都是单内核线程情况下,如是多内核线程(Go的GPM模型),那还需加一层各个协程到对应内核线程的映射,调度器实现也会更复杂一些。