1 开篇
Hello Gopher!
想必大家也知道,得益于Go语言天生的并发特性,很多工程师一脚跨入了并发编程的大门。
Go底层的协程机制 ,其效率之高,使得很多Web应用仿佛不费吹灰之力,并发能力就翻了一番,表现喜人。
但,Gopher们虽然迈进了并发编程的大门,却发现自己还是偶尔会被并发的门槛绊倒。
有时候线上内存泄露导致机器宕机,有时候共享内存导致数据不一致,有时候死锁出现得百思不得其解…
做技术活需要有匠心,知其然知其所以然,Gopher要问问自己三个问题:
什么是并发?
Go语言为何要实现goroutine?
Go语言如何进行并发?
这系列的文章共二篇,
(一)从操作系统OS Scheduler的角度,理解前两个问题
(二)深入探究Go Scheduler的机制,理解GO如何实现并发
本篇为第一篇,结构如下:
进程与线程;
线程的并发和调度;
协程和Goroutine;
结语
2 进程与线程
操作系统是一个精密,复杂又庞大的软件,管理着软件,也管理着硬件。
理解操作系统,永远都绕不过两个概念:进程,线程。
进程:进程是程序运行的过程,拥有独立的空间,持有系统分配的资源。
线程:线程是比进程更轻量的调度单位,进程持有线程,操作系统调度着线程的运行。
光看概念比较晦涩,下面提供一张假想图:
当手机上的微信程序处于运行中,主进程持有了若干的线程,每个线程有专门作业的处理逻辑。
那么,计算机(手机也是计算机)是如何同时做多件事呢?
为了明白这个问题,得先了解计算机如何做一件事。
每个程序最终都是一系列需要按顺序执行的机器指令集合。
你写下的一段代码,最终会被翻译成如下的样子:
操作系统通过线程,按序执行这些指令。
每个程序在开始运行时,操作系统会为其创建一个进程,这个被创建的进程,会持有一个主线程,用来运行程序。一个进程可以持有多个线程。
进程更多是持有计算机的内存资源,线程则持有计算资源,也就是CPU的计算能力。
线程的运行是彼此独立的,轮流在CPU内核上运行。
上图的三个线程ABC,都是独立运行,轮流在CPU上跑,当线程A在CPU上执行的时候,用户的消息才可以被发送出去。
3 线程的并发执行
理解了计算机怎么做一件事,那么计算机是如何同时做多件事的?
接下来讨论的内容全部基于单核的计算器,现在的机器大都是多核,确实会同时执行不同的程序,但为了简化问题,我们就限定在单核。
其实,计算机“同时做多件事“,是个假象。
上边提到,线程ABC在CPU上轮流执行。
不仅如此,任一时刻,一个CPU上只有一个线程在运行。
我们都知道电影之所以是“动态的“,是因为它的帧率高于人眼能感知的范围。
类似的, 计算机切换在CPU上执行线程的频率,远远高于人的感知范围,所以用户感觉计算机在“同时做多件事“,看起来就像下图:
那么,计算机是按什么规则去切换线程呢?怎么切呢?
这就涉及到线程的状态,和操作系统对线程的调度 OS Scheduler的工作。
线程有三个基本的状态:
Waiting(阻塞):线程被暂停运行,在等待某个资源准备完毕。
举个例子:线程在等待网络请求的响应,会进入阻塞状态;
Runnable(就绪):线程准备就绪,需要OS Scheduler分配时间片,好让自己能在Core上运行一会。
举个例子:网络响应的事件到了,等待这个事件的线程可以进入就绪状态;
Executing(执行):线程正在CPU上爽快执行。
线程简化的状态机:
4 OS Scheduler
OS Scheduler的工作:
当一个线程要在CPU上执行时,OS Scheduler 会给它分配限额的时间片。
时间到了,线程就会被OS Scheduler让出资源,没得商量。
OS Scheduler基于上面介绍的几个状态,决策是否需要把某个线程的计算资源让出来,切换给另外一个线程执行。比方说,线程阻塞时,需要让出CPU资源,替换别的线程
所以线程,OS Scheduler和CPU的关系模型如下:
线程的调度决策,涉及大量硬核算法,无法一文介绍。
计算机领域的先驱用了几十年的时间,给这个问题找到了成熟的解法。
在高级应用的角度,开发者只需要知道,只要CPU上的线程阻塞了,或者时间用完了,OS Scheduler就会从CPU上让出当前线程(还有其他让出条件,如中断等,此处省略)。
OS Scheduler解决了程序的并发问题,也提高了CPU的利用率:
系统可以通过切换线程,并发执行多个程序;
CPU的计算资源的利用率提升了;
5 上下文切换
OS Scheduler对线程进行的切换,被称为“上下文切换”。
上面的线程轮流运行图,在切换的间隙我留了一些黄色空间,其实是上下文切换所需的时间成本。
上下文切换会做什么呢?
简单来说包括:
保存当前线程A的堆栈数据
保存线程A下一个要执行指令的地址
将线程A的资源让出
恢复要上位的线程B之前的运行现场
给线程B分配计算资源
…
这系列的操作,称为上下文切换(Context Switch)。
上下文切换需要执行这么多的工作,可见其有时间成本。
时间对于CPU来说,就是资源。
机器存在差异,一般而言,上下文切换需要1000+纳秒,这个时间大概可以执行10K+条指令。
可见每一次切换都意味着计算资源的浪费。
6 协程
到此简要总结一下上文:
线程是操作系统进行调度的基本单位
操作系统通过上下文切换实现并发
上下文切换有时间成本
上文提到,当线程发生阻塞时,OS Scheduler会让出线程的资源。
举个例子:假如线程A是一个微信发消息的线程,它的主要工作内容就是不断发送网络请求,也就是要等待网络请求的响应,会进入阻塞,这时候就算分配的时间没用完,也会被让出资源。
操作系统让出线程的时候可不管线程是哪个进程的,有可能它就按优先级,把微信发消息的线程让出,切换拼多多拉取商品列表的线程。
好嘛,从微信进程(进程)角度来说:我的子任务“发消息“要等待,那操作系统好歹让我干点别的,拉取一下朋友圈也行呀,怎么直接就把我切下来了呢?我时间还没用完呢…
为了优化上面这两个问题:
从程序角度,线程能尽量用满时间片;
从CPU角度,减少上下文切换;
出现了用户态线程。
操作系统的空间分用户态和内核态,因此,线程也分两大类:
内核态线程:运行在内核态空间,具有计算资源,操作硬件等高权限;
用户态线程:运行在用户态空间,其执行需要依赖内核态线程,占用资源比内核态线程少很多;
用大白话说:
能在CPU上跑的都是内核态线程,用户态线程就是应用程序自己内部搞的鬼,CPU根本不知道也不关心。
用专业的术语描述:
OS Scheduler调度的,是内核态线程;
用户态线程对操作系统来说,只是一些数据,并非可调度的对象;
用户态线程需要交由内核线程执行;
由于不持有真正执行所需的资源,用户态线程本身的上下文切换成本降低很多;
用户态线程的调度,由用户空间的应用程序去实现;
协程(Coroutine),就是用户态线程的一种实现。
协程在Wikipedia的部分解释:
Coroutines are very similar to threads. However, coroutines are cooperatively multitasked, whereas threads are typically preemptively multitasked.
协程非常类似于线程。然而,协程是协同多任务的,而线程通常是抢占多任务的。(机翻质量甚好。)
协程起到什么作用呢?
其实,解释中的“协同”说明了设计的初衷。
还是举微信的例子:
假如用协程来运行发消息,朋友圈和支付,并将它们都绑定在一个内核线程上,并且在用户程序做了协程的协同切换,则:
CPU认为该线程一直在执行,允许其跑满分配的时间片;
用协程来运行的好处不言而喻:减少了CPU进行上下文切换的频率。
其实,实现了协程的编程语言很多,下面是Wikipedia的介绍,有的是内部实现,有的是给使用者提供了API:
不同语言实现的协程和内核线程的关系,有如下几种:
N对1
优点:协程的上下文切换快
缺点:无法利用CPU多核的计算能力
N对N
优点:专属线程,充分利用CPU的计算能力
缺点:线程数量增多之后,线程上下文切换频繁
N对M(N可以大于M) (也是GO语言选择的方案)
优点:权衡了CPU的多核计算能力和协程的上下文切换
缺点:调度策略复杂 ,需要解决饥饿,优先级权重等问题
7 Goroutine
Go语言实现的Goroutine,是用户态线程,也是协程,它的取名也来自Coroutine (协程)。
Go设计者开发了Goroutine的调度器——Go Scheduler。
包含了三个主要的概念:
G:Goroutine,用户态线程(协程)
M:Machine,内核线程
P:Processor,Go定义的处理器,能调度Goroutine
GMP的关系类似内核线程,CPU和OS Scheduler的关系:
G需要在M上运行,且被P管理调度。
如果从全局的角度再看一次,则如图:
8 结语和下一篇的内容
相信经过这篇文章的介绍类比,Gopher多多少少加深了对Goroutine,乃至线程的一些理解。
这时候可能会存在一些疑问:
Go Scheduler的调度原理和OS Scheduler类似吗?
Goroutine也有类似线程的若干个状态吗?
Goroutine是如何排队等待调度的?
Gorouinte的生命周期又是如何?
这些疑问,将在下一篇文章讲述,也是介绍GO协程设计的第二篇文章,结构如下:
Goroutine调度机制;
Go Gc和Goroutine调度之间的关系;
常见的并发风险和解决方案;
希望这篇文章有所帮助,不对的地方请指出。