参考资料
- http://www.gevent.org/contents.html
- https://uwsgi-docs-zh.readthedocs.io/zh_CN/latest/Gevent.html
Python脚本的执行效率一直来说并不是很高,特别是Python下的多线程机制,长久以来一直被人们诟病。很多人都在思考如何让Python执行的更快一些,其中典型的方式包括:
- 将复杂的代码转由C语言完成
- 多进程并发执行
- 多线程完成IO操作
然后,人们讨论的更多的则是Gevent的协程机制。在理解Gevent之前,我们需要弄明白几个基本的概念:进程(Process)、线程(Thread)、协程(Coroutine)、计算机IO方式等。
协程
进程和线程都是操作系统中的模型,操作系统通过进程和线程这两种模型来执行程序。进程是操作系统分配资源(如CPU、内存等)和调度的基本单位,可以将其看作是包含系统分配的资源和执行流两部分,通过进程模型,操作系统可以灵活地管理程序的执行。线程是执行流,一般而言一个进程只包含一个执行流,也就是说一个进程只包含一个线程,通过线程模型,一个进程可以拥有多个执行流,进而提供程序的处理能力。
协程coroutine
其实是corporate routine
的缩写,直译为协同的例程,简称为协程。在Linux中线程是轻量级的进程,因此也将协程coroutine
称为轻量级的线程,又称为微线程。协程简单的理解就是程序的执行流,在暂停和再次执行的过程中可以记录当前的状态,在暂停后需要再次执行时可以从暂停前的状态继续执行。协程暂停执行时,可以调度到另一个协程执行,这两个协程之间的关系是对等的。
协程和生成器generator
的概念很像,生成器也可以保存当前执行状态并再次运行时恢复之前的状态,不过区别在于协程暂停时可以调度到另一个协程执行,而生成器暂停时会由它的调用者继续执行。
协程的调度由使用者所决定,而不像进程和线程那样由操作系统进行调度,Gevent中的协程在暂停时会执行一个称为Hub的协程,由Hub选择处于等待执行状态的协程继续执行流。
线程是抢占式的调度,多个线程并行执行时抢占共同的系统资源,而协程则是协同式的调度。其实Greenlet并非一种真正的并发机制,而是在同一线程内的不同函数的执行代码块之间进行切换,实施“你运行一会儿,我运行一会儿”,在进行切换时必须制定何时切换以及切换到哪儿。
进程与协程
进程与协程有什么异同点呢?
进程与协程都可以看作是一种执行流,执行流可以挂起,后续可以在挂起的位置恢复执行。
例如:在Linux的Shell中执行Hello程序
开始时Shell进程在运行并等待命令行的输入,当执行Hello程序时,Shell通过系统调用来执行请求,此时系统调用会将控制权传递给操作系统,操作系统保存Shell进程的上下文并创建Hello进程以及上下文并将控制权交给Hello进程。当Hello进程终止后操作系统恢复Shell进程的上下文,并将控制权传回给Shell进程,Shell进程继续等待下一个命令的输入。
当挂起一个执行流时,此时需要保存两样东西,其一是栈,其实在切换前局部变量以及函数的调用都需要保存否则将无法恢复,其二是寄存器状态,寄存器状态用于当执行流恢复后需要执行什么。寄存器和栈的结合可以理解为上下文,上下文切换的理解是CPU看上去像是在并行执行多个进程,这其实是通过CPU在进程间切换来实现的,操作系统实现这种交错执行的机制称为上下文切换。操作系统保存跟踪进程运行所需的所有状态信息,这种状态就是上下文。在任意时刻操作系统只能执行一个进程代码,当操作系统决定将控制权从当前进程转移到某个进程时,就会进行上下文切换,也就是保存当前进程的上下文,并恢复新进程的上下文。然后将控制权传递给新进程,新进程从从它上次停止的地方开始。
进程与协程的不同点在于
- 执行流的调用者不同,进程是内核调度,而协程是用户态调度,也就是说进程的上下文是在内核态中保存并恢复的,而协程是在用户态保存恢复的,很显然用户态的代价要跟低。
- 进程会被强占,而协程不会。也就是说协程如果不主动让出CPU,那么其他协程就没有执行的机会。
- 对内存的占用不同,协程只需要4KB的栈空间就足够了,而进程占用的内存要大的多。
- 从操作系统角度讲,多协程的程序是单进程单协程的。
线程与协程
线程与协程有什么异同点呢?
协程也被称为微线程,线程之间上下文切换的成本相对于协程而言是比较高的,尤其是开启的线程较多的时候。而协程的切换成本则比较低。另外,同样线程的切换更多的是依靠操作系统来控制,而协程的执行则是由用户自己来控制。
计算机IO方式
根据计算机组成原理,计算机中IO的控制方式包括程序查询方式、程序中断方式、DMA方式、通道方式。目前计算机采用DMA和通道方式进行IO控制,这样在进行IO操作时,CPU可以尽量不参与到这个过程中而去执行其它的操作。由于IO操作一般都比较耗时,采用DMA和通道方式可以将CPU从IO过程中解放出来,从而提高系统的效率。
在程序运行过程中一把会遇到两种类型的IO,即当前机器的磁盘IO和网络IO,这两种IO操作一般会阻塞程序的执行,浪费CPU时间,因为此时程序分配到了时间片,在该时间片内程序是独占CPU资源,由于IO被阻塞CPU没有被其他程序享有进而被浪费。因此在编写高性能程序时,IO是需要重点关注的。
目前可以通过如下途径解决因IO带来的效率问题:
- 减少IO次数,也就是优化程序的结构,将需要读写的数据汇集在一起进行一次性读写。
- 提高硬件的IO速度,比如使用SSD磁盘。
- IO时不阻塞当前执行流,由DMA控制器或通道负责IO操作,CPU继续执行程序的其他部分或执行其他程序。
大多涉及到IO的高性能库一般都是通过第三种途径解决IO的性能瓶颈,例如Tornado的异步操作,而Gevent正是基于Greenlet的协程。Gevent实现了Python标准库中一些阻塞库的非阻塞版本,如socket
、os
、select
等,可以使用这些非阻塞的库替代Python中阻塞的库。
网络IO模型
- 阻塞式单线程
最基本的IO模型,只有在处理完毕一个请求后才能处理下一个请求,缺点是效能差,如果有请求阻塞住,会让服务无法继续接受请求,但这种模型编写代码相对比较简单,在应对访问量不大的情况下非常适用。
- 阻塞式多线程
针对于单线程接受请求数量有限的缺点,一个很自然的想法是给每个请求开一个线程去处理,这样做的好处是能够接受更多的请求。缺点是当线程产生到一定数量之后,进程之间需要大量上下文切换的操作,此时会占用CPU大量的时间,不过这样处理的话编写代码的难度稍高于单进程的情况。
- 非阻塞式事件驱动
为了解决多线程的问题,有一种做法是利用循环来检查是否有网络IO事件的发生,以便决定如何来进行处理,比如Reactor设计模式。这种做法的好处是进一步降低了CPU的资源消耗,缺点是这样做会让程序难以编写,因为请求接受后的处理过程由Reactor来决定,使得程序的执行流程难以把握。当接收到一个请求后如果涉及到阻塞的操作,这个请求的处理过程会停下来去接受另一个请求,程序执行的流程不会像线性程序那样直观,比如Twisted框架就是采用此种模型的典型案例。
- 非阻塞式协程
非阻塞式协程是为了解决事件驱动模型执行流程不直观的问题,在本质上也是事件驱动的,但加入了协程的概念。
Gevent
Gevent是一种基于协程的Python网络库,使用Greenlet提供并封装了libevent
事件循环的高层同步API,使开发者在不改变编程习惯的同时,以同步的方式编写异步IO代码。简单来说,Gevent是基于libev
和Greenlet的一个异步的Python框架。
libev
是一个高性能的事件循环event loop
实现。事件循环(IO多路复用)是解决阻塞问题实现并发的一种方式。事件循环event loop
会捕获并处理IO事件的变化,当遇到阻塞时就会跳出,当阻塞结束时则会继续。这一点依赖于操作系统底层的select
函数及其升级版poll
和epoll
。而Greenlet则是一个Python的协程管理和切换的模块,通过Greenlet可以显式地在不同的任务之间进行切换。
Libev
Gevent的基本原理来自于libevent
&libev
,熟悉C语言的同学应该对这个lib
不陌生。本质上libevent
或者说libev
都是一种事件驱动模型。这种模型对于提高CPU的运行效率,增强用户的并发访问非常有效。但因为其本身是一种事件机制,所以编写起来有些绕,并不是很直观。因此为了修正这个问题,有人引入了用户态上下文切换机制,也就是说,如果代码中引入了带IO阻塞的代码时,lib
本身会自动完成上下文的切换,全程用户都是没有察觉的,这又是Gevent的由来。
Libev是高性能事件循环模型的网络库,包含大量新特性,是继libevent
之后的一套全新的网络库。libev
追求的目标是速度更快、bug更少、特性更多、体积更小。和libevent
类似,可以作为其替代者,提供更高的性能且无需复杂的配置。
libev
机制提供了对指定文件描述符发生时调用回调函数的机制,libev
是一个事件循环器,向libev
注册感兴趣的事件,比如Socket可读事件,libev
会对所注册的事件的源进行管理,并在事件发生时出发相应的程序。
Yield
Python对协程的支持是非常有限的,使用生成器generator
中的yield
可以一定程序上实现协程。比如传统的生产者-消费者模型,即一个线程写消息一个线程读消息,通过锁机制控制队列和等待,但一不小心就可能出现死锁。如果改用协程,生产者生产消息后直接通过yield
跳转到消费者并开始执行,等消费者执行完毕后再切换回生产者继续生产,这样做效率极高。
$ vim test.py
#! /usr/bin/env python3
# -*- coding:utf-8 -*-
import time
def consumer():
r = ""
while True:
n = yield r
if not n:
return
print("consumer %s"%n)
r = "200 OK"
def producer(c):
c.__next__()
n = 0
while n < 3:
n = n + 1
print("producer %s"%n)
r = c.send(n)
print("producer return %s\n"%r)
c.close()
if __name__ == "__main__":
c = consumer()
producer(c)
$ python3 test.py
producer 1
consumer 1
producer return 200 OK
producer 2
consumer 2
producer return 200 OK
producer 3
consumer 3
producer return 200 OK
代码分析:首先调用c.__next__()
启动生成器,一旦生产出东西,则通过c.send(n)
切换到消费者consumer
来执行,消费者consumer
通过yield
获取到消息后处理,然后通过yield
将结果传回。生产者producer
获取到消费者consumer
处理的结果后继续生产下一条消息。整个过程无锁,由一个线程执行,生产者和消费者协作完成任务,所以称之为协程。
Python通过yield
提供了对协程的基本支持,但并不完全。而第三方的Gevent为Python提供了比较完善的协程支持,Gevent是第三方库,可通过Greenlet实现协程。另外,Python中由于GIL的存在导致多线程一直不是很好用,相比之下,协程的优势就更加突出了。
Greenlet
Greenlet是指使用一个任务调度器和一些生成器或协程实现协作式用户空间多线程的一种伪并发机制,也就是所谓的微线程。Greenlet机制的主要思想是生成器函数或协程函数中的yield
语句挂起函数的执行,直到稍后使用next()
或send()
操作进行恢复为止。可以使用一个调度器循环在一组生成器函数在将协作多个任务。
既然Gevent使用的是Greenlet,因此需要理解Greenlet的工作原理:每个协程都有一个parent
,最顶层的协程是man thread
或者是当前线程,每个协程遇到IO时会见控制权交给最顶层的协程,它会检测到哪个协程的IO Event已经完成并将控制权交给它。
Greenlet的基本思路是:当一个Greenlet遇到IO操作时,比如访问网络时会自动切换到其它的Greenlet,等到IO操作完成,再在适当的时候切换回来继续执行。由于IO操作非常耗时,经常会使程序处于等待状态,有了Gevent自动切换协程,就保证总有Greenlet在运行,而不是等待IO。由于切换是在IO操作时自动完成,所以Gevent需要修改Python自带的标准库,这一过程在启动时通过monkey patch
猴子补丁来完成。
Swich
一个Greenlet是一个很小的独立微线程,可以把它想象成一个堆栈帧,栈底是初始调用,栈顶是当前Greenlet的暂停位置,使用Greenlet创建一堆这样的堆栈,然后在它们之间跳转执行。跳转并不是绝对的,因为一个Greenlet必须选择跳转到选择好的另一个Greenlet,这会让前一个挂起,而后一个恢复,两个Greenlet之间的跳转又被称之为切换switch
。当创建一个Greenlet时它会得到一个初始化过的空堆栈,当第一次切换到它时会启动指定的函数,然后切换跳出Greenlet,当最终栈底函数结束时,Greenlet的堆栈又会变成空的,而Greenlet也就死掉了。当然,Greenlet也会因为一个未捕获的异常而死掉。
Monkey-patching
Monkey-patching猴子补丁这个叫法源自于Zope框架,大家在修正Zope的Bug时经常会在程序后追加更新部分,这些被称作“杂牌军补丁(guerillapatch)”,后来guerilla
逐渐写成了gorllia
(猩猩),再后来就写成了monkey
(猴子),所以猴子补丁的叫法就这么莫名其妙的得来了。之后在动态语言中,不改变源代码而对功能进行追加和变更就统称为“猴子补丁”。所以猴子补丁并不是Python中专有的,猴子补丁充分利用了动态语言的灵活性,可以对现有语言API进行追加、替换、修改,甚至性能优化等。使用猴子补丁的方式Gevent能够修改标准库中大部分的阻塞式系统调用,包括socket
、ssl
、threading
、select
等模块,使其变为协作式运行。
Monkey-patching猴子补丁是将标准库中大部分的阻塞式调用替换成非阻塞的方式,包括socket
、ssl
、threading
、select
、httplib
等。通过monkey.path_xxx()
函数来打补丁,根据Gevent文档中的建议,应当将猴子补丁的代码尽可能早的被调用,这样可以避免一些奇怪的异常。
使用Gevent的性能要比传统的线程高,但不得不说的一个坑是如果使用Monkey-patching猴子补丁,Gevent将直接修改标准库中大部分的阻塞式调用,包括socket
、ssl
、threading
、select
等模块,而变为协作式运行。但无法保证在复杂的生产环境中那些地方使用标准库因补丁而出现的奇怪问题。另外是第三方库的支持,需要确保项目中使用到的其他网络库也必须使用纯Python或明确支持Gevent。
Gevent应该在什么场景中使用呢?
Gevent的优势在于可以通过同步的逻辑实现并发操作,大大降低编写并行或并发程序的难度。另外,在一个进程中使用Gevent可以有效地避免对临界资源的互斥访问。如果程序中涉及到较多的IO,可以使用Gevent替代多线程来提高程序的效率,但是由于Gevent中的协程的调度是由使用者而非操作系统决定的,Gevent主要解决的问题是IO问题,通过提高IO-bound类型的程序的效率,另外由于是在一个进程中实现协程,而操作性i同是以进程为单位分配处理资源的(一个进程分配一个处理机)。因此,Gevent并不适合对任务延迟有要求的场景,比如交互式程序中。也不适用于CPU-bound类型的任务和需要使用多处理机的场景(通过运行多个进程,每个进程内实现协程来解决这个问题。)。
安装使用
Ubuntu系统下可通过apt-get
安装gevent
库
$ sudo apt-get install python-gevent
如果使用的Python3的版本,安装如下:
$ sudo apt-get install python3-gevent
也可以直接使用Python的包管理工具pip命令进行安装,不过需要注意版本与权限。
$ pip install gevent
入门案例:使用Gevent控制程序执行顺序
$ vim test.py
#! /usr/bin/env python3
# -*- coding:utf-8 -*-
import gevent
from gevent import monkey
monkey.patch_socket()
def fn(n):
for i in range(n):
print(gevent.getcurrent(), i)
# 创建协程对象
greenlet1 = gevent.spawn(fn, 3)
greenlet2 = gevent.spawn(fn, 3)
# 等待greenlet1执行结束
greenlet1.join()
greenlet2.join()
# 获取fn的返回值
# print(greenlet1.value)
$ python3 test.py
0
1
2
0
1
2
根据执行结果可知:greenlet
是依次运行而不是交替运行的,如果要让greenlet
交替运行则需要通过gevent.sleep()
交出控制权。
green.spawn
会启动所有协程,协程都是运行在同一个线程之中的,所以协程不能够跨线程同步数据。
$ vim test.py
#! /usr/bin/env python3
# -*- coding:utf-8 -*-
import gevent
from gevent import monkey
monkey.patch_socket()
def fn(n):
for i in range(n):
print(gevent.getcurrent(), i)
gevent.sleep(0)
greenlet1 = gevent.spawn(fn, 3)
greenlet2 = gevent.spawn(fn, 3)
# 合并两步操作
gevent.joinall([greenlet1, greenlet2])
$ python3 test.py
0
0
1
1
2
2
程序的重要部分是将任务函数封装到gevent.spawn
中,初始化的Greenlet列表存放在数组线程中,此数组会被传给gevent.joinall
函数,gevent.joinall
函数会阻塞当前流程,并执行所有给定的Greenlet,执行流只会在所有的Greenlet执行完毕后才会继续向下执行。
在Gevent中gevent.sleep()
模拟的是Gevent可以识别的IO阻塞,若使用time.sleep()
或其他阻塞,Gevent是不能够直接识别的,需要使用猴子补丁,注意猴子补丁必须放在被打补丁的前面,如time
、socket
模块之前。
#! /usr/bin/env python3
# -*- coding:utf-8 -*-
import gevent
from gevent import monkey
monkey.patch_all()
import time
def fn(n):
for i in range(n):
print(gevent.getcurrent(), i)
time.sleep(1)
greenlet1 = gevent.spawn(fn, 3)
greenlet2 = gevent.spawn(fn, 3)
gevent.joinall([greenlet1, greenlet2])
案例:Greenlet模块内部使用了协程的概念,在单线程内需要手动调用switch
函数切换协程。
$ vim test.py
#! /usr/bin/env python3
# -*- coding:utf-8 -*-
from greenlet import greenlet
def eat(name):
print("%s eat 1"%name)
g2.switch("egon")
print("%s eat 2"%name)
g2.switch()
def play(name):
print("%s play 1"%name)
g1.switch()
print("%s play 2"%name)
g1 = greenlet(eat)
g2 = greenlet(play)
g1.switch("egon")
$ python3 test.py
egon eat 1
egon play 1
egon eat 2
egon play 2
根据上述代码可以发现,协程一旦遇到IO操作就会自动切换到其它协程,使用yield
是无法实现的。
例如:
$ vim test.py
#! /usr/bin/env python3
# -*- coding:utf-8 -*-
import gevent
from gevent import socket
urls = [
"www.baidu.com",
"www.python.org",
"www.example.com"
]
jobs = [
gevent.spawn(socket.gethostbyname, url) for url in urls
]
gevent.joinall(jobs, timeout=2)
result = [
job.value for job in jobs
]
print(result)
$ python3 test.py
['112.80.248.75', '151.101.108.223', '93.184.216.34']
使用gevent.spawn
函数spawn
引发一些任务jobs
,再通过gevent.joinall
将所有任务jobs
加入到为协程执行队列中等待其完成,同时设置超时时间为2秒。执行后的结果通过检查gevent.greenlet.value
值来收集。
gevent.socket.gethostbyname
函数与标准的socket.gethostbyname
有着相同的接口,但不会阻塞整个解释器,因此会使其他Greenlet跟随着无阻塞的请求而执行。
猴子补丁
from gevent import monkey
patch_all
patch_all(
socket = True,
dns = True,
time = True,
select = True,
thread = True,
os = True,
ssl = True,
httplib = False,
subprocess = True,
sys = False,
aggressive = True,
Event = False,
builtins = True,
signal = True
)
Gevent的Monkey可以为socket、dns、time、select、thread、os、ssl、httplib、subprocess、sys、aggressive、Event、builtins、signal模块打上的补丁,打上补丁后他们就是非阻塞的了。