Python - 协程开发那点事儿

前述

本文将带着以下列举的问题进行深入浅出的扩展,所有知识点作者都将以大白话和通俗举例进行讲解。新手发车,如果有哪里不对的地方请多多指正。

  • 什么是同步?
  • 什么是异步?
  • 什么是协程?
  • 为什么用协程?
  • 怎么用协程?

阅读前提

  • 熟悉Python核心语法
  • 了解系统常见词汇,例如线程进程
  • 熟悉电脑的开机/关机 (手动狗头)

正文

什么是同步?

老规矩,设想一个场景:
你今天打算吃完饭后外出剪头发,剪完头发后去书店看一会书,回家的时候还要在楼下便利店买两瓶啤酒。

那么,从同步的角度去看,你此时要做的事情应该是什么的顺序呢?

  1. 吃饭
  2. 剪头发
  3. 书店看书
  4. 到家楼下
  5. 买啤酒
  6. 回家

仔细查看这些顺序之间的关系,有没有发现这样一个特点,即必须前者完成,后者才能进行
是不是只有在吃完饭后,才能出门剪头发,也只有在剪完头发后才能进行后面的任务(⚠禁止影分身术)
那么这就是同步,当多个任务集合到一起并处于同步的状态时,他们之间是有互相排斥和必须等待这两个特点的。

通过上述场景,我想你大概对同步场景有基础的概念和印象建立,接下来看一下同步在代码里的表现形式

def task1():
    print('我是任务1')

def task2():
    print('我是任务2')

def task3():
    print('我是任务3')

if __name__ == '__main__':
    task1()
    task2()
    task3()

执行结果👇
我是任务1
我是任务2
我是任务3

通过打印结果可以看到,task函数之间的执行是按照顺序的,即从1-3,依次完成的。
而这就是大家常见的编码场景,多个函数之间必须等待上一个函数执行完毕才运行下一个函数,这个过程会存在同步等待的过程。
那么到这里,相信你对同步的基本印象应该已经建立完毕,恭喜你解锁新成就《我终于知道同步是个啥》🎉

什么是异步

此处场景与同步场景一致

那么,从异步的角度去看,它的顺序应该是什么的呢?

  • .吃饭
  • .剪头发
  • .书店看书
  • .到家楼下
  • .买啤酒
  • 回家

仔细查看此处我对它们顺序的定义,跟对同步顺序的定义有什么不同,异步的顺序是不是失去序号呢?
从这里的表现细节,我想我接下来要说的很清楚了,异步的任务不强调执行顺序,更需要将重心放在执行的过程和结果处理的本身。
异步可以在吃饭的同时,进行着 剪头发 书店看书 买啤酒 回家 这些任务
当然了,我这里的场景跟生活贴近,所以这些任务看起来会比较无厘头,但我最终想要描述的一个概念是异步是允许多任务同时进行的

而通过上述对异步的描述,我想你已经能建立跟同步区分开来的执行概念了,接下来看一下异步在代码里的表现形式
这里你暂时不需要去理解代码为什么这样写,只需要看他们的执行过程和结果。

import asyncio
import random


async def task1():
    print("任务1开始")
    await asyncio.sleep(random.randint(1, 3))  # 模拟处理一个任务所需要等待的时间
    print("任务1结束")


async def task2():
    print("任务2开始")
    await asyncio.sleep(random.randint(1, 3))
    print("任务2结束")


async def task3():
    print("任务3开始")
    await asyncio.sleep(random.randint(1, 3))
    print("任务3结束")


async def running():
    task_list = [
        task1(),
        task2(),
        task3()
    ]
    await asyncio.wait(task_list)


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

执行结果👇
任务2开始
任务1开始
任务3开始
任务1结束
任务3结束
任务2结束

通过打印结果可以看到,task函数的执行是无序的,它们具体什么时候开始,什么时候结束,完全是由内部任务系统进行调度和任务本身耗时决定。
在异步状态里,一个异步函数的执行并不会导致主程序堵塞,完全可以在一个异步函数启动后立刻执行下一行的代码,这个过程不存在类似于同步等待的堵塞过程。
到这里,相信你对异步的基本印象应该已经建立完毕,恭喜你解锁新成就《我终于知道异步是个啥》🎉


经过上述对同步和异步的描述,接下来就可以扩展出来以下的内容,例如协程,线程。

  • 同步的编码方式不用过多赘述,通常一个应用最开始就是以同步的方式在运行着
  • 异步的编码方式,在Python里可以有这些方式:
    • 多线程
    • 多进程
    • 协程
  • 而在一般业务场景中,通常选择以上三种方式任意一种就行,但不得不说,Python的多线程是存在缺陷的
    • 传统多线程
      • 充分利用多CPU核心,多条线程是分布在不同的核心运行的,它们之间存在同时运行的时刻
    • Python多线程
      • GIL全局解释器锁详解限制,多条线程不存在同时运行的可能
      • 一个Python应用,从创建到销毁都只有一条线程在运行着
      • 在Python里,之所以有多线程的运行效果只是因为它们的之间的调度速度极快,整个Python应用在不同线程来回切换
      • 以至于让运行应用的人感觉它们是同时运行着,但在遇到某些IO密集型的应用,多线程的速度有时反而不足单线程了
    • 在了解多线程的缺陷后,我们在开发上就要根据不同的业务需求选择异步的编码方式
      • 协程
      • 进程
        • 多进程这里不多叙述,因为它的运行逻辑并不复杂

