关注码农话题
做一个实实在在的内行人

以生活例子说明单线程与多线程

1. 程序设计的目标

在我看来单从程序的角度来看,一个好的程序的目标应该是性能与用户体验的平衡。当然一个程序是否能够满足用户的需求暂且不谈,这是业务层面的问题,我们仅仅讨论程序本身。围绕两点来展开,性能与用户体验。

性能:高性能的程序应该可以等同于CPU的利用率,CPU的利用率越高(一直在工作,没有闲下来的时候),程序的性能越高。
体验:这里的体验不只是界面多么漂亮,功能多么顺手,这里的体验指程序的响应速度,响应速度越快,用户体验越好。

下面我们就这两点进行各种模型的讨论。

2. 单线程多任务无阻塞

以生活中食堂打饭的场景作为比喻,假设有这样的场景,小A,小B,小C 在窗口依次排队打饭。 假设窗口负责打饭的阿姨打一个菜需要耗时1秒。如果小A需要2个菜,小B需要3个菜,小C需要2个菜。如下:

阿姨(CPU):打一个菜需要1秒
小A:2个菜
小B:3个菜
小C:2个菜

那么在这种模型下将所有服务做完阿姨需要耗时 2 + 3 + 2 = 7秒
阿姨 = CPU
小A,小B,小C = 任务(这里是以任务为概念,表示需要做一些事情)
这种模型下CPU是满负荷不间断运转的,没有空闲,用户体验还不错。这种程序中每个任务的耗时都比较小,是非常理想的状态,一般情况下基本不太可能存在。

3. 单线程多任务IO阻塞

将上面的场景稍微做改动:
阿姨:打一个菜需要1秒
小A:2个菜,但是忘记带钱了,要找同学送过来,估计需要等5分钟可以送到(可以理解为磁盘IO)
小B:3个菜
小C:2个菜

这种情况下小A这里发生了阻塞,实际上小A这里耗费了5分钟也就是 300秒+ 2个菜的时间,也就是302秒,而CPU则空闲了300秒,实际上工作2秒。
所有服务做完花费 302 + 3 + 2 = 307秒  CPU实际工作7秒,等待300秒。 极大浪费了CPU的时钟周期。 用户体验很差,因为小A阻塞的时候,后面的所有人都等着,而实际上此时CPU空闲。所以单线程中不要有阻塞出现。

4. 单线程多任务异步IO

还是上面的模型,加入一个角色:值日生小哥,他负责事先询问每一个人是否带钱了,如果带钱了则允许打菜,否则把钱准备好了再说。

<1> 值日生小哥问小A准备好打菜了吗,小A说忘带钱了,值日生小哥说,你把钱准备好了再说,小A开始准备(需要300秒,从此刻开始记时)。
<2> 值日生小哥问小B准备好打菜了吗,小B说可以了,阿姨服务小B,耗时3秒
<3> 值日生小哥问小C准备好打菜了吗,小C说可以了,阿姨服务小C,耗时2秒
<4> 值日生小哥问小A准备好了没有,小A说还要等一会,阿姨由于没有人过来服务,处于空闲状态
<5> 300秒之后,小A准备好了,阿姨服务小A,耗时2秒
整个过程做完耗时 300 + 2 = 302秒  CPU工作7秒,空闲295秒

值日生小哥相当于select模型中的select功能,负责轮询任务是否可以工作,如果可以则直接工作,否则继续轮询。在小A阻塞的300秒里面,阿姨(CPU)没有傻等,而是在服务后面的人,也就是小B和小C,所以这里与模型3不同的是,这里有5秒CPU是工作的。 如果打饭的人越多,这种模型CPU的利用率越高,例如如果有小D,小E,小F…… 等需要服务,CPU可以在小A阻塞的300秒期间内继续服务其他人。实际上值日生小哥轮询也会耗时,这个耗时是很少的,几乎可以忽略不计,但是如果任务非常多,这个轮询还是会影响性能的,但是epoll模型已经不使用轮询的方式,相当于A,B,C会主动跟值日生小哥报告,说我准备好了,可以直接打菜了。

