python 异步编程之协程

目录

  • 概述
    • 为什么使用协程
    • 协程的特点和原理
    • 协程优缺点
  • gevent实现协程
  • asyncio协程装饰器
    • 任务和事件循环
    • 任务状态
  • async/await原生协程
    • 回调
    • gather
    • 取消任务
      • loop cancel
      • task cancel
    • 排定任务
      • loop.run_forever
      • call_soon
      • call_later
      • call_at
  • 协程锁

概述

为什么使用协程

在多线程程序中,线程切换由操作系统决定,无法人为干预。各个线程间无关联,没有先后顺序,不涉及互相引用,耦合性为零,这种场景使用多线程是很适合的。协程是在线程的基础上编写由程序员决定代码执行顺序、可以互相影响的高耦合度代码的一种高级程序设计模式。

在线程中, 不论如何设计,在一个线程内部,代码都是顺序执行的,遇到 IO 都得阻塞。直到出现了协程,这句话变成了伪命题。一个线程内部可以有多个协程,相当于一个车间内部有多个子任务,一个协程遇到 IO 阻塞,CPU 会自动去另一个协程中工作,而且去哪里工作由程序自己说了算,此外连创建线程和线程切换的开销都省了。

协程的特点和原理

协程的作用:是在执行函数A时,可以随时中断,去执行函数B,然后中断继续执行函数A(可以自由切换)。但这一过程并不是函数调用(没有调用语句),这一整个过程看似像多线程,然而协程只有一个线程执行.

在实现多任务时, 线程切换从系统层面远不止保存和恢复 CPU上下文这么简单。 操作系统为了程序运行的高效性每个线程都有自己缓存Cache等等数据,操作系统还会帮你做这些数据的恢复操作。 所以线程的切换非常耗性能。但是协程的切换只是单纯的操作CPU的上下文,所以一秒钟切换个上百万次系统都抗的住。

协程原理是不是和前面我们讲过的yield很相似,协程就是由yield的进化而来的。关于python的yield参考文章:python中的yield。

协程优缺点

优点:

  1. 不仅是处理高并发(单线程下处理高并发),节省资源(协程的本质是一个单线程)
  2. 协程的切换开销更小,属于程序级别的切换,无需线程上下文切换的开销,操作系统完全感知不到,因而更加轻量级方便切换控制流,简化编程模型;
  3. 单线程内就可以实现并发的效果,最大限度地利用cpu
  4. 高并发+高扩展性+低成本:一个CPU支持上万的协程都不是问题。所以很适合用于高并发处理

缺点:

  1. 缺点是无法利用多核资源,本质是单核的,它不能同时将单个CPU的多个核用上,协程需要和进程配合才能运行在多CPU上;
  2. 一旦引入协程,就须要检测单线程下全部的IO行为, 实现遇到IO就切换,少一个都不行,觉得一旦一个任务阻塞了,整个线程就阻塞了, 其余的任务即使是能够计算,可是也没法运行了

gevent实现协程

gevent是python中实现协程的第三方库。gevent 是对greenlet进行的封装,而greenlet 又是对yield进行封装。

gevent切换协程是自动完成的。

看下面一个例子:

import gevent
 
 
def f1():
    for i in range(1, 6):
        print('f1', i)
        gevent.sleep(0)
 
 
def f2():
    for i in range(6, 11):
        print('f2', i)
        gevent.sleep(0)
 
 # 创建协程对象
g1 = gevent.spawn(f1)
g2 = gevent.spawn(f2)
# 多个协程对象等待协程执行结束,与线程守护类似
gevent.joinall([g1, g2])

asyncio协程装饰器

在 Python 3.4 中,asyncio 模块出现,此时创建协程函数须使用 asyncio.coroutine 装饰器标记。此前的包含 yield from 语句的函数既可以称作生成器函数也可以称作协程函数,为了突出协程的重要性,现在使用 asyncio.coroutine 装饰器的函数就是真正的协程函数了。

任务和事件循环

coroutine 协程

协程对象,使用 asyncio.coroutine 装饰器装饰的函数被称作协程函数,它的调用不会立即执行函数,而是返回一个协程对象,即协程函数的运行结果为协程对象,注意这里说的 “运行结果” 不是 return 值。协程对象需要包装成任务注入到事件循环,由事件循环调用。

task 任务

