更多优质内容
请关注公众号

从IO模型到协程(六) asyncio和协程实现高并发-张柏沛IT博客

正文内容

从IO模型到协程(六) asyncio和协程实现高并发

栏目:Python 系列:从IO模型到协程系列 发布时间:2020-11-17 16:35 浏览量:1271

协程不是计算机提供的,而是程序员认为创造的(计算机只提供线程和进程)

协程又被称为微线程,是一种用户态内的上下文切换技术(线程和进程的调度由cpu和内核决定,而协程的调度由我们开发者在用户态程序代码中决定)。简而言之就是通过一个线程实现代码块相互切换执行。

python实现协程的2种方式:
1.作为生成器的协程(yield和yield from)
2.async 和 await 原生协程

 

asyncio库

asyncio是用于异步io高并发编程的模块。

asyncio包含的功能:
1.asyncio包含各种特定模块(select/poll/epoll)的事件循环功能
2.传输和抽象协议
3.对TCP、UDP、SSL、子进程、延时调用以及其他的具体支持
4.模仿futures模块但适用于事件循环使用的Future类
5.基于yield from,可以让你用顺序的方式编写并发代码
6.必须使用一个将产生阻塞IO的调用时,有接口可以把这个事件转移到线程池


我们来看看asyncio的官方手册是如何描述asyncio这个包的:
asyncio 是用来编写并发代码的库,使用 async/await 语法。
asyncio 被用作很多提供高性能Python异步框架的基础,包括网络和网站服务,数据库连接库,分布式任务队列等等。
asyncio 往往是构建 IO 密集型和高层级结构化网络代码的最佳选择。

简单的来说,asyncio内置的事件循环和各种异步非阻塞的方法赋予了协程灵魂,让单线程协程做到真正的高并发。如果说单单只有协程,那协程就是一只小虫子,如果有了asyncio和协程(事件循环 + 异步非阻塞方法 + 协程),那么协程就是一头巨龙。

 

接下来举一个例子让我们初步认识asyncio。这个例子中我们不用具体明白每句代码是干啥用的,而是要了解协程的一个切换过程。

例子1:asyncio的简单使用,模拟io操作

# coding=utf-8

import asyncio

# 用asyncio.coroutine装饰器声明func函数是一个协程函数
@asyncio.coroutine
def func1():
    print(1)
    yield from asyncio.sleep(2)     # 用sleep()模拟io请求
    print(2)

@asyncio.coroutine
def func2():
    print(3)
    yield from asyncio.sleep(2)     # 用sleep()模拟io请求
    print(4)

# 封装2个协程到tasks中
tasks = [
    asyncio.ensure_future(func1()),
    asyncio.ensure_future(func2())
]
loop = asyncio.get_event_loop()     # 创建事件循环对象
loop.run_until_complete(asyncio.wait(tasks))       # 开始事件循环

当开始事件循环的时候,会先运行func1()这个协程,当遇到 yield from  asyncio.sleep(2)的时候就会自动切换到tasks中的func2()协程去运行,当func2也运行到yield from  asyncio.sleep(2)的时候,此时两个协程都处于sleep等待状态,这个时候整个线程就会发生阻塞,直到其中一个协程睡醒,就会通知事件循环loop,loop就会切换到这个协程恢复它的运行。

这就是asyncio运行协程时做的事:在协程发生等待时(例如遇到IO操作),切换运行其他协程,以保证单线程一直不闲着,不是在协程A中运行就是在协程B中运行。但是如果所有协程都遇到了要等待的操作,则整个线程发生阻塞让出CPU。

 

但是在python 3.8之后,asyncio.coroutine装饰器就已经废除,改为使用async 和 await

将上面的代码改为使用async 和 await; async 就相当于上例的@asyncio.coroutine,await相当于上例的yield from

# coding=utf-8

import asyncio, time

# 用async关键字声明func函数是一个协程函数
async def func1():
    print(1)
    await asyncio.sleep(2)     # 用sleep()模拟io请求
    print(2)

async def func2():
    print(3)
    await asyncio.sleep(2)     # 用sleep()模拟io请求
    print(4)

# 封装2个协程到tasks中
tasks = [
    asyncio.ensure_future(func1()),
    asyncio.ensure_future(func2())
]
loop = asyncio.get_event_loop()     # 创建事件循环对象
loop.run_until_complete(asyncio.wait(tasks))       # 开始事件循环

 

