一, 上集回顾
在上一篇中我们主要研究了 python 的多线程困境, 发现多核情况下由于 GIL 的存在, python 的多线程程序无法发挥多线程该有的并行威力. 在文章的结尾, 我们提出如下需求: 既然 python 的多线程只是实现了并发功能, 那么我们是否能够进一步的提升并发的能力, 减小多线程的切换开销以及避免应对多线程复杂的同步问题? 那么一个较好的解决方案就是我们本篇要介绍的协程技术. 本篇仍然主要注重理论知识介绍, 不着重讲 python 的协程代码实现.
首先我们放个图在这里, 表示进程, 线程, 协程的关系, 图片涉及的内容会后续介绍.
进程, 线程, 协程的关系
二, 前景知识
协程并不是一个新的概念, 事实上, 协程的概念比线程提出来的还要早, 协程涉及到的知识也不是新的知识, 所以介绍协程之前, 我们首先明确一些基础知识, 包括并发和并行的概念以及了解线程调度的相关概念.
并发和并行, 虚线和实线代表两个不同的任务
2.1 并发
计算机中每一个线程都是一个执行任务, 假设我们现在有一个单核的 CPU,CPU 每时每刻只能调度执行一个线程, 我们第一种做法就是让所有的线程排好队, 一个任务一个任务的依次执行, 执行完一个执行下一个. 采用这种方式的调度带来的问题就是, 如果当前执行的任务陷入了死循环, 那么 CPU 会一直卡在这个任务上, 导致后续的任务无法执行. 所以, 操作系统采用的方案是, 每个任务分一个时间片来执行, 时间片结束之后便切换任务, 换另一个执行, 做到雨露均沾. 假设我们有 4 个任务, 每个任务都分 250ms 进行计算, 那么 1s 后, 每个任务的拥有者都发现自己的任务往前进行了一点, 这就是我们提到的并发 (concurrency). 在 POSIX 中, 并发的定义要求 "延迟调用线程的函数不应该导致其他线程的无限期延迟". 我们上面的四个任务中, 并发操作之间可能任意交错, 对任务的拥有者来说, 1s 后四个任务都往前推进了一部分, 好像四个任务是并行执行的, 但是实际 CPU 执行任务的时候还是一个一个执行的, 所以并发不代表操作同时进行. 那么如果我有四个核心的 CPU 会怎么样呢, 4 个 CPU 核心会各自拿一个任务执行, 这种情况才是我们常说的并行.
2.2 并行
并行只在多处理器的情况下才存在, 因为每个处理器可以各自执行一个任务, 这时四个任务便是并行执行的. 单处理器的情况下是没办法做到并行的. 所以我们回顾中会说, 即使在多核的 CPU 计算资源情况下, python 的多线程没有达到并行而只能达到并发, 因为多个线程无法同时被执行, 只能击鼓传花似的被依次的执行.
2.3 线程调度 -- 上下文切换
线程上下文切换
前文提到, 为了实现并发, 我们需要让 CPU 交替切换的执行不同的任务, 但当操作系统从 thread1 切换到 thread2 的时候, 操作系统实际上打断了 thread1 的执行流程, 那么下一次 thread1 重新被执行的时候, 怎么能保证是继续上一次被打断的时候的位置继续执行的呢? 所以切换的时候要保存任务的执行环境信息, 比如代码运行到哪一行了, 哪些变量被赋值了, 当时寄存器都是那些值等等. 保存当前线程的执行环境信息, 加载下一个线程的执行环境的操作就称为上下文切换. 有了上下文切换, 我们就不用担心任务被打断后会丢失一些执行信息导致下一次接着执行的时候出错.
2.4 线程调度 -- 阻塞调用
当运行中的线程调用 sleep 操作时, 被阻塞, 操作系统调度其他程序, 直到该线程获得唤醒信号
CPU 是非常稀缺的计算资源, 每一纳秒都是珍贵的, 所以我们调度任务的目标就是让 CPU 不停的去计算, 别让它空闲着. 当线程 A 中的代码调用了文件读取操作时, 会发生什么呢?
- def read_file():
- file_object = open("file_name")
- #当执行这一步的时候, 操作系统挂起当前线程, 调度执行其他线程
- data = file_object.read()
- #在未来的某个时刻, 数据准备好了, 操作系统调度执行该线程, 继续往下执行
- return data
由于存储的访问速度非常慢, CPU 就会原地空转一直等着 DMA 把数据准备好, 准备好了之后再往下执行. 那么 CPU 等待的这段时间就完全被空闲浪费了, 因为 CPU 等待的时候还有其他的任务迫切的需要任务计算. 所以操作系统选择当线程 A 调用文件读取这样的阻塞操作的时候, 就把线程 A 阻塞挂起, 停止执行线程 A, 然后调度另一个线程继续执行, 当线程 A 需要的数据准备好了之后, 操作系统便会在未来的某个时刻调度线程 A 继续执行, 如果线程 A 的数据始终都准备不好, 那么线程 A 就永远不会被调度执行.
三, 协程理解
协程是用户级的线程, 是线程之上的轻量级线程
有了前面的基础知识, 我们理解协程就会简单很多, 事实上, 协程本质就是用户态下的线程, 进程里的线程的切换调度是由操作系统来负责的. 但是线程内的协程的调度执行, 是由线程来负责的. 如果我们把协程对应到原生线程, 那么协程所在的原生线程就是操作系统的角色. 即原生线程需要负责什么时候切换协程, 什么时候挂起协程. 协程切换的时候, 线程需要把协程 A 的执行环境进行保存, 在下一次执行 A 的时候, 线程需要恢复执行环境, 这样就可以从 A 之前的位置继续执行.
用户线程即为协程, 操作系统感知不到协程的存在, 只调度内核线程
在这里我们需要提醒的是, 多线程的使用是可以让一个程序获得更多的计算时间的, 但是协程的使用不会, 多线程的使用在多核的情况下, 可以达到并行的效果, 但是协程的使用不会达到并行的效果. 因为操作系统感知不到协程的存在, 只会把时间片和 CPU 核心分给线程. 至于分给线程的时间, 线程又会分配给哪个协程来运行, 那是线程自己决定的内容. 比如分配 2ms 给一个拥有两个协程的线程 A, 线程被操作系统调度指派给了 CPU 核心 C1, A 会决定在 C1 运行哪个线程,, 可以雨露均沾, 让两个协程各自运行 1ms, 也可以是把 2ms 全部分配给一个协程, 自始至终, 所有的协程都运行在 CPU 核心 C1 上, 所以无法实现协程并行.
线程内部自主进行协程调度
那使用协程的好处是什么呢? 提高线程的并发度, 减小切换的开销, 限于篇幅, 这里就不展开讲, 其结论就是, 协程的切换只是线程栈内的切换操作, 不涉及内核操作, 其切换速度远快于线程.
如果我们要实现协程调度, 我们该实现哪些功能呢. 比如有一个线程底下有两个协程 A,B, 根据用户输入的文件名, A 协程进行文件读取, 并返回文件内容, B 协程根据文件名计算哈希值并返回.
- # 以下代码并非真实的 python 协程代码, 只是为了说明例子
- def coroutine_A(file_path):
- file_object = open(file_path)
- #协程执行到文件阅读, 则挂起协程, 切换到 B
- data = file_object.read()
- #数据准备好之后, 线程获得通知, 然后在未来某个时刻调度协程 A 继续执行
- file_object.close()
- return data
- def coroutine_B(file_path):
- hash_value = Hash(file_path)
- return hash_value
线程首先调度执行 A, 执行到文件读取部分发现需要等待, 于是挂起协程 A 并切换到协程 B 执行. 所以要实现调度协程, 那么至少需要实现协程挂起操作和协程恢复运行两个操作, 如果不想手动进行调度, 那么可以实现一个中央的调度器来帮助进行调度.
四, 协程的实现
协程主要有如下两个特点:
协程可以保留运行时的状态数据
协程可以出让自己的执行权, 当重新获得执行权时从上一次暂停的位置继续执行
保留运行时状态数据就是上下文切换时做的工作, 便于下一次执行时能继续上一次暂停的位置执行. 协程出让执行权, 指的是如果线程指定一个协程运行, 除非该协程主动放弃执行权, 不然线程无法将协程挂起切换.
Lua 很早就有了语言级别对协程的实现, 我个人觉得其协程 API 还是比较清晰的, 在这里简单介绍说明一下.
API | 说明 |
---|---|
create(f) | 创建协程。协程创建好之后,其初始状态为挂起状态 |
resume(co,[val1,...]) | 调度执行协程。第一次执行 resume 操作时,会从 create 传入的函数开始执行,之后会在该协程主函数调用 tield 的下一个操作开始执行,直到这个函数执行完毕。调用 resume 操作必须在主线程中。 |
yeild | 挂起当前协程的运行,主动放弃执行权,调用 yeild 操作必须在协程中 |
status | 协程状态,有 dead、runing、suspend、normal |
五, Talk is cheap, show me the code
python 的协程实现历史较为悠久, 很多介绍协程的文章会从很早的协程库开始介绍, 因为本篇博客更多专注于协程的概念理解, 并不专注于 python 的协程技术实现, 我们就直接从最新的协程代码编写方式开始介绍.
python3.4 之后引入了 asyncio 模块, 使得协程的使用更加的方便, 其中关键词 async 表明这一块函数是一个协程块, 而不是普通的函数模块 (函数模块从中间退出之后, 是不会保留运行环境的, 但是协程会保留), await 关键字表明协程主动出让执行权. 我们定义三个协程模块, 并让调度器进行调度执行 A 和 B. 首先调度运行协程 B, 运行到 sleep 函数的时候遇到 await 关键字并出让执行权, 这时调度器切换执行协程 A, 协程 A 执行又遇到 await, 再一次出让执行权. 这时两个协程都在等待唤醒的信号. 等待到了信号之后, 两个协程被唤醒进而调度执行, 然后运行结束. 结果如下
- import asyncio
- import time
- # async 关键字表明这是个协程
- async def coroutine_A():
- print("协程 A 开始执行")
- print("协程 A 出让执行权")
- # await 关键字表明主动出让执行权
- await asyncio.sleep(2)
- print("协程 A 重新获得执行权, 并执行结束")
- async def coroutine_B():
- print("协程 B 开始执行")
- print("协程 B 出让执行权")
- await asyncio.sleep(2)
- print("协程 B 重新获得执行权, 并执行结束")
- async def coroutine_C():
- while(1):
- print("由于协程 A,B 始终等待时钟信号, 协程 C 执行")
- await asyncio.sleep(0.4)
- if __name__ == "__main__":
- start_time = time.time()
- loop = asyncio.get_event_loop()
- group1 = [coroutine_A(), coroutine_B()]
- group1 = asyncio.gather(*group1)
- loop.run_until_complete(asyncio.gather(group1, return_exceptions=True))
- print("程序运行时间: {}".format(time.time() - start_time))
程序结果 1:
协程 B 开始执行
协程 B 出让执行权
协程 A 开始执行
协程 A 出让执行权
协程 B 重新获得执行权, 并执行结束
协程 A 重新获得执行权, 并执行结束
程序运行时间: 2.002208709716797
此时我们加上第三个协程进行调度, 这样当 A,B 等待时钟信号的时候我们在等待的期间, 让调度器执行调度协程 C, 虽然协程 C 也调用 sleep 函数, 但是由于睡眠时间短, 所以很快又会被唤醒进行调度执行. 当然了, 由于协程 C 是死循环, 所以协程 A,B 结束之后, 会一直执行协程 C.
- import asyncio
- import time
- # async 关键字表明这是个协程
- async def coroutine_A():
- print("协程 A 开始执行")
- print("协程 A 出让执行权")
- # await 关键字表明主动出让执行权
- await asyncio.sleep(2)
- print("协程 A 重新获得执行权, 并执行结束")
- async def coroutine_B():
- print("协程 B 开始执行")
- print("协程 B 出让执行权")
- await asyncio.sleep(2)
- print("协程 B 重新获得执行权, 并执行结束")
- async def coroutine_C():
- while(1):
- print("由于协程 A,B 始终等待时钟信号, 协程 C 执行")
- await asyncio.sleep(0.4)
- if __name__ == "__main__":
- start_time = time.time()
- loop = asyncio.get_event_loop()
- # 加入协程 C 进行调度
- group1 = [coroutine_A(), coroutine_B(), coroutine_C()]
- group1 = asyncio.gather(*group1)
- loop.run_until_complete(asyncio.gather(group1, return_exceptions=True))
- print("程序运行时间: {}".format(time.time() - start_time))
程序运行部分结果:
协程 B 开始执行
协程 B 出让执行权
协程 A 开始执行
协程 A 出让执行权
由于协程 A,B 始终等待时钟信号, 协程 C 执行
由于协程 A,B 始终等待时钟信号, 协程 C 执行
由于协程 A,B 始终等待时钟信号, 协程 C 执行
由于协程 A,B 始终等待时钟信号, 协程 C 执行
协程 A 重新获得执行权, 并执行结束
协程 B 重新获得执行权, 并执行结束
我们前面提到过, 协程的两大特点, 一是可以保存运行时环境, 另一个便是可以主动出让执行权. 那么假如有一个协程 C 始终不出让执行权, 即在代码中, 不用 await 关键字, 那么其他协程是不是就没办法被执行了呢, 很不幸的是, 的确是这样的. 我们看下代码
- import asyncio
- import time
- async def coroutine_A():
- print("协程 A 开始执行")
- print("协程 A 出让执行权")
- await asyncio.sleep(2)
- print("协程 A 重新获得执行权, 并执行结束")
- async def coroutine_B():
- print("协程 B 开始执行")
- print("协程 B 出让执行权")
- await asyncio.sleep(2)
- print("协程 B 重新获得执行权, 并执行结束")
- # 协程 C 始终不出让执行权
- async def coroutine_C():
- while(1):
- time.sleep(0.4)
- print("协程 C 不使用 await 关键字, 故不选择出让执行权, 所以继续执行 C")
- if __name__ == "__main__":
- start_time = time.time()
- loop = asyncio.get_event_loop()
- group1 = [coroutine_C(),coroutine_A(),coroutine_B()]
- group1 = asyncio.gather(*group1)
- loop.run_until_complete(asyncio.gather(group1, return_exceptions=True))
- print("程序运行时间: {}".format(time.time() - start_time))
程序运行结果
协程 B 开始执行
协程 B 出让执行权
协程 A 开始执行
协程 A 出让执行权
协程 C 不使用 await 关键字, 故不选择出让执行权, 所以继续执行 C
协程 C 不使用 await 关键字, 故不选择出让执行权, 所以继续执行 C
协程 C 不使用 await 关键字, 故不选择出让执行权, 所以继续执行 C
协程 C 不使用 await 关键字, 故不选择出让执行权, 所以继续执行 C
协程 C 不使用 await 关键字, 故不选择出让执行权, 所以继续执行 C
协程 C 不使用 await 关键字, 故不选择出让执行权, 所以继续执行 C
协程 C 不使用 await 关键字, 故不选择出让执行权, 所以继续执行 C
协程 C 不使用 await 关键字, 故不选择出让执行权, 所以继续执行 C
...
从结果中我们可以看到, B 和 A 都主动出让了执行权, 但由于 C 中虽然同样调用了 sleep() 函数, 但是没有使用 await 关键字来出让执行权, 所以始终 C 就被执行, 永远轮不到 A 和 B 执行了.
六, 总结
很多讲协程的博客都是从异步 / 同步的角度出发, 但我始终觉得异步实际上无处不在, 并不是只有协程才有的概念, 协程说到底就是用户态下的线程, 如果我们了解清楚线程, 包括线程的上下文切换, 线程的调度我们就能很好的理解协程.
七, 后记
终于写完了这篇博客, 为了写这篇, 花了好久的时间去查资料, 还顺便把本科的操作系统课的课件翻出来看了一遍. 最大的感受就是想要把这个内容在一篇博客中尽可能的说清楚, 真的有点难, 因为涉及到的内容太多了, 上文中还有许多的概念和结论没有展开说, 但是限于篇幅, 只能日后有需要再进行展开介绍了. 不管怎么说, 这个 flag, 算是拔掉了.
来源: https://www.qcloud.com/developer/article/1542064