将协程对象作为参数创建任务,任务是对协程对象的进一步封装,其中包含任务的各种状态。

event_loop 事件循环

将多线程比喻为工厂里的多个车间,那么协程就是一个车间内的多台机器。在线程级程序中,一台机器开始工作,车间内的其它机器不能同时工作,需要等上一台机器停止,但其它车间内的机器可以同时启动,这样就可以显著提高工作效率。在协程程序中,一个车间内的不同机器可以同时运转,启动机器、暂停运转、延时启动、停止机器等操作都可以人为设置。

事件循环能够控制任务运行流程,也就是任务的调用方。

import time
import asyncio


def main():
    start = time.time()

    @asyncio.coroutine  # 使用协程装饰器创建协程函数
    def do_some_work():
        print("start work")
        time.sleep(1)  # 模拟 IO 操作
        print("work completed")

    # 创建事件循环。每个线程中只能有一个事件循环,get_event_loop 方法会获取当前已经存在的事件循环,如果当前线程中没有,新建一个
    loop = asyncio.get_event_loop()
    coroutine = do_some_work()  # 调用协程函数获取协程对象
    # 将协程对象注入到事件循环,协程的运行由事件循环控制。事件循环的 run_until_complete 方法会阻塞运行,直到任务全部完成。
    # 协程对象作为 run_until_complete 方法的参数,loop 会自动将协程对象包装成任务来运行。后面我们会讲到多个任务注入事件循环的情况
    loop.run_until_complete(coroutine)

    end = time.time()
    print("耗时%ds" % (end - start))

main()

# start work
# work completed
# 耗时1s

任务状态

事件循环的 create_task 方法可以创建任务,另外asyncio.ensure_future方法也可以创建任务,参数须为协程对象。

task 是asyncio.Task类的实例,为什么要使用协程对象创建任务?因为在这个过程中 asyncio.Task 做了一些工作,包括预激协程、协程运行中遇到某些异常时的处理。

task 对象的 _state 属性保存当前任务的运行状态,任务的运行状态有 PENDINGFINISHED 两种。

import time
import asyncio


def main():
    start = time.time()

    @asyncio.coroutine
    def do_some_work():
        print("start work")
        time.sleep(1)
        print("work completed")

    loop = asyncio.get_event_loop()
    task = loop.create_task(do_some_work())  # 创建任务
    print("task is instance of asyncio.Task:", isinstance(task, asyncio.Task))
    print("task state:", task._state)
    loop.run_until_complete(task)
    print("task state:", task._state)

    end = time.time()
    print("耗时%ds" % (end - start))

main()

# task is instance of asyncio.Task: True
# task state: PENDING
# start work
# work completed
# task state: FINISHED
# 耗时1s

async/await原生协程

在 Python 3.5 中新增了 async / await 关键字用来定义协程函数。这两个关键字是一个组合,其作用等同于 asyncio.coroutine 装饰器和 yield from 语句。此后协程与生成器就彻底泾渭分明了。

回调

有了 asyncio / await 关键字,我们继续学习 asyncio 模块的基本功能。

假如协程包含一个 IO 操作(这几乎是肯定的),等它处理完数据后,我们希望得到通知,以便下一步数据处理。这一需求可以通过向 future 对象中添加回调来实现。那么什么是 future 对象?task 对象就是 future 对象,我们可以这样认为,因为 asyncio.Task 是 asyncio.Future 的子类。也就是说,task 对象可以添加回调函数。回调函数的最后一个参数是 future 或 task 对象,通过该对象可以获取协程返回值。如果回调需要多个参数,可以通过偏函数导入。

简言之,一个任务完成后需要捎带运行的代码可以放到回调函数中。修改上一个程序如下:

import time
import asyncio
from functools import partial


def main():
    start = time.time()

    async def do_some_work():
        print("start work")
        time.sleep(1)
        print("work completed")

    # 回调函数,协程终止后需要顺便运行的代码写入这里
    def callback(name, task):  # 最后一个参数必须是task或future
        print('[callback] Hello {}'.format(name))
        print('[callback] coroutine state: {}'.format(task._state))

    loop = asyncio.get_event_loop()
    task = loop.create_task(do_some_work())
    task.add_done_callback(partial(callback, "task"))  # 添加回调函数
    loop.run_until_complete(task)

    end = time.time()
    print("耗时%ds" % (end - start))