asyncio的核心是 事件循环 + 协程 

其实事件循环本质上就是一个死循环,并且在循环过程中不断检测协程是否已就绪可以继续运行

下面给出一段伪代码,这段伪代码描述了事件循环到底做了什么事情:

# Fake code

任务列表 = [task1, task2, ...]		# 任务队列中的元素是用协程对象封装而成的任务对象,所以任务队列也可以叫做协程池

while True:
	可执行的任务列表, 已完成的任务列表 = 去任务列表检查所有任务()
	
	for 就绪任务 in 可执行的任务列表:
		继续执行已就绪的任务(如果是生成器协程,这里的代码就是next(gen)或者gen.send())
		
	for 已完成的任务 in 已完成的任务列表:
		从任务列表移除 已完成的任务
		
	if len(任务列表) == 0:
		break

在事件循环中,io未完成的任务会被忽略,io操作完成的任务会被返回以供处理。

asyncio库如何运行一个协程?只需把协程放到事件循环中即可开始运行

 

例子2:开始运行一个协程

import asyncio


# 定义一个协程函数
async def func():
    print("开始运行协程")
    print("协程运行完毕")


coro = func()  # 创建一个协程,此时协程函数还不会开始运行
loop = asyncio.get_event_loop()  # 获取一个事件循环对象

loop.run_until_complete(coro)  # 将协程或者任务放到“任务列表”中,并开始事件循环

在python3.7中,asyncio.run(coro)也可以运行协程,它其实做的就是 loop = asyncio.get_event_loop() 和 loop.run_until_complete(coro)这两件事。
run方法其实也是启动了一个事件循环。

 

await 关键字

await只能在async声明的函数中使用。
await后面只能接可等待对象(Awaitable对象),在python中,可等待对象有3种:协程、Future对象和Task对象。

可以理解为await就是“等”的意思,await后面接需要等待的异步非阻塞操作(比如异步IO操作,asyncio.sleep()异步休眠),而且这些操作的返回值必须是Awaitable对象。

当协程执行到await等待这些需要等待的异步操作时,就会暂停这个协程的运行,并切换到其他协程运行,直到await后面接的等待操作结束才会恢复该协程的运行(这个过程由run_until_complete内部的事件循环完成,具体怎么完成的请看上面的伪代码)

例子3-1:await

# coding=utf-8

import asyncio,random,time

# 协程函数:请求一个页面
async def request_url(url):
    print("请求url %s" % url)
    await asyncio.sleep(1)      # 模拟网络io请求
    print("完成请求 %s" % url)

    if url.find("detail") == -1:    # 如果这个url是一个列表页则获取详情页链接
        detail_urls = ["%s/detail%s" % (url, str(random.randint(1,100))) for i in range(5)]
        return detail_urls
    else:   # 如果url不是列表页则直接返回html内容
        return url + "的html内容"

# 协程函数:爬取一个列表页下所有详情页内容
async def crawl(list_url):
    print("start crawl")

    detail_urls = await request_url(list_url)

    for detail_url in detail_urls:
        detail_html = await request_url(detail_url)
        print("详情页内容: %s" % detail_html)

st = time.time()
asyncio.run(crawl("/list1"))
print("用时: %.5f" % (time.time() - st))		# 用时: 6.04015

这个例子启动了一个协程,这个协程先请求了1个列表页,再请求从列表页获取到的5个详情页,用时大约6点几秒。5个详情页的请求是顺序执行的而非并发的。

一个协程内的多个await之间(多个request_url协程之间)是串行的而不是并发的(如果把一个协程内多个要await的Awaitable放到事件循环任务列表中,他们之间就会变为并发,学到Task对象时会讲解),必须要等上一个await中的代码执行完了才能执行下一个await。但是并不是说串行是没有意义的,当下一个协程依赖上一个协程的返回值时,await的这种串行功能就有用。

 

例子3-2:并发爬取多个列表页

# coding=utf-8

import asyncio,random,time

# 协程函数:请求一个页面
async def request_url(url):
    print("请求url %s" % url)
    await asyncio.sleep(1)      # 模拟io请求
    print("完成请求 %s" % url)

    if url.find("detail") == -1:    # 如果这个url是一个列表页则获取详情页链接
        detail_urls = ["%s/detail%s" % (url, str(random.randint(1,100))) for i in range(5)]
        return detail_urls
    else:   # 如果url不是列表页则直接返回html内容
        return url + "的html内容"

