前言
之前看Python教程的时候了解了一些协程的概念,相对还是比较肤浅,但是协程对Python语言而言是一个很重要的特性,加上近期看了我司架构师标哥的一篇讲协程的文章,感觉豁然开朗。
为什么需要协程
协程这东西,不是Python独有的,在很多其他脚本语言比如Lua也有,协程的存在,让单线程跑出了并发的效果,对计算资源的利用率高,开销小。但是说起来和Python解释器的设计也有关系,Python的多线程并不支持多核,因为Python的线程虽然是真正的线程,但解释器执行代码时,有一个GIL锁:Global Interpreter Lock,任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。
GIL是Python解释器设计的历史遗留问题,通常我们用的解释器是官方实现的CPython,要真正利用多核,除非重写一个不带GIL的解释器。
实现一个协程
协程是一种用户态的轻量级线程。本篇主要研究协程的C/C++的实现。
首先我们可以看看有哪些语言已经具备协程语义:
- 比较重量级的有C#、erlang、golang*
- 轻量级有python、lua、javascript、ruby
- 还有函数式的scala、scheme等。
目前看到大概有四种实现协程的方式:
第一种:利用glibc 的 ucontext组件(云风的库)
第二种:使用汇编代码来切换上下文(实现c协程)
第三种:利用C语言语法switch-case的奇淫技巧来实现(Protothreads)
第四种:利用了 C 语言的 setjmp 和 longjmp( 一种协程的 C/C++ 实现,要求函数里面使用 static local 的变量来保存协程内部的数据)
这里有一个兄弟已经使用ucontext来实现简单的协程库(),我就不Copy了。
可以看出来,协程相对线程而言,有一定的相似性,它是借助用户空间的上下文切换调度来达到调用者与被调用者之间多次协同的目的。但是调度的主动权却在用户,以下是进程,线程,协程的一个对比。
协程最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。
第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。
因为协程是一个线程执行,那怎么利用多核CPU呢?最简单的方法是多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。
协程的使用场景
前面介绍了协程的概念,但是,协程在什么情况下使用呢?协程既然诞生了,总有它的理由。前面我们看到,一个线程内的多个协程是串行执行的,不能利用多核,所以,显然,协程不适合计算密集型的场景。那么,协程适合什么场景呢?
异步非阻塞式I/O。
I/O 本来是阻塞的(相较于 CPU 的时间世界而言),就目前而言,无论 I/O 的速度多块,也比不上 CPU 的速度,所以一个 I/O 相关的程序,当其在进行 I/O 操作时,CPU 实际上是空闲的。我们假设这样的场景: 1个线程有5个 I/O 相关的事情(子程序)要处理。如果我们绝对的串行化,那么当其中一个 I/O 阻塞时,其他4个 I/O 并不能得到执行,因为程序是绝对串行的,5个 I/O 必须一个一个排队等候处理,当一个 I/O 阻塞时,其它四个也得在那傻等着。如下图所示:
而协程则能比较好地解决这个问题,当一个协程(特殊的子程序)阻塞时,它可以切换到其他没有阻塞的协程上去继续执行,这样就能得到比较高的效率。如下图所示:
还有一个简单的例子证明协程的实用性。假设你有一个生产者-消费者的关系,这里一个协程生产产品并将它们加入队列,另一个协程从队列中取出产品并使用它。为了提高效率,你想一次增加或删除多个产品。代码可能是这样的:
var q := new queue
生产者协程
loop
while q is not full
create some new items
add the items to q
yield to consume
消费者协程
loop
while q is not empty
remove some items from q
use the items
yield to produce
详细比较:
因为相对于子例程,协程可以有多个入口和出口点,可以用协程来实现任何的子例程。事实上,正如Knuth所说:“子例程是协程的特例。”
每当子例程被调用时,执行从被调用子例程的起始处开始;然而,接下来的每次协程被调用时,从协程返回(或yield)的位置接着执行。
因为子例程只返回一次,要返回多个值就要通过集合的形式。这在有些语言,如Forth里很方便;而其他语言,如C,只允许单一的返回值,所以就需要引用一个集合。相反地,因为协程可以返回多次,返回多个值只需要在后继的协程调用中返回附加的值即可。在后继调用中返回附加值的协程常被称为产生器。
Python协程库Eventlet
前文也讲了,协程在异步IO上是能提升很多效率的,Python在这块就有一个针对异步IO的协程库Eventlet,eventlet是一个用来处理和网络相关的Python库函数,而且可以通过协程来实现并发,在eventlet里,把“协程”叫做greenthread(绿色线程)。所谓并发,就是开启了多个greenthread,并且对这些greenthread进行管理,以实现非阻塞式的I/O。比如说用eventlet可以很方便的写一个性能很好的web服务器,或者是一个效率很高的网页爬虫,这都归功于eventlet的“绿色线程”,以及对“绿色线程”的管理机制。更让人不可思议的是,eventlet为了实现“绿色线程”,竟然对python的和网络相关的几个标准库函数进行了改写,并且可以以补丁(patch)的方式导入到程序中,因为python的库函数只支持普通的线程,而不支持协程,eventlet称之为“绿化”。
这里要注意的是Eventlet的使用场景,因为异步IO的多个协程之间的调度相对而言规则比较简单,所以其调度是由Eventlet里的Hub组件完成的,而完全定制化的由用户来做调度,并不能使用Eventlet
Eventlet API分析
1. Greenthread Spawn 生成函数
(1)Greenthread Spawn(spawn,孵化的意思,即如何产生greenthread)
主要有3个函数可以创建绿色线程:
1)spawn(func, *args, **kwargs):
创建一个绿色线程去运行func这个函数,后面的参数是传递给这个函数的参数。返回值是一个eventlet.GreenThread对象,这个对象可以用来接受func函数运行的返回值。在绿色线程池还没有满的情况下,这个绿色线程一被创建就立刻被执行。其实,用这种方法去创建线程也是可以理解的,线程被创建出来,肯定是有一定的任务要去执行,这里直接把函数当作参数传递进去,去执行一定的任务,就好像标准库中的线程用run()方法去执行任务一样。
2)spawn_n(func, *args, **kwargs):
这个函数和spawn()类似,不同的就是它没有返回值,因而更加高效,这种特性,使它也有存在的价值。
3)spawn_after(seconds, func, *args, **kwargs)
这个函数和spawn()基本上一样,都有一样的返回值,不同的是它可以限定在什么时候执行这个绿色线程,即在seconds秒之后,启动这个绿色线程。
2. Greenthread Control 协程控制函数
1)sleep(seconds=0)
中止当前的绿色线程,以允许其它的绿色线程执行。
2)eventlet.GreenPool
starmap(self, function, iterable)和imap(self, function, *iterables):
这是一个类,在这个类中用set集合来容纳所创建的绿色线程,并且可以指定容纳线程的最大数量(默认是1000个),它的内部是用Semaphore和Event这两个类来对池进行控制的,这样就构成了线程池。
Starmap和imap这两个函数和标准的库函数中的这两个函数实现的功能是一样的,所不同的是这里将这两个函数的执行放到了绿色线程中。前者实现的是从iterable中取出每一项作为function的参数来执行,后者则是分别从iterables中各取一项,作为function的参数去执行。
3)eventlet.GreenPile
这也是一个类,而且是一个很有用的类,在它内部维护了一个GreenPool对象和一个Queue对象。这个GreenPool对象可以是从外部传递进来的,也可以是在类内部创建的,GreenPool对象主要是用来创建绿色线程的,即在GreenPile内部调用了GreenPool.spawn()方法。而Queue对象则是用来保存spawn()方法的返回值的,即Queue中保存的是GreenThread对象。并且它还实现了next()方法,也就意味着GreenPile对象具有了迭代器的性质。所以如果我们要对绿色线程的返回值进行操作的话,用这个类是再好不过的了。
4)eventlet.Queue
说到队列就不得不画个类图了,基类是LightQueue,它实现了大部分的队列的常用方法。它是用collections做为实现队列的基本数据结构的。而且这个LightQueue的实现,不单单实现了存取操作,我觉得在本质上它实现了一个生产者和消费者问题,定义了两个set()类型的成员变量putters和getters,前者用来存放在队列满时,被阻塞的绿色线程,后者用来存放当队列空时,被阻塞的绿色线程。类中的putting()和getting()方法就是分别得到被阻塞的绿色线程的数量。
Queue继承了LightQueue,并且又增加了它自己的两个方法:task_done()和join()。task_done()是被消费者的绿色线程所调用的,表示在这个项上的所有工作都做完了,join()是阻塞,直到队列中所有的任务都完成。LifoQueue和PriorityQueue是存放数据的两种不同的方式。
3. Network Convenience Functions(和网络相关的函数)
这些函数定义在convenience.py文件中,对和socket相关的网络通信进行了包装,注意,这里用的socket是经过修改后的socket,以使它使用绿色线程,主要有以下一个函数:
1)connect(addr, family=socket.AF_INET, bind=None)
主要执行了以下几个步骤:新建了一个TCP类型的socket,绑定本地的ip和端口,和远程的地址进行连接,源码如下:
def connect(addr, family=socket.AF_INET, bind=None):
sock = socket.socket(family, socket.SOCK_STREAM)
if bind is not None:
sock.bind(bind)
sock.connect(addr)
return sock
2)listen(addr, family=socket.AF_INET, backlog=50)
过程和connect()类似,只是把connect()换成了listen(),backlog指定了最大的连接数量,源码如下:
def listen(addr, family=socket.AF_INET, backlog=50):
sock = socket.socket(family, socket.SOCK_STREAM)
if sys.platform[:3]=="win":
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(addr)
sock.listen(backlog)
return sock
3)serve(sock, handle, concurrency=1000)
这个函数直接创建了一个socket服务器,在它内部创建了一个GreenPool对象,默认的最大绿色线程数是1000,然后是一个循环来接受连接,源码如下:
def serve(sock, handle, concurrency=1000):
pool = greenpool.GreenPool(concurrency)
server_gt = greenthread.getcurrent()
while True:
try:
conn, addr = sock.accept()
gt = pool.spawn(handle, conn, addr)
gt.link(_stop_checker, server_gt, conn)
conn, addr, gt = None, None, None
except StopServe:
return
4)wrap_ssl(sock, *a, **kw)
给socket加上ssl(安全套接层),对数据进行加密。
还有几个比较重要的API这里就不罗列了,等以后用到了再进行分析吧,下面看几个官方的例子:
4. Use Case
(1)官方上引以为傲的“网页爬虫”,用到了绿色线程池和imap()函数
urls = ["http://www.google.com/intl/en_ALL/images/logo.gif",
"https://wiki.secondlife.com/w/images/secondlife.jpg",
"http://us.i1.yimg.com/us.yimg.com/i/ww/beta/y3.gif"]
import eventlet
from eventlet.green import urllib2
def fetch(url):
print "opening", url
body = urllib2.urlopen(url).read()
print "done with", url
return url, body
pool = eventlet.GreenPool(200)
for url, body in pool.imap(fetch, urls):
print "got body from", url, "of length", len(body)
(2)socket服务器
import eventlet
def handle(fd):
print "client connected"
while True:
# pass through every non-eof line
x = fd.readline()
if not x: break
fd.write(x)
fd.flush()
print "echoed", x,
print "client disconnected"
print "server socket listening on port 6000"
server = eventlet.listen(('0.0.0.0', 6000))
pool = eventlet.GreenPool()
while True:
try:
new_sock, address = server.accept()
print "accepted", address
pool.spawn_n(handle, new_sock.makefile('rw'))
except (SystemExit, KeyboardInterrupt):
break
(3)使用GreenPile的例子
import eventlet
from eventlet.green import socket
def geturl(url):
c = socket.socket()
ip = socket.gethostbyname(url)
c.connect((ip, 80))
print '%s connected' % url
c.sendall('GET /\r\n\r\n')
return c.recv(1024)
urls = ['www.google.com', 'www.yandex.ru', 'www.python.org']
pile = eventlet.GreenPile()
for x in urls:
pile.spawn(geturl, x)
# note that the pile acts as a collection of return values from the functions
# if any exceptions are raised by the function they'll get raised here
for url, result in zip(urls, pile):
print '%s: %s' % (url, repr(result)[:50])
参考资料:
http://eventlet.net/doc/index.html
http://mp.weixin.qq.com/s?__biz=MzAwNDAxOTM5Mw==&mid=508479846&idx=5&sn=ee86e3914c01bf290b6da1a61b96e343&chksm=00882cc837ffa5de480c3b7674832d182562bf35e78eac9eb8094c5001093efd20bbfb631126&mpshare=1&scene=23&srcid=0712c6k1wJ1QCYISc3m4m8FP#rd