正文开始之前先抛出一个思考:让一个静态网站满足海量用户访问本质上是一个并行问题还是并发问题?
并发的世界
并发这个概念在真实的世界比在程序的世界更加普遍更加自然,程序员们只是将其抽象出来,以在代码中更真实的还原现实世界。那,什么是并发呢?饭桌上,你喝一口汤,扒几下饭,啃个大鸡腿,抬头看了眼电视再回头和家人闲聊几句,这就是并发。时间永远是往前走,但事情不总是先后来,而是会同时来到你面前,你需要同时去处理它们。随着软件越来越深的渗透到现实世界,越来越复杂的需求场景反映到程序世界,就是日益复杂的软件系统,那,简化系统设计的路子是什么呢?答案是模仿现实世界的行为,也就是将程序并发化。
一见到并发二字,立刻想到网站,立刻想到高访问量,立刻想到性能优化,立刻想到服务器不要挂。鲁迅说这是不对滴。
回到吃饭的问题,假设作为程序员的你需要为一个机器人写一个模仿人吃饭的程序,可能会这样写:
喝一口汤 —> 扒一下饭 —> 再扒一下饭 —> 啃一口鸡腿 —> 如果有人和你说话,就和对方说话 —> 如果没有,这次先扒下饭 —> 嗯再喝一口汤..........这是个不支持并发的程序,硬生生将并发的真实世界场景写成单逻辑流程的,程序的复杂性由此而来。
这次你打算重构一下,加上并发设计:
1,吃米饭,吃一会停一会
2,喝汤,喝一会停一会
3,啃鸡腿,啃一会停一会
4,和人说话,说一会停一会
5,判断,如果嘴闲着,就随机调1~4干。现在开始。
是不是看上去感觉简洁了很多呢?显然,这样的并发设计和性能优化(吃快点?)一点关系都没有,主要是为了将程序拆分成各个独立的执行单元从而简化程序设计。而要支持这种设计,需要一个调度系统(5)的实现,这个实现可以是在硬件层,或操作系统层,或语言层中进行,甚至自己实现一个出来也未尝不可。
goroutines
Golang对并发的支持,是我见过最简单但不简陋的。
do some things...
go someFunc()
do some things...
只要在一个可执行函数前面加一个关键词go,就能开启一个独立的goroutine从而并发的去执行这个函数,是不是超级简单呢。goroutine实际上与协程的概念和作用是一样的,都可以理解为轻量级的线程。然而goroutine的开销非常非常小,一个程序可以开到的goroutine需以百万计。除却程序设计上的考虑,这种轻量级的线程还可以让我们任性且轻松的处理密集IO场景的问题,而不用担心带来代码量的膨胀。(golang对并发支持的具体语法细节将在下一篇讨论)
举个栗子,一个日志收集中转处理程序,需要将聚合的一条条记录发送到远程后台,如果是这样写:
for i in list
http.send(i)
那大量的时间将浪费在等待网络连接和数据传输上,为了提高性能,可以这样写:
for i in list
go http.send(i)
所有的连接请求都会先后发出而不等待函数返回,每个请求等待网络连接和数据传输的时间很大部分将会出现重合,这个重合的时间,就是我们节省的时间,从而整个的执行时间将会大大缩短。这是不是就是异步呢?(对js了解的同学可以发现这里和异步回调是有着异曲同工之妙的)。是的,并发使异步成为了可能,后面将会更仔细的讨论并发和异步的关系。现在先来简单了解下goroutines的底层实现设计。
goroutines调度器的实现
总所周知,进程是靠着自己独立的堆栈来维护运行状态,线程则是进程的进一步扩展。多任务运行时,内核负责对它们进行轮换,由于进程和线程都带着很多信息,所以切换操作非常昂贵。对于小而多的并发操作来说,每次开一个线程去执行可能会得不偿失,过多的CPU时间浪费在了上下文切换上。我们需要的,是这样一种“线程” : 极小的堆栈 + 无身份信息。这样就会换来极低的切换成本。
自然引出的问题,就是这样一种低级别的线程和OS线程如何对应?
1)1 :1 对应,这样就goroutines == Thread了,要来何用= =
2)N :1,多个goroutines在一个Thread上面跑
这种上下文切换就会很快,OS对进程的切换我们管不着,而Goroutine的堆栈和任务切换是我们自己打理的,只要保证goroutine足够轻量,切换成本就会很少。但N:1有个缺点,就是利用不了多核,在多核机器上,同一个进程下的多个线程实际是可以并行处理的。
3)N :M,多对多的关系,Golang所采用的方式,在多核机器上,能充分利用CPU的资源。
写一句go func(),还要指定它是在哪一个Thread上运行,这就很累了,所以在代码逻辑上,最好的做法是能够隐藏掉Thread层,只管goroutine,这就给调度器的实现带来了挑战。
golang的处理是,在一个全局的调度器下面,为每一个Thread再创建一个局部的调度器,维护着调度的上下文,看起来是这样子的:
G代表全局调度器,维护着Context和Thread的1:1对应关系,Context代表局部调度器,维护着Thread和goroutine的N : 1对应关系,每新添一个goroutine,G负责将其加入一个Context中,Context负责对由它管理的goroutine进行轮换执行。这样在整体上,就产生了N : M的对应关系,我们只需打理goroutine,剩下的交给G和Context去打理。
OS级别上,内核有权暂停一个线程转而去执行另一个线程,但是在线程级别,就没有这种能力了,因为从线程的角度看下去,每一个goroutine都是一段普通的代码而已,并做不到主动去暂停一个goroutine的运行从而调度其他的goroutine,暂停一个goroutine需要它自己主动去申请暂停,Context和Thread这一层才能感知到该进行下一次调度了。所以,该怎么做呢?在早期的实现中,借用了很多system call的异步操作,在看似完全是同步操作的goroutine中,你执行了一个需要调用到system call的地方,都会被尽可能地转化成异步操作,在这个封装的异步操作里面,会主动发出一次暂停申请,告诉Context,我该歇歇了,Context就进行一次调度,去执行其他goroutine了,等到下次回来的时候,这个异步操作差不多已经返回了。这样就实现了goroutine层面的调度。这里有一个比较坑的地方,也是使用这种方法的代价,想一下,如果在这个goroutine里面,没有一条语句用到system call呢?
如果没有一条语句会用到异步操作,那么这种goroutine将会一直运行不会停下,,,
所以一个完全CPU密集运算的goroutine是非常自私的,不会去主动让出这个线程。
go PrintOneToTen()
go PrintOneToTen()
像这样的代码,你以为可以打印出两串1到10的数字互相穿插的结果,实际很可能是这样的
1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10
我在一台虚拟机上面测试(单核),就是上面的这种结果,多核的就不会,因为两个goroutine被挂到不同的Context上面了,独占线程也没事。然而问题在多核机器上也并没有解决,因为你还是防止不了本该并发执行的两个goroutine被挂到同一个Context上面的悲剧。所以在后来的实现中,加入了调用普通函数时的随机踢掉换人,如果goroutine中连一个调用函数操作都没有,你还可以显式的去调用runtime.GoSched( ),主动让出资源通知Context该进行调度了。
并发和异步的关系
这里的异步是指宏观上的异步,一个系统的各个组成部分之间,如果不需要互相之间信息流完全的同步,就可以说这个系统组件之间是异步的。
以一个游戏广告为例(工作碰到的一个系统的简化版),假设有一个展示的图片A,链接是a,点击后会跳到某APP商店此游戏的下载链接b,用户可以选择下载还是不下载,我们这个系统需要记录这个过程以便结算广告费。
过程是这样的:
最简单的程序莫过于单逻辑流程的结构:用户点击 —> 记录点击 —> 返回下载链接b —> 等待用户决定是否下载 —> 若用户下载,记录下载 —> 进行费用结算。这样的设计可以说毫无并发性可言,虽然你可以每收到一次点击就开一个线程去处理,但是这样的逻辑还是一杠子捅到底的,资源耗费非常大,承载不了太多用户的同时访问。
就像文章开头说的,应该模仿现实世界的行为来设计程序,在简化程序设计的同时,提高并发性。
这样的系统其实很像街边卖水果的是不是,一边吆喝(展示A),一边跟客人说价格(接受点击a),一边和已经买的客人结账(下载记录)。卖水果看起来是一件事情,其实可以是很多件小事情的组合。像极了大商场里,吆喝的是一群工作人员,讨价还价又是另外一群人,门口还有个负责结账的。处理上述游戏广告系统问题就是要学会怎么去拆分,拆分成有各自独立逻辑的组件:各自独立的逻辑流程,时间线上可以交叉重叠。
我们的系统被拆分了主要的三部分,一部分专门处理点击,一部分专门处理下载,剩下的处理费用结算,每次用户行为被id标记以便区分。注意,这里三部分依然还是在同一份代码同一个程序里面,但是现在已经是并发化了。如果是写成三个函数,那么go func1();go func2();go func3();就已经足以表示这个系统的逻辑了,简单吧,这就是并发设计带来的简洁性。
那性能呢?在单机单核里面,这样的并发设计并不会显著的提高性能,但是三部分独立的逻辑流程,为性能提高创造了条件。如果单机不再单核,而是有三个CPU了,三个goroutine分别挂到三个Context上,三个goroutine得以并行处理,性能就得以提高,更有显著益处的是,当访问峰值来临时,可以创建大量的处理点击的goroutine,而不必同时提高另外两者,尤其结算组件甚至可以暂停挪出资源给另外两个用,等到峰值过去后,再调度结算组件进行剩下的工作。
单机撑不住了,加机器就是了,本来逻辑独立的三部分就可以拆分成三个程序在不同的机器上跑。所以并发设计一旦完成,就已经可以将程序拆分了,只是有没必要而已。学会将一个问题设计成可以并发处理的多个子问题,就是本文的目的。
限制单逻辑流程程序的,是数据的同步问题,数据从产生到处理完毕,一杠子必需捅到底,这一条处理完了才能处理下一条,从而限制了处理效率。并发设计其实就是将逻辑环节之间的数据传递异步化,每一个环节只处理一部分问题,然后就传递给下游而不管下游怎么去处理,上面拆分的三部分,没拆分之前数据处理是一环扣一环的,必须等到每一环处理完成后才能重新来过,而拆分之后,每部分只需要和数据库打交道就可以了,这个数据库其实就充当了异步读写的硬件锁存器。所以并发设计是将一个系统的数据流进行拆分,在各个环节之间变同步为异步,从而将每个环节的逻辑过程独立出来。
并发不是并行
并发和并行是两个非常容易搞混的概念,看完上面的讨论后,相信你可以得出结论:并发是在讲程序/系统的结构。
并行则不然,并行讲的是程序的执行,更多的是物理相关的,两段程序同时在两个CPU被处理,才说它们是并行的。但是,如果只从执行效果上面看,并行可以说是并发的一个子集,毕竟可以并发处理的程序,就可以并行处理。有一个著名的视频和幻灯片,讲的就是并发不是并行,如果你能坚持看到这里,建议你去看下这个30分钟左右的视频,非常有意思 : )
文章开头的思考,有结论了吗,是并行问题还是并发问题呢???
原文转自谢培阳的博客