三、异步IO和协程
3.1 多任务
无论是多线程还是多进程,一旦数量过去,效率一定是提不上去的。因为虽然时间片的切换是有时间损耗的,切换的越多,时间损耗越多。如果有几千个任务同时进行,操作系统可能就主要忙着切换任务,根本没有多少时间去执行任务了,这种情况最常见的就是硬盘狂响,点窗口无反应,系统处于假死状态。
此外是否采取多进程和多线程还要考虑是执行CPU密集型任务还是IO密集型任务。如果是CPU密集型任务,任务的执行主要依赖CPU运算,任务越多,不必要的任务切换的时间损耗就越多,效率就越低。如果是IO密集型任务,如果采取多任务,在某个任务等待IO操作时,其他任务可以先进行,从而减少整体的IO等待时间,减少CPU运转的空档期,提高CPU运转效率。所以IO型任务可以使用多任务。
常见的多任务形式除了多进程、多线程,还包括异步IO、协程。
3.2 非阻塞编程
3.2.1 协程和异步IO
Coroutine(协程)是一种程序组件,其行为类似于子例程(subroutine)或函数,但与这些不同的是,协程可以在其执行过程中被挂起(suspend)和恢复(resume),而不需要终止或调用方等待其完成。协程通常与生成器(generator)和异步I/O库一起使用,以构建非阻塞的、事件驱动的程序。
异步IO是一种编程模型,它允许程序在等待IO操作时继续执行其他任务,从而提高程序的效率和响应能力。
异步IO与协程紧密配合。因为协程可以在等待IO操作时挂起,并在IO操作完成时恢复执行。这种挂起和恢复的能力使得异步IO操作可以无缝地集成到协程的执行流程中,从而实现真正的非阻塞并发编程。
3.2.2 async def和await
async def和await是实现异步编程的关键工具。我们可以通过async def关键字创建(定义)协程,并通过await表达式挂起和恢复执行。
async def:定义一个异步函数,这样的函数可以包含等待IO操作(或其他异步操作)的挂起点。
await:用于标记一个挂起点。await后常跟一个返回awaitable对象(通常是另一个协程或异步操作)的表达式。
当异步函数中的await表达式被执行时,函数会暂停执行,直到awaitable对象(通常是另一个协程或异步操作)完成,并恢复执行。这使得异步函数能够在等待IO操作(比如网络请求或数据库查询)时释放控制权,允许其他任务继续执行。
异步函数通过asyncio.run()函数或者获取事件循环函数来运行。尝试一个简单的异步IO:
import asyncio
async def cc(num):
count = 0
count += num
# 假设这个加上的动作需要0.1s
await asyncio.sleep(0.1) # 挂起等待0.1秒
return count
async def main():
result = await cc(10) # 挂起等待cc(10)执行
print(result)
if __name__ == '__main__':
# asyncio.run运行协程
asyncio.run(main())
# 获取事件循环并运行主协程
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
3.2.3 asyncio库
① 几个概念
1)Task对象:
- Task是Future的子类,用于表示一个并发任务。它封装了一个协程(coroutine)对象,使得这个协程可以在事件循环中被调度和执行。
- create_task函数可以创建Task对象。创建后,该协程就会被添加到事件循环中等待被调度执行。
2)Future对象:
- 用于表示尚未完成的异步操作结果的占位符,在异步编程中,Future对象通常用于接收异步操作的结果
- gather函数可以执行一组异步操作(如协程,其他Future对象)并等待它们全部完成。并返回一个包含所有结果的Future对象。
3)事件循环:
- 事件循环是一个事件队列,所有待执行的任务会加入事件循环挂起,当一个异步程序处于挂起等待IO操作完成时,事件循环会从队列中取出下一个任务来补位使用主线程,从而避免了线程的CPU闲置,提高了线程的并发性能,实现单线程的非阻塞编程。待执行的任务可以是IO操作,定时器事件,用户交互等
- 事件循环最大作用是可以在单线程内以非阻塞的方式处理并发任务,避免了线程切换的开销
② 常用函数
asyncio.create_task(coro) | 创建一个Task对象,来执行给定的协程。coro是由一个async def定义的异步函数。对于创建的Task对象,我们通常会用await等待任务完成并获取结果,或通过gather函数并发执行并获取结果。 |
asyncio.gather(*aws, loop=None) | 作用是并发执行任意个个awaitable对象,如协程和任务,在全部对象执行完后,返回一个包含所有结果的Future对象。Future对象是列表的形式。其中*aws表示一个解包的列表或者元组,也可以直接传入任意个awaitable变量(如Task对象)。loop=None可省略,省略默认获取当前事件循环,python3.7以上版本支持。 |
asyncio.get_event_loop() | 获取当前事件循环。如果当前线程没有事件循环,这个函数会创建一个事件循环,并设为当前时间循环。 |
asyncio.run_until_complete(coro) | 通常与get_event_loop()一起用,用于运行传入的协程或Future对象并返回结果 |
asyncio.run(main()) | 这个函数是启动异步函数最直接的方法,他创建一个事件循环,传入协程,运行完后自动退出循环。 |
asyncio.sleep(s) | 创建一个挂起指定秒数的协程,常用于模拟网络延迟和测试中引入等待时间 |
③ 案例
运用协程并发执行提高效率的案例:
假设要在一个线程内下载三个文件,单个文件的下载时间是2秒。
单线程内使用异步编程:
import asyncio
from time import time
async def download(name):
print('下载的电影名:%s' % name)
# 模拟下载时间2s
await asyncio.sleep(2)
print('%s下载完成' % name)
async def main():
start = time()
tasks = [asyncio.create_task(download(name)) for name in ['霸王别姬', '花样年华', '东邪西毒']]
result = asyncio.gather(*tasks) # 生成一个包含所有协程的Future对象
await result # 运行完毕Future对象并返回结果
end = time()
print('共耗时%.2fs' % (end-start))
# print(result) # 错误读取Future对象的结果:<_GatheringFuture finished result=[None, None, None]>
if __name__ == '__main__':
asyncio.run(main())
"""
下载的电影名:霸王别姬
下载的电影名:花样年华
下载的电影名:东邪西毒
霸王别姬下载完成
花样年华下载完成
东邪西毒下载完成
共耗时2.00s
"""
单线程内不使用异步编程:
import asyncio
from time import time, sleep
def download(name):
print('下载的电影名:%s' % name)
# 模拟下载时间2s
sleep(2)
print('%s下载完成' % name)
def main():
start = time()
# 单线程内依次下载三个文件
download('霸王别姬')
download('花样年华')
download('东邪西毒')
end = time()
print('共耗时%.2fs' % (end-start))
# print(result) # 错误读取Future对象的结果:<_GatheringFuture finished result=[None, None, None]>
if __name__ == '__main__':
main()
"""
下载的电影名:霸王别姬
霸王别姬下载完成
下载的电影名:花样年华
花样年华下载完成
下载的电影名:东邪西毒
东邪西毒下载完成
共耗时6.01s
"""
所以,想充分利用CPU的多核特性,可以多进程+协程结合使用。一方面充分利用多核CPU,一个核心运行一个进程/线程,实现并行计算;另一方面可以运用协程实现非阻塞编程,提高单个线程的效率。两者结合可以获得更高的CPU性能。
四、练习
4.1 使用多进程对复杂任务“分而治之
完成1~100000000求和的计算密集型任务
直接求和(结果在代码后):
import time
def main():
num_sum = 0
start = time.time()
for i in range(100000001):
num_sum += i
end = time.time()
print(num_sum)
print('耗时%.2fs' % (end-start))
if __name__ == '__main__':
main()
"""
5000000050000000
耗时6.28s
"""
使用多进程计算(结果在代码后):
"""
多进程计算后汇总
"""
from multiprocessing import Process, Queue
from time import time
def split(a, b, num):
"""计算a-b拆分成num份分别计算"""
diff = b - a + 1
if diff % num == 0:
# 把a-b分成num份,然后加入列表分别求值
ss = diff // num
ls = []
for i in range(1, num + 1):
ls.append([i for i in range(a + (i - 1) * ss, a + i * ss + 1)])
return ls
else:
return None
def calculate(num_list, queue):
num = sum(num_list)
queue.put(num) # 把计算结果放进某个队列
def main():
ls = split(1, 100000000, 10)
ps = []
q = Queue() # 定义一个队列,存放计算结果
results = []
for i in range(10):
p = Process(target=calculate, args=(ls[i], q))
p.start()
ps.append(p)
for i in range(10):
results.append(q.get())
start = time()
for p in ps:
p.join()
sum_value = sum(results)
end = time()
print(sum_value)
print('耗时%.2fs' % (end - start))
if __name__ == '__main__':
main()
"""
电脑运行多进程会报错。。得不到结果,其他小伙伴可以试试
"""
使用多线程计算(结果在代码后):
"""
多线程计算后汇总
"""
from threading import Thread
from time import time
def split(a, b, num):
"""计算a-b拆分成num份分别计算"""
diff = b - a + 1
if diff % num == 0:
# 把a-b分成num份,然后加入列表分别求值
ss = diff // num
ls = []
for i in range(1, num + 1):
ls.append([i for i in range(a + (i - 1) * ss, a + i * ss + 1)])
return ls
else:
return None
def calculate(num_list, results):
return results.append(sum(num_list))
def main():
ls = split(1, 100000000, 8)
results = []
ps = []
for i in range(8):
p = Thread(target=calculate, args=(ls[i], results))
p.start()
ps.append(p)
start = time()
for p in ps:
p.join()
sum_value = sum(results)
print(sum_value)
end = time()
print('耗时%fs' % (end - start))
if __name__ == '__main__':
main()
"""
5000000500000008
耗时0.002512s
"""
上面的代码需要大概6秒左右的时间,而下面的代码只需要不到1秒的时间,跟骆昊大神学习的只比较了运算的时间,没有考虑列表创建及切片操作花费的时间,实际上如果算上前面的时间还挺慢的。
全部时间:
def main():
start = time()
ls = split(1, 100000000, 8)
...
print(sum_value)
end = time()
print('耗时%fs' % (end - start))
if __name__ == '__main__':
main()
"""
5000000500000008
耗时6.148524s
"""