流畅的python笔记(十七)使用期物处理并发

目录

前言

一、网络下载的三种风格

依序下载的脚本

使用concurrent.futures模块的下载脚本flags_threadpool.py

期物在哪里

二、阻塞型IO和GIL

三、使用concurrent.futures模块启动进程

四、实验Executor.map方法

五、显示下载进度并处理错误


前言

本章讨论concurrent.futures模块。

期物的概念:期物指一种对象,表示异步执行的操作,是concurrent.futures模块和asynicio包的基础。

一、网络下载的三种风格

为了高效处理网络IO,需要使用并发,因为网络有很高的延迟,所以为了不浪费CPU周期去等待,最好在收到网络响应之前做些其他的事。以下有三个脚本从网上下载20个国家的国旗图像:

  1. flags.py,下载完一个图像,将其保存在硬盘中之后,才请求下一个图像。
  2. flags_threadpool.py,几乎同时请求所有图像,每下载完一个文件就保存一个文件。
  3. flags_asyncio.py,几乎同时请求所有图像,每下载完一个文件就保存一个文件。

依序下载的脚本

 

  1.  导入requests库,这个库不在标准库中,因此按照惯例,在导入标准库中的模块(os、time和sys)之后导入,而且使用一个空行分隔开。
  2.  列出人口最多的20个国家的ISO 3166国家代码,按人口数量逆序排列。得到一个字符串列表。
  3.  获取国旗图像的网站。
  4. 保存图像的本地目录。
  5.  把img保存到DEST_DIR目录中,命名为filename。
  6.  指定国家代码,构建URL,然后下载图像,返回响应中的二进制内容。 
  7.  显示一个字符串,然后刷新sys.stdout,这样能在一行消息中看到进度,在python中得这么做,因为正常情况下遇到换行才会刷新stdout缓冲。
  8. download_many是与并发实现比较的关键函数。
  9. 按字母表顺序迭代国家代码列表,明确表明输出的顺序与输入一致,返回下载的国旗数量。
  10. main函数记录并报告运行download_many函数之后的耗时。
  11. main函数必须调用执行下载的函数;我们把download_many函数当作参数传给main函数,这样main函数可以用作库函数,在后边的示例中接收download_many函数的其他实现。

使用concurrent.futures模块的下载脚本flags_threadpool.py

concurrent.futures模块的主要特色是ThreadPoolExecutor和ProcessPoolExecutor类,这两个类实现的接口能分别在不同的线程或进程中执行可调用的对象。这两个类在内部维护着一个工作线程或进程池,以及要执行的任务队列。此接口抽象层级很高,无需关注实现细节。

  1.  重用flags模块中的几个函数。
  2.  设定ThreadPoolExecutor类最多使用几个线程。
  3.  下载一个图像的函数download_one,这是各个线程中执行的函数。
  4.  设定工作线程数量:等于允许使用的最大值MAX_WORKERS与要处理的数量(即要下载的国旗数量)之间较小的那个值,以免创建多余的线程。
  5. 使用工作的线程数实例化ThreadPoolExecutor类得到executor,executor.__exit__方法会调用executor.shutdown(wait=True)方法,它会在所有线程都执行完毕前阻塞线程。
  6. map方法与内置的map函数类似,其第一个参数是一个函数,第二个参数是一个可迭代对象,把第二个参数中的每个值传给第一个参数产生一个输出。但是这里的executor.map是多线程的,即download_one函数会在多个线程中并发调用。map方法返回一个生成器,因此可以迭代,获取各个函数的返回值。
  7. 调用flags模块中的main函数,传入download_many函数的增强版。

期物在哪里

从python3.4起,标准库中有两个名为Future的类:concurrent.futures.Future和asyncio.Future。这两个类作用相同,都表示可能已经完成或者尚未完成的延迟计算。

        期物封装待完成的操作,可以放入队列,完成的状态可以查询,得到结果(或抛出异常)后可以获取结果(或异常)

        通常情况下不应自己创建期物,而只能由并发框架(concurrent.futures或asyncio)实例化。因为期物表示终将发生的事情,而确定某件事会发生的唯一方式是执行的时间已经排定。因此只有排定把某件事交给concurrent.futures.Executor子类处理时,才会创建concurrent.futures.Future实例。例如,Executor.submit()方法的参数是一个可调用的对象,调用这个方法会为传入的可调用对象排期,并返回一个期物。

        客户端代码不应改变期物的状态,并发框架在期物表示的延迟计算结束后会改变期物的状态,而我们无法控制计算何时结束。

        concurrent.futures.Future和asyncio.Future这两种期物都有.done()方法,这个方法不阻塞,返回值是布尔值,指明期物链接的可调用对象是否已经执行。客户端代码不会询问期物是否运行结束,而是会等待通知。因此,两个Future类都有add_done_callback()方法,这个方法只有一个参数,类型是可调用的对象,期物运行结束后会调用指定的可调用对象(期物是可调用对象延迟调用的时间?一旦期物结束,就会执行可调用对象?所以多线程程序中期物是用来给每个线程排期的?)。

        此外还有个result()方法:

  • 在期物运行结束后调用的话,这个方法在两个Future类中的作用相同:返回可调用对象的结果,或者重新抛出执行可调用对象时抛出的异常。
  • 如果期物没有运行结束,result方法在两个Future类中的行为相差很大。对concurrent.futures.Future实例来说,调用f.result()方法会阻塞调用方所在的线程,直到有结果返回,此时result方法可以接收可选的timeout参数,如果在指定的时间内期物没有运行完毕,会抛出TimeoutError异常。asyncio.Future.result方法不支持设定超时时间,获取期物的结果最好使用yield from结构,不过对于concurrent.futures.Future实例不能这么做。

        从使用角度理解期物,使用concurrent.futures.as_completed函数,其参数是一个期物列表,返回值是一个迭代器,在期物运行结束后产出期物。

        使用futures.as_completed函数,只需修改download_many函数,把教抽象的executor.map调用换成两个for循环:一个用于创建并排定期物,另一个用于获取期物的结果。

  1. 只使用人口最多的五个国家进行演示。
  2. 把使用的线程数设定为3。
  3. 按照字母表顺序迭代国家代码,明确表明输出的顺序与输入一致。
  4. executor.submit方法排定可调用对象的执行时间,然后返回一个期物,表示这个待执行的操作。
  5. 存储各个期物,后面传给as_completed函数。
  6. 显示一个信息,包含国家代码和对应的期物。
  7. as_completed函数在期物运行结束后产出期物。
  8. 获取该期物的结果。
  9. 显示期物及其结果。

