前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Python线程、协程探究(二)—— 揭开协程的神秘面纱

Python线程、协程探究(二)—— 揭开协程的神秘面纱

原创
作者头像
eedalong
修改2019-11-21 17:41:03
1.3K0
修改2019-11-21 17:41:03
举报

一、上集回顾

上一篇中我们主要研究了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操作时,被阻塞,操作系统调度其他程序,直到该线程获得唤醒信号
当运行中的线程调用sleep操作时,被阻塞,操作系统调度其他程序,直到该线程获得唤醒信号

CPU是非常稀缺的计算资源,每一纳秒都是珍贵的,所以我们调度任务的目标就是让CPU不停的去计算,别让它空闲着。当线程A中的代码调用了文件读取操作时,会发生什么呢?

代码语言:python
复制
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
复制
# 以下代码并非真实的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,再一次出让执行权。这时两个协程都在等待唤醒的信号。等待到了信号之后,两个协程被唤醒进而调度执行,然后运行结束。结果如下

代码语言:python
复制
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))
代码语言:javascript
复制
程序结果1:

协程B开始执行
协程B出让执行权
协程A开始执行
协程A出让执行权
协程B重新获得执行权,并执行结束
协程A重新获得执行权,并执行结束
程序运行时间: 2.002208709716797

此时我们加上第三个协程进行调度,这样当A、B等待时钟信号的时候我们在等待的期间,让调度器执行调度协程C,虽然协程C也调用sleep函数,但是由于睡眠时间短,所以很快又会被唤醒进行调度执行。当然了,由于协程C是死循环,所以协程A、B结束之后,会一直执行协程C。

代码语言:python
复制
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))
代码语言:javascript
复制
程序运行部分结果:

协程B开始执行
协程B出让执行权
协程A开始执行
协程A出让执行权
由于协程A,B始终等待时钟信号,协程C执行
由于协程A,B始终等待时钟信号,协程C执行
由于协程A,B始终等待时钟信号,协程C执行
由于协程A,B始终等待时钟信号,协程C执行
协程A重新获得执行权,并执行结束
协程B重新获得执行权,并执行结束

我们前面提到过,协程的两大特点,一是可以保存运行时环境,另一个便是可以主动出让执行权。那么假如有一个协程C始终不出让执行权,即在代码中,不用await关键字,那么其他协程是不是就没办法被执行了呢,很不幸的是,的确是这样的。我们看下代码

代码语言:python
复制
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))
代码语言:javascript
复制
程序运行结果
协程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,算是拔掉了。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、上集回顾
  • 二、前景知识
    • 2.1 并发
      • 2.2 并行
        • 2.3 线程调度——上下文切换
          • 2.4 线程调度——阻塞调用
          • 三、协程理解
          • 四、协程的实现
          • 五、Talk is cheap, show me the code
          • 六、总结
          • 七、后记
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档