|
1 | 1 | # Attack on Python - 协程 🐍
|
2 | 2 |
|
3 |
| - |
4 |
| - |
5 |
| - |
6 |
| - |
7 |
| - |
8 |
| - |
9 |
| - |
10 |
| -<extoc></extoc> |
11 |
| - |
12 | 3 | ## 介绍
|
13 |
| -[协程 (`Coroutine`)](https://zh.wikipedia.org/wiki/%E5%8D%8F%E7%A8%8B) , 就是一组可以协调工作 [(协作式)](https://zh.wikipedia.org/wiki/%E5%8D%8F%E4%BD%9C%E5%BC%8F%E5%A4%9A%E4%BB%BB%E5%8A%A1) 的子程序 `(函数)` |
14 |
| - |
15 |
| -协程的本质就是一组函数 , 一组协同工作的函数 |
16 |
| - |
17 |
| -### 线程和协程的区别 |
18 |
| - |
19 |
| -相对于线程而言 , 线程是抢占式的 , 它的调度方案是由操作系统控制的 , 而协程是协作式或者说非抢占式的 , 由程序自己主动让出处理器 , 所以协程会更加的灵活 ; 并且线程是昂贵的 , 线程上下文切换的成本要高于协程上下文切换 , 而且协程与 `CPU` 和操作系统通常没有关系 , 所以没有理论上限 , 也就是说 , 协程要比线程轻得多 , 这也是为什么协程又被叫做 "微线程" |
20 |
| - |
21 |
| -"微线程" 只是用来说明协程比线程要轻量级 , 但是协程和线程完全是两个概念 , 说白了协程只是一组函数 , 而线程是操作系统的内核对象 |
22 |
| - |
23 |
| -在历史上是先有的协程 , 后有的线程 , 协程出现的目的是为了实现并发 , 而线程则是为了并行 , 也就是线程可以利用多核优势 (当然在 `Python` 中由于 `GIL` 锁线程同样无法利用多核优势) , 而协程无法利用多核优势 , 并且非抢占式调度的公平性是一个很大的问题 , 所以相对而言协程都没参与多核 `CPU` 并行处理 , 而线程可以利用多核达到真正的并行计算 , 这两者的差距就不言而喻了 , 这也是为什么后来线程比协程要更加广泛 ; 而到了现代 , 协程更多的是用来做成一组执行队列 , 比如迭代器 , 事件循环等等 |
24 |
| - |
25 |
| -那为什么协程现在被网络上神化了呢 ? 接着往下看 |
26 |
| - |
27 |
| -要实现协程 , 需要实现中断 , 恢复 , 切换上下文这三项功能 , 在实现之前 , 我们先说说生成器 |
28 |
| - |
29 |
| -## 生成器 |
30 |
| - |
31 |
| -生成器是一次生成一个值的特殊类型函数 , 它可以进行惰性求值 , 因为生成器每次调用都只生成一个值 , 所以他不需要提前将数据加载到内存 |
32 |
| - |
33 |
| -在 `Python` 中我们可以通过 `yield` 来定义一个生成器 , `yield` 语句会把你需要的值返回给调用生成器的地方 , 后退出函数 下一次调用生成器函数的时候又从上次中断的地方开始执行 , 而生成器内的所有变量参数都会被保存下来供下一次使用 , 这就是生成器实现的原理 |
34 |
| - |
35 |
| -### 生成器和协程的关系 |
36 |
| - |
37 |
| -生成器和协程的区别就是它们都可以挂起自身的执行 , 或者说拥有中断能力 , 但是协程除了中断能力 , 还拥有控制能力 , 协程可以控制在它让位之后哪个协程立即续它来执行 , 而生成器不能 , 生成器只能把控制权转交给调用生成器的调用者 , 所以生成器 , 也叫做 "半协程" , 是协程的子集 |
38 |
| - |
39 |
| -## 实现协程 |
40 |
| - |
41 |
| -在 `Python` 的早期版本里 , 我们可以通过 `yield` 以及 `send` 方法来实现协程 |
42 |
| - |
43 |
| -```python |
44 |
| -import time |
45 |
| -from queue import Queue |
46 |
| - |
47 |
| -q = Queue() |
48 |
| - |
49 |
| -def produce(consumer): |
50 |
| - count = 0 |
51 |
| - while True: |
52 |
| - while not q.qsize(): |
53 |
| - count += 1 |
54 |
| - q.put(count) |
55 |
| - time.sleep(1) |
56 |
| - print('[PRODUCER] Producing %s...' % count) |
57 |
| - # 恢复 |
58 |
| - consumer.send(count) |
59 |
| - |
60 |
| -def consumer(): |
61 |
| - while True: |
62 |
| - while q.qsize(): |
63 |
| - count = q.get() |
64 |
| - time.sleep(1) |
65 |
| - print('[CONSUMER] Consuming %s...' % count) |
66 |
| - # 中断 |
67 |
| - yield |
68 |
| - |
69 |
| -consumer = consumer() |
70 |
| -# 初始化生成器 |
71 |
| -next(consumer) |
72 |
| -produce(consumer) |
73 |
| -``` |
74 |
| - |
75 |
| -一个协程作为生产者 , 一个协程作为消费者 , 这样我们就实现了一个简单的多任务生产者消费者模型 , 生产者消费者协同工作自动切换 , `yield` 来中断执行 , `send` 来恢复执行 , 而代码逻辑控制了上下文的切换 , 这个例子只是为了证明协程的实用性 |
76 |
| - |
77 |
| -你可能会发现 , 把上面的代码按照 `yield` 拆分成几个函数功能上是一样的 , 我们把拆分的函数叫做**子例程** , 实际上 , 子例程可以看做是特定状态的协程 , 任何的子例程都可以转写成不使用 `yield` 的协程 |
78 |
| - |
79 |
| -### 子例程和协程的区别 |
80 |
| - |
81 |
| -相对于子例程而言 , 协程更加灵活 , 协程更加适合用来实现彼此比较熟悉的程序组件 , 或者说耦合度高一点的组件 , 比如 : [协作式多任务](https://zh.wikipedia.org/wiki/%E5%8D%8F%E4%BD%9C%E5%BC%8F%E5%A4%9A%E4%BB%BB%E5%8A%A1)、[异常处理](https://zh.wikipedia.org/wiki/%E5%BC%82%E5%B8%B8%E5%A4%84%E7%90%86)、[事件循环](https://zh.wikipedia.org/wiki/%E4%BA%8B%E4%BB%B6%E5%BE%AA%E7%8E%AF)、[迭代器](https://zh.wikipedia.org/wiki/%E8%BF%AD%E4%BB%A3%E5%99%A8)、[无限列表](https://zh.wikipedia.org/wiki/%E6%83%B0%E6%80%A7%E6%B1%82%E5%80%BC)和[管道](https://zh.wikipedia.org/wiki/%E7%AE%A1%E9%81%93_(%E8%BD%AF%E4%BB%B6)) |
82 |
| - |
83 |
| -协程的切换概念是 "让步" , 而子例程的切换概念是 "出产" , 一个主动 , 一个被动 , 以下摘自 [Wiki](https://zh.wikipedia.org/wiki/%E5%8D%8F%E7%A8%8B) : |
84 |
| - |
85 |
| -- 子例程可以调用其他子例程 , 调用者等待被调用者结束后继续执行 , 故而子例程的生命期遵循后进先出 , 即最后一个被调用的子例程最先结束返回 , 协程的生命期完全由对它们的使用需要来决定 |
86 |
| -- 子例程的起始处是惟一的入口点 , 每当子例程被调用时,执行都从被调用子例程的起始处开始 , 协程可以有多个入口点 , 协程的起始处是第一个入口点 , 每个 `yield` 返回出口点都是再次被调用执行时的入口点 |
87 |
| -- 子例程只在结束时一次性的返回全部结果值 , 协程可以在 `yield` 时不调用其他协程 , 而是每次返回一部分的结果值 , 这种协程常称为[生成器](https://zh.wikipedia.org/wiki/%E7%94%9F%E6%88%90%E5%99%A8_(%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BC%96%E7%A8%8B))或[迭代器](https://zh.wikipedia.org/wiki/%E8%BF%AD%E4%BB%A3%E5%99%A8) |
88 |
| - |
89 |
| -所以到这里 , 协程的应用并没有线程那么广泛 , 可能也并没有想象中那么强大 , 而且协程是在单线程下的 , 只要一处阻塞那么整个协程全部都得阻塞 , 并且 `IO` 是系统调用 , 这个不是用户态能处理的 , 协程无法绕开 |
90 |
| - |
91 |
| -以上就是 "纯协程" 了 , 所以综上 , 协程的性能是比不过线程的 , 所以遇到 `IO` 正确的操作应该是使用多线程 , 不会一堵全堵 , 但是线程的调度算法是比较僵硬的 , 时间片的算法无法准确地识别线程是否正在等待 `IO` , 从而造成了很多空等的 `CPU` 资源 , 所以我们应该使用像 `epoll` 这种异步回调的方式 , 让我们来看看异步回调的代码是怎么写的 : |
92 |
| - |
93 |
| -有3个 `IO` 操作按顺序执行 , 先执行 `select_data` , 耗时 `1` 秒 , 随后执行 `update_data` , 耗时 `0.5` 秒 , 最后再执行 `delete_data` , 耗时 `0.3` 秒 |
94 |
| - |
95 |
| -```python |
96 |
| -import time |
97 |
| - |
98 |
| -# 3个 IO 操作顺序执行, 顺序如下: select_data, update_data, delete_data |
99 |
| -# 功能函数 |
100 |
| -def select_data(callback): |
101 |
| - def callback_for_select(): |
102 |
| - time.sleep(1) |
103 |
| - result = 'select_data_result\n' |
104 |
| - return callback(result) |
105 |
| - # 模拟IO回调 |
106 |
| - return callback_for_select() |
107 |
| - |
108 |
| - |
109 |
| -def update_data(select_result, callback): |
110 |
| - def callback_for_update(): |
111 |
| - time.sleep(0.5) |
112 |
| - result = select_result + 'update_data_result\n' |
113 |
| - return callback(result) |
114 |
| - # 模拟IO回调 |
115 |
| - return callback_for_update() |
116 |
| - |
117 |
| - |
118 |
| -def delete_data(update_result, callback): |
119 |
| - def callback_for_delete(): |
120 |
| - time.sleep(0.3) |
121 |
| - result = update_result + 'delete_data_result' |
122 |
| - return callback(result) |
123 |
| - # 模拟IO回调 |
124 |
| - return callback_for_delete() |
125 |
| - |
126 |
| -# 我们的调用代码 |
127 |
| -def select_callback(select_result): |
128 |
| - def update_callback(update_result): |
129 |
| - def delete_callback(delete_result): |
130 |
| - result = delete_result |
131 |
| - return result |
132 |
| - return delete_data(update_result, delete_callback) |
133 |
| - return update_data(select_result, update_callback) |
134 |
| - |
135 |
| -result = select_data(select_callback) |
136 |
| -print(result) |
137 |
| - |
138 |
| -# 运行结果 |
139 |
| -""" |
140 |
| -select_data_result |
141 |
| -update_data_result |
142 |
| -delete_data_result |
143 |
| -""" |
144 |
| -``` |
145 |
| - |
146 |
| -不难发现 , 异步回调实际上就是一组子例程协同工作的过程 , 只不过它的切换由我们注册的回调函数来控制 , 上面这段代码中 , 通过闭包来保存上下文 , 为了能让这段代码跑起来 , 我们这里就通过调用回调函数来模拟 `IO` 事件的回调 |
147 |
| - |
148 |
| -上面这段代码 , 如果我们使用同步的方式 , 会是这样的 : |
149 |
| - |
150 |
| -```python |
151 |
| -# 功能函数 |
152 |
| -def select_data(): |
153 |
| - return 'select_data_result\n' |
154 |
| - |
155 |
| -def update_data(select_data_result): |
156 |
| - return select_data_result + 'update_data_result\n' |
157 |
| - |
158 |
| -def delete_data(update_data_result): |
159 |
| - return update_data_result + 'delete_data_result' |
160 |
| - |
161 |
| -# 我们的调用函数 |
162 |
| -select_result = select_data() |
163 |
| -update_result = update_data(select_result) |
164 |
| -delete_result = delete_data(update_result) |
165 |
| -``` |
166 |
| - |
167 |
| -异步和同步在代码的可读性上差别还是相当的大的 , 异步回调的代码实现相当的复杂 , 而且很容易遇到 `callback hell` , 而在上面我们已经知道了 , 子例程可以看作是特定的协程 , 任何子例程都可以转写为不调用 `yield` 的协程 , 如下 : |
168 |
| - |
169 |
| -```python |
170 |
| -def select_data(): |
171 |
| - # 模拟IO回调 |
172 |
| - yield 'select_data_result\n' |
173 |
| - |
174 |
| -def update_data(select_result): |
175 |
| - # 模拟IO回调 |
176 |
| - yield select_result + 'update_data_result\n' |
177 |
| - |
178 |
| -def delete_data(update_result): |
179 |
| - # 模拟IO回调 |
180 |
| - yield update_result + 'delete_data_result' |
181 |
| - |
182 |
| -def main(): |
183 |
| - select_result = next(select_data()) |
184 |
| - update_result = next(update_data(select_result)) |
185 |
| - delete_result = next(delete_data(update_result)) |
186 |
| - return delete_result |
187 |
| - |
188 |
| -main() |
189 |
| -``` |
190 |
| - |
191 |
| -这里简单的解释一下 , 因为代码无法体现出 `IO` 的异步回调 , 所以在异步回调的版本中通过 `callback_for_xx() ` 进行模拟 , 而这个 `yield` 的版本中就是通过 `yield` 进行模拟 , 另外不管是操作系统的切换(线程切换) , 还是我们自己控制的切换(协程切换) , 都是切换出当前的执行线让 `CPU` 去做别的事情 |
192 |
| - |
193 |
| -到这里我们直接对比 , 明显协程的方式的实现代码要比异步回调方式的实现代码可读性要高得多 , 没有了回调噩梦 |
194 |
| - |
195 |
| -这也是我们为什么要使用协程的原因 , 可以更好的和 `异步IO` 结合 , 如果用一句话概括的话 : **让原来要使用异步回调方式写的非人类代码 ,可以用看似同步的方式写出来** |
196 |
| - |
197 |
| -还有一点要说明的是 , 现在网络上的 "协程" 其实不只是 "协程" , 你在上面可以看到我有写过 "纯协程" ; 网络上的协程实际上是协程和一些组件的结合体 , 因为协程本质就是一组协同工作的程序 , 举个典型例子 , `IO` 阻塞就不是协程能处理的 , 而是协程 + `epoll` 的结果 , 而且有的协程库还融合了多线程来实现 |
198 |
| - |
199 |
| -## 使用协程 |
200 |
| - |
201 |
| -到这里 , 协程的概念已经讲完了 , 那么协程要怎么去使用呢 ? |
202 |
| - |
203 |
| -在 `Python 3.4` 引入了 `asyncio` 对异步 IO 的支持 , 而在 `Python 3.5` 引入了 `async/await` 两个关键字提供了对无栈协程(见后文)的支持 |
204 |
| - |
205 |
| -协程的使用有个很大的问题 , 那就是我们要如何去控制调度 , 有一个好的想法就是我们可以弄一个任务队列 , 然后再跑一个死循环 , 切换就把当前任务追到队列的尾部 , 再从头部取一个任务 , 直到所有任务完成 , 当然它还要你应该具备遇到时钟阻塞 , `IO` 切换的功能 , 它就是事件循环 |
206 |
| - |
207 |
| -我们先看看已有的 `asyncio` 怎么去编写异步代码 : |
208 |
| - |
209 |
| -```python |
210 |
| -import asyncio |
211 |
| - |
212 |
| -# 功能函数 |
213 |
| -async def select_data(): |
214 |
| - await asyncio.sleep(1) |
215 |
| - return 'select_data_result\n' |
216 |
| - |
217 |
| -async def update_data(select_data_result): |
218 |
| - await asyncio.sleep(0.5) |
219 |
| - return select_data_result + 'update_data_result\n' |
220 |
| - |
221 |
| -async def delete_data(update_data_result): |
222 |
| - await asyncio.sleep(0.3) |
223 |
| - return update_data_result + 'delete_data_result' |
224 |
| - |
225 |
| -# 调用函数 |
226 |
| -async def main(): |
227 |
| - select_result = await select_data() |
228 |
| - update_result = await update_data(select_result) |
229 |
| - delete_result = await delete_data(update_result) |
230 |
| - return delete_result |
231 |
| - |
232 |
| -print(asyncio.get_event_loop().run_until_complete(main())) |
233 |
| -``` |
234 |
| - |
235 |
| -`async` 用来定义一个协程 , `await` 则是用来切换上下文 , 最后利用 `asyncio.get_event_loop` 获取事件循环来完成我们的任务 |
236 |
| - |
237 |
| -## 事件循环 |
238 |
| - |
239 |
| - |
240 |
| - |
241 |
| -## 有栈协程与无栈协程 |
242 | 4 |
|
| 5 | +关于协程相关的内容 , 已经移至[《Attack on Tornado - 协程》](https://attack-on-backend.github.io/tornado/08-%E5%8D%8F%E7%A8%8B.html) , 请前往阅读 |
0 commit comments