这个例子中调用future.result()方法绝不会阻塞,因为future由as_completed函数产出。

  1.  排定的期物按字母表排序,期物的repr方法会显示期物的状态:前三个期物的状态是running,因为有三个工作的线程。
  2.  后两个期物的状态的pendin,等待有线程可用。
  3.  这一行中第一个CN是运行在一个工作线程中的download_one函数输出,随后内容是download_many函数输出。
  4. 这里有两个线程输出国家代码,然后主线程中的download_many函数输出第一个线程的结果。

 

二、阻塞型IO和GIL

CPython解释器本身就不是线程安全的,因此有全局解释器锁GIL,一次只允许使用一个线程执行python字节码。因此一个python进程通常不能同时使用多个CPU核心。

        编写python代码时无法控制GIL,不过执行耗时的任务时,可以使用一个内置的函数或一个使用C语言编写的扩展释放GIL。其实有个使用C语言编写的python库能管理GIL,自行启动操作系统线程,利用全部可用的CPU核心。但这样做会极大增加库代码的复杂度。

        然而,标准库中所有执行阻塞型IO操作的函数,在等待操作系统返回结果时都会释放GIL,这意味着在python语言这个层次上可以使用多线程,而IO密集型python程序会从中受益:一个python线程等待网络响应时,阻塞型IO函数会释放GIL,再运行一个线程

        

三、使用concurrent.futures模块启动进程

这个模块实现的是真正的并行计算,因为它使用ProcessPoolExecutor类把工作分配给多个python进程处理。因为如果需要CPU密集型处理,使用这个模块能绕开GIL,利用所有可用的CPU核心。

        ProcessPoolExecutor和ThreadPoolExecutor类都实现了通用的Executor接口,因此使用基于concurrent.futures模块能轻松把基于线程的方案转成基于进程的方案。

这两个实现Executor接口的类唯一值得注意的区别是,ThreadPoolExecutor.__init__方法需要max_workers参数,指定线程池中线程的数量。在ProcessPoolExecutor类中,那个参数是可选的,而且大多数情况下不使用,因为默认值是os.cpu_count()函数返回的CPU数量。对于CPU密集型的处理来说,不可能要求使用超过CPU数量的职程。而对IO密集型处理来说,可以在一个ThreadPoolExecutor实例中使用10个、100个或1000个线程,最佳线程取决于做的什么事,以及可用内存有多少,要仔细测试才能找到最佳线程数。

四、实验Executor.map方法

若想并发运行多个可调用的对象,最简单的方式是使用Executor.map方法。

  1. display函数的作用就是把传入参数打印出来,并在前边加上时间戳。
  2. loiter函数在开始时显示一个消息,然后休眠n秒,最后在结束时再显示一个消息,消息用制表符缩进,缩进的量由n的值确定。
  3. loiter返回n*10。
  4. 拆功能键ThreadPoolExecutor实例,有3个线程。
  5. 把5个任务提交给executor,range(5)是可迭代对象,可以传5个值给loiter函数。因为只有3个线程,因此只有3个任务会立即开始:loiter(0)、loiter(1)、loiter(2)。这是非阻塞调用。
  6. 立即显示调用executor.map方法的结果:一个生成器results。
  7. for循环中enumerate函数会隐式调用next(results),这个函数又会在内部表示第一个任务loiter(0)的_f期物上调用_f.result()方法。result方法会阻塞,直到期物运行结束,因此这个循环每次迭代时都要等待下一个结果做好准备。

  1.  这次运行从15:56:50开始。
  2.  第一个线程执行loiter(0),因此休眠0秒,甚至会在第二个线程开始之前就结束。
  3.  loiter(1)和loiter(2)立即开始,因为线程池中有三个职程,可以并发运行三个函数。
  4.  executor.map方法返回的结果results是生成器,不管有多少任务,也不管max_workers的值是多少,目前不会阻塞。
  5. loiter(0)运行结束了,第一个职程可以启动第四个线程,运行loiter(3)。
  6. 此时执行过程可能阻塞,具体情况取决于传给loiter函数的参数:results生成器的__next__方法必须等到第一个期物运行结束。此时不会阻塞,因为loiter(0)在循环开始前结束。注意这一点之前的所有时间都在同一刻发生(15:56:50)。
  7. 一秒钟后,即15:56:51,loiter(1)运行完毕,这个线程闲置,可以开始运行loiter(4)。
  8. 显示loiter(1)的结果:10。现在for循环会阻塞,等待loiter(2)的结果。
  9. 同上:loiter(2)运行结束,显示结果;loiter(3)也一样。
  10. 2秒钟后loiter(4)运行结束,因为loiter(4)在15:56:51开始,休眠了4秒。

 

五、显示下载进度并处理错误

暂略。

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值