main()

# start work
# work completed
# [callback] Hello task
# [callback] coroutine state: FINISHED
# 耗时1s

注意:task 对象的 add_done_callback 方法可以添加回调函数,注意参数必须是回调函数,这个方法不能传入回调函数的参数,这一点需要通过 functools 模块的 partial 方法解决,将回调函数和其参数 name 作为 partial 方法的参数,此方法的返回值就是偏函数,偏函数可作为 task.add_done_callback 方法的参数。

gather

实际项目中,往往有多个协程创建多个任务对象,同时在一个 loop 里运行。为了把多个协程交给 loop,需要借助 asyncio.gather 方法。任务的 result 方法可以获得对应的协程函数的 return 值。

await 关键字等同于 Python 3.4 中的 yield from 语句,后面接协程对象。

使用asyncio.sleep 方法代替time.sleep,因为asyncio.sleep 的返回值为协程对象,这一步为阻塞运行。asyncio.sleep 与 time.sleep 是不同的,前者阻塞当前协程,即 corowork 函数的运行,而 time.sleep 会阻塞整个线程,所以这里必须用前者,阻塞当前协程,CPU 可以在线程内的其它协程中执行。

import time
import asyncio
from functools import partial


def main():
    start = time.time()

    async def do_some_work(name, t):
        print(f"{name} start work")
        await asyncio.sleep(t)
        print(f"{name} work completed")
        return name

    loop = asyncio.get_event_loop()
    task1 = loop.create_task(do_some_work("协程1", 3))
    task2 = loop.create_task(do_some_work("协程2", 1))
    gather = asyncio.gather(task1, task2)
    
    # 将任务对象作为参数,asyncio.gather 方法创建任务收集器。
    # 注意,asyncio.gather 方法中参数的顺序决定了协程的启动顺序
    loop.run_until_complete(gather)
    print("task1 result:", task1.result())
    print("task2 result:", task2.result())

    # 多数情况下无需调用 task 的 result 方法获取协程函数的 return 值,
    # 因为事件循环的 run_until_complete 方法的返回值就是协程函数的 return 值。
    # result = loop.run_until_complete(gather)
    # print(result)

    end = time.time()
    print("耗时%ds" % (end - start))

main()

# 协程1 start work
# 协程2 start work
# 协程2 work completed
# 协程1 work completed
# task1 result: 协程1
# task2 result: 协程2
# 耗时3s

上面的代码已经是异步编程的结构了,在事件循环内部,两个协程是交替运行完成的。简单叙述一下程序协程部分的运行过程:

-> 首先运行 task1

-> 打印 [corowork] Start coroutine ONE

-> 遇到 asyncio.sleep 阻塞

-> 释放 CPU 转到 task2 中执行

-> 打印 [corowork] Start coroutine TWO

-> 再次遇到 asyncio.sleep 阻塞

-> 这次没有其它协程可以运行了,只能等阻塞结束

-> task2 的阻塞时间较短,阻塞 1 秒后先结束,打印 [corowork] Stop coroutine TWO

-> 又过了 2 秒,阻塞 3 秒的 task1 也结束了阻塞,打印 [corowork] Stop coroutine ONE

-> 至此两个任务全部完成,事件循环停止

-> 打印两个任务的 result

-> 打印程序运行时间

-> 程序全部结束

补充说明:

1.多数情况下无需调用 task 的 add_done_callback 方法,可以直接把回调函数中的代码写入 await 语句后面,协程是可以暂停和恢复的

2.多数情况下同样无需调用 task 的 result 方法获取协程函数的 return 值,因为事件循环的 run_until_complete 方法的返回值就是协程函数的 return 值:

import time
import asyncio
from functools import partial


def main():
    start = time.time()

    async def do_some_work(name, t):
        print(f"{name} start work")
        await asyncio.sleep(t)
        print(f"{name} work completed")
        return name

    loop = asyncio.get_event_loop()
    task1 = loop.create_task(do_some_work("协程1", 3))
    task2 = loop.create_task(do_some_work("协程2", 1))
    gather = asyncio.gather(task1, task2)

    # 多数情况下无需调用 task 的 result 方法获取协程函数的 return 值,
    # 因为事件循环的 run_until_complete 方法的返回值就是协程函数的 return 值。
    result = loop.run_until_complete(gather)
    print(result)

    end = time.time()
    print("耗时%ds" % (end - start))

