1.1. 什么是多任务
什么叫“多任务”呢?简单地说,就是操作系统可以同时运行多个任务。打个比方,你一边在用浏览器上网,一边在听MP3,一边在用Word赶作业,这就是多任务,至少同时有3个任务正在运行。还有很多任务悄悄地在后台同时运行着,只是桌面上没有显示而已。
现在,多核CPU已经非常普及了,但是,即使过去的单核CPU,也可以执行多任务。由于CPU执行代码都是顺序执行的,那么,单核CPU是怎么执行多任务的呢?
答案就是操作系统控制CPU轮流让各个任务交替执行,任务1执行0.01秒,切换到任务2,任务2执行0.01秒,再切换到任务3,执行0.01秒……这样反复执行下去。表面上看,每个任务都是交替执行的,但是,由于CPU的执行速度实在是太快了,我们感觉就像所有任务都在同时执行一样。
真正的并行执行多任务只能在多核CPU上实现,但是,由于任务数量远远多于CPU的核心数量,所以,操作系统也会自动把很多任务轮流调度到每个核心上执行。
1.2. 并发与并行
- 并发:指的是任务数多余cpu核数,通过操作系统的各种任务调度算法,实现用多个任务“一起”执行(实际上总有一些任务不在执行,因为切换任务的速度相当快,看上去一起执行而已)
- 并行:指的是任务数小于等于cpu核数,即任务真的是一起执行的
1.3. CPU执行程序的原理
相关术语
RAM:指内存,断电后内容无法保存,因此叫做易失性存储;另一个相关的概念是ROM,一般指外存,例如硬盘。RAM的速度远快于ROM,CPU与内存直接进行数据交换。
CPU:计算机的所有计算操作都由它执行,只要先记住它是一块有输入和输出的集成电路就行了。
Instruction:指令,是CPU进行操作的基本单元,大致包含操作对象、操作对象的地址、对操作对象进行何种操作。
RAM相关结构
程序要想被CPU执行,首先要被编译成CPU可以执行的指令操作,这里就不详细介绍,本文就假设程序已经被编译好了,放在了内存中。内存中存放的数据分为两类,一类是指令;另一类是数据,不管是指令还是数据都有其对应的地址。
下图就是接下来我们将会涉及的内存结构。
在上图中,现在已经存放了地址为100、104、108、112的一系列指令;地址为2000、2004、2008的一系列数据。
CPU相关结构
这里只放出CPU的执行指令时涉及的基本结构,真实的情况还会复杂很多。
这里涉及到的结构有Program Counter(程序计数器)、Instruction Register(指令寄存器)、Data Register(数据寄存器)、ALU(算数逻辑单元),可以将计数器、寄存器都可以简单理解为存放数据的器件。上述程序计数器用来存放指令的地址;指令寄存器用来存放指令(初学者可能会搞混数据和地址的区别,稍加区分就可以分辨);数据寄存器存放参与计算的数据,下图中的A、B、C都是数据寄存器;ALU就是用于计算的器件。
执行过程
为便于理解,仅涉及到CPU和内存间的数据交换。
在了解了RAM和CPU相关结构之后,接下来就可以正式开始说明执行的过程,其实就是对以上叙述内容的一个组合。
1.程序计数器初始内容为100,指向内存中的某一项指令,注意100指的是地址; 2.指令寄存器根据程序计算器的指向地址,将内存中地址为100的指令抓取到自身,此时存放LOAD A,2000; 3.CPU按照指令内容,将内存地址为2000的数据,上载到数据寄存器A中,此时CPU和RAM的状态如下图所示;
以上3步已完成一个指令的基本操作步骤。接下来程序计数器依次指向104指令地址、108指令地址、112指令地址,分别完成将2004地址的数据赋值给B数据寄存器;ALU将A、B内的数据相乘赋值给C数据寄存器;将C数据寄存器数据写入内容地址2008中。
这样就完成了50×0.1这个简单程序的计算,最后CPU和RAM所处状态如下图所示。
1.4. 程序、进程与线程
- 程序是含有指令和数据的文件,被存储在磁盘或其他的数据存储设备中,也就是说程序是静态的代码。
- 进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。简单来说,一个进程就是一个执行中的程序,它在计算机中一个指令接着一个指令地执行着,同时,每个进程还占有某些系统资源如CPU时间,内存空间,文件,输入输出设备的使用权等等。换句话说,当程序在执行时,将会被操作系统载入内存中。
- 线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率
1.5. 一个故事讲完进程、线程和协程
很久以前,有两个程序,暂且称他们旺财和小强吧。
旺财和小强这两个程序都很长,每个都有十几万行。 他们两个的人生价值就是到CPU上去运行,把运行结果告诉人类。
CPU是稀缺资源,只有一个,他们俩必须排着队,轮流使用。
旺财从头到尾执行完了,让出CPU, 让小强从头儿去执行。
人类把这种处理方式叫做批处理(单进程)。
进程
长久以来,两人相安无事。 后来CPU的速度越来越快, 远远超过了内存,硬盘的速度。
人类想到,这批处理系统的效率有点低啊,你看当小强需要从硬盘上读取数据的时候,CPU也一直在等待,这是多大的浪费啊!这时候完全可以让旺财来运行一下嘛!
当然得保存好小强的执行现场:具体执行到那一行程序指令了, 函数调用到什么层次了,每个函数调用都有什么样的参数,CPU寄存器中的值..... 等等一系列东西。
如果不把小强的执行现场给保存下来,等到小强的数据从银盘读完了,就没法回到中断处来继续执行了。
这个执行现场,再加上小强的代码,就是一个执行中的程序,被称为“进程” 。
旺财和小强在运行的时候,也被改造成了进程。
人类还规定:进程不能长时间占据CPU, 只能在CPU上执行一小会儿,然后马上切换到别的进程去执行。
旺财和小强不以为意:不就是执行一会儿,歇一会儿,然后继续执行嘛!
但是他们不知道的是,由于CPU运行速度超快,旺财和小强虽然在不断地切换运行,在人类那缓慢的世界里看来,旺财和小强好像是同时在执行一样。 这就是并发。
(在人类看来,小强和旺财似乎是在同时执行)
多年以后,他们俩才真正地实现了并行: 在一个豪华电脑中,每人都被分配了一个CPU , 真正地同时执行, 这是后话了(多核)。
线程
这时候旺财已经有了界面,还能访问网络,每当它联网的时候(这也是个非常非常耗时的操作),就得把CPU让给小强。
即使旺财再次被调度执行,由于网络数据还没有返回,他必须等待,什么事情都做不了,在人类看来,界面根本无法操作,旺财不响应了! 气得人类经常把旺财kill掉。
旺财心里苦,他很纳闷小强怎么就没有问题,小强不是要读写硬盘吗? 那也是很慢的操作啊。
小强说:“你傻啊,内部只有一个执行的流程,一遇到耗时的操作就得等待,你看看我,内部搞了两个执行流程(线程),一个用来读写硬盘(T1),另外一个处理界面(T2)。我和操作系统商量好了,如果T1在读写硬盘, 就可以调度我的T2来执行,这样界面至少还可以操作。 ”
旺财觉得很有意思,也采用了类似办法。
于是,一个进程中至少有一个执行的流程(主线程),也可以开启新的执行流程(线程)。
线程变成了最小的调度单位。
协程
这一天,旺财被一个叫做生产者和消费者的问题折腾地死去活来,两个线程,一个线程向队列中放数据,另外一个从队列中取数据,处理起两个线程的协作就显得很麻烦,不但需要加锁,还得做好线程的通知和等待。
正在感慨多线程编程之难的时候, 旺财震惊地发现,小强用了一个极为简单的办法把生产者,消费者问题给解决了。
这个方法的代码如下:
# 生产者def producer(c):
#其他代码
while True:
value = ...生成数据...
c.send(value)
# 消费者
def consumer():
#其他代码
while True:
value = yield
print(value)
c = consumer()
producer(c)
“这....这怎么执行啊,那个yield是怎么回事?” 旺财表示不解。
“简单啊,你看那个生产者,是不是向消费者发送了数据? ” 小强说。
“对啊,然后呢,生产者发送了数据以后,会马上进行下一轮循环吗?”
“这就是关键所在了,”小强说,“ 它们是这么执行的:”
生产者发送数据,暂停运行,不进行下一轮循环(可以理解为进入到消费者函数yield位置处继续执行)
消费者其实一直在value = yield 那里等待,直到数据到来,现在数据来了,取出处理(value就是生产者发送过来的数据)。
消费者在循环中再次yield, 暂停执行(再次自动切换回生产者函数)。
生产者继续下一轮的循环,生成新的消息,发送给消费者。