python协程小记

Python协程中的疑问

Python中的async/await究竟是怎么用的?

有许多教程在介绍中会这么告诉你

1
2
3
4
5
import asyncio
async def demo1():
# 等待5s
await asyncio.sleep(5)
print("睡眠结束")

但是大家可以看到,sleep函数只能使用asyncio包中自带的函数,一旦换成平时使用的time包就会报错。比如下面:

1
2
3
4
5
6
7
import time,asyncio
async def demo2():
# 预期等待5s
await time.sleep(5) #的确是等待了5s
# 报错了:
# TypeError: object NoneType can't be used in 'await' expression
print("这条消息不应该被打印")

并且,实际测试中我们也知道,虽然demo1可以并行,但是demo2根本无法并行,这是为什么呢?

只能用asyncio包里的sleep函数作展示,连个requests包都用不了,这协程到底又有什么用呢?

系统调度与用户程序调度

本来这次我是想先写一些调度方面的科普的,但是调度牵扯到非常复杂的知识,我的积累还不够写出一篇好科普来,所以先写一些简单的表象。

我们知道操作系统负责调度进程与线程,通过操作系统的调度,即使是单核处理器也可以表现得像一个同时进行多任务的电脑,这是普通用户使用电脑的基础。而操作系统进行运行调度的最小单位是线程,所以我们下面仅使用线程进行说明。

线程是由操作系统调度的,用户程序并不能控制操作系统如何调度线程,并且,用户程序也不能得知操作系统何时、如何调度线程。但是,用户程序掌握着从操作系统中获取的部分资源,这些资源可以让操作系统决定调度策略。比如最简单的,如果一个线程调用了sleep()函数,那么操作系统得知这个线程会等待一定的时间,那么在这一段时间内便不会让这个线程运行。此时这个线程便失去了在CPU中执行的权利,我们称其为进入了阻塞状态。当达到预计的睡眠时间后,操作系统会考虑继续运行这个线程(但并不一定立刻运行该线程)。

当然,除了sleep()函数,一些IO有关的函数,比如request.get()也是会通知系统后进入阻塞状态,直到收到第一个TCP数据包为止都会阻塞。但系统调度的不透明,不仅仅表现在用户程序对系统的调度一无所知,系统也不知道用户程序何时会触发调度(原因请参考停机问题),而为了能运行其他程序,会在一定时间或者一些系统内置的条件达到后强行暂停线程的运行,这被称之为挂起

操作系统调度进程是需要消耗时间的,虽然一次时间短到可以忽略,但如果频繁地调度,消耗的时间就会长到无法忽视。而服务器软件常常需要同时处理成千上万个连接,如果采用线程调度的方式,就会有很多时间就会消耗在线程的调度上。同时,也会出现从挂起中恢复后刚运算了十几步,就触发IO进入阻塞这种极端情况。

为了规避这种情况,人们又发明了协程。协程的核心是使用操作系统的非阻塞IO请求,其调度不经过系统,而由程序本身进行。所以一个协程程序只需要有一个线程在运行即可。也因此协程中不允许使用线程睡眠(time.sleep()),不允许使用阻塞的IO请求,也无法利用多核CPU。但是协程的优点就是可以由程序员掌控调度的过程,可以出现一些极端情况。同时程序内部调度不如操作系统调度那般复杂,减少了调度的工作量。

常见问题和解答

相信大家了解到这里,许多问题就会迎刃而解,下面我写一些常见的问题和答案。


Q:为什么协程要有一个入口点?

A:因为你需要将这些协程函数加入程序自己的调度器,没有一个中心调度器的话程序不知道如何运行协程函数。


Q:为什么无法使用传统的网络请求函数?

A:因为传统的网络请求工具使用了阻塞的IO接口,调用它们系统会不再运行这个线程,程序自身协程调度器自然也就没了意义。


Q:asyncio.sleep(n)为什么能实现协程睡眠?

A:这个函数并没有通知系统,而是通知了自己内部的调度器,所以系统不会因此挂起这个线程。


Q:协程的优缺点?

A: 如下。

优点

  1. 由程序员掌控调度时机,调度比操作系统更有效率
  2. 程序的调度器不如操作系统调度器那般复杂,同时不会触发用户态和内核态之间的切换,可以将更多CPU用于执行程序逻辑而不是调度。
  3. 由于调度时机可控,可以省去锁、信号量等工具,无需关心同步问题。

缺点

  1. 具有传染性,也就是如果使用了任何一个协程IO函数,那么所有的其他IO函数都需要换成协程IO才不会阻塞。最后的结果一定是从主函数到其他函数全部是协程函数。
  2. 需要额外的标准库学习成本,需要定制的第三方包。

Q:作者怎么看待Python的协程?

A:协程具有要么不用,要么全用的属性。而现在的Python环境来看,不用是最好的选择。目前协程还是小众的Python产品,配齐全套协程工具并且在需要时随时加入新功能是很难的。就像即使Tornado网络性能占优但大家还是愿意基于Django开发Python后端一样,Python协程还需要继续发展。


Q:那如果我一定要用协程,还要用传统的网络请求函数怎么办?

A:虽然我建议你全部使用多线程来代替,但是如果出于某些原因(比如最实现最基本功能的包使用await),一定要混合也不是没有办法。下面给出一个可行的代码示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import asyncio
import threading
def awaitable(func):
async def _wrapper(*args, **kwargs):
def _runner():
try:
result = func(*args, **kwargs)
# 不能直接使用 future.set_result(result)
# 很大概率会导致无法继续运行!下同。
loop.call_soon_threadsafe(future.set_result, result)
except Exception as e:
loop.call_soon_threadsafe(future.set_exception, e)

loop = asyncio.get_running_loop()
future = loop.create_future()
# 创建一个线程包装器来执行。
t = threading.Thread(target=_runner)
# 启动线程,等待future。
t.start()
return await future

return _wrapper

这是一个装饰器,可以装饰在任何你想要的函数上,装饰上之后就可以使用await调用了。这里举个实际使用的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
@awaitable
def requests_get(url):
return requests.get(url)


async def main():
resp = await requests_get("https://www.baidu.com")
print(resp.text)


if __name__ == '__main__':
asyncio.run(main())

当然有经验的读者一定能看出,这不过是一个多线程await包装器而已,实际上还是多线程,并没有协程的优势,但这种方式的确能暂时规避这个痛点。所以我希望大家尽量不要使用该方法,直接用多线程就好了,连Java十万并发都直接靠系统线程调度呢,在Python这性能真的不是很重要的。


Q:除了Python还有其他支持协程的编程语言吗?

A:当然有,多的是。常见的语言级支持比如C#、Rust、Kotlin、JavaScript。同时C也可以通过外部包等方式进行协程开发,C++也计划于C++20中支持协程。

  • Copyrights © 2019-2025 Ytyan

请我喝杯咖啡吧~