并行
1. 基于协程的并行
1.1 基本原理
1.1.1 概述
协程是一种在线程中被调度的函数,也称作微线程。它的调度与进程、线程不同,完全在用户空间中进行,即协程的调度器行为与操作系统无关。
协程可以在等待异步 I/O 时立刻保存当前执行栈帧的上下文,并从调度器处返回,调度器会转而调度和执行其他协程。当 OS 通知调度器异步 I/O 结束后,调度器会带着异步 I/O 的结果调度该协程,并从内存中恢复上下文,从刚才退出协程的位置进入协程继续执行。
由于协程只会在开发者指定的位置被调度,所以几乎可以不考虑对资源的原子性访问的问题。
注意,协程并不会加快 CPU 绑定的 (占用 CPU 时间的) 代码的执行速度,因为协程始终在一个线程中被调度,它真正的威力在于允许下面这种行为,即当一个协程在等待异步 I/O 时,立刻转而调度执行其他协程的 CPU 绑定的代码,这意味着我们可以一次性调度多个协程,在同一时刻迅速发出多个 I/O 请求,并一起等待返回。
所以,其主要应用场景是 I/O 密集程序,例如网络应用 (HTTP Server、Client),Node.js 就是一个基于单线程非阻塞 I/O 的 JavaScript Runtime,被主要用于 HTTP 后端应用的快速开发。
1.1.2 协程的实现
-
这个开源仓库演示了使用生成器的,基于事件循环、事件队列的协程,以及简单的 HTTP 异步 I/O 库的实现。
-
文章 http://gaolihai.cool/#/工具和技术/协程.md
这篇文章阐述了协程相关的的概念、原理、实现方式以及优点,剖析了上述开源库的实现细节。
-
文章 aosabook a-web-crawler-with-asyncio-coroutines
这篇文章来自开源书籍 500 Lines or Less
阐述了如何在 Python 中使用非阻塞 socket 以及 I/O 多路复用器 selector 实现简单的 HTTP 请求以及协程调度器。
-
文章 JavaScript Promise Generator async/await
这篇文章讲解了 JavaScript Promise 对象的实现。
1.1.3 协程的优点
-
开销小
- 协程调度的性能开销比线程调度小很多,协程切换迅速
- 协程调度的内存开销也比线程调度小很多
-
一般情况下,不需要考虑在并发环境下对资源原子性访问的问题,节省了锁的开销
-
逻辑更简洁
- 相比线程来说,协程让从上下文推导代码逻辑变得更加简单
Threads make local reasoning difficult, and local reasoning is perhaps the most important thing in software development.
线程使得局部推理变得困难,而局部推理可能是软件开发中最重要的事情。
—— https://github.com/glyph/
1.2 运行协程
参考文档 -> https://docs.python.org/zh-cn/3/library/asyncio-task.html
Python 协程库实际上维护了一个最近时间最小堆(树),每次调度一个最近的可调度协程。
1.2.1 asyncio.run
asyncio.run(coro, *, debug=False)
coroutine asyncio.sleep(delay, result=None, *, loop=None)
调用一个协程并不会使其被调度执行,而是返回一个内建的 coroutine
对象,我们可以通过 asyncio.run
方法来调度执行该协程。
如果了解协程的实现,应该不难发现,协程的某个行为与生成器函数非常相似,
即协程函数在调用后不会直接执行,而是返回一个协程对象,
实际上 Python 内部的协程函数和生成器函数共用一套实现。
import asyncio
async def asyncfun(time):
await asyncio.sleep(time)
print('done')
asyncio.run(asyncfun(1))
一秒后输出 done
。
1.2.2 await
除了使用 asyncio.run
对协程进行调度执行外,协程对象在其他协程函数的上下文中也可以通过 await
进行调度执行。
await
除了可以对协程进行调度执行,还会返回协程的结果。
import asyncio
async def sleepAndPrintDone(time):
await asyncio.sleep(time)
print('done')
async def main():
for _ in range(3):
await sleepAndPrintDone(1)
asyncio.run(main())
依次打印了三次 done
。
1.3 并发协程
1.3.1 asyncio.create_task
asyncio.create_task(coro, *, name=None)
事件循环是开始进行协程调度的入口, asyncio.run
负责管理事件循环,将传入的协程绑定到事件循环上。
而 asyncio.create_task
则是负责直接将所有传入的协程绑定到 当前正在运行的 事件循环上,该协程会被立刻自动调度执行,并返回一个 Task
对象,我们可以在异步上下文中 await
它。
import asyncio
import time
async def sleepAndPrintDone(time):
await asyncio.sleep(time)
print('done')
async def main():
task1 = asyncio.create_task(
sleepAndPrintDone(1)
)
task2 = asyncio.create_task(
sleepAndPrintDone(2)
)
print(f"started at {time.strftime('%X')}")
await task1
await task2
print(f"finished at {time.strftime('%X')}")
asyncio.run(main())
这个示例下进行了两个协程的调度,一个将阻塞 1s,另一个是 2s,但实际上这个程序只需要 2s 就会执行完毕。
这是因为,当我们调用 asyncio.create_tesk
后,该协程将会立刻被绑定到事件循环进行调度,而不必等到 await
时才绑定(对于 Task
对象的 await task
并不是开始调度,而是等待结束),所以前一秒两个协程都在等待,第一秒时第一个协程结束,第二秒时第二个协程结束。
这种调用方式也被成为并发。
1.3.2 asyncio.gather
awaitable asyncio.gather(*aws, loop=None, return_exceptions=False)
awaitable
被称为 可等待对象,指的是在协程函数中允许跟在 await
关键字后的对象,协程对象(协程函数的返回值)、Task、以及一些低层级对象(Future) 都是可等待对象。
如果对 Drincann/py-coro-impl 的实现有所了解,应该知道
await
后跟的对象类型与协程执行器是强耦合的,它们共同配合着自动执行一个协程(或者生成器迭代器)。在 Drincann/py-coro-impl 中,
yield from
后必须跟一个Promise
对象,在 Python 内建的协程支持中,则是awaitable
对象,它可以看做是一个接口,代表一类对象。
asyncio.gather
接收多个可等待对象,并返回一个可等待对象,当对返回的可等待对象进行 await
时,将并发等待所有传入的可等待对象。
注意,与 Task
不同,Task
被创建时就立刻开始调度执行,而该接口返回的对象只有在开始等待时才开始调度执行。
它的实现类似:
async def gather(*awaitableObjs):
return [await task for task in [asyncio.create_task(obj) for obj in awaitableObjs]]
即首先并发调度所有协程,然后等待所有协程直到全部执行完毕后返回结果列表。
下面给出一个使用示例:
import asyncio
async def sleepAndPrintDone(time):
await asyncio.sleep(time)
print('done')
async def main():
await asyncio.gather(
sleepAndPrintDone(1),
sleepAndPrintDone(1),
sleepAndPrintDone(1),
sleepAndPrintDone(1),
)
asyncio.run(main())
一秒后立刻依次打印四个 done
。
1.3.3 asyncio.wait
该接口将在 3.10 移除,所以更建议使用
asyncio.gather
代替它。
coroutine asyncio.wait(aws, *, loop=None, timeout=None, return_when=ALL_COMPLETED)
这个接口与 asyncio.gather
类似,它们在使用上唯一的区别是,asyncio.wait
接收的可等待对象们将不再是位置参数,而是一个 iterable
。
import asyncio
async def sleepAndPrintDone(time):
await asyncio.sleep(time)
print('done')
async def main():
await asyncio.wait([
sleepAndPrintDone(1),
sleepAndPrintDone(1),
sleepAndPrintDone(1),
sleepAndPrintDone(1),
])
asyncio.run(main())
1.4 同步和异步 I/O
我们刚才提到:
它真正的威力在于允许下面这种行为,即当一个协程在等待异步 I/O 时,立刻转而调度执行其他协程的 CPU 绑定的代码,这意味着我们可以一次性调度多个协程,在同一时刻迅速发出多个 I/O 请求,并一起等待返回。
这里提到了 异步 I/O,实际上异步 I/O 是协程调度在 I/O 密集的程序中效率非常高的主要原因,可以这么讲,高效率协程 = 异步 I/O 接口 + 协程调度
。
什么是异步 I/O 接口(也称非阻塞式 I/O 接口)?
有异步就有同步,同步 I/O 接口(也称阻塞式 I/O 接口)指的是,该接口被调用后,将阻塞直到等待 I/O 结束后返回,在 Python 中这样的接口比比皆是,例如:
import requests
for _ in range(100):
res = requests.get(url='http://gaolihai.cool/doc/README.md')
print(res.text)
requests.get
就是一个典型的同步 I/O 接口,如果服务端需要 200ms 才响应,那么主线程就会阻塞 200ms 直到操作系统通知 I/O 结束并恢复线程,继续执行。
这种接口的调用即便写在协程中进行并发调用,也不会有任何效率的提升,因为协程本身是在一个线程中通过一个调度器进行调度执行的。如果将同步 I/O 接口的调用放在协程中调度,I/O 要花费 1s,那么所有协程的执行将都延后 1s,我们后面将介绍一个标准库接口 asyncio.to_thread
来解决这个问题。
说回来,异步 I/O 接口只在必须占用 CPU 时间的代码上使用 CPU,一旦进行等待 I/O 则立刻转而执行后面的代码,直到 I/O 结束后由操作系统回调和通知主线程结果,于是协程调度器就可以在异步 I/O 接口开始等待时立刻拿到 CPU 控制权并立即调度其他需要 CPU 的协程,这就实现了并发。
一个典型的异步 I/O 接口就是非阻塞 socket,或者一些第三方的异步 HTTP 请求库,例如
aiohttp
。
HTTP 接口当然也有异步版本,我们以一个基于非阻塞 socket 实现的异步 HTTP/1.0 GET 接口为例:
get(*, url, callback)
该接口占用 CPU 的部分仅仅是向服务端发送请求的过程,一旦发送就立即开始等待 I/O,此时它不再像 requests.get
那样阻塞,而是立刻返回并执行后面的代码。
当 I/O 结束时,将由操作系统通知程序,协程调度器在收到通知后某一刻将回调callback
参数,回调函数中将被传入异步 I/O 的结果,即响应结果(HTTP 响应报文)。
于是,通过包装,可以将该接口包装成 awaitable
对象并在协程中等待,参考 3. 数据模型#awaitable-objects — Python 3.9.7 文档。
同时也可以参考在我的协程实现中,它是如何基于生成器迭代器将异步 I/O 接口对接到协程执行器中的,参考 Drincann/py-coro-impl 。
这里使用成熟的异步 HTTP 请求库 aiohttp
作演示:
接口文档 -> https://docs.aiohttp.org/en/stable/
import aiohttp
import asyncio
import time
import requests
async def asyncRequest():
session = aiohttp.ClientSession()
response = await session.get('http://gaolihai.cool/doc/README.md')
print(await response.text())
await session.close()
asyncio.run(asyncio.gather(
*[asyncRequest() for i in range(10)]
))
1.5 同步 I/O 接口的协程包装器
我们提到:
这种接口的调用即便写在协程中进行并发调用,也不会有任何效率的提升,因为协程本身是在一个线程中通过一个调度器进行调度执行的。如果将同步 I/O 接口的调用放在协程中调度,I/O 要花费 1s,那么所有协程的执行将都延后 1s。
同步 I/O 接口将会阻塞协程调度所在的线程,在这种情况下,如果该同步接口无可替代,可以尝试将它放在另一个线程中运行,这样它就会阻塞住另一个线程,这样主线程的协程调度就不受影响。
这里可以使用 threading
包创建新线程,但在使用协程的程序中再使用线程时,将两者进行结果汇总的复杂性会增加,我们需要自己管理细粒度的锁,以维护线程安全。
不过标准库提供了线程安全的接口 asyncio.to_thread
,它已经为我们处理好了这些复杂度带来的问题,该接口将一个同步任务放在新线程中执行,并包装为一个可等待对象返回,这样一个同步接口就被包装成了一个异步接口的可等待对象。
注意,使用该接口包装同步 I/O 接口是下下策。
协程相较于线程的优点就在于它上下文资源少,恢复快,协程间调度时切换迅速。但使用该接口包装后,反而又回头使用了基于线程的并行。所以这种方式最好仅在没有替代方案、或对效率要求不敏感的情况下使用。
在下面的示例中,我们将在使用异步接口的同时,使用 asyncio.to_thread
来包装一个同步接口,并像使用一个协程一样 await
它:
注意,该接口在 Python 3.9 中加入
import aiohttp
import asyncio
import requests
def syncRequest():
for _ in range(10):
requests.get(url='http://gaolihai.cool/doc/README.md')
print('sync')
async def asyncRequest():
session = aiohttp.ClientSession()
await session.get('http://gaolihai.cool/doc/README.md')
print('async')
await session.close()
async def main():
await asyncio.gather(
*[asyncRequest() for _ in range(10)]
)
await asyncio.to_thread(syncRequest)
asyncio.run(main())
输出:
async
async
async
async
async
async
async
async
async
async
sync
sync
sync
sync
sync
sync
sync
sync
sync
sync
1.6 同步异步的效率对比
这段代码分别使用同步和异步 I/O 接口访问了 http://gaolihai.cool/doc/README.md
,并计算了两者用时:
import aiohttp
import asyncio
import time
import requests
def syncRequest():
start = time.time()
for _ in range(10):
res = requests.get(url='http://gaolihai.cool/doc/README.md')
# print(res.text)
print(f'{time.time() - start}s')
async def asyncRequest():
session = aiohttp.ClientSession()
response = await session.get('http://gaolihai.cool/doc/README.md')
# print(await response.text())
await session.close()
async def main():
start = time.time()
await asyncio.gather(
*[asyncRequest() for i in range(10)]
)
print(f'{time.time() - start}s')
syncRequest()
asyncio.run(main())
0.24110913276672363s
0.03390836715698242s
结果指出,异步接口相比同步接口的效率大致高了一个数量级。
所以,对于爬虫这种高 I/O 的程序,使用协程调度异步接口带来的效率提升还是非常可观的。
1.7 低层级接口
到此为止,常用的用来调度协程的高层级接口已经介绍完了,低层级的接口以及其他工具介绍见 https://docs.python.org/zh-cn/3/library/asyncio.html,包含了以下内容。
- 协程锁
- 流
- 进程的协程包装器
- 协程同步容器
- 异常
高层级指面向普通开发者用户的,包装粒度大,使用方便简单的接口。
低层级指的是较底层的接口,一般面向一些第三方库,或标准库的开发者,例如 aiohttp 的开发就必须使用低层级的协程接口,将实现的异步 api 对接到标准库的协程执行器中。
如果有兴趣学习这些低层级接口,建议在此之前先阅读开头提到的参考资料,熟悉协程的基本实现方式。
在将一种技术应用在开发中时,应该首先理解它的工作原理,这一学习技巧也被称为费曼原理。
What I cannot create, I do not understand.
—— 费曼
实际上一些看似复杂的技术都有它的 “玩具” 版本,我们只需要很少的代码就可以实现它们的核心功能。而当我们真的能够理解这些技术的底层原理,并手写出了一个 “玩具” 版本,这就代表着我们真正掌握了它的核心。
那么在使用这些技术时将更加如鱼得水,很轻松就能写出优雅、简洁、高性能的代码,这显然得益于我们基本上了解了它的实现细节。
参考资料中提到的 500 Lines or Less 就是这样一本书,它每一章都用 500 行或更少的代码为读者展示一个优秀的开源技术的实现细节,每个章节的作者都是开源项目的核心贡献者,读完一章就可以动手实现一个麻雀虽小、五脏俱全的 “玩具” 实现。
2. 基于线程的并行
2.1 概述
参考文档 -> https://docs.python.org/zh-cn/3/library/threading.html
threading
是基于低层级接口 _thread
包装的高层级线程接口。
注意,Python (准确的说是 Python 解释器的 CPython 实现,下文同) 中存在一个被称为 GIL (Global Interpreter Lock) 即全局解释器锁的机制。
GIL 确保同一时刻只有一个线程在运行 Python 字节码,这导致 Python 失去了多核上的并发性能,而且由于线程的上下文切换(线程调度)会引起额外的性能开销,这导致多线程性能大部分情况下反而不如单线程。
还要注意,GIL 几乎只是简化了 CPython 的实现,它并不能保证 Python 是线程安全的。
测试下面的代码可以发现,几乎不会发生 count
的值停在 4,000,000 的情况:
import threading
count = 0
def task():
global count
for i in range(1000000):
count += 1
threading.Thread(target=task).start()
threading.Thread(target=task).start()
threading.Thread(target=task).start()
threading.Thread(target=task).start()
# 用于等待除当前线程(主线程)外的所有线程结束
for thread in threading.enumerate():
thread.join() if thread is not threading.current_thread() else 'ignore'
print(count)
所以,面对多线程的性能限制,以及几乎一定存在的额外的性能开销(为解决线程安全问题加锁而导致的),在 Python 中使用协程代替线程是一个在实践中被广泛采用的方案。只有在少数情况下,我们推荐在实践中使用线程,例如上文提到的 asyncio.to_thread
接口。
2.2 构造 Thread
class threading.Thread(
group=None, # 忽略该参数
target=None, # callable 对象
name=None, # 线程的名称
args=(), # 传入线程的位置参数
kwargs={}, # 传入线程的关键字参数
*,
daemon=None, # 是否是守护线程
)
Thread
是一个用于描述线程的类,每一个实例都抽象了一个独立的线程实体。
构造函数的参数:
target
参数是一个可调用对象,即callable
,例如函数对象是一个可调用对象。name
可以为线程设置一个名称,默认以'Thread-1'
的方式命名,可以从对象的name
属性上获得args
和kwargs
分别是传入线程的位置参数和关键字参数daemon
是守护线程的标志位,后面会解释什么是守护线程,可以从对象的daemon
属性上获得
虽然构造函数的参数很多,但基本上我们只需要关注 target
和 args
、kwargs
。
使用它创建和允许线程非常简单:
在 Thread
对象上调用 start
方法,它会在一个独立的控制进程中运行该线程。
from threading import Threadimport time
def task(sec):
time.sleep(sec)
print('done')
Thread(target=task, args=(1,)).start()
Thread(target=task, args=(1,)).start()
Thread(target=task, args=(1,)).start()
一秒后立刻依次输出三个 done
。
注意,这里的
time.sleep
是同步接口,他将会阻塞当前线程,而 “基于协程的并行” 一节中的asyncio.sleep
是异步接口,不会阻塞当前线程,而是位于由协程标准库维护的最近时间最小堆中,由协程调度器在时间结束时进行调度。
2.3 运行线程
run()
方法将会在当前线程中直接运行构造对象时传入的target
对象,不会创建新线程。start()
方法用于在一个独立的控制进程中调用,即创建一个线程进行调度。
2.4 等待线程完成
在某个线程对象 thread1 上调用 join(timeout=None)
可以阻塞调用该方法的线程,直到 thread1 代表的线程执行结束或超时(由 timeout
参数确定)后,调用该方法的线程将被唤醒,从调用处返回。
在下面这个例子中,主线程将在每次循环的 thread1.join()
调用处阻塞,等待一秒并输出 done
,然后继续下一次循环:
from threading import Threadimport time
def task(sec):
time.sleep(sec)
print('done')
for _ in range(3):
thread1 = Thread(target=task, args=(1,)) thread1.start() thread1.join()
三秒内每秒末输出一个 done
。
我们还可以通过 is_alive()
来判断从 join()
调用处返回的原因是超时还是线程退出,is_alive()
调用返回一个布尔值,指明线程是否真正运行。
我们在 join()
后调用 thread1.is_alive()
,若线程正在运行,则是由于超时而返回。
概述一节中的例子展示了如何在主线程中等待其他线程结束,其中 enumerate()
方法返回所有活动线程的可迭代对象,current_thread()
返回当前线程对象(在这个例子中是主线程)。
for thread in threading.enumerate():
thread.join() if thread is not threading.current_thread() else 'ignore'
2.5 守护模式线程
守护线程的含义是 为其他非守护模式的线程服务的线程,可以在构造对象时设置 daemon
参数为 True
或 False
来决定对象是否为守护模式,或直接修改对象的 daemon
属性。
Python 程序将在所有非守护模式的线程结束后退出,而当所有的非守护模式的线程结束后,所有守护线程将被终止。
- 构造出的线程将默认继承当前线程的守护模式,而主线程不是守护线程,所以在主线程中构造出的线程对象默认不是守护线程。
- 当线程开始执行后,
daemon
属性不允许被修改。
2.6 线程锁
线程的并发是一个无法避开的问题,这一节中我们讲解 Python 标准库对常用线程锁的支持。
2.6.1 原始锁
class threading.Lock
-
acquire(blocking=True, timeout=-1)
该方法将锁锁定,并返回一个布尔值表示是否成功获得锁,如果锁已经被锁定,则阻塞直到其他线程释放锁。
blocking
默认为True
,表示是阻塞模式,设置为False
时即便无法获得锁也不会阻塞。timeout
可以设置阻塞超时时间,默认为 -1,表示不超时。
-
release()
释放锁。
-
locked()
检查当前线程是否获得锁,返回一个布尔值。
下面这段代码将会发生死锁:
import threading
lk = threading.Lock()
lk.acquire()
lk.acquire()
2.6.2 重入锁
class threading.RLock
重入锁允许一个线程多次获得锁而不会阻塞,且锁从属于某一个获得它的线程,一个线程的重入锁不能被另一个线程释放。
必须释放与加锁次数相等的次数,才能将一个重入锁转变为未锁定状态。
重入锁在为复杂业务逻辑扩展功能时非常有用:
例如我们使用了一个使用原始锁实现的线程安全的容器类。如果后来需要对多次容器操作加锁,那么直接在外层加锁就会导致在内层容器操作时发生死锁(获得了两次锁),这时候就必须修改容器类的实现,但将业务逻辑与工具类耦合在实践中通常是不可接受的,只会徒增复杂度。这时候使用重入锁就可以对任意扩展的锁定行为开放,而不必修改工具类的实现。
-
acquire(blocking=True, timeout=-1)
该方法将锁锁定,并返回一个布尔值表示是否成功获得锁,如果锁已经被锁定,则阻塞直到其他线程释放锁。
-
release()
释放锁。
2.6.3 信号量
class threading.Semaphore(value=1)
# 有界信号量, 当释放的信号量超过初始值将抛出异常
class threading.BoundedSemaphore(value=1)
这是计算机科学史上最古老的同步原语之一,早期的荷兰科学家 Edsger W. Dijkstra 发明了它。
构造时传入的 value
参数就是信号量的值。
-
acquire(blocking=True, timeout=None)
获取信号量。
-
release(n=1)
释放信号量,可以令信号量的值 + n,默认为 1。
这些是比较常用的基本锁,此外还有条件对象、事件对象等线程同步机制,见本节开始处的文档链接。
2.7 多线程请求示例
import threading
import requests
resultHTML = []
def syncRequest():
resultHTML.append(requests.get(
url='http://gaolihai.cool/doc/README.md').text)
for _ in range(10):
threading.Thread(target=syncRequest).start()
for thread in threading.enumerate():
thread.join() if thread is not threading.current_thread() else 'ignore'
print(resultHTML)
3. 基于进程的并行
参考文档 -> https://docs.python.org/zh-cn/3/library/multiprocessing.html
实际上在爬虫这种 I/O 密集的程序上使用进程并不是一种明智的行为,多进程在资源和进程调度上要比线程的开销更大,而且涉及到进程间通信,不管是在开发的复杂度上,还是在通信的额外开销上,都要花费更大的代价,而换来的仅仅是能够避免 GIL 对多核性能的限制。
然而这个限制只是对 CPU 限制,爬虫的大部分时间都花在了等待 I/O 上,也就是说,爬虫的性能瓶颈在 I/O 上而不是 CPU 上,只有瓶颈在 CPU 上的 Python 程序,使用多进程释放多核 CPU 性能后才能有效提升执行效率。
Python 多进程库 multiprocessing
的 api 与多线程库 threading
形式几乎一样,见上面的参考文档。