main()

# 协程1 start work
# 协程2 start work
# 协程2 work completed
# 协程1 work completed
# ['协程1', '协程2']
# 耗时3s

3.事件循环有一个 stop 方法用来停止循环和一个 close 方法用来关闭循环。以上示例中都没有调用 loop.close 方法,似乎并没有什么问题。所以到底要不要调用 loop.close 呢?简单来说,loop 只要不关闭,就还可以再次运行 run_until_complete 方法,关闭后则不可运行。有人会建议调用 loop.close,彻底清理 loop 对象防止误用,其实多数情况下根本没有这个必要。

4.asyncio 模块提供了 asyncio.gather 和 asyncio.wait 两个任务收集方法,它们的作用相同,都是将协程任务按顺序排定,再将返回值作为参数加入到事件循环中。前者在上文已经用到,后者与前者的区别是它可以获取任务的执行状态(PENING & FINISHED),当有一些特别的需求例如在某些情况下取消任务,可以使用 asyncio.wait 方法。

取消任务

在事件循环启动之后停止之前,我们可以手动取消任务的执行,注意 PENDING 状态的任务才能被取消,FINISHED 状态的任务已经完成,不能取消。下面举例说明。

loop cancel

# 停止任务

import asyncio

async def work(id, t):
    print('Working...')
    await asyncio.sleep(t)
    print('Work {} done'.format(id))

def main():
    loop = asyncio.get_event_loop()
    coroutines = [work(i, i) for i in range(1, 4)]
    try:
        loop.run_until_complete(asyncio.gather(*coroutines))
    except KeyboardInterrupt:
        loop.stop()    # stop 方法取消所有未完成的任务,停止事件循环
    finally:
        loop.close()   # 关闭事件循环

if __name__ == '__main__':
    main()

# Working...
# Working...
# Working...
# Work 1 done
# ^C%

task cancel

任务的 cancel 方法也可以取消任务,而 asyncio.Task.all_tasks 方法可以获得事件循环中的全部任务。

import asyncio


async def work(id, t):
    print('Working...')
    await asyncio.sleep(t)
    print('Work {} done'.format(id))

def main():
    loop = asyncio.get_event_loop()
    coroutines = [work(i, i) for i in range(1, 4)]
    # 程序运行过程中,快捷键 Ctrl + C 会触发 KeyboardInterrupt 异常
    try:
        loop.run_until_complete(asyncio.gather(*coroutines))
    except KeyboardInterrupt:
        print()
        # 每个线程里只能有一个事件循环,此方法可以获得事件循环中的所有任务的集合
        # 任务的状态有 PENDING 和 FINISHED 两种
        tasks = asyncio.Task.all_tasks()
        for i in tasks:
            print('取消任务:{}'.format(i))
            # 任务的 cancel 方法可以取消未完成的任务
            # 取消成功返回 True ,已完成的任务取消失败返回 False
            print('取消状态:{}'.format(i.cancel()))
    finally:
        loop.close()

if __name__ == '__main__':
    main()

# Working...
# Working...
# Working...
# Work 1 done
# ^C
# 取消任务: result=None>
# 取消状态:False
# 取消任务: wait_for=()]> cb=[gather.._done_callback() at /usr/local/Cellar/python/3.7.3/Frameworks/Python.framework/Versions/3.7/lib/python3.7/asyncio/tasks.py:664]>
# 取消状态:True
# 取消任务: wait_for=()]> cb=[gather.._done_callback() at /usr/local/Cellar/python/3.7.3/Frameworks/Python.framework/Versions/3.7/lib/python3.7/asyncio/tasks.py:664]>
# 取消状态:True

排定任务

排定 task / future 在事件循环中的执行顺序,也就是对应的协程先执行哪个,遇到 IO 阻塞时,CPU 转而运行哪个任务,这是我们在进行异步编程时的一个需求。前文所示的多任务程序中,事件循环里的任务的执行顺序由 asyncio.ensure_future / loop.create_task 和 asyncio.gather 排定,这一节介绍 loop 的其它方法。

loop.run_forever

事件循环的 run_until_complete 方法运行事件循环,当其中的全部任务完成后,自动停止事件循环;run_forever 方法为无限运行事件循环,需要自定义 loop.stop 方法并执行之才会停止。

import asyncio

