关于进程、线程、多线程模型、时间片以及调度等概念
写在前面
元旦假期已经开始了,意味着 2018 年终是要溜走了。假期第一天,气温突然降下来,深圳的冬天终究证明了自己的尊严,而我则瑟瑟地躲在熟悉的角落里继续码字。
本想简单地写一写 Golang 的调度原理,不过看了几篇文章后发现,有必要在写 Go 调度器前先整理一下 Linux 的调度原理,复习一下进程、线程、时间片、内核调度这些概念,然后再去探究 Go 调度器就不会那么难了。
现在就让我们把大学课堂上学习的知识点捡回来,复习一下进程、线程、多线程模型、时间片以及调度等概念。
适用人群
入门——初级√——中级——高级;本文适应初级及以上。
Linux系统调度原理
单核CPU+内存 vs 四方小木桌
记得在我很小的时候,家里买了一个四方小木桌,一家人吃饭的时候会把它用来做餐桌,我和老爸下象棋的时候会把它用来做象棋桌,除夕夜一家人打牌赌压岁钱的时候则会把它用来做牌桌。四方小木桌是一种通用的工具,我们可以在上面完成许许多多的事情,就像服务器上的 CPU 与内存,我们可以在上面运行许多进程。
(在 CPU+内存 上可以运行很多进程,就像在小木桌上可以做很多事情)
进程 vs 吃饭下棋
教科书里对进程的描述比较学院风,很多网络上的文章也把进程描述得很抽象,这导致让一些非计算机专业的同学理解起来并不是那么容易。如果接上一小节的比喻,把服务器的 CPU(假设只有一个核)和内存等资源比喻为四方小木桌,其实我们完全可以把进程理解为在这个小木桌上完成的事项——比如吃饭,下象棋,打牌,等等。
能够在小木桌上做的这些事情,无论是吃饭、下象棋或者打牌,都需要花费一定的时间做准备。比如,为了在小木桌上吃饭我们需要先把碗、筷、盘子及饭菜等放到桌面上,为了在小木桌上下象棋我们需要先把棋盘、棋子摆放在桌面上,为了在小木桌上打牌我们需要先把扑克放在桌面上。当然,所有事情结束后还需要清理一下桌面,把各类东西归位(比如饭后把碗筷和盘子收拾掉,下完象棋把象棋收好,打完牌把扑克收好)。
类似的,当跑一个进程的时候也有类似的步骤:需要1)首先把进程需要的相关资源加载到内存里,2)然后才能让进程真正地运行起来。在小木桌上吃完饭再准备下象棋,需要先收拾碗筷再摆放棋盘棋子,这个过程比较繁琐;进程之间的切换类似的道理,相对来说代价也比较大。
线程 vs 和家人谈谈心
如果到了饭点的时候,大家一声不吭地围着小木桌吃饭,会显得气氛非常尴尬;如果下象棋的时候双方一声不吭地落棋,也会显得气氛局促;同时作为一家人,有必要坐在一起谈谈心沟通一下感情。于是,我们可以借吃饭或者下象棋的时机和家人谈谈心,聊一聊妈妈关心的柴米油盐(比如番茄炒蛋这道菜的味道怎么样),聊一聊爸爸关心的工作事业,聊一聊小朋友的烦恼疑惑,等等。
如果把吃饭这个进程里的 “吃” 这个动作看成一个线程,那么饭桌上 “谈心” 这个动作就可以看成是另一个线程。同样的道理,如果把下象棋这个进程里的 “落棋” 这个动作看成一个线程,那么棋桌上 “谈心” 这个动作也可以看成是另一个线程。我们当然可以在吃完饭或者下完棋并把小木桌收拾干净以后,然后大家再坐到一起谈心,不过那样的话切换时间成本太高了。
线程是进程里的一个具体的动作,一个进程里可以只包含一个线程,也可以包含很多的线程,且属于同一个进程的所有线程共享当前进程的所有资源。当操作系统的内核只有进程的抽象概念时,完成一个过程需要两步,1)把过程需要的相关资源加载到内存,2)然后执行过程。后来有了线程的抽象概念,完成一个过程就有机会利用已经加载好的资源,直接完成某个过程从而节省了资源加载到内存的时间(比如饭菜准备好已经开始吃饭了,再顺便谈谈心,不需要准备两次饭菜,也不需要收拾小木桌后再专门坐到一起聊天)。
时间片
如果把时间线拉的足够久,比如以年为单位来看四方小木桌,我们可以认为小木桌既能用来吃饭,也能用来下象棋。现在假设一种极端情况,比如家里有一个嗜好下象棋的爷爷,一年四季都把棋盘摆放在小木桌上面,小木桌成为下象棋专用,导致家里人没有吃饭和打牌的地方了,这肯定不妥。于是爸爸不得不和爷爷商量,以 6 个小时为时间段交替使用小木桌,比如某 6 个小时里连续使用小木桌来专门下棋,接下来的 6 个小时里就要用小木桌来专门吃饭或者打牌了,如此轮流进行。爷爷比较通情达理,觉得公平合理,于是答应了。
上面的 6 个小时期限就是一种时间片的划分。对于 CPU 来说,可以把 50ms(毫秒)作为一个时间片划分使用权,在每个时间片里运行不同的进程。因为 50ms 是一个相对人类生理时间来说非常短的值,所以让我们误觉得一颗 CPU 核可以同时做很多事情。
更合理的时间片划分
把吃饭、下象棋、打牌都分配成 6 个小时的时间片,而且每件事交替轮流进行,其实是一种非常粗暴的做法。比如吃饭这件事重要非常,如果因为爸爸上班赶时间应该允许吃饭这件事提前打断下象棋这件事(让爷爷先把棋谱记下来,等吃完饭再接着玩);而一家人打牌的机会比较少,没有必要一直分配那么多时间,造成了小木桌资源的极大浪费。那么应该怎么分配时间才更合理呢——这就需要认真规划一下了。
相同的道理,对于内核而言,也不能一刀切地把时间片统一切分成为 50ms,为了保证 CPU 的使用效率最大化,同时也能保证让使用电脑的人满意,完全可以给不同的线程分配不同的时间片时长。之所以这么讲,可以考虑这两种极端情况:内核把时间片全都给了计算密集型的进程(比如全都给了视频转码相关的线程),这种情况下 CPU 的使用效率是最大的;但是这个时候如果我正在写博客,我会发现敲键盘的响应速度异常慢,就没有办法继续码字了。为了照顾键盘和鼠标的响应速度而把计算密集型进程的时间片定义的非常小(比如 5ms),这个时候进程切换(上下文切换)的边界成本就变得非常高,假设上下文切换需要 5ms,时间片也为 5ms,CPU 的有效运行效率就只剩 50% = 5/(5+5) 了。
因此时间片的划分是一个很讲究的事情,具体的可以参考 这篇博客 的内容。
内核调度是一个非常复杂的过程
我花了一些时间尝试理清楚内核调度的细节,不过这个过程非常的复杂,不是一时半会儿就能理清楚的;但是我们可以先意识到这个问题,然后慢慢琢磨里面的原理。
①上一小节提到,为了提升 CPU 的运行效率,同时增加对用户的响应速度,时间片的划分要非常考究。②当给所有的进程分配了时间片以后,CPU 依然需要到某个地方把进程取出来依次执行,那么取出的顺序应该怎么定义才能保证 CPU 利用效率的同时保证响应速度呢?③或许我们觉得可以通过中断抢占的方式来保证响应速度,以按下键盘的某个键为例,触发相应的中断后让 CPU 优先处理监听此中断的进程, 处理完相应的中断后再返回之前被中断的进程继续执行;不过这里面需要注意中断的时机,是不是要等到计算密集型的进程把 50ms 时间片用完以后再触发中断,否则(把 50ms 时间片进行切分)无形中又增加了上下文切换的成本。④虽然我敲击键盘的速度已经够快了,不过对 CPU 而言,敲击键盘的间隙也会显得非常非常久;为了保证用户体验而给我码字所在的进程分配足够长的时间片,但是时间片用不完怎么办,不能让 CPU 一直等我吧!这个时候必然需要引入某种状态来进一步细化内核调度的算法,优化上下文切换的时机。⑤当然,还有很多种情况需要考虑,希望以后有时间能深挖一下 Linux 的内核源码探究一下这部分的内容,这里就不深究了。
多线程模型
(用户级和内核级线程)
这部分的内容我认为 《这篇》博客已经讲的比较透彻了,因此只把相关部分摘录一下。
线程的实现可以分为两类:用户级线程(User-LevelThread, ULT)和内核级线程(Kernel-LevelThread, KLT)。内核级线程又称为内核支持的线程。
-
在用户级线程中,有关线程管理的所有工作都由应用程序完成,内核意识不到线程的存在。应用程序可以通过使用线程库设计成多线程程序。通常,应用程序从单线程起始,在该线程中开始运行,在其运行的任何时刻,可以通过调用线程库中的派生例程创建一个在相同进程中运行的新线程。上图(a) 说明了用户级线程的实现方式。这种将多个用户级线程映射到一个内核级线程,线程管理在用户空间完成的方式,是线程模型中的多对一模型(n:1);因为此种模型的线程管理是在用户空间进行的,因此效率比较高(毕竟管理上更为灵活),但是当内核空间的进程被阻塞的时候,所有用户空间的线程都会被阻塞。
-
在内核级线程中,线程管理的所有工作由内核完成,应用程序没有进行线程管理的代码,只有一个到内核级线程的编程接口。内核为进程及其内部的每个线程维护上下文信息,调度也是在内核基于线程架构的基础上完成。上图(b)说明了内核级线程的实现方式。这种将每个用户级线程映射到一个内核级线程的方式,是线程模型中的一对一模型(1:1);这种模型可以充分利用内核线程的并发优势,内核空间的某个线程被阻塞,可以启用另一个线程继续执行,不好的地方是创建内核级线程的开销比较大,会影响到应用程序的性能。
-
在一些系统中,使用组合方式的多线程实现。线程创建完全在用户空间中完成,线程的调度和同步也在应用程序中进行。一个应用程序中的多个用户级线程被映射到一些(小于或等于用户级线程的数目)内核级线程上。上图(c)说明了用户级与内核级的组合实现方式。这种将 n 个用户级线程映射到 m 个内核级线程上(m <= n)的方式,是线程模型中的多对多模型(n:m)。这种模型集合了上面两种模型的优点于一身,不过这种模型所对应的调度系统会变得非常复杂(目前 Go 语言采用的就是这种模型,所以说 Go 的调度器实现很复杂)。
小结
本文简单地复习了计算机基本原理中的几个概念,涉及到进程与线程、多线程模型、时间片、以及调度等内容。这些内容基础而重要,不过如果非计算机专业科班出身(或者大学里没有好好听课😎)很容易成为架构、编码或运维的盲点,建议读者能够静下心来自己再去了解一下这部分的内容,可以参考下面的一些参考文章,或者读一读相关的书籍。
参考
- 进程、线程、多线程相关总结 - GavinJun - 博客园
- 线程的概念和多线程模型 - 博客园 多对一模型,一对一模型,多对多模型
- linux内核调度算法(1)—快速找到最高优先级进程 - 陶辉的专栏 - CSDN博客 结合linux内核源码讲优先队列讲调度
- linux内核调度算法(2)—CPU时间片如何分配 - 陶辉的专栏 - CSDN博客 比较好的分配文章
- linux内核调度算法(3)—多核系统的负载均衡 - 陶辉的专栏 - CSDN博客
- 进程的基本状态及转换和阻塞及挂起的理解 - Trinity - CSDN博客 线程状态很详细的描述
- Linux 时间片调度-cainiao413-ChinaUnix博客 对时间片的另一种朴素的描述
- 在阻塞式io中,如果一个线程在等待io操作,那么cpu还会分配时间片给该线程吗? - 简书
- 深入理解计算机系统 (豆瓣) 强烈推荐这本非常不错的参考书