# 协程函数:爬取一个列表页下所有详情页内容
async def crawl(list_url):
    print("start crawl")

    detail_urls = await request_url(list_url)

    for detail_url in detail_urls:
        detail_html = await request_url(detail_url)
        print("详情页内容: %s" % detail_html)

st = time.time()
task_list = [crawl("/list" + str(i+1)) for i in range(10)]    # 创建10个crawl协程
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(task_list))
print("用时: %.5f" % (time.time() - st))		# 用时: 6.03965

 

例子3-1和例子3-2的区别是,3-1只开了一个爬取列表页的协程,而3-2开了10个列表页的协程。但是3-1和3-2花的时间都是6秒多。
在例子3-2中,多个crawl协程之间是并发的,不同crawl协程中的多个request_url(list_url)协程之间是并发的,多个request_url(detail_url)之间是并发的。而一个crawl协程内多个request_url子协程是串行的。


介绍一个技巧,无论是在这个例子还是之后的例子,如果想具体知道整个线程在多个协程和子协程的切换顺序和代码运行的路径过程,很简单,多print然后运行一下就知道了。这样也就知道哪些协程是并发的哪些是串行的了。

 

Task对象

下面是官方对Task对象的描述:Tasks用于并发调度协程,通过asyncio.create_task(协程)的方式创建Task对象,这样可以让协程加入事件循环中等待被调度执行。
除了使用asyncio.create_task()函数之外,还可以使用低层级的loop.create_task()或ensure_future()。不建议手动实例化Task对象。
create_task()只能在python 3.7之后使用。而ensure_future()兼容以前版本。
create_task()必须在事件循环已经建立好的情况下才能调用否则会报错。
create_task()做了两件事:把协程放到事件循环的任务列表中监听;返回一个Task对象,这个Task对象中包含了协程对象

 

例子4:改变例子3-1,让5个详情页的请求是并发的而不是串行的。

# coding=utf-8

import asyncio,random,time

# 协程函数:请求一个页面
async def request_url(url):
    print("请求url %s" % url)
    await asyncio.sleep(1)      # 模拟io请求
    print("完成请求 %s" % url)

    if url.find("detail") == -1:    # 如果这个url是一个列表页则获取详情页链接
        detail_urls = ["%s/detail%s" % (url, str(random.randint(1,100))) for i in range(5)]
        return detail_urls
    else:   # 如果url不是列表页则直接返回html内容
        return url + "的html内容"

# 协程函数:爬取一个列表页下所有详情页内容
async def crawl(list_url):
    print("start crawl")

    detail_urls = await request_url(list_url)

	# 创建多个Task对象(此时会将这些爬详情页的协程放入事件循环运行),存放在列表中
    task_list = [asyncio.create_task(request_url(detail_url)) for detail_url in detail_urls]

	# 请尝试注释掉这3行代码运行,看看会发生什么
    for task in task_list:
        detail_html = await task	# task对象是可等待对象,可以放在await后面	
        print("详情页内容: %s" % detail_html)

st = time.time()
asyncio.run(crawl("/list1"))
print("用时: %.5f" % (time.time() - st))      # 用时: 2.02461

当执行create_task()的时候,协程被放到事件循环的任务列表参与被调度,这时详情页的request_url()函数内的代码就开始执行。await task会等待详情页的request_rul协程执行完并返回协程运行的结果给detail_html变量,然后才结束crawl协程。

注意:await request_url(list_url) 与 5个 await task 是串行的,而5个 await task 之间是并发的(运行到await asyncio.sleep(1)时,单线程会在5个详情页协程之间切换)。create_task()的作用就是将5个await task之间的运行关系从串行变为并发。这也是官方文档所说的:“Tasks用于并发调度协程”的真正含义。

上面的写法还可以写的更优雅一些:

# 协程函数:爬取一个列表页下所有详情页内容
async def crawl(list_url):
    print("start crawl")

    detail_urls = await request_url(list_url)

    # 创建多个Task对象,存放在列表中
    task_list = [asyncio.create_task(request_url(detail_url)) for detail_url in detail_urls]

    # 请尝试注释掉这行代码运行,看看会发生什么
    done, _ = await asyncio.wait(task_list)   # done 是一个集合封装着多个对象r,r.result就是request_url()的返回值

    [print("详情页内容: %s" % res.result) for res in done]

 