async def work(loop, t):
    print('start')
    await asyncio.sleep(t)  # 模拟 IO 操作
    print('after {}s stop'.format(t))
    loop.stop()             # 停止事件循环,stop 后仍可重新运行

loop = asyncio.get_event_loop()             # 创建事件循环
task = asyncio.ensure_future(work(loop, 1)) # 创建任务,该任务会自动加入事件循环
loop.run_forever()  # 无限运行事件循环,直至 loop.stop 停止
loop.close()

# start
# after 1s stop

以上是单任务事件循环,将 loop 作为参数传入协程函数创建协程,在协程内部执行 loop.stop 方法停止事件循环。下面是多任务事件循环,使用回调函数执行 loop.stop 停止事件循环:

import time
import asyncio
import functools

def loop_stop(loop, future):    # 函数的最后一个参数须为 future / task
    loop.stop()                 # 停止事件循环,stop 后仍可重新运行

async def work(t):              # 协程函数
    print('start')
    await asyncio.sleep(t)      # 模拟 IO 操作
    print('after {}s stop'.format(t))

def main():
    loop = asyncio.get_event_loop()
    # 创建任务收集器,参数为任意数量的协程,任务收集器本身也是 task / future 对象
    tasks = asyncio.gather(work(1), work(2))
    # 任务收集器的 add_done_callback 方法添加回调函数
    # 当所有任务完成后,自动运行此回调函数
    # 注意 add_done_callback 方法的参数是回调函数
    # 这里使用 functools.partial 方法创建偏函数以便将 loop 作为参数加入
    tasks.add_done_callback(functools.partial(loop_stop, loop))
    loop.run_forever()  # 无限运行事件循环,直至 loop.stop 停止
    loop.close()        # 关闭事件循环

if __name__ == '__main__':
    start = time.time()
    main()
    end = time.time()
    print('耗时:{:.4f}s'.format(end - start))

# start
# start
# after 1s stop
# after 2s stop
# 耗时:2.0021s

loop.run_until_complete 方法本身也是调用 loop.run_forever 方法,然后通过回调函数调用 loop.stop 方法实现的。

call_soon

事件循环的 call_soon 方法可以将普通函数作为任务加入到事件循环并立即排定任务的执行顺序。

import asyncio


def hello(name):          # 普通函数
    print('[hello] Hello, {}'.format(name))


async def work(t, name):  # 协程函数
    print('[work ] start', name)
    await asyncio.sleep(t)
    print('[work ] {} after {}s stop'.format(name, t))


def main():
    loop = asyncio.get_event_loop()
    # 向事件循环中添加任务
    asyncio.ensure_future(work(1, 'A'))     # 第 1 个执行
    # call_soon 将普通函数当作 task 加入到事件循环并排定执行顺序
    # 该方法的第一个参数为普通函数名字,普通函数的参数写在后面
    loop.call_soon(hello, 'Tom')            # 第 2 个执行
    # 向事件循环中添加任务
    loop.create_task(work(2, 'B'))          # 第 3 个执行
    # 阻塞启动事件循环,顺便再添加一个任务
    loop.run_until_complete(work(3, 'C'))   # 第 4 个执行


if __name__ == '__main__':
    main()

# [work ] start A
# [hello] Hello, Tom
# [work ] start B
# [work ] start C
# [work ] A after 1s stop
# [work ] B after 2s stop
# [work ] C after 3s stop

call_later

此方法同 loop.call_soon 一样,可将普通函数作为任务放到事件循环里,不同之处在于此方法可延时执行,第一个参数为延时时间。

import asyncio
import functools


def hello(name):            # 普通函数
    print('[hello]  Hello, {}'.format(name))


async def work(t, name):    # 协程函数
    print('[work{}]  start'.format(name))
    await asyncio.sleep(t)
    print('[work{}]  stop'.format(name))


def main():
    loop = asyncio.get_event_loop()
    asyncio.ensure_future(work(1, 'A'))         # 任务 1,立即执行,阻塞1秒
    loop.call_later(1.2, hello, 'Tom')          # 任务 2,延时1.2秒执行
    loop.call_soon(hello, 'Kitty')              # 任务 3,立即执行
    task4 = loop.create_task(work(2, 'B'))      # 任务 4,立即执行,阻塞2秒
    loop.call_later(1, hello, 'Jerry')          # 任务 5,延时1秒执行
    loop.run_until_complete(task4)


