用户态线程
用户级线程由应用程序通过线程库实现,所有的线程管理工作都由应用程序负责 (包括进程切换)。在用户级线程中,线程的切换可以再用户态下完成,无需操作系统的干预。用户感受得到用户级线程,但是操作系统却意识不到他们的存在。
当操作系统不支持线程时,为了研究线程的可行性,研究人员编写了一个线程的函数库,用函数库来实现线程。这个线程库包含了创建线程、终止线程等,开发者可以通过调用这些函数来实现所需的功能。
这个线程库,是位于用户空间的,操作系统内核对这个库一无所知,所以从内核的角度看,它还是按正常的方式管理进程,即使用这个函数库创建出多个线程,这些线程一次也只能在一个CPU核上运行。当一个线程陷入了阻塞,从操作系统内核的角度来看就是进程阻塞了,那么整个进程就会进入阻塞态,在阻塞操作结束前,这个进程都无法得到 CPU 资源。那就相当于,该进程中所有的线程都被阻塞了。
事实上,这也是用户态线程的缺点。这些用户态线程只能占用一个核,无法做到并行加速,而且由于用户态线程对操作系统透明,操作系统无法主动切换线程。对此,开发者需要为用户态线程定制调度算法。
优点:
- 管理开销小:创建、销毁不需要系统调用。
- 切换成本低:用户空间程序可以自己维护,不需要操作系统自己调度。
缺点:
- 与内核协作成本高:用户线程由用户空间进行管理,当它进行 I/O 的时候,需要频繁进行用户态到内核态的切换。
- 线程间通信成本高:用户态线程的通信和同步机制是由内核提供的。当需要进行线程同步时,通信需要 IO,I/O需要系统调用,因此用户态线程需要支付额外的系统调用成本。
- 无法利用多核优势:操作系统调度的仍然是这个线程所属的进程,所以无论每次一个进程有多少个用户态的线程,都只能并发执行一个线程,因此一个进程的多个线程无法利用多核的优势。
- 当一个进程的一个用户态线程阻塞了,操作系统无法及时发现和处理阻塞问题,它不会更换执行其他线程,从而造成资源浪费。
内核态线程
内核级线程的管理工作(创建和撤销)由操作系统的内核程序完成,线程调度、切换等工作都由内核负责。因此,内核级线程的切换必然需要在核心态下才能完成。用户感受不到内核级线程,但是操作系统意识得到他们的存在。
操作系统只“看得见”内核级线程,因此只有内核级线程才是CPU分配的单位。
操作系统内核会提供一个应用程序设计接口API,供开发者使用内核线程。
为了实现线程,内核里有一个用来记录系统里所有线程的线程表。当需要创建一个新线程的时候,需要进行一个系统调用,然后由操作系统进行线程表的更新。
相比于用户态线程,操作系统知道内核态线程的存在,它可以自由调度各个线程,从而充分利用多核,实现真正的并行。
假如线程 A 阻塞了,与他同属一个进程的线程也不会被阻塞。这是内核级线程的优势。、
让操作系统进行线程调度,意味着每次切换线程,就需要「陷入」内核态,而操作系统从用户态到内核态的转变是有开销的,所以说内核级线程切换的代价要比用户级线程大。
还有很重要的一点——线程表是存放在操作系统固定的表格空间或者堆栈空间里,所以内核级线程的数量是有限的,扩展性比不上用户级线程。
创建一个新的线程时,就需要进行一次系统调用,然后由操作系统对线程表进行更新。
优点:
- 可以利用多核 CPU 实现并行:操作系统知道内核态线程的存在,它可以自由调度各个线程,从而充分利用多核,实现真正的并行
- 操作系统级优化:内核中的线程操作 I/O 不需要进行系统调用;进程中的一个线程被阻塞,内核能调度同一进程的其他线程(就绪态)占有CPU运行
缺点:
- 创建成本高:创建的时候需要系统调用,也就是切换到内核态
- 扩展性差:由一个内核程序管理,创建的数量不能太多
- 切换成本较高:内核线程在用户态运行,线程调度和管理在内核实现。线程调度时,控制权从一个线程改变到另一线程,需要模式切换,系统开销较大。
协程
协程是用户级线程,是比线程更加轻量级的存在。它相当于一个特殊的函数,这个函数可以在某个地方挂起,并且可以重新在挂起处继续运行。它的调度完全由开发者进行调度,避免了内核态上下文切换造成的性能损失,从而突破了线程在IO上的性能瓶颈。
一个操作系统中可以有多个进程;一个进程可以有多个线程,一个线程可以有多个协程。
一个线程内的多个协程的运行是串行的,这点和多进程(多线程)在多核CPU上执行时是不同的。多进程在多核CPU上是可以并行的。当线程内的某一个协程运行时,其他协程必须挂起。
它是用于将异步代码同步化的编程机制,使得程序的执行流可以在多个并行事务之间切换但又不必承担切换带来的过高的性能损耗。因为协程切换是在线程内完成的,涉及到的资源比较少,不像线程(进程)切换那样,上下文的内容比较多,切换代价较大。协程本身是非常轻巧的,可以简单理解为只是切换了寄存器和协程栈的内容。这样代价就非常小。
线程安全
线程安全是指在多线程环境下,共享资源能够被多个线程同时访问而不会导致数据错误或程序异常。
线程是操作系统内核调度的,有CPU时间片的概念,进行抢占式调度。在所有线程相互独立且不会操作同一资源的模式下,抢占式的线程调度器是非常不错的选择,因为它可以保证所有的线程都可以被分到时间片不被垃圾代码所拖累。而如果操作同一资源,抢占式的线程为了保证线程安全,需要使用合适的同步机制,如互斥锁、信号量等,来控制对共享资源的访问和修改,从而避免出现竞争条件和数据冲突。
协程对内核来说是透明的,也就是系统并不知道有协程的存在,是完全由用户自己的程序进行调度的,因为是由用户程序自己控制的,那么就很难像抢占式调度那样做到强制CPU控制权切换到其他进程/线程,通常只能进行协作式调度,需要协程自己主动把控制权转让出去之后,其他协程才能执行。
与线程相比,协程这种主动让出型的调度方式更为高效。一方面,它让调用者自己来决定什么时候让出,比操作系统的抢占式调度所需要的时间代价要小很多。后者为了能恢复现场会在切换线程时保存相当多的状态,并且会非常频繁地进行切换。另一方面,协程本身可以做成用户态,每个协程的体积比线程要小得多,因此一个进程可以容纳数量相当可观的协程任务。
因为同一时间只能有一个协程任务运行,并且协程切换并不是系统态抢占式,那么协程一定是安全协程。
在操作共享变量的过程中,没有主动放弃执行权(await),也就是没有切换挂起状态,那就不需要加锁,执行过程本身就是安全的;可是如果在执行事务逻辑块中主动放弃执行权了,会分两种情况,如果在逻辑执行过程中我们需要判断变量状态,或者执行过程中要根据变量状态进行一些下游操作,则必须加锁,如果我们不关注执行过程中的状态,只关注最终结果一致性,则不需要加锁。
协程切换的问题
协程只有在等待I/O的过程中才能重复利用线程。协程本质是通过多路复用来完成的。
协程本身不是线程,只是一个特殊的函数,它不能被操作系统感知到(操作系统只能感知到进程和内核级线程),如果某个线程中的协程调用了阻塞IO,那么将会导致线程切换发生。因此只有协程是不够的,是无法解决问题的,还需要异步来配合协程。
因此,实际上我们可以把协程可以看做是一种用户级线程的实现,协程+异步才能发挥出协程的最大作用。
协程的使用
在一个线程多个协程的情况下,在内核看来只有一个线程在运行,这些协程事实上在串行执行,只能使用一个CPU核。如果想要高效利用CPU,还是得使用线程。协程最大的优势在于协程的切换比线程的切换快。
因此,计算型的操作,利用协程来回切换执行,没有任何意义,来回切换并保存状态反倒会降低性能。
协程适用于IO密集型任务。可以利用协程在IO等待时间就去切换执行其他任务,当IO操作结束后再自动回调,那么就会大大节省资源并提供性能,从而实现异步编程(不等待任务结束就可以去执行其他代码)。
协程栈
线程在切换时,它的中断状态会保存在线程栈中。而协程的特点是,对于某一个方法,可以执行到某个操作的时候 yield 出去,然后在某个时候再 resume。实现一个协程的关键点在于如何保存、恢复和切换上下文。
因此,按照是否开辟相应的调用栈来保存上下文,可以将协程分为两类:
有栈协程:每个协程都有自己的调用栈,类似与线程的调用栈
无栈协程:协程没有自己的调用栈,挂起点的状态通过状态机或闭包等语法实现。
微信的 libco、阿里的 cooobjc、Golang 中的 goroutine、Lua 中的协程都是有栈协程;类似 ES6、Dart 中的 await/async、Python 的 Generator、Kotlin 中的协程、C++20 中的 cooroutine 都是无栈协程。
有栈协程:
在内存中给每个协程开辟一个栈内存,当协程挂起时会将它的运行上下文从系统栈保存至其所分配的栈内存中,当协程恢复时会将其运行时上下文从栈内存中恢复至系统栈中。
优点:可以在任意函数调用层级的位置进行挂起。并转移调度权。
缺点:有栈协程会改变函数调用栈。由于有栈协程需要保存各个协程自己的运行时上下文,一般会通过堆来分类内存空间。如果内存分配过小,可能会产生栈溢出,如果内存分配过大,可能会产生内存浪费。当协程恢复时,需要将运行时上下文从堆中拷贝至栈中,存在一定的开销。
无栈协程:
无栈协程不会为各个协程开辟相应的调用栈。无栈协程通常是基于状态机或闭包来实现的。
基于状态机的解决方案一般是通过状态机,记录上次协程挂起时的位置,并基于此决定协程恢复时开始执行的位置。这个状态必须存储在栈以外的地方,从而避免状态与栈一同销毁。
相比于有栈协程,无栈协程不需要修改调用栈,也无需额外的内存来保存调用栈,因此它的开销会更小。但是,相比于保存运行时上下文这种实现方式,无栈协程的实现还是存在比较多的限制,最大缺点就是,它无法实现在任意函数调用层级的位置进行挂起。
协程调度器
1:1调度
1个协程绑定1个线程,这种最容易实现。协程的调度都由CPU完成了,但有一个缺点是协程的创建、删除和切换的代价都由CPU完成,上下文切换很慢,同等于线程切换。
N:1调度
N个协程绑定1个线程,优点就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。但也有很大的缺点,1个进程的所有协程都绑定在1个线程上,一是某个程序用不了硬件的多核加速能力,二是一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了。
M:N调度
M个协程绑定N个线程,是N:1和1:1类型的结合,克服了以上2种模型的缺点,但实现起来最为复杂。