PS:create_task()必须在事件循环已经建立好的情况下才能调用否则会报错
事件循环没有启动,你往任务列表里面放Task或协程也不会被调度

总结:
1.只有放到了任务列表中的协程或任务,这些协程或任务之间是并发的,其他的任务或协程和它们之间的关系都是串行的。
2.一个协程X,如果它遇到 <await 协程Y或任务Y>这样的结构时,会往协程Y里面钻去运行协程Y内的代码(协程Y是协程X的子协程),如果协程Y中还有<await 子协程Z>,还会继续下沉,直到遇到 <await io操作或asyncio.sleep>就会切换到其他协程或者阻塞整个线程。
3.一个协程X,如果它遇到  <await io操作或asyncio.sleep> 时,如果此时事件循环内的任务列表中还有不处于“等待”的状态的协程,线程就会切换到这样的协程去运行,如果任务列表中全都是处于“等待io”的状态的协程,此时整个线程都会阻塞(其实本质上是切回了主调用方执行到事件循环的select.select()方法,被这个方法阻塞,而非被asyncio.sleep阻塞,因为asyncio.sleep是个非阻塞异步方法,而且协程中也不允许使用阻塞的方法,否则会阻塞整个线程也阻塞了其他所有协程),直到有协程的io操作完成(假设是协程Y),此时线程会马上切换到协程Y。
4.在协程异步io编程中,一个单线程如果发生阻塞,这个阻塞肯定是发生调用方的事件循环的检测任务方法上(select.select())。
5.一个协程必须等所有它里面的子协程全都执行完以后才会结束无论子协程之间是否是并发的。如果一个协程内的所有子协程全都处于等待状态,那么这个父协程也会处于等待状态(这个时候线程是否阻塞就看还有没有其他协程是就绪的了)。

还是那句话:在每个协程中多print几下,然后运行,看看print的结果就知道运行的顺序和各个协程之间的并发或串行关系了。
 

Future对象(这里说的是asyncio中的Future)

Future类是Task类的父类,是一个更低级的可等待对象。当await一个future对象时,如果future对象中已经设置了结果result,则await不会等待,直接返回future中的result。如果future对象中没有设置结果result,则await会等待,此时要么由事件循环切换协程要么被事件循环阻塞整个线程

例子5:Future对象

# coding=utf-8

import asyncio,random,time

# 需要传入一个future对象作为参数
async def set_after(fut):
    await asyncio.sleep(2)
    fut.set_result("666")       # 在一个协程完成之后,在future对象中设置结果

async def main():
    # 获取当前事件循环
    loop = asyncio.get_running_loop()

    # 创建一个空的Future对象
    fut = loop.create_future()

    await asyncio.create_task(set_after(fut))	# 创建一个set_after协程,并丢到事件循环中与main并发运行,并await等待这个协程运行完

    result = await fut      # 得到666

    print(result)

asyncio.run(main())

asyncio.run(main())

分析:调用set_after的时候set_after还没有执行而是返回一个协程,调用create_task的时候set_after才准备开始运行。set_after执行到await sleep(2)时会发生等待并切回main运行到await fut 。由于fut中还没设置结果,因此阻塞,整个线程也被阻塞住,2秒后,fut对象设置结果666,会返回给result,线程结束。

asyncio.Future对象 和 concurrent.futures.Future对象

前者是使用asyncio协程完成任务时会涉及的对象;后者是使用线程池或者进程池完成任务时会遇到的对象。两个Future对象没有半毛钱关系,但是他们的共同点都是用于等待任务结果的返回。

以后写代码可能会出现交叉使用这两种Future对象,这意味着同时使用协程和多线程多进程开发。例如:一个项目里面都是用的协程进行异步编程,但是这个项目引入了一个第三方库如mysql数据库,它是使用的多线程异步编程,此时就会交叉使用。

 

 

如果想要结合多线程和协程的话,就要通过Future对象,下面是python官方的例子:

例子6:多线程结合协程

# coding=utf-8

import asyncio,random,time

def func():
    time.sleep(2)   # 模拟io操作,这里是个同步阻塞方法
    return "666"

