历史背景
远古时代
ENIAC,它上面没有操作系统,更别提进程、线程和协程了
进程时代
- 单进程—串行
计算机有了操作系统,每个程序都是一个进程,但是操作系统在一段时间只能运行一个进程,直到这个进程运行完,才能运行下一个进程
单进程依然是太慢了,比如进程读数据阻塞了,CPU就在哪浪费着
- 多进程并发
操作系统最早的并发能力:当一个进程阻塞的时候,切换到另外等待执行的进程,尽量把CPU利用起来
线程时代
进程拥有太多资源,在创建、切换和销毁的时候,都会占用很长的时间,CPU虽然利用起来了,但CPU有很大的一部分都被用来进行进程调度了,怎么才能提高CPU的利用率?
大家希望能有一种轻量级的进程,调度不怎么花时间,这样CPU就有更多的时间用在执行任务上
操作系统支持了线程,线程在进程里面,线程运行所需要资源比进程少多了,跟进程比起来,切换简直是“不算事”
一个进程可以有多个线程,CPU在执行调度的时候切换的是线程
- 如果下一个线程也是当前进程的,就只有线程切换,“很快”就能完成
- 如果下一个线程不是当前的进程,就需要切换进程,这就得费点时间了
CPU调度切换的是进程和线程
多线程看起来很美好,但实际多线程编程却像一坨屎
一是由于线程的设计本身有点复杂,二是由于需要考虑很多底层细节,比如锁和冲突检测
协程
多进程、多线程已经提高了系统的并发能力,但高并发场景下,为每个任务都创建一个线程是不现实的
- 消耗大量的内存(每个线程的内存占用级别为MB)
- 线程多了之后调度也会消耗大量的CPU
如何充分利用CPU、内存等资源的情况下,实现更高的并发?
既然线程的资源占用、调度在高并发的情况下,依然是比较大的,是否有一种东西,更加轻量?
线程分为内核态线程和用户态线程,用户态线程需要绑定内核态线程,CPU并不能感知用户态线程的存在,它只知道它在运行1个线程,这个线程实际是内核态线程
用户态线程实际有个名字叫协程(co-routine),为了容易区分,我们使用协程指用户态线程,使用线程指内核态线程
coroutine,User-level threads, Application-level threads, Green threads都指一样的东西,就是不受OS感知的线程
Green threads的实现列表,比如Lua、Go、Erlang、Common Lisp、Haskell、Rust、PHP、Stackless Python,所以,认为用户态线程就是协程
协程跟线程是有区别的
- 线程由CPU调度是抢占式的
- 协程由用户态调度是协作式的,一个协程让出CPU后,才执行下一个协程
协程和线程有3种映射关系:N:1、1:1、M:N
不少语言支持了协程,比如:Lua、Erlang、就算语言不支持,也有库支持协程,比如C语言的coroutine(风云)、Kotlin的kotlinx.coroutines、Python的gevent
goroutine
就像前面说的多线程编程太不友好了,Go为了提供更容易使用的并发方法,使用了goroutine和channel
goroutine来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被runtime调度,转移到其他可运行的线程上
最关键的是,程序员看不到这些底层的细节,降低了编程的难度,提供了更容易的并发
Go语言的老调度器
M:N类型,用了4年左右就被替换掉了
M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局G队列是有互斥锁进行保护的。
老调度器有4个缺点:
- 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争
- M转移G会造成延迟和额外的系统负载。比如当G中包含创建新协程的时候,M创建了G’,为了继续执行G,需要把G’交给M’执行,也造成了很差的局部性,因为G’和G是相关的,最好放在M上执行,而不是其他M'
- M中的mcache是用来存放小对象的,mcache和栈都和M关联造成了大量的内存开销和差的局部性
- 系统调用导致频繁的线程阻塞和取消阻塞操作增加了系统开销
新调度器
两大思想
- 复用线程
协程本身就是运行在一组线程之上,不需要频繁的创建、销毁线程,而是对线程的复用
在调度器中复用线程还有2个体现:
- work stealing,当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程
- hand off,当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行
- 利用并行
GOMAXPROCS设置P的数量,当GOMAXPROCS大于1时,就最多有GOMAXPROCS个线程处于运行状态,这些线程可能分布在多个CPU核上同时运行,使得并发利用并行。另外,GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行
两小策略
- 抢占
在coroutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死,这就是goroutine不同于coroutine的一个地方
- 全局G队列
在新的调度器中依然有全局G队列,但功能已经被弱化了,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G
上面提到并行了,关于并发和并行再说一下:Go创始人Rob Pike一直在强调go是并发,不是并行,因为Go做的是在一段时间内完成几十万、甚至几百万的工作,而不是同一时间同时在做大量的工作。并发可以利用并行提高效率,调度器是有并行设计的。
并行依赖多核技术,每个核上在某个时间只能执行一个线程,当我们的CPU有8个核时,我们能同时执行8个线程,这就是并行。