什么是协程?

协程,又称微线程,是一种用户态的轻量级线程

  • 为什么说协程是一种微线程呢?首先要明确的是,操作系统本身并不提供协程的概念
    • 我们在任务管理器所能看到的也只有进程线程
    • 协程这个概念更多是由开发者根据语言特性去具现出来的,它属于一种运行策略
    • 在运行的视角上,他属于线程级别,但又比线程级别低一个等级
      • 因为一个协程应用,它始终只有一条主线程在运行着,它是基于主线程控制着多个不同的协程任务在运行着的

为了方便进一步理解协程的意义,这里需要引入一个概念

子程序
  • 在所有语言的上下文运行过程中,每行代码都是具有层级意义的
line1:task1()
line2:task2()
line3:task3()
  • 从运行上可以体验出来,我们的编码总是具有顺序的
  • 同步视角下,task2 想运行必须等 task1 运行完毕
  • 我们可以把不同的task认为是一个子程序,子程序的调用总是一个入口,一次返回,彼此之间的调用顺序是明确的
协程的子程序
  • 首先,已知的是线程是系统级别,它们由操作系统调度。协程是程序级别,它们是根究开发者自己的编码决定调度方式
  • 我们将一个线程中执行的多个函数称为子程序,那么协程就是在一个子程序运行到需要堵塞等待时,切换到另外的子程序去运行
  • 即一个线程下
    • 函数1在运行的过程,遇到某个需要等待的操作(例如HTTP请求,它需要目标服务器响应回数据)
      • 此时函数1在等待的过程,协程调度就会去自动切换到函数2的运行
        • 而在函数1结束等待时
        • 函数2的运行则会在遇到中断的时候重新返回函数1
          • 函数1会继续在等待结束后的代码行继续工作
    • 在协程应用里,调度系统总是管理着多个运行效果一致或不一致的协程任务

协程的优缺点

优点
  • 调度效率高,因为子程序切换并不是线程切换,它们是一个线程下的来回切换而已,并不会产生跟线程切换一样的CPU开销
    • 同多线程相比,协程数量越多,协程效率越高
  • 协程不会受GIL锁的限制,因为从始至终只需要一条主线程在运行,它们之间不会出现变量和资源的读写冲突。
    • 故,在协程中操作共享资源可以不加锁,我们更多的是注重对运行状态的处理
缺点
  • 无法利用多核CPU,因为协程本质是单线程,它不能同时在多个CPU核心上使用,失去了标准线程使用多核CPU的能力
    • 但这并不是一个致命缺点,Python本身就没有真正意义上多核心运行的多线程!!
      • 通常要利用多核心的话,Python必须使用多进程去工作才可以
  • 协程需要进行堵塞操作
    • 这里说的堵塞操作是指,控制协程的主函数需要堵塞
      • 因为主函数需要去等待并处理多个协程任务的运行结果

怎么用协程?

Python3.4之前
  • 协程的开发方式各有不同
  • 编码方式比较繁琐

所以这里不去说Python3.4之前应该用什么方式,它们都是比较古老的 (作者偷懒 )

  • 小声BB
    • 其实我并没有了解3.4之前的开发方式应该是什么样
    • 因为现在Python3已经挺普及了
    • 网上很多文章因为时间不同,他们的描述大多会配合旧版本的异步编码方式讲解
    • 这在我学习的过程中带来很多疑惑,我究竟该考虑什么样的协程编码方式,了解什么样的协程编码知识呢?
    • 所幸在实践之后,高版本的异步编码方式已经足够我使用
    • 所以在这篇文章里,所讲的都是最新的异步编码方法
    • 你不会有同时学习新旧版本的心智负担!
Python3.4之后
asyncio

asyncio 是 Python 3.4 版引入的标准库,直接内置了对异步IO的支持
asyncio 的编程模型就是一个消息循环

消息循环

这里简单的说明一下,不引用更复杂的原理描述
我们可以简单认为消息循环就是一个调度系统,其内部就是循环的执行不同的可运行的协程任务

import asyncio  
import time  
  
async def run():  
    print('进入IO操作')  
    time.sleep(2)  # 模拟堵塞  
    print('退出IO操作')  
  
if __name__ == '__main__':  
    # 获取异步函数的[[协程]]对象  
    coroutine = run()  
    # asyncio的编程模型是消息循环,所以需要创建一个事件循环对象  
    loop = asyncio.get_event_loop()  
    # 给事件循环对象传入一个协程对象  
    # run_until_complete 方法表示要将传入的协程对象运行到结束为止  
    # 此方法运行流程就是「立即运行异步函数直到完成」  
    loop.run_until_complete(coroutine)
