一、重温进程&线程
对操作系统来说,线程是最小的执行单元,进程是最小的资源管理单元。
- 进程是系统分配资源的最小单位
- 线程是CPU调度的最小单位
- 由于默认进程内只有一个线程,所以多核CPU处理多进程就像是一个进程一个核心
进程是系统资源分配的最小单位, 系统由一个个进程(程序)组成 一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。
- 文本区域存储处理器执行的代码;
- 数据区域存储变量和进程执行期间使用的动态分配的内存;
- 堆栈区域存储着活动过程调用的指令和本地变量。
线程共享进程的代码,文件句柄等资源,变量等数据,内存地址空间。
- 线程属于进程
- 线程共享进程的内存地址空间
- 线程几乎不占有系统资源
- 通信问题: 进程相当于一个容器,而线程而是运行在容器里面的,因此对于容器内的东西,线程是共同享有的,因此线程间的通信可以直接通过全局变量进行通信,但是由此带来的例如多个线程读写同一个地址变量的时候则将带来不可预期的后果,因此这时候引入了各种锁的作用,例如互斥锁等。
线程具有五种状态:
- 初始化
- 可运行
- 运行中
- 阻塞
- 销毁
这五种状态的转化关系如下:
为什么需要协程?
线程之间是如何进行协作的呢?最经典的例子就是生产者/消费者模式:
若干个生产者线程向队列中写入数据,若干个消费者线程从队列中消费数据。
- 协程是属于线程的。协程程序是在线程里面跑的,因此协程又称微线程和纤程等
- 协程没有线程的上下文切换消耗。协程的调度切换是用户(程序员)手动切换的,因此更加灵活,因此又叫用户空间线程.
- 原子操作性。由于协程是用户调度的,所以不会出现执行一半的代码片段被强制中断了,因此无需原子操作锁。
二、协程
之前的文章中介绍过多线程,多进程。下面开始介绍协程。
2.1 定义
协程,英文Coroutines,是一种比线程更加轻量级的存在。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。
协程并不是并行,而是类似于并发。即使在多核CPU上。
Python中的异步IO模块asyncio就是基本的协程模块。主要是解决在采用多线程的情况下,运行实现IO密集型任务。
2.2 Python中的实现
协程就是一种用户态内的上下文切换技术,又称微线程,纤程,一种用户态的轻量级线程。所谓协程,就是同时开启多个任务,但一次只顺序执行一个。等到所执行的任务遭遇阻塞,就切换到下一个任务继续执行,以期节省下阻塞所占用的时间。对协程来说,无需保留锁,在多个线程之间同步操作,协程自身就会同步,因为在任意时刻只有一个协程运行。
其实在其他语言中,协程的其实是意义不大的,多线程即可已解决I/O的问题,但是在python因为他有GIL(Global Interpreter Lock 全局解释器锁 )在同一时间只有一个线程在工作,所以:如果一个线程里面I/O操作特别多,协程就比较适用。
进程和线程是抢占式的调度,而协程是协同式的调度,也就是说,协程需要自己做调度。协程看上去也是子程序,但执行过程中,在子程序内部可中断(不是函数调用,有点类似CPU的中断,实际上是程序员控制的中断),然后转而执行别的子程序,在适当的时候再返回来接着执行。
协程可以处于下面四个状态中的一个。当前状态可以导入inspect模块,使用inspect.getgeneratorstate(...) 方法查看,该方法会返回下述字符串中的一个。
-
'GEN_CREATED' 等待开始执行。
-
'GEN_RUNNING' 协程正在执行。
-
'GEN_SUSPENDED' 在yield表达式处暂停。
-
'GEN_CLOSED' 执行结束。
Python中利用协程实现生产者-消费者模式: 代码中创建了一个叫做consumer的协程,并且在主线程中生产数据,协程中消费数据。
其中 yield 是python当中的语法。当协程执行到yield关键字时,会暂停在那一行,等到主线程调用send方法发送了数据,协程才会接到数据继续执行。 但是,yield让协程暂停,和线程的阻塞是有本质区别的。协程的暂停完全由程序控制,线程的阻塞状态是由操作系统内核来进行切换。因此,协程的开销远远小于线程的开销。
分析:
传统的生产者-消费者模型是一个线程写消息,一个线程取消息,通过锁机制控制队列和等待,但一不小心就可能死锁。
如果改用协程,生产者生产消息后,直接通过yield跳转到消费者开始执行,待消费者执行完毕后,切换回生产者继续生产,效率极高。
python可以通过 yield/send 的方式实现协程。在python 3.5以后,async/await 成为了更好的替代方案。
Python协程的发展:
- 最初的生成器变形yield/send
- 引入@asyncio.coroutine和yield from(Python 3.3 中的 yield from 和 Python 3.4 中的 asyncio )
- 在最近的Python3.5版本中引入async/await关键字(Python 3.5)
Python中的协程经历了很长的一段发展历程。最初的生成器yield和send()语法,然后在Python3.4中加入了asyncio模块,引入@asyncio.coroutine装饰器和yield from语法,在Python3.5上又提供了async/await语法,目前正式发布的Python3.6中asynico也由临时版改为了稳定版。
(1)yield/send
当一个函数中包含yield语句时,python会自动将其识别为一个生成器。这时fib(20)并不会真正调用函数体,而是以函数体生成了一个生成器对象实例。yield在这里可以保留fib函数的计算现场,暂停fib的计算并将b返回。而将fib放入for…in循环中时,每次循环都会调用next(fib(20)),唤醒生成器,执行到下一个yield语句处,直到抛出StopIteration异常。此异常会被for循环捕获,导致跳出循环。
发展:
1)在 Python2.2 中,第一次引入了生成器,生成器实现了一种惰性、多次取值的方法,此时还是通过 next 构造生成迭代链或 next 进行多次取值。
2)直到在 Python2.5 中,yield 关键字被加入到语法中,这时,生成器有了记忆功能,下一次从生成器中取值可以恢复到生成器上次 yield 执行的位置。
3)之前的生成器都是关于如何构造迭代器,在 Python2.5 中生成器还加入了 send 方法,与 yield 搭配使用。我们发现,此时,生成器不仅仅可以 yield 暂停到一个状态,还可以往它停止的位置通过 send 方法传入一个值改变其状态。最初的yield只能返回并暂停函数,并不能实现协程的功能。后来,Python为它定义了新的功能——接收外部发来的值( send 方法),这样一个生成器就变成了协程。
def jump_range(up_to):
step = 0
while step < up_to:
jump = yield step
print("jump", jump)
if jump is None:
jump = 1
step += jump
print("step", step)
if __name__ == '__main__':
iterator = jump_range(10)
print(next(iterator)) # 0
print(iterator.send(4)) # jump4; step4; 4
print(next(iterator)) # jump None; step5; 5
print(iterator.send(-1)) # jump -1; step4; 4
(2)yield from
yield from用于重构生成器。yield from的作用还体现可以像一个管道一样将send信息传递给内层协程,并且处理好了各种异常情况。
asyncio是一个基于事件循环的实现异步I/O的模块。通过yield from,我们可以将协程asyncio.sleep的控制权交给事件循环,然后挂起当前协程;之后,由事件循环决定何时唤醒asyncio.sleep,接着向后执行代码。
发展:
1)在 Python3.3 中,生成器又引入了 yield from 关键字,yield from 实现了在生成器内调用另外生成器的功能,可以轻易的重构生成器,比如将多个生成器连接在一起执行。
def gen_3():
yield 3
def gen_234():
yield 2
yield from gen_3()
yield 4
def main():
yield 1
yield from gen_234()
yield 5
for element in main():
print(element) # 1,2,3,4,5
从图中可以看出 yield from 的特点。使用 itertools.chain 可以以生成器为最小组合进行链式组合,使用 itertools.cycle 可以对单独一个生成器首尾相接,构造一个循环链。使用 yield from 时可以在生成器中从其他生成器 yield 一个值,这样不同的生成器之间可以互相通信,这样构造出的生成链更加复杂,但生成链最小组合子的粒度却精细至单个 yield 对象。
2)短暂的asynico.coroutine 与yield from
有了Python3.3中引入的yield from 这项工具,Python3.4 中新加入了asyncio库,并提供了一个默认的event loop。Python3.4有了足够的基础工具进行异步并发编程。
asyncio是Python 3.4版本引入的标准库,直接内置了对异步IO的支持。asyncio的异步操作,需要在coroutine中通过yield from完成。
并发编程同时执行多条独立的逻辑流,每个协程都有独立的栈空间,即使它们是都工作在同个线程中的。以下是一个示例代码:
@asyncio.coroutine
def test(i):
print("test_1",i)
r=yield from asyncio.sleep(1)
print("test_2",i)
loop=asyncio.get_event_loop()
tasks=[test(i) for i in range(5)]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()
test_1 3
test_1 4
test_1 0
test_1 1
test_1 2
test_2 3
test_2 0
test_2 2
test_2 4
test_2 1
说明:从运行结果可以看到,跟gevent达到的效果一样,也是在遇到IO操作时进行切换(所以先输出test_1,等test_1输出完再输出test_2)。
在Python3.4中,asyncio.coroutine 装饰器是用来将函数转换为协程的语法,这也是 Python 第一次提供的生成器协程 。只有通过该装饰器,生成器才能实现协程接口。使用协程时,你需要使用 yield from 关键字将一个 asyncio.Future 对象向下传递给事件循环,当这个 Future 对象还未就绪时,该协程就暂时挂起以处理其他任务。一旦 Future 对象完成,事件循环将会侦测到状态变化,会将 Future 对象的结果通过 send 方法方法返回给生成器协程,然后生成器恢复工作。
asyncio说明:
@asyncio.coroutine:asyncio模块中的装饰器,用于将一个生成器声明为协程。可以把一个generator标记为coroutine类型,然后,我们就把这个coroutine扔到EventLoop中执行。
test()会首先打印出test_1,然后,yield from语法可以让我们方便地调用另一个generator。由于asyncio.sleep()也是一个coroutine,所以线程不会等待asyncio.sleep(),而是直接中断并执行下一个消息循环。当asyncio.sleep()返回时,线程就可以从yield from拿到返回值(此处是None),然后接着执行下一行语句。
把asyncio.sleep(1)看成是一个耗时1秒的IO操作,在此期间,主线程并未等待,而是去执行EventLoop中其他可以执行的coroutine了,因此可以实现并发执行。
从Python3.4开始asyncio模块加入到了标准库,通过asyncio我们可以轻松实现协程来完成异步IO操作。asyncio是一个基于事件循环的异步IO模块,通过yield from,我们可以将协程asyncio.sleep()的控制权交给事件循环,然后挂起当前协程;之后,由事件循环决定何时唤醒asyncio.sleep,接着向后执行代码。
(3)async/await
在Python3.5中引入的async和await就不难理解了:可以将他们理解成asyncio.coroutine/yield from的完美替身。当然,从Python设计的角度来说,async/await让协程表面上独立于生成器而存在,不再使用yield语法,将细节都隐藏于asyncio模块之下,语法更清晰明了。
几个重要概念:
- event_loop:事件循环,相当于一个无限循环,我们可以把一些函数注册到这个事件循环上,当满足条件发生的时候,就会调用对应的处理方法。
- coroutine:协程,在 Python 中常指代为协程对象类型,我们可以将协程对象注册到时间循环中,它会被事件循环调用。我们可以使用 async 关键字来定义一个方法,这个方法在调用时不会立即被执行,而是返回一个协程对象。
- task:任务,它是对协程对象的进一步封装,包含了任务的各个状态。
- future:代表将来执行或没有执行的任务的结果,实际上和 task 没有本质区别。
await 的行为类似 yield from,但是它们异步等待的对象并不一致,yield from 等待的是一个生成器对象,而await接收的是定义了__await__方法的 awaitable 对象。在 Python 中,协程也是 awaitable 对象,collections.abc.Coroutine 对象继承自 collections.abc.Awaitable。
为了简化并更好地标识异步IO,从Python 3.5开始引入了新的语法async和await,可以让coroutine的代码更简洁易读。
请注意,async和await是针对coroutine的新语法,要使用新的语法,只需要做两步简单的替换:
- 把@asyncio.coroutine替换为async;
- 把yield from替换为await。
import asyncio
async def test(i):
print("test_1",i)
await asyncio.sleep(1)
print("test_2",i)
loop=asyncio.get_event_loop()
tasks=[test(i) for i in range(5)]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()
import asyncio
async def execute(x):
print('Number:', x)
coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')
loop = asyncio.get_event_loop()
loop.run_until_complete(coroutine) # 将协程注册到事件循环 loop 中,然后启动
print('After calling loop')
Coroutine: <coroutine object execute at 0x1034cf830>
After calling execute
Number: 1
After calling loop
async 定义的方法就会变成一个无法直接执行的 coroutine 对象,必须将其注册到事件循环中才可以执行。
从 Python 语言发展的角度来说,async/await 并非是多么伟大的改进,只是引进了其他语言中成熟的语义,协程的基石还是在于 eventloop 库的发展,以及生成器的完善。
从结构原理而言,asyncio 实质担当的角色是一个异步框架,async/await 是为异步框架提供的 API,因为使用者目前并不能脱离 asyncio 或其他异步库使用 async/await 编写协程代码。即使用户可以避免显式地实例化事件循环,比如支持 asyncio/await 语法的协程网络库 curio,但是脱离了 eventloop 如心脏般的驱动作用,async/await 关键字本身也毫无作用。
asyncio模块
基于协程的异步IO模块。asyncio的使用可分三步走:
- 创建事件循环
- 指定循环模式并运行
- 关闭循环
通常我们使用asyncio.get_event_loop()方法创建一个循环。
运行循环有两种方法:一是调用run_until_complete()方法,二是调用run_forever()方法。run_until_complete()内置add_done_callback回调函数,run_forever()则可以自定义add_done_callback()。
使用run_until_complete()方法:
import asyncio
async def func(future):
await asyncio.sleep(1)
future.set_result('Future is done!')
if __name__ == '__main__':
loop = asyncio.get_event_loop()
future = asyncio.Future()
asyncio.ensure_future(func(future))
print(loop.is_running()) # 查看当前状态时循环是否已经启动
loop.run_until_complete(future)
print(future.result())
loop.close()
使用run_forever()方法:
import asyncio
async def func(future):
await asyncio.sleep(1)
future.set_result('Future is done!')
def call_result(future):
print(future.result())
loop.stop()
if __name__ == '__main__':
loop = asyncio.get_event_loop()
future = asyncio.Future()
asyncio.ensure_future(func(future))
future.add_done_callback(call_result) # 注意这行
try:
loop.run_forever()
finally:
loop.close()
协程池
作用:控制协程数量
2.3 应用
2.3.1 第三方库
使用greenlet模块。Python的 greenlet就相当于手动切换,去执行别的子程序,在“别的子程序”中又主动切换回来。很多知名的网络并发框架如eventlet,gevent都是基于它实现的。使用switch()方法切换协程,也比”yield”, “next/send”组合要直观的多。
基本语法及参数:
greenlet(run=None, parent=None)
参数”run”就是其要调用的方法;参数”parent”定义了该协程对象的父协程,也就是说,greenlet协程之间是可以有父子关系的。如果不设或设为空,则其父协程就是程序默认的”main”主协程。这个”main”协程不需要用户创建,它所对应的方法就是主程序,而所有用户创建的协程都是其子孙。大家可以把greenlet协程集看作一颗树,树的根节点就是”main”,上例中的”gr1″和”gr2″就是其两个字节点。在子协程执行完毕后,会自动返回父协程。
示例:
from greenlet import greenlet
def test1():
print 12
gr2.switch()
print 34
def test2():
print 56
gr1.switch()
print 78
gr1 = greenlet(test1)
gr2 = greenlet(test2)
gr1.switch()
12
56
34
Gevent 是一个第三方库。在Python2中推荐使用,比原生yield容易很多。其可以轻松通过gevent实现并发同步或异步编程,在gevent中用到的主要模式是Greenlet, 它是以C扩展模块形式接入Python的轻量级协程。 Greenlet全部运行在主程序操作系统进程的内部,但它们被协作式地调度。
gevent会主动识别程序内部的IO操作,当子程序遇到IO后,切换到别的子程序。如果所有的子程序都进入IO,则阻塞。
当一个greenlet遇到IO操作时,比如访问网络,就自动切换到其他的greenlet,等到IO操作完成,再在适当的时候切换回来继续执行。由于IO操作非常耗时,经常使程序处于等待状态,有了gevent为我们自动切换协程,就保证总有greenlet在运行,而不是等待IO。
爬虫示例:
import gevent
from gevent import monkey;monkey.patch_all()
import urllib2
def get_body(i):
print "start",i
urllib2.urlopen("http://cn.bing.com")
print "end",i
tasks=[gevent.spawn(get_body,i) for i in range(3)]
gevent.joinall(tasks)
start 0
start 1
start 2
end 2
end 0
end 1
从结果来看,多线程与协程的效果一样,都是达到了IO阻塞时切换的功能。不同的是,多线程切换的是线程(线程间切换),协程切换的是上下文(可以理解为执行的函数)。而切换线程的开销明显是要大于切换上下文的开销,因此当线程越多,协程的效率就越比多线程的高。(多进程的切换开销更大)
Gevent使用说明:
- monkey可以使一些阻塞的模块变得不阻塞,机制:遇到IO操作则自动切换,手动切换可以用gevent.sleep(0)(将爬虫代码换成这个,效果一样可以达到切换上下文)
- gevent.spawn 启动协程,参数为函数名称,参数名称
- gevent.joinall 停止协程
2.3.2 生成器的实现
比如xrange。
2.3.3 异步爬虫
- grequests (requests模块的异步化)
- 爬虫模块+gevent(比较推荐这个)
- aiohttp (这个貌似资料不多,目前我也不太会用)
- asyncio内置爬虫功能 (这个也比较难用)
2.4 优点和缺点
优点:
- 无需线程上下文切换的开销
- 无需原子操作锁定及同步的开销
- 方便切换控制流,简化编程模型
- 高并发+高扩展性+低成本:一个CPU支持上万的协程都不是问题。所以很适合用于高并发处理。
缺点:
- 无法利用多核资源:协程的本质是个单线程,它不能同时将单个CPU 的多个核用上,协程需要和进程配合才能运行在多CPU上.当然我们日常所编写的绝大部分应用都没有这个必要,除非是cpu密集型应用。
- 进行阻塞(Blocking)操作(如IO时)会阻塞掉整个程序
三、总结
(1)因为协程是一个线程执行,那怎么利用多核CPU呢?最简单的方法是多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。
说明:协程可以处理IO密集型程序的效率问题,但是处理CPU密集型不是它的长处,如要充分发挥CPU利用率可以结合多进程+协程。
(2)在Python中,协程基本可以用来代替多线程。PyCon 2018 上,来自 Facebook 的 John Reese 介绍了 asyncio 和 multiprocessing 各自的特点,并开发了一个新的库,叫做 aiomultiprocess。一方面我们使用异步协程来防止阻塞,另一方面我们使用 multiprocessing 来利用多核成倍加速,节省时间其实还是非常可观的。
(3)