Python3学习笔记:清晰理解协程
线程和进程
在了解协程之前,我们先简单了解一下进程与线程,并发与并行的概念。
- 进程:是资源分配的独立单位。我们可以将其通俗的理解为电脑中运行的程序的实例,例如打开一个浏览器就是启动了一个浏览器进程,打开一个记事本就是启动一个记事本进程。
- 线程:是操作系统调度的基本执行单位。当我们启动了一个浏览器时(即启动了一个浏览器进程),我们可能会做播放视频、下载文件等操作。在浏览器进程中,我们执行的这些操作,我们可以称之为子任务。而子任务播放视频与子任务下载文件就是浏览器进程中的线程。
从上述介绍中,可以看出,一个进程中是可以同时执行多个线程的(就像浏览器中的播放视频和下载文件可以同时进行一样)。并且,一个进程中至少会有一个线程。
- 并发:在一段时间内,处理多个任务。举个例子,就是一个老师,在一个小时内,同时辅导3个学生功课,但是同一时刻只能辅导一个学生,在学生之间不停的切换。
- 并行:在同一时刻,有多个任务同时执行。举个例子,就是3个老师,同时给3个学生辅导功课(即一个老师辅导一位学生),3个学生的辅导是同时开展的。
什么是协程
什么是协程?我们先来看下对协程的概括:协程被称为微线程或者纤程,是一种用户态的轻量级线程。
其本质就是一个单线程,协程的作用就是在一个线程中人为控制代码块的执行顺序。(记住这句就可以了)
具体解释如下:
在一个线程中有很多函数,我们称这些函数为子程序。当一个子程序A在执行过程中可以中断执行,切换到子程序B,执行子程序B。而在适当的时候子程序B还可以切换回子程序A,去接着子程序A之前中断的地方(即回到子程序A切换到子程序B之前的状态)继续往下执行,这个过程,我们可以称之为协程。看到这段解释,是不是觉得和yield
关键字的操作很相似。
Tip:关于
yield
和生成器不了解的,可以看[《Python3学习笔记:清晰理解迭代器、生成器以及yield表达式》][Python3学习笔记:清晰理解迭代器、生成器以及yield表达式],下面会涉及到。
协程的工作方式
为了方便理解,我们用一个简单示例来展示协程的工作大致工作方式,以下是一个经典的 生产者-消费者 模型:
import time
def consumer():
print("[消费者]:我准备好接收来自生产者的消息了!")
while True:
y = (yield)
time.sleep(1)
print("[消费者]:接收到来自生产者的消息 %s" % y)
def producer():
c = consumer()
c.send(None)
i = 1
while i < 5:
time.sleep(1)
print("[生产者]:向消费者发送消息 %s" % i)
c.send(i)
i += 1
c.close()
producer()
打印结果:
[消费者]:我准备好接收来自生产者的消息了!
[生产者]:向消费者发送消息 1
[消费者]:接收到来自生产者的消息 1
[生产者]:向消费者发送消息 2
[消费者]:接收到来自生产者的消息 2
[生产者]:向消费者发送消息 3
[消费者]:接收到来自生产者的消息 3
[生产者]:向消费者发送消息 4
[消费者]:接收到来自生产者的消息 4
示例中,消费者是个生成器,生产者与消费者的交互如下:
- 生产者先在内部通过
c.send(None)
激活了消费者,生产者中断执行,消费者获取程序控制权开始执行 - 消费者执行中遇到
yield
后被挂起,停止了执行,等待生产者发送消息,并将程序控制权返回给生产者,生产者接着上次中断的地方继续执行 - 生产者进入
while
循环,每隔1秒,向消费者发送一条消息,消费者的yield
收到消息后,从上次yield
中断的地方继续往后执行,当再次遇到yield
后重复【2】中的流程
可以看到,消费者每次都需要等待生产者传入消息之后才会继续执行之后的任务。
以上是协程的一个大概的工作方式,可以人为控制子程序(即函数)的执行顺序,人为的切换子程序的执行。上述示例比较简单,也并不是真正的协程,这是协程的工作方式而已。下面我们来看一下真正的协程是什么样的。
真正的协程实现
我们下面要实现的真正的协程,需要使用python标准库中的asyncio
模块。asyncio
是在python3.4时引入的,在python3.5的时候,asyncio
添加了两个关键字aysnc
和await
。async
关键字可以将一个函数修饰为协程对象,await
关键字可以将需要耗时的操作挂起,一般多是IO操作。(注:如果使用的是python3.4请用@asyncio.coroutine
代替async
,yield from
代替await
)
import asyncio
import time
start = time.time()
async def teach_student(n, t):
print("开始辅导学生 %d 。。。" % n)
await asyncio.sleep(t)
print("学生 %d 结束辅导" % n)
t1 = teach_student(1, 2)
t2 = teach_student(2, 1)
t3 = teach_student(3, 3)
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.gather(t1, t2, t3))
loop.close()
end = time.time()
print("执行总时间:", end-start)
打印结果:
开始辅导学生 1 。。。
开始辅导学生 2 。。。
开始辅导学生 3 。。。
学生 2 结束辅导
学生 1 结束辅导
学生 3 结束辅导
执行总时间: 2.996910333633423
上面是模拟一段1位老师3位辅导学生功课的场景,就是我们在一开始提到的并发中的例子。下面我们来解析一下上面的示例:
- 我们使用
async
将teach_student()
修饰为一个协程对象(coroutine),用await
来挂起asyncio.sleep(t)
这个异步休眠的操作,我们可以将这个异步休眠操作理解为学生在做功课。 - 我们可以看到,当我们执行了
t1=teach_student()
后,teach_student()
并没有立即开始执行,因为这里teach_student()
仅仅是返回给你一个协程对象而已,协程对象是不能直接执行的。 - 想要执行协程,需要将协程对象添加到消息循环中,我们首先通过
loop = asyncio.get_event_loop()
获取一个消息循环,然后通过loop.run_until_complete(asyncio.gather(t1, t2, t3))
将协程对象添加到消息循环中,并开始循环执行。 - 我们按照添加协程对象的顺序,开始执行协程:
- 首先执行的是t1,可以理解为老师开始辅导第一个学生。
- 当碰到
await
时,将后面的操作asyncio.sleep()
挂起,可以理解为学生接受了辅导后在做功课!此时老师就没有必要再守着第一位学生了,可以趁着第一位学生做功课的时候,开始辅导第二个学生。所以,t2开始执行和 t1 一样,遇到await
挂起,然后开始执行 t3 。 - 当老师把所有学生都辅导一遍后,老师会回过头检查学生的功课情苦,从第一个学生开始,第一个学生功课需要两秒时间,还没有完成,则第一个学生不能结束辅导下课。接着检查第二个学生,第二个学生功课只需要1秒,已经完成功课,则第二个学生率先结束辅导下课。最后检查第三个学生,第三个学生功课需要3秒,也没有完成功课,不予下课。
- 对于没有完成的功课的学生,老师会不停的按顺序,重复检查。新一轮检查开始,第一个学会完成功课,结束辅导。而后最慢的是第三个学生耗时最长,足足三秒,最后一个结束功课。
- 最后总计辅导三个学生,一共使用了约3秒。
以上就是一个真正的协程示例,如果老师辅导学生,每次都等辅导的学生完成功课后,在辅导下一个学生,会花费足足7秒时间(同步顺序执行)。而协程的方式,则是老师在辅导完一位学生后,就让学生自己做功课,老师不等待,立马开始辅导下一位学生,极大的缩短了时间,提高了效率(异步执行)。并且从以上示例也可以看出使用asyncio
实现的协程的一些特性:
- 使用
async
修饰返回的协程对象,不能直接执行,需要添加到事件循环event_loop
中执行。 - 协程主要是用于实现并发操作,其本质在同一时刻,只能执行一个任务,并非多个任务同时展开。
- 协程中被挂起的操作,一般都是异步操作,否则使用协程没有啥意义,不能提高执行效率。
- 协程是在一个单线程中实现的,其并发并未涉及到多线程。
为什么要使用协程
在了解为什么要使用协程之前,我们要先了解多线程在单核CPU下的执行情况。当你的电脑只是单核CPU时,所谓的多线程的执行,其实只是CPU在不停的切换执行的线程,例如线程A执行0.0.1秒后,切换到线程B执行0.01秒,再切换到线程C执行0.01秒,但是同一时间,只有一个线程在执行。只有在多核CPU时,多线程才有可能做到几个线程同时执行。
但是,我们在Python中这一情况又有所不同。我们广泛使用的Python解释器是CPython(我们经常说的Python2和Python3 就是解释器CPython的Python版本) ,除此之外还有JPython、pypy等其他解释器。但是CPython才是使用最广泛的。而CPython解释器中存在GIL锁(全局解释器锁 Global Interpreter Lock,注意GIL不是Python语言的特性,只存在CPython解释器中),它的作用就是防止多线程时线程抢占资源,所以在同一时间只允许一个线程在执行,即使在多核CPU情况下也是一样 ,所以CPU的单核和多核对于多线程的运行效率并没有多大帮助,还是线程之间的不停切换。
基于以上情况,在一些多线程的场景时,我们就可以使用协程来代替多线程,并且可以做的更灵活。我们下面来看下协程的优势:
- 线程是系统调度的,协程是程序员人为调度的,更加灵活,简化编程模型
- 与多线程相比,协程无需上下文切换的开销,避免了无意义的调度,提高了性能
- 与多线程相比,协程不需要像线程一样,无需原子操作锁定和同步的开销
所以,在处理一些高并发场景时,有时协程比多线程更加适合,比如做爬虫时。