async def main():
    loop = asyncio.get_running_loop()   # 获取事件循环

    # run_in_executor方法做了两件事
    # 1.调用ThreadPoolExecutor的submit方法去线程池申请一个线程执行func函数,并返回一个concurrent.futures.Future对象
    # 2.调用asyncio.wrap_future将多线程的Future对象包装为asyncio的Future对象,因为asyncio的future对象才支持await语法
    fut = loop.run_in_executor(None, func)  # 该方法不阻塞,第一参传一个线程池或进程池对象,None则默认线程池
    result = await fut    # 会阻塞等待func返回666才继续
    print(result)

asyncio.run(main())

当一些python模块不支持使用协程进行异步io编程时才会把多线程和协程混用。

 

例子7:混用asyncio协程 + 不支持异步的模块

以爬虫为例,下面使用requests这个同步模块写爬虫

# coding=utf-8

import asyncio,random,time,requests

# 爬一个url就开一个协程
async def getUrl(url):
    loop = asyncio.get_running_loop()

    # 启用线程池完成请求url的任务,并返回一个Future对象
    fut = loop.run_in_executor(None, requests.get, url)

    result = await fut      # 等待请求本url的这个特定线程运行完成并获取fut对象中设置的结果(requests.get的返回值)

    print(result.text)

urls = [
    "https://www.zbpblog.com/",
    "https://www.zbpblog.com/blog-196.html",
    "https://www.zbpblog.com/blog-195.html",
    "https://www.zbpblog.com/blog-194.html",
]

# 创建协程
task_list = [getUrl(url) for url in urls]

# 创建事件循环
loop = asyncio.get_event_loop()

# 将协程放入任务列表中监控
loop.run_until_complete(asyncio.wait(task_list))

这个是正确示范。这个例子中一个url对应开一个协程,一个协程中又对应开一个线程去爬url。
但是在实际应用中没有必要这样写,如果真的要用requests这个非异步模块做爬虫的话,就直接用多线程就可以了,没有必要又用多线程又用协程。
而且,如果直接使用协程会比用多线程+协程爬的效率更高,原因是线程池会限制并发的线程数,顶多就只能开十几二十个线程,此时协程就会被有限的线程数给拉低了爬取速度(短板效应)。而直接用协程的话,可以开成千上万个协程,同样是一秒,如果多线程可以爬5个url,协程就可以爬5000个url(当然啦,如果这么做的话肯定会被对方服务器给封禁IP的,而且对方服务器也不一定能并发处理这么多的连接)。

 

下面做一个错误示范:
例子8:错误示例

# coding=utf-8

import asyncio,random,time,requests

# 爬一个url就开一个协程
async def getUrl(url):
    result = requests.get(url)		# 加await会报错,因为requests.get()返回的不是一个Awaitable对象

    print(result.text)

urls = [
    "https://www.zbpblog.com/",
    "https://www.zbpblog.com/blog-196.html",
    "https://www.zbpblog.com/blog-195.html",
    "https://www.zbpblog.com/blog-194.html",
]

# 创建协程
task_list = [getUrl(url) for url in urls]

# 创建事件循环
loop = asyncio.get_event_loop()

# 将协程放入任务列表中监控
loop.run_until_complete(asyncio.wait(task_list))

这个实例没有报错,错就错在不能在协程中使用requests.get这个同步阻塞方法(同步就是要等操作完成才返回,异步是操作没完成也可以立刻返回,即使是返回None)。如果在协程中用阻塞的方法,就完全达不到单线程并发的效果。

 

使用Task对象或Future对象使调用方能得到协程的返回值

例子9:获取协程的返回值

# coding=utf-8

import asyncio

async def func(i):
    print("开始")
    await asyncio.sleep(1)
    print("结束")

    return i

loop = asyncio.get_event_loop()     # 开启事件循环必须放在create_task之前否则会报错
task_list = [loop.create_task(func(i)) for i in range(5)]
loop.run_until_complete(asyncio.wait(task_list))    # 这个方法会阻塞,直到所有协程运行完才会往下面的代码走

print(123)

for task in task_list:
    print(task.result())    # 获取协程的返回值,这个方法会阻塞,直到协程return了才会被唤醒。但是能过得了run_until_complete这个方法的阻塞,走到这里的result()方法都不会阻塞的

 

