并发模型及线程模型概述
同步异步
要了解各种并发模型思想,首先要了解什么是同步,什么是异步?什么是阻塞,什么是非阻塞?
举一个例子说明,领导安排小东开发:
同步阻塞:领导安排小东开发后,一直盯着小东开发,直到小东开发完成之后,才去干其它活。
同步不阻塞:领导安排小东开发后,就忙自己的事情去了,每隔一会儿就过来询问小东是否完成了没。
异步阻塞:领导安排小东开发后,不盯着小东,也不会忙其他事,小东开发完成后就通知领导。
异步不阻塞:领导安排小东开发后,就忙自己的事情去了,小东开发完成后就通知领导。
总结:
同步,是调用者主动去查看调用的状态;
异步,则是被调用者来通知调用者;
阻塞和非阻塞的区别是调用后是否立即返回
并发模型
进程和线程的区别
进程是操作系统资源分配的基本单位,线程是 CPU 调度的基本单位。从操作系统层面去看是进程,从 CPU 层面去看是线程。
进程的空间是独立,各个进程相互不干扰,每个进程拥有自己的进程内存,上下文环境,进程控制块,一个进程至少有一个或者多个线程。线程属于进程,线程要存在必须依赖于进程,线程共享进程的内存,但线程有自己的栈空间,能创建多少个线程也取决于进程内存的大小。
线程的上下文切换代价比进程要小的多。
进程之间强调的是通信,线程之间强调的是同步(数据安全)。
并发模型
单进/线程-循环处理任务
单进程和单线程本质是一样的,不具备并发处理的能力,只能串行处理任务。
多进程单线程
主进程负责监听和管理连接,当有客户端请求来时,就fork一个子进程来处理连接。这种模型会比较稳定,最大的好处是隔离性,子进程万一 crash 并不会影响到父进程,也不会产生线程安全问题。但是创建进程对内存消耗会比较,并且CPU在多个进程间来回切换开销也大,所以子进程不适合过多,总的来说这种模型对系统的负担过重
单进程多线程
单进程多线程和多进程单线程的方式类似,只不过是替换成线程。主线程负责监听,管理连接,子线程负责处理业务逻辑。多线程提高了响应速度,让IO和计算相互重叠,降低延时。但是也带来了频繁地创建、销毁线程,这对系统也是个不小的开销。同时也要处理同步的问题,当多个线程请求同一个资源时,需要用锁之类的手段来保证线程安全。最后一个是线程的奔溃会影响整个进程,稳定性不如多进程。
多进程多线程
这种并发模型是在开启多个子进程后,每个子进程下面又会开启多个线程。这种模式下并发承受能力会比单纯的多进程好许多,但是这个也分情况,比如在CPU密集型作业下未必会比多进程好,因为每一个进程下的多线程上下文不断切换的开销是非常大的。cpu 本来就在多个进程间切换,现在又要在单个进程下的多个线程间切换,cpu 大部分时间都在切换上下文了,真正用于计算的时间反而很少,因此影响了其性能。
IO多路复用
IO多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时会阻塞应用程序,交出cpu。多路是指网络连接,复用指的是同一个线程。
协程
协程基于用户空间的调度器,具体的调度算法由具体的编译器和开发者实现,相比多线程和事件回调的方式,更加灵活可控。不同语言协程的调度方式也不一样,python 是在代码里显式地
yield
进行切换,golang 则是用 go 语法来开启goroutine
,具体的调度由语言层面提供的运行时执行。
线程模型
线程的实现可以分为三类,内核级线程,用户级线程,以及混合型线程。
以下内容摘抄自Goroutine并发调度模型深度解析之手撸一个高性能Goroutine池
内核调度实体(KSE,Kernel Scheduling Entity)
内核级线程模型
用户线程与内核线程KSE是一对一(1 : 1)的映射模型,也就是每一个用户线程绑定一个实际的内核线程,而线程的调度则完全交付给操作系统内核去做,应用程序对线程的创建、终止以及同步都基于内核提供的系统调用来完成,大部分编程语言的线程库(比如Java的java.lang.Thread、C++11的std::thread等等)都是对操作系统的线程(内核级线程)的一层封装,创建出来的每个线程与一个独立的KSE静态绑定,因此其调度完全由操作系统内核调度器去做,也就是说,一个进程里创建出来的多个线程每一个都绑定一个KSE。
这种模型的优势和劣势同样明显:优势是实现简单,直接借助操作系统内核的线程以及调度器,所以CPU可以快速切换调度线程,于是多个线程可以同时运行,因此相较于用户级线程模型它真正做到了并行处理;但它的劣势是,由于直接借助了操作系统内核来创建、销毁和以及多个线程之间的上下文切换和调度,因此资源成本大幅上涨,且对性能影响很大。
graph BT A[内核线程] -->B(用户线程) C[内核线程] -->D(用户线程) E[内核线程] -->F(用户线程)
用户级线程模型
用户线程与内核线程KSE是多对一(N : 1)的映射模型,多个用户线程的一般从属于单个进程并且多线程的调度是由用户自己的线程库来完成,线程的创建、销毁以及多线程之间的协调等操作都是由用户自己的线程库来负责而无须借助系统调用来实现。一个进程中所有创建的线程都只和同一个KSE在运行时动态绑定,也就是说,操作系统只知道用户进程而对其中的线程是无感知的,内核的所有调度都是基于用户进程。许多语言实现的 协程库基本上都属于这种方式(比如python的gevent)。
graph BT A[内核线程] -->B(用户线程) A-->D(用户线程) A -->F(用户线程)
由于线程调度是在用户层面完成的,也就是相较于内核调度不需要让CPU在用户态和内核态之间切换,这种实现方式相比内核级线程可以做的很轻量级,对系统资源的消耗会小很多,因此可以创建的线程数量与上下文切换所花费的代价也会小得多。
但该模型有个原罪:并不能做到真正意义上的并发,假设在某个用户进程上的某个用户线程因为一个阻塞调用(比如I/O阻塞)而被CPU给中断(抢占式调度)了,那么该进程内的所有线程都被阻塞(因为单个用户进程内的线程自调度是没有CPU时钟中断的,从而没有轮转调度),整个进程被挂起。即便是多CPU的机器,也无济于事,因为在用户级线程模型下,一个CPU关联运行的是整个用户进程,进程内的子线程绑定到CPU执行是由用户进程调度的,内部线程对CPU是不可见的,此时可以理解为CPU的调度单位是用户进程。所以很多的协程库会把自己一些阻塞的操作重新封装为完全的非阻塞形式,然后在以前要阻塞的点上,主动让出自己,并通过某种方式通知或唤醒其他待执行的用户线程在该KSE上运行,从而避免了内核调度器由于KSE阻塞而做上下文切换,这样整个进程也不会被阻塞了。
两级线程模型(混合型线程模型)
两级线程模型是博采众长之后的产物,充分吸收前两种线程模型的优点且尽量规避它们的缺点。
在此模型下,用户线程与内核KSE是多对多(N : M)的映射模型:首先,区别于用户级线程模型,两级线程模型中的一个进程可以与多个内核线程KSE关联,也就是说一个进程内的多个线程可以分别绑定一个自己的KSE,这点和内核级线程模型相似;其次,又区别于内核级线程模型,它的进程里的线程并不与KSE唯一绑定,而是可以多个用户线程映射到同一个KSE,当某个KSE因为其绑定的线程的阻塞操作被内核调度出CPU时,其关联的进程中其余用户线程可以重新与其他KSE绑定运行。
graph BT A[内核线程] -->B(用户线程) A-->D(用户线程) A -->F(用户线程) K[内核线程] -->B K-->D K-->F Z[内核线程] -->B Z-->D Z-->F
所以,两级线程模型既不是用户级线程模型那种完全靠自己调度的也不是内核级线程模型完全靠操作系统调度的,而是中间态(自身调度与系统调度协同工作),也就是 — 『薛定谔的模型』(误),因为这种模型的高度复杂性,操作系统内核开发者一般不会使用,所以更多时候是作为第三方库的形式出现,而Go语言中的runtime调度器就是采用的这种实现方案,实现了Goroutine与KSE之间的动态关联,不过Go语言的实现更加高级和优雅;该模型为何被称为两级?即用户调度器实现用户线程到KSE的『调度』,内核调度器实现KSE到CPU上的『调度』
参考链接:
https://juejin.im/entry/6844903680651558919
https://juejin.im/post/6844904200141438984
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!