if __name__ == '__main__':
    main()

# [workA]  start
# [hello]  Hello, Kitty
# [workB]  start
# [hello]  Hello, Jerry
# [workA]  stop
# [hello]  Hello, Tom
# [workB]  stop

call_at

  • call_soon 立刻执行,call_later 延时执行,call_at 在某时刻执行
  • oop.time 就是事件循环内部的一个计时方法,返回值是时刻,数据类型是 float
import asyncio
import functools


def hello(name):            # 普通函数
    print('[hello]  Hello, {}'.format(name))


async def work(t, name):    # 协程函数
    print('[work{}]  start'.format(name))
    await asyncio.sleep(t)
    print('[work{}]  stop'.format(name))


def main():
    loop = asyncio.get_event_loop()
    start = loop.time()  # 事件循环内部时刻
    asyncio.ensure_future(work(1, 'A'))  # 任务 1
    # loop.call_later(1.2, hello, 'Tom')
    # 上面注释这行等同于下面这行
    loop.call_at(start + 1.2, hello, 'Tom')  # 任务 2
    loop.call_soon(hello, 'Kitty')  # 任务 3
    task4 = loop.create_task(work(2, 'B'))  # 任务 4
    # loop.call_later(1, hello, 'Jerry')
    # 上面注释这行等同于下面这行
    loop.call_at(start + 1, hello, 'Jerry')  # 任务 5

    loop.run_until_complete(task4)


if __name__ == '__main__':
    main()

# [workA]  start
# [hello]  Hello, Kitty
# [workB]  start
# [hello]  Hello, Jerry
# [workA]  stop
# [hello]  Hello, Tom
# [workB]  stop

这三个 call_xxx 方法的作用都是将普通函数作为任务排定到事件循环中,返回值都是 asyncio.events.TimerHandle 实例,注意它们不是协程任务 ,不能作为 loop.run_until_complete 的参数。

协程锁

按照字面意思来看,asyncio.lock 应该叫做异步 IO 锁,之所以叫协程锁,是因为它通常使用在子协程中,其作用是将协程内部的一段代码锁住,直到这段代码运行完毕解锁。协程锁的固定用法是使用 async with 创建协程锁的上下文环境,将代码块写入其中。

with 是普通上下文管理器关键字,async with 是异步上下文管理器关键字
能够使用 with 关键字的对象须有 __enter____exit__ 方法
能够使用 async with 关键字的对象须有__aenter____aexit__ 方法
async with 会自动运行 lock 的 __aenter__ 方法,该方法会调用 acquire 方法上锁
在语句块结束时自动运行 __aexit__ 方法,该方法会调用 release 方法解锁
这和 with 一样,都是简化 try … finally 语句

import asyncio


l = []
lock = asyncio.Lock()   # 协程锁

async def work(name):
    print('lalalalalalalala')     # 打印此信息是为了测试协程锁的控制范围
    # 这里加个锁,第一次调用该协程,运行到这个语句块,上锁
    # 当语句块结束后解锁,开锁前该语句块不可被运行第二次
    # 如果上锁后有其它任务调用了这个协程函数,运行到这步会被阻塞,直至解锁
    async with lock:
        print('{} start'.format(name))  # 头一次运行该协程时打印
        if 'x' in l:                    # 如果判断成功
            return name                 # 直接返回结束协程,不再向下执行
        await asyncio.sleep(0); print('----------')  # 阻塞 0 秒,切换协程
        l.append('x')
        print('{} end'.format(name))
        return name

async def one():
    name = await work('one')
    print('{} ok'.format(name))

async def two():
    name = await work('two')
    print('{} ok'.format(name))

def main():
    loop = asyncio.get_event_loop()
    tasks = asyncio.wait([one(), two()])
    loop.run_until_complete(tasks)

if __name__ == '__main__':
    main()
    print(l)

# lalalalalalalala
# one start
# lalalalalalalala
# ----------
# one end
# one ok
# two start
# two ok
# ['x']

参考:

https://www.lanqiao.cn/courses/1278/learning/?id=10349
https://www.cnblogs.com/lizexiong/p/17195462.html
https://blog.csdn.net/c_lanxiaofang/article/details/126394229

你可能感兴趣的:(python进阶知识,python,开发语言,后端)