例子10:在协程完成后调用回调函数

# coding=utf-8

import asyncio


async def func(i):
    print("开始")
    await asyncio.sleep(1)
    print("结束")

    return i

# 协程完成后会自动调用该回调函数,并且传入一个future对象
def cb(future):
    print("执行回调函数,协程返回值为: %s" % future.result())

loop = asyncio.get_event_loop()  # 开启事件循环必须放在create_task之前否则会报错
task_list = [loop.create_task(func(i)) for i in range(5)]   # 将任务放入任务列表中(或者说把协程放到协程池中调度)
for task in task_list:
    task.add_done_callback(cb)    # 让协程结束后自动调用一个回调函数
loop.run_until_complete(asyncio.wait(task_list))  # 等待协程运行,这个方法会阻塞

如果我想往cb中传入其他参数,可以使用 functools.partial(),他可以把一个函数变为另一个函数,并传入你想传的参数。

用法如下:

from functools import partial

# 其他参数要放在future参数前。
def cb(url, future):
    print("执行回调函数,协程返回值为: %s" % future.result())
	
...

task.add_done_callback(partial(cb, url))

 

异步上下文管理器

定义了 __aenter() 和 __aexit__ 方法的对象就是异步上下文管理器。这两个方法都要使用async声明

异步上下文管理器支持使用 async with

首先我们要知道什么是上下文管理器,它类似于一个在执行操作之前先帮你自动创建一个资源标识或者创建连接,执行完操作之后帮你自动关闭这个连接的管理器。

__aenter__编写执行操作前的操作, __aexit__执行操作完成后的操作。

 

例子11:异步上下文管理器(这是一段伪代码)

# coding=utf-8

import asyncio

class AsyncContextManager:
    async def __aenter__(self):
        # 做数据库连接的操作
        self.conn = await mysql.connect()    # 伪代码,连接这个行为必须是异步的才行
        pass

    async def do_something(self):
        # 操作数据库
        await asyncio.sleep(1)
        return 666

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        await self.conn.close()       # 这个close方法也必须是异步非阻塞的才行

async def func():
	async with AsyncContextManager() as f:
		result = await f.do_something()		# 这里必须要await
		print(result)
		
asyncio.run(func())
	

执行到 async with 会自动调用 __aenter__ 
当跳出async with 代码块的时候会执行 __aexit__

async with 也必须在协程中使用,所以要把它放在协程函数 func 中。

在协程中,如果执行到async with ,意味着__aenter__和__aexit__方法中会发生异步等待的操作(如上面的异步连接mysql),所以执行到async with 也会切换到其他协程。对于async with 的理解,我会在介绍aiomysql的连接池的时候用例子来说明。

 

uvloop

这是asyncio事件循环的替代方案,uvloop是一个第三方库。asyncio本身内置的事件循环的效率是uvloop的一半。

它的使用方式非常简单:
import uvloop 

# 将asyncio内部的事件循环替换为uvloop的事件循环
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())

# 之后的运行协程的方式和之前一模一样

 

run_until_complete 和 run_forever

我们知道,当执行 get_event_loop 方法的时候会获取一个事件循环对象,但事件循环还没开始。
当执行run_until_complete 和 run_forever 时会开始事件循环。二者的区别是,前者会在所有协程结束后停止事件循环,后者不会而是一直事件循环下去。

其实,前面我一直没有提的是,run_until_complete方法内部会把传进这个方法的协程coro给封装为Task对象(run_forever也会),然后再放到任务列表中。

 

了解了这些基本上就了解如何去写一个高并发协程编程的程序了,下节我们将介绍一些配合asyncio库的异步方法库(aioredis,aiomysql和aiohttp),因为我们知道asyncio+协程编程中必须要使用异步的方法才不会阻塞整个线程。“从IO模型到协程”系列文章最终会以写一个爬虫以检验协程的学习成果而结束,了解这些异步方法的库也为我们最后编写这个高并发单线程协程爬虫做准备。




更多内容请关注微信公众号
zbpblog微信公众号

如果您需要转载,可以点击下方按钮可以进行复制粘贴;本站博客文章为原创,请转载时注明以下信息

张柏沛IT技术博客 > 从IO模型到协程(六) asyncio和协程实现高并发

热门推荐
推荐新闻