运行流程图

asyncio的协程运行图


实践开发前,需要了解Python的异步特性

async
  • async/await 是Python3.5之后出现的关键字
  • async用于定义一个用于异步运行的函数
    • 这个函数在被调用后,并不会立刻执行函数内部代码
    • 而是返回一个协程对象(coroutine)
      • 协程对象是协程开发中最基本的任务单位
      • 协程任务运行时,就是由多个不同协程对象在调度切换的
# 在函数定义前面加上async关键字
# 代表声明一个异步执行的函数
# 这个函数被调用后并不会立刻执行,而是返回一个协程对象(coroutine)
async def run():  
    # 模拟阻塞IO操作代码  
    print('进入IO操作')  
    await asyncio.sleep(2)  
    print('退出IO操作')  
    print('正常代码')  
    return 'a'

if __name__ == '__main__':  
    print(run())
await
  • 执行await表达式的函数必须是经过async定义的
    • await的作用是在遇到需要等待的IO操作时,挂起本次协程任务,运行await后面跟上的异步任务并取到它的返回值后才往下运行
      • 用大白话讲,就是同步运行一个异步函数
import asyncio


async def run1():
    print('1')
    # 同步运行异步函数,直到函数运行完成后再进入下一行代码运行
    print('异步函数同步运行中')
    # 使用await挂起本次协程,在本次协程任务被挂起的时候
    # 事件循环对象会处理另外在等待执行的协程任务
    await asyncio.sleep(2)
    # 结束等待后,本次协程任务会再次运行到此
    print('异步函数同步运行结束')
    print('2')


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

这里认识一个方法,asyncio.run,它可以传入协程对象进行执行,这个方法的运行逻辑是运行一个协程对象直到结束并取到它的返回值。

总结
  • python3.4之后提供了asyncio包,用于异步开发
    • asyncio的运行机制是消息循环(即事件循环)
  • python3.5之后提供了async/await关键字
    • async用于声明异步函数
      • async定义的函数同普通函数的不同
        • 调用后不会立刻执行函数内部代码
        • 调用后返回的是协程(coroutine)对象
    • await用于异步函数内同步运行异步函数

多任务协程

  • 这里更多是为了扩展,你可以做更多的实践去加深自己的协程印象
    • asyncio中用于多任务协程的方法
      • asyncio.wait
        • 传入协程对象的任务集合
        • 返回done,pending两个结果值
          • done存放的是完成的任务
            • 使用result()提取返回值
          • pending存放的是未完成的任务
      • asyncio.gather
        • 传入不定量传参的任务
        • 返回所有任务的返回值
代码1
import asyncio
import random


async def run(x):
    print(f'任务{x}开始了')
    # 模拟IO操作,因为IO操作比较耗时
    # 挂起本次协程以便另外的协程任务运行
    await asyncio.sleep(random.randint(2, 5))
    print(f'任务{x}完成了')
    return x


async def main():
    arr = [1, 2, 3, 4, 5]
    # 创建协程任务
    task = [run(i) for i in arr]
    # 使用asyncio.gather注册多个任务并启动任务
    # *args的作用是将序列对象进行展开,用法是给函数传递不定数量的位置实参
    await asyncio.gather(*task)


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

代码2
import asyncio
import random


async def run(x):
    print(f'任务{x}开始了')
    # 模拟IO操作,因为IO操作比较耗时
    # 挂起本次协程以便另外的协程任务运行
    await asyncio.sleep(random.randint(2, 5))
    print(f'任务{x}完成了')
    return x


async def main():
    arr = [1, 2, 3, 4, 5]
    # 创建协程任务
    # 单纯的run()返回的是协程对象
    task = [run(i) for i in arr]
    # 启动任务
    done, pending = await asyncio.wait(task)
    # 获取返回值
    for curr in done:
        print(curr.result())


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

代码3

此处代码扩展了asyncio.Semaphore的方法使用,Semaphore主要是用于控制协程任务同时进行数量的,用法固定

import asyncio
import random


async def run(signal, x):
    async with signal:
        print(f'任务{x}开始了')
        # 模拟IO操作,因为IO操作比较耗时
        # 挂起本次协程以便另外的协程任务运行
        await asyncio.sleep(random.randint(2, 5))
        print(f'任务{x}完成了')
    return x


async def main():
    semaphore = asyncio.Semaphore(10)  # 创建信号量,控制协程任务同时进行数量
    arr = [i for i in range(100)]
    task = [run(semaphore, i) for i in arr]
    done, pending = await asyncio.wait(task)
    for curr in done:
        print(curr.result())


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


结尾

  • 这是作者输出的第一篇文章,如果哪里有错误或可改进的地方,请随时@我
  • 文中列举的代码,均是我在学习过程中敲写记录的,如果你哪里运行不通欢迎在评论区留言与我沟通
  • 之后我将不定时的把所学到的知识进行文章输出,如果你对Python,网络爬虫,前后端开发有兴趣,欢迎关注我哦!
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值