这种模式下用户体验好,CPU利用率高(任务越多利用率越高)

5. 单线程多任务,有耗时计算

回到最开始的模型,如下:
阿姨:打一个菜需要1秒
小A:200个菜
小B:3个菜
小C:2个菜

顺序做完所有任务,需要耗时 200 + 3 + 2 = 205秒, CPU无空闲,但是用户体验却不是很好,因为显然后面的 B,C 需要等待小A 200秒的时间,这种情况下是没有IO阻塞的,但是任务A本身太耗CPU了,所以说如果单线程中出现了耗时的操作,一定会影响体验(IO操作或者是耗时的计算都属于耗时的操作,都会导致阻塞,但是这两种导致阻塞的性质是不一样的)。在所有的单线程模型中都不允许出现阻塞的情况,如果出现,那么用户体验是极差的,例如在UI编程中(QT,C# Winform)是不允许在UI线程中做耗时的操作的,否则会导致UI界面无响应。 编写Nodejs程序的时候,我们所写的代码实际上是在一个线程中执行的,所以也不允许有阻塞的操作(当然整个Nodejs框架实现异步,一定不止一个线程)。

出现阻塞的情况一般有2种,一种是IO阻塞,例如典型的如磁盘操作,这种情况下的阻塞会导致CPU空闲等待(当然现代操作系统中如果IO阻塞,操作系统一定会将导致IO阻塞的线程挂起)。这种阻塞的情况,可以通过异步IO的方法避免,这样就避免程序中仅有的单线程被操作系统挂起。另一种情况下是确实有非常多的计算操作,例如一个复杂的加密算法,确实需要消耗非常多的CPU时间,这种情况下CPU并不是空闲的,反而是全负荷工作的。这种CPU密集的工作不适合放在单线程中,虽然CPU的利用率很高,但是用户体验并不是很好。这种情况下使用多线程反而会更好,例如如果3个任务,每个任务都在一个线程中,也就是有3个线程,A任务在ThreadA中,B任务在ThreadB中,C任务在ThreadC中,那么即使A任务的计算量比较大,B,C两个任务所在的线程也不必等待A任务完成之后再工作,他们也有机会得到调度,这是由操作系统来完成的。这样就不会因为某一个任务计算量大,而导致阻塞其他任务而影响体验了。

6. 多线程程序

我们将上面的模型改造成多线程的模型是怎样的呢,我们在模型5的基础上添加一个角色,管理员大叔(操作系统的角色):
阿姨:打一个菜需要1秒
小A:200个菜
小B:3个菜
小C:2个菜

加入管理员大叔之后变成这样的了,小A打两个菜之后,大叔说,你打的菜太多了,不能因为你要打200个菜,让后面的同学都没有机会打菜,你打两个菜之后等一会,让后面的同学也有机会。
大叔让小B打两个菜,然后让小C打两个菜(小C完成),然后再让小A打两个菜(完成之后小A总共就有4个菜了),再让小B打1个菜(此时小B总共打3个菜,完成),然后小A打剩下的196个菜。

CPU的利用率:很高,阿姨在不断的工作
用户体验:不错,即使小A要打200个菜,小B,小C也有机会。 当然如果小A说我是帮校长打菜,要快一点(线程优先级高),那也只能先把小A服务完
总耗时:   200 + 3 + 2 + (大叔指挥安排所消耗的时间,包括从小C切换回小A的时候,大叔要知道小A上次打的菜是哪两个,这次应该接着打什么菜,这相当于线程上下文切换的开销以及线程环境的保存与恢复),所以并不是线程越多越好,线程非常多的时候大叔估计会焦头烂额吧,要记住这么状态,切换来切换去也耗时间。

这种模型下实际上是将小A的耗时任务,分成多份去执行而不是集中执行,所以小A要完成他的任务,可能需要更多的时间(期间他也需要等别人,阿姨不会一直为他一个人服务,但是阿姨为他服务的时间是没有变化的),这种其实有点以时间换取用户体验(小B和小C的体验,小A的体验可能就不会那么好了,但是小A本来也非常耗时,所以多等一会是不是也没关系)

那么IO阻塞和CPU计算耗时阻塞这两者有什么区别呢? 区别在于IO阻塞是不使用CPU的,而CPU计算耗时导致的阻塞是会使用CPU的。 例如上面的例子中,小A说忘记带钱了需要同学送钱,于是小A等着同学送钱过来,这个过程中阿姨并没有为小A提供服务,这个过程中为小A提供服务的是他的同学(送钱过来),实际上小A的同学相当于现代计算机系统中的DMA(直接内存操作),小A同学送钱的过程相当于DMA从磁盘读取数据到内存的过程,这个过程基本不需要CPU干预。

当然在DMA技术还没有出现的年代,从磁盘读取文件也是需要CPU发送指令去读取的,也就是说需要CPU的计算,应用到这里的场景中,就是阿姨亲自跑一趟帮小A把钱拿过来。

7. 多CPU

多CPU是一个更加复杂的问题,多CPU如何调度? 小A在第一个窗口打两个菜,又跑到第二个窗口打两个菜这种情况如何处理。小A在第一个窗口,小B在第二个窗口他们要同一个菜,但是这个菜只够一个人,那么两个窗口阿姨如何分配这种需求(实际上应该是由操作系统也就是管理员大叔来决定如何分配,也就是多核下的线程同步与互斥)?

多核CPU情况下,多线程的调度,互斥,锁与同步相对来讲更加复杂,多核情况下是真正的并行,同一时刻有多个线程在同时运行,他们的竞争怎么处理,多个CPU之间如何同步(多CPU之间的缓存状态一致性)等等一系列的问题。

8. 多线程与多进程

上面描述的多线程实际上是讨论的是多线程的调度问题,这里我们说一说多线程与多进程与资源的分配问题。什么意思呢,一群人(多个线程)在一个桌子(进程)上吃饭,他们会涉及到一些问题,比如多个人可能会夹一个菜(竞争),A和B同时看到盘子里面有一块肉,同时伸出筷子去夹,A先夹走,B迟了一点伸到盘子的时候已经没了,只能缩回来(临界资源,互斥),有一个点心需要用馍夹肉一起吃。A夹了肉,B夹了馍,A需要B的馍,B需要A的肉,他们僵持不下谁都不让步(死锁)。

多线程之间的资源共享是非常方便的,因为他们共用进程的资源空间(在一个桌子上),但是需要注意一系列的问题,竞争,死锁,同步等。如果在旁边再开一个桌子(进程)。 那么桌子之间讲话,递东西又不方便(进程间通信),而开一个桌子的开销比在一个桌子上多加一个人的开销要大。另外一个桌子上的人数不可能无限制增加,桌子的容量有限也坐不下这么多人(进程的线程句柄是有限制的)。一个桌子坏了不会影响到另一个桌子上面人的就餐情况(进程间相互独立,一个进程崩溃不会影响另一个),而一个桌子上的某人喝挂了需要送医院,估计这一桌人都要散了(线程挂掉会导致整个进程也挂掉)。所以多线程与多进程是各有优缺点,不能一概而论。

说明:多线程桌子的比喻受到知乎用户[pansz]的启发,但是该比喻似乎说明不了线程同步的情况。

9. 总结

单线程程序:适合IO异步,不能阻塞,不能有大量耗CPU的计算。典型如Nodejs,还有一些网络程序
多线程程序:适合CPU密集型程序


入职你的梦想 VS 变现你的技术

IT面试宝典码农市场