Python - 并行执行器

并发网络下载

flags.py、flags_threadpool.py、flags_asyncio.py 三个版本分析可得:

  1. 对于网络I/O 操作,不管使用哪种并发结构 ————线程或者协程————只要代码写的没有问题,吞吐量都比依次执行的代码高很多。
  2. 对于可以控制发起多少请求的HTTP客户端,线程与协程之间的性能差异不明显

对于同时接受很多客户端访问的服务器来说,区别还是有的:协程的伸缩能力更好,因为协程使用的内存比线程少很多,而且没有上下文切换的开销

依序下载的脚本

# flags.py

import time
from pathlib import Path
from typing import Callable

import httpx


POP20_CC = ('CH IN US ID BR PK NG BD RU JP MX PH VN ET EG DE IR TR CD FR').split()
BASE_URL = 'http://mp.ituring.com.cn/files/flags'
DEST_DIR = Path('downloaded')


def save_flag(img: bytes, filenames: str) -> None:  # 1
    (DEST_DIR / filenames).write_bytes(img) 


def get_flag(cc: str) -> bytes:  # 2
    url = f'{BASE_URL}/{cc}/{cc}.gif'.lower()
    resp = httpx.get(url, timeout=6.1,  # 3
follow_redirects=True)  # 4

    resp.raise_for_status() # 5
    return resp.content


def down_many(cc_list: list[str]) -> int: # 6
    for cc in sorted(cc_list): # 7
        image = get_flag(cc)
        save_flag(image, f'{cc}.gif')
        print(cc, end=' ', flush=True) # 8
    return len(cc_list)


def main(downloader: Callable[[list[str]], str]) -> None: # 9
    DEST_DIR.mkdir(exist_ok=True) # 10
    t0 = time.perf_counter()  # 11
    count = downloader(POP20_CC)
    elapsed = time.perf_counter() - t0
    print(f'\n{count} downloads in {elapsed:.2f} s')


if __name__ == '__main__':
    main(down_many)
  1. 把image 字节序列保存到 DEST_DIR 目录中, 命名为 filenames
  2. 指定国家代码,构建url,然后下载图像,返回相应中的二进制内容
  3. 最好为网络指定一个合理的超时时间,防止莫名其妙阻塞几分钟。
  4. HTTPX,默认不跟踪重定向,这里无须设置follow_redirects=True,想强调的是httpx和request之间的区别。设置之后可以灵活应对未来的变化,说不定会把图像移到其他地方
    5.这个脚本没有处理错误,但是这个方法在HTTP状态码不是2XX 时抛出异常————为免失败后悄无声息。强烈建议调用该方法。
  5. download_many 函数是与并发实现比较的关键
  6. 按字母表顺序迭代国家代码列表,明确表明输出的顺序与输入一致。返回下载的国旗数量
  7. 在同一行中一次显示一个国家代码,展示下载进度。 end=' '参数把常规的换行符替换为一个空格,在同一行依次显示各个国家代码。flush=Ture 参数不可缺少,因为Python 的输出默认以行为单位缓冲。即Python 只在换行符后显示可打印的字符。
  8. 调用main 函数必须传入下载任务的函数。如此一来,我们可以把main作为库函数使用,在threadpool 和 asyncio 示例中出入 down_many 的其他实现
  9. 如果需要则创建DEST_DIR 目录,如果目录以存在则不抛出错误
  10. 记录并报告dowloader 函数的运行时间

使用concurrent.futures 模块下载

# 实例22-3 flags_threadpool.py:使用futures.ThreadPoolExector 类实现多线程下载的脚本

from concurrent import futures

from flags import save_flag, get_flag, main # 1


def download_one(cc: str): # 2
    image = get_flag(cc)
    save_flag(image, f'{cc}.gif')
    print(cc, end=' ', flush=True)
    return cc


def download_many(cc_list: list[str]) -> int:
    with futures.ThreadPoolExecutor() as excutor: # 3
        res = excutor.map(download_one, sorted(cc_list)) # 4
 
    return len(list(res)) # 5


if __name__ == '__main__':
    main(download_many) # 6
  1. 重用 flags 模块中的几个函数
  2. 下载单个图像的函数。这是在各个职程中执行的函数
  3. 实例化 ThreadPoolExector,作为上下文管理器。 exector._exit_ 方法将调用 exector.shutdown(wait=True), 在所有线程都执行完毕前阻塞线程
  4. map 方法的作用与内置函数map类似,不过download_one 函数会哎多个线程中并发调用。map方法返回一个生成器,通过迭代可以获取各个函数调用返回的值 ————这里,每次调用download_one 返回一个国家代码
  5. 返回获取的结果数量。如果有i安城抛出异常。那么 异常在list 构造方法尝试从exector.map 返回的迭代器中获取相应的返回值时抛出。
  6. 调用flags 模块中的main函数,传入download_many函数的并发版

ThreadPoolExector 构造函数接受多个参数,这里没有用到。其中,第一个参数最为重要,即max_workers。该参数设置最多执行多少个工作线程。max_workers 的值为None时(默认值),从Python3.8开始,ThreadPoolExector 使用以下表达式决定线程数量。

max_workers = min(32, os.cpu_count() + 4)

ThreadPoolExector 文档解释了这么做的依据:

这个默认值为I/O 密集型任务保留至少5个职程。对于那些释放了GIL的CPU 密集型任务,最多使用32个CPU核。这样能够在多核设备中不知不觉使用特大量资源。
现在,ThreadPoolExector 在启动max_workers 个工作线程之前也会重用空闲的工作线程

future 对象在哪里

future 对象是 concurrent.future 模块 和 asyncio 包的核心组件,可是,作为这两个库的用户,我们有时却见不到future 对象。示例20-3 在背后用到了future对象,但是我编写的代码没有直接使用它。

从Python 3.4 起,标准库中有两个名为Future的类: concurrent.futures.Futur 和 asyncio.Future。二者的作用相同:两个Future 类的实例都表示可能已经完成或者尚未完成的延时计算。这与Twisted 中的Deferred类、Tornado中的Future类,以及现代JavaScript 中的 Promise 类似。

future对象封装待完成的操作,可以放入队列,完成的状态可以查询,得到结果(异常)后可以获取

但是要记住一点,future 对象不应自己动手创建,只能由并发框架(concurrent.futures 或 asyncio) 实例化。原因很简单:future 对象表示终将运行的操作,必须排期运行,而这是框架的工作。具体而言,只有把可调用对象交给concurrent.futures.Exector 子类执行时才创建concurrent.futures.Future 实例。例如,Exector.submit() 方法接受一个可调用对象,排期执行,返回一个Future 实例。

应用程序代码不应该改变future 对象的状态,并发框架在future 对象表示的延迟计算结束后改变future对象的状态,而我们无法掌控计算何时结束。

两种future对象都有.done() 方法。该方法不阻塞,返回一个布尔值,指明future 对象包装的可调用对象是否已经执行。客户端代码通常不询问future对象是否运行结束,而是等待通知。因此,两个Future类都有.add_done_callback() 方法:提供一个可调用对象,在future对象执行完毕后调用;这个可调用对象的唯一参数是future对象。注意,回调的这个可调用对象与future对象包装的函数在同一个工作线程或进程中运行。

此外,还有.result() 方法。该方法在两个Future 类中的作用相同,当future 对象运行结束后,返回可调用对象的结果,或者重新抛出可执行调用对象时抛出的异常。可是,如果future对象没有运行结束,那么result 方法在两个Future 类中的行为相差甚远。对于concurrency.futures.Future 实例,调用f.result() 方法将阻塞调用方所在的线程,直到有结果返回。此时,result 方法可以接受可选的timeout 参数,如果在指定的时间内future对象没有运行完毕,则抛出TimeoutError异常。asyncio.Future.result 方法不支持设定超时时间,对于asyncio 包,获取future对象的结果首选 await。不过await 对concurrency.futures.Future 不起作用。

这两个库中有几个函数返回future对象,其他函数则使用future对象,以用户易于理解的方式实现自身。示例22-3 中的Exector.map 方法属于后者,它返回一个迭代器,迭代器的_next_ 方法调用各个future 对象的resutl 方法,因此我们得到的是各个future 对象的结果,而非future 对象本身。

为了从实用的角度理解future对象,可以使用 concurrency.futures.as_completed 函数重写示例20-3。这个函数的参数使一个future对象构成的可迭代对象,返回值是一个迭代器,在future 对象运行结束后产出 future 对象。

为了使用futures.as_complete 函数,只需要修改download_many 函数,把较高级的exector.map 调用换成两个for循环:一个用于创建并排定future对象,另一个用于获取future对象的结果。同时,我们将添加几个print调用,显示运行结束前后的future对象。修改后的download_many 函数如示例20-4 所示,代码行数由5增加到17,不过现在我们能一窥神秘的future对象了。其他函数不变,与示例20-3 中一样。

# 示例20-4 flags_threadpool_futures.py: 把download_many 函数中的exector.map 换成exector.submit 和 futures.as_completed

def download_many(cc_list: list[str]) -> int:

    with futures.ThreadPoolExecutor() as excutor: # 1
        to_do: list[futures.Future] = []
        for cc in sorted(cc_list): # 2
            future = excutor.submit(download_one, cc) # 3
            to_do.append(future) # 4
            print(f'Schedule for {cc}: {future}') # 5

        for count, future in enumerate(futures.as_completed(to_do), 1): # 6
            res: str = future.result() # 7
            print(f'{future} result: {res !r}') # 8

    return count

  1. 把max_workers 设为3,以便在输出中观察待完成的future对象
  2. 按照字母表顺序迭代国家代码,强调返回的结果是无序的
  3. exector.submit() 方法排定可调用对象的执行时间,返回一个future对象。表示待执行的操作
  4. 存储各个future对象,后面传给as_completed 函数
  5. 显示一个消息,包含国家代码和对象的future对象
  6. as_completed 函数在future 对象运行结束后产出future 对象
  7. 获取future 对象的结果
  8. 显示future 对象及其结果

注意,在这个示例中调用future.resutl()方法绝不会阻塞,因为future 由as_completed 函数产出。运行示例20-4 得到的输出如 示例20-5所示

# 示例 20-5  
Schedule for BR: <Future at 0x27f46009000 state=running> # 1
Schedule for CH: <Future at 0x27f460099f0 state=running>
Schedule for ID: <Future at 0x27f4600a260 state=running>
Schedule for IN: <Future at 0x27f4600aad0 state=pending> # 2
Schedule for US: <Future at 0x27f4600ab90 state=pending>
IDCH  <Future at 0x27f4600a260 state=finished returned str> result: 'ID'
<Future at 0x27f460099f0 state=finished returned str> result: 'CH'
BR <Future at 0x27f46009000 state=finished returned str> result: 'BR' # 3
IN <Future at 0x27f4600aad0 state=finished returned str> result: 'IN'
US <Future at 0x27f4600ab90 state=finished returned str> result: 'US'

5 downloads in 0.39 s
  1. 按字母顺序排定future对象。future对象的repr() 方法显示future对象的状态;可以看到,前3个 future 对象的状态是running,因为由3个工作线程。
  2. 后两个future 对象的状态是pending,等待有线程可用。
  3. 这一行里的第一个BR 是运行在一个工作线程中的download_one 函数输出的,随后的内容是download_many 函数输出的。

下面简单说明如何在CPU密集型作业中使用concurrent.futures 轻松绕开 GIL

使用 concurrent.futures 启动进程

concurrent.futures 模块的文档副标题是 "Launching parallel tasks"(执行并行任务)、这个模块实现的是真正的并行计算,因为它使用ProcessPoolExector 类把工作分配给多个Python 进程处理。

ProcessPoolExector 和 ThreadPoolExector 类都实现了 Exector 接口,因此使用 concurrent.futures 模块能特别轻松地把基于线程的方案转成基于进程的方案。

下载国企的示例或其他I/O 密集型作业,使用ProcessPoolExector 类得不到什么好处。这一点易于验证,只需要把示例 20-3 中下面这几行:

    def download_many(cc_list: list[str]) -> int:
        with futures.ThreadPoolExecutor() as excutor:

改成:

   def download_many(cc_list: list[str]) -> int:
       with futures.ProcessPoolExecutor() as excutor:

ProcessPoolExcector 构造函数也有一个max_workers 参数,默认值为None。这里,执行器限制职程的数量不能超过os.cpu_count() 返回的数字。

相交线程,进程使用的内存更多,启动时间更长,因此ProcessPoolExector 的价值在CPU密集型作业中才能体现出来。下面回到19.6节中的素数检测,使用concurrent.futures 重写。

ThreadPoolExecutor和ProcessPoolExecutor 的区别:

ThreadPoolExecutor.__init__方法需要max_worker参数,指定线程池中线程的数量。在ProcessPollExecutor类中,这个参数是可选的,而且大多数情况下不使用,默认值是os.cpu_count() 函数返回的cpu数量。因为这样处理说的通,因为对CPU密集型的处理来说,不可能要求使用超过CPU数量的职程,而对I/O密集型处理来说,可以在一个TheadPoolExecutor实例中使用10个,100个或1000个线程,最佳线程数取决于做的是什么事儿,以及可用内存有多少,因此要仔细测试才能找到最佳的线程数

重写多核版素数检测程序

# 示例 22-6: 使用ProcessPoolExector 重写procspy
import sys
from concurrent import futures  # 1
from time import perf_counter
from typing import NamedTuple

from primes import is_prime, NUMBERS


class PrimeResult(NamedTuple): # 2
    n: int
    flag: bool
    elapsed: float


def check(n: int) -> PrimeResult:
    t0 = perf_counter()
    res = is_prime(n)
    return PrimeResult(n, res, perf_counter() - t0)


def main() -> None:
    if len(sys.argv) < 2:
        workers = None  # 3
    else:
        workers = int(sys.argv[1])
    exector = futures.ProcessPoolExecutor(workers) # 4
    actual_workers = exector._max_workers  # type: igonre      # 5 

    print(f'Checking {len(NUMBERS)} numbers with {actual_workers} processes:')
    t0 = perf_counter()

    numbers = sorted(NUMBERS, reverse=True) # 6
    with exector: # 7
        for n, prime, elapsed in exector.map(check, numbers): # 8
            label = 'P' if prime else ' '
            print(f'{n:16} {label} {elapsed:9.6f}s')

    time = perf_counter() - t0
    print(f'Total time:{time:.2f}s')


if __name__ == '__main__':
    main()

  1. 没有必要导入mutiprocessing、SimpleQueue 等。一切都隐藏在concurrent.futures 背后。
  2. PrimeResult 元组和 check函数与procs.py 中的一样,但是现在不需要那些队列和worker函数了。
  3. 未提供命令行参数时,我们不自己决定workers 的数量,而是把值设未None, 让ProcessPoolExector 来决定
  4. 在7 长故意的with块之前构建ProcessPoolExector 实例,以便在下一行显示职程的具体数量
  5. _max_workers 是ProcessPoolExector 的实例属性,文档中没有记载。我决定使用它显示 workers 变量的值为None 时有多少职程。不出所料,Mypy 报错了,因此我加上了 type: igonre 注释,用来静默报错。
  6. 倒序排列要检查的数。这里将显示 proc_pool.py 与 proc.py 在行为上的差别。详见本例后面的说明
  7. 是红exector 作为上下文管理器
  8. exector.map 调用返回check返回的PrimeResult 实例。顺序与numbers 参数相同

运行示例20-6,会发现结果的出现顺序完全是倒序的,如示例20-7 所示。相比之下,procs.py的输出(示例19-13)则取决于各个数的素数检测难度。例如,procs.py 在靠近顶部的位置显示7777777777777777的结果,因为它有一个较小的因子7,所以is_prime 很快就能判断它不是素数。

相比之下,7777777536340681,is_prime 要用很长时间才能判断它是合数。 判断 7777777777777753 是素数的时间更长————因此,在procs.py 的输出中,这两个数出现在靠近末端的位置

运行proc_pool.py,你不仅会注意到结果倒序显示,还会发现显示 9999999999999999 的结果之后,程序卡住。

# 示例20-7 proc_pool.py 的输出
Checking 20 numbers with 16 processes:
9999999999999999    0.000005s  # 1
9999999999999917 P  4.352024s  # 2
7777777777777777    0.000007s  # 3
7777777777777753 P  4.133456s 
7777777536340681    4.024149s
6666667141414921    3.915010s
6666666666666719 P  3.866880s
6666666666666666    0.000003s
5555555555555555    0.000006s
5555555555555503 P  3.639214s
5555553133149889    3.640252s
4444444488888889    3.602341s
4444444444444444    0.000002s
4444444444444423 P  3.584374s
3333335652092209    3.098235s
3333333333333333    0.000006s
3333333333333301 P  3.174799s
 299593572317531 P  0.940279s
 142702110479723 P  0.666768s
               2 P  0.000002s
Total time:4.49s
  1. 这一行很快就显示
  2. 这一行用4.3秒 才显示
  3. 余下各行几乎立即显示

proc_pool.py 的行为缘何如此?原因如下:

  • 前面说过,exector.map(check,numbers) 返回结果的顺序与numbers 中数的顺序一致。
  • proc_pool.py 默认使用的职程数量与CPU核数量相等————max_workers 为None 时,ProcessPoolExector 的行为,在我的笔记本计算机中,是16个进程
  • 由于numbers 是倒序提交的,因此首先检测9999999999999999 。该数的因子是9,得到的结果速度很快
  • 第二个数是9999999999999917 , 这是样本中最大的素数,检测用时比其他数都长
  • 同时,余下的11个进程检测其他数,它们可能是素数、因子较大的合数,或者因子非常小的合数
  • 当负责检测9999999999999917 的职程最终判断它是素数之后,其他进程已经完成工作,因此结果立即显示出来

还有一点需要说明的的是如果将示例20-6 的ProcessPoolExector 更换为 ThreadPoolExector 计算的总耗时 20S,进一步说明 CPU密集型任务适合使用 ProcessPoolExector

实现exector.map 方法

本节研究Exector.map:


from concurrent import futures
from time import sleep, strftime


def display(*args): # 1
    print(strftime('[%H:%M:%S]'), end=' ')
    print(*args)


def loiter(n): # 2
    msg = '{}loiter({}): doing nothing for {}s...'
    display(msg.format('\t' * n, n, n))
    sleep(n)
    msg = '{}loiter({}): done'
    display(msg.format('\t' * n, n))
    return n * 10 # 3


def main():
    print('Script starting')
    with futures.ThreadPoolExecutor(max_workers=3) as executor:  # 4
        results =  executor.map(loiter, range(5))  # 5
        display('results:', results) # 6
        for i, result in enumerate(results):  # 7
            display('result {}:{}'.format(i, result)) 

if __name__ == '__main__':
    main()

  1. 这个函数很简单,把掺入的参数打印出来,并在前面加上[HH:MM:SS] 格式的时间戳
  2. loiter函数的作用更简单,只是在开始时显示一个消息,然后休眠n秒,最后在结束时再显示一个消息:消息使用制表符缩进,缩进量由n的值确定
  3. loiter 函数返回 n * 10,以便让我们了解收集结果的方式
  4. 创建ThreadPoolExecutor示实例,有3个线程
  5. 把5个任务提交给exector。因为只有3个线程,所有只有3个任务立即开始:loiter(0)、loiter(1)、loiter(2),这些都是非阻塞调用
  6. 立即显示调用exector.map 方法的结果:一个生成器,如示例20-9中的输出所示。
  7. for 循环中的 enumerate 函数隐式调用next(results),这个函数又在(内部)表示第一个任务(loiter(0)) 的futuer对象_f 上调用_f.result()。result方法会阻塞,直到future 对象运行结束,因此这个循环每次迭代都要等待下一个结果做好准备。

示例20-9是某次运行示例20-8得到的输出。

# 示例20-9 某次运行 demo_exector_map.py 得到的输出
Script starting
[23:10:39] loiter(0): doing nothing for 0s... # 1
[23:10:39] loiter(0): done
[23:10:39] 	loiter(1): doing nothing for 1s... # 2
[23:10:39] 		loiter(2): doing nothing for 2s...
[23:10:39] 			loiter(3): doing nothing for 3s...[23:10:39]
 results: <generator object Executor.map.<locals>.result_iterator at 0x000001BC1D181A10> # 3
[23:10:39] result 0:0 # 4
[23:10:40] 	loiter(1): done # 5
[23:10:40] 				loiter(4): doing nothing for 4s...
[23:10:40] result 1:10 # 6
[23:10:41] 		loiter(2): done # 7
[23:10:41] result 2:20
[23:10:42] 			loiter(3): done
[23:10:42] result 3:30
[23:10:44] 				loiter(4): done # 8
[23:10:44] result 4:40

Process finished with exit code 0
  1. 第一个线程执行loiter(0) ,因此休眠0秒,甚至在第二个线程开始之前就结束,不过具体情况因人而异
  2. loiter(1) 和 loiter(2) 立即开始(因为线程池中有3个职程,可以并发运行3个函数)。
  3. 这一行表明,exector.map 方法返回的结果(results) 是一个生成器。不管有多少任务,也不管max_workers 的值是多少,目前都不会阻塞
  4. 此时执行过程可能阻塞,具体情况取决于传给loiter函数的参数:results 生成器的_next_ 方法必须等到第一个futuer对象运行结束。此时不会阻塞,因为loite(0)在循环开始前已结束。注意,这一点之前的所有事件都在同一时刻发生,即15:56:50.
  5. 1秒后,即23:10:40,loiter(1)运行完毕。这个线程闲置,可以开始运行loiter(4)
  6. 显示loiter(1) 的结果:10。现在,for 循环会阻塞,等待loiter(2)的结果
  7. 同上,loiter(2) 运行结束,显示结果; loiter(3) 也一样
  8. 2秒后,loiter(4) 运行结束,因为loiter(4) 在 23:10:40 时开始,空等了4秒

Exector.map 函数易于使用,不过通常最好等结果准备好之后再获取,不要考虑提交顺序的问题。为此,要把Exector.submit 方法和futures.as_completed 函数结合起来使用,像示例20-4 那样

exector.submit 和 futures.as_completed 这个组合比 exector.map更灵活,因为submit 方法能处理不同的可调用对象和参数,而exector.map 只适用于不同参数调用同一个可调用对象。此外,传给futures.as_completed 函数的一系列future 对象可以来自多个执行器,例如一些由 ThreadPoolExector 实例创建,另一些由ProcessPoolExector 实例创建

# ThreadPoolExecutor 写法二
def display(*args):
    print(strftime('[%H:%M:%S]'), end=' ')
    print(*args)


def loiter(n):
    msg = '{}loiter({}): doing nothing for {}s...'
    display(msg.format('\t' * n, n, n))
    sleep(n)
    msg = '{}loiter({}): done'
    display(msg.format('\t' * n, n))
    return n * 10


def main():
    print('Script starting')
    to_do = []
    with futures.ThreadPoolExecutor(max_workers=3) as executor:  # max_workers=3: 指定线程池中的线程数
        for n in range(5):
            future = executor.submit(loiter, n)
            to_do.append(future)

        for i, future in enumerate(futures.as_completed(to_do)):
            display('result {}:{}'.format(i, future.result()))

多进程实现版本:

def display(*args):
    print(strftime('[%H:%M:%S]'), end=' ')
    print(*args)


def loiter(n):
    msg = '{}loiter({}): doing nothing for {}s...'
    display(msg.format('\t' * n, n, n))
    sleep(n)
    msg = '{}loiter({}): done'
    display(msg.format('\t' * n, n))
    return n * 10


def main():
    print('Script starting')
    with futures.ProcessPoolExecutor() as executor:  # max_workers=3: 指定线程池中的线程数
        results =  executor.map(loiter, range(5))  # 将 0~4 传递给loiter函数,返回一个迭代器
        display('results:', results)
        for i, result in enumerate(results):  # 取loiter返回的每个结果
             display('result {}:{}'.format(i, result))

if __name__ == '__main__':
    main()   # 


# out:
'''
Script starting
[14:05:18] results: <generator object _chain_from_iterable_of_lists at 0x000001D7C5266B90>
[14:05:18] loiter(0): doing nothing for 0s...
[14:05:18] loiter(0): done
[14:05:18] 	loiter(1): doing nothing for 1s...
[14:05:18] result 0:0
[14:05:18] 		loiter(2): doing nothing for 2s...
[14:05:18] 			loiter(3): doing nothing for 3s...
[14:05:18] 				loiter(4): doing nothing for 4s...
[14:05:19] 	loiter(1): done
[14:05:19] result 1:10
[14:05:20] 		loiter(2): done
[14:05:20] result 2:20
[14:05:21] 			loiter(3): done
[14:05:21] result 3:30
[14:05:22] 				loiter(4): done
[14:05:22] result 4:40

Process finished with exit code 0
'''

main()函数一定要写在 if _name_ == 'main': 中,否则会多进程循环调用

concurrent.futures.process.BrokenProcessPool: A process in the process pool was terminated abruptly while the future was running or pending.

参考:https://www.likecs.com/show-305534761.html#sc=1800

显示下载进度并处理错误

前面几节脚本没有处理错误,这样做是为了便于阅读和比较3中方案(依序、多线程和异步) 的结构

为了处理可能出现的各种错误,创建了 flags2 系列示例

  • flags2_common.py
    这个模块中包含了所有flags2 示例通用的函数和设置,例如main函数,负责解析命令行参数、计时和报告结果。这个脚本中的代码其实时提供支持的

  • flags2_sequential.py
    能正确处理错误,以及显示进度条的依序下载版HTTP客户端。flags2_threadpool.py 脚本会用到这个模块里的download_one 函数

  • flags2_threadpool.py
    基于futures.ThreadPool 类实现的并发版HTTP 客户端,演示如何处理错误,以及集成进度条

  • flags2_asyncio.py
    与前一个脚本的作用相同,不过使用asynciof 和 httpx 实现

flag2 系列 示例处理错误的方式

这3个示例在负责下载一个文件的函数(download_one) 中使用相同的策略处理HTTP 404 错误。其他异常则向上冒泡,交给download_many 函数 或 supervisor 协程 处理

我们还是线分析依序下载版本的代码,因为这一版更易于理解,而且使用线程池的脚本重用了这里的大部分代码。示例20-14 给出的是flags_sequential.py 和 flags2_threadpool 脚本真正用于下载的函数。

from collections import Counter
from http import HTTPStatus

import httpx
import tqdm  # type: ignore # 1
from flags2_common import main, save_flag, DownloadStatus  # 2

DEFAULT_CONCUR_REQ = 1
MAX_CONCUR_REQ = 1


def get_flag(base_url: str, cc: str) -> bytes:
    url = f'{base_url}/{cc}{cc}.gif'.lower()
    resp = httpx.get(base_url, timeout=3.1, follow_redirects=True)
    resp.raise_for_status() # 3
    return resp.content


def download_one(cc: str, base_url: str, verbose: bool = False) -> DownloadStatus:
    try:
        image = get_flag(base_url, cc)
    except httpx.HTTPStatusError as exc: # 4
        res = exc.response
        if res.status_code == HTTPStatus.NOT_FOUND:  # 5:
            status = DownloadStatus.NOT_FOUND
            msg = f'not found: {res.url}'
        else:
            raise  # 6
    else:
        save_flag(image, f'{cc}.gif')
        status = DownloadStatus.OK
        msg = 'OK'
    if verbose:  # 7
        print(cc, msg)

    return status
  1. 导入显示进度条的tqdm库,让Mypy 跳过检查
  2. 从flag2_common 模块中导入两个函数和一个枚举
  3. 如果HTTP状态码不在range(200,300) 范围内,抛出TTTPStatusError
  4. download_one 函数捕获HTTPStatusError,特别处理HTTP 404 错误
  5. 把局部变量status 设为 DownloadStatus.NOT_FOUND。DownloadStatus 是从flags2_common.py 中导入的一个枚举
  6. 重新抛出其他HTTPSStatusError 异常,向上冒泡,传给调用方。
  7. 如果在命令行中设定了 -v/--verbose 选项,显示国家代码和状态消息。这就是详细模式中看到的进度信息。
def download_many(cc_list: list[str],
                base_url: str,
                verbose: bool,
                  _unused_concur_req: int)  -> Counter[DownloadStatus]:
    counter: Counter[DownloadStatus] = Counter() # 1
    cc_iter = sorted(cc_list) # 2
    if not verbose:
        cc_iter = tqdm.tqdm(cc_iter) # 3
    for cc in cc_iter:
        try:
            status = download_one(cc,base_url, verbose) # 4
        except httpx.HTTPStatusError as exc: # 5
            error_msg = 'HTTP error {resp.status_code} - {resp.reason_phrase}'
            error_msg = error_msg.format(resp=exc.response)
        except httpx.RequestError as exc: # 6
            error_msg = f'{exc} {type(exc)}'.strip()
        except KeyboardInterrupt: # 7
            break
        else: # 8
            error_msg = ''
        if error_msg:
            status = DownloadStatus.ERROR # 9
        counter[status]  += 1 # 10
        if verbose and error_msg: # 11
            print(f'{cc} error:{error_msg}')
    return counter # 12
  1. 这个Counter 实例用于统计不同的下载状态:DownloadStatus.OK、DownloadStatus.NOT_FOUND 或 DownloadStatus.ERROR
  2. cc_iter 存放通过参数传入的国家代码列表,按字母表顺序排列
  3. 如果不是详细模式,则把cc_iter 传给tqdm 函数,返回一个迭代器,产出cc_iter 中的项。同时显示进度条动画。
  4. 不断调用download_one 函数
  5. get_flag 抛出的HTTP 状态码异常和未被 download_one 处理的异常在这里处理
  6. 其他与网络有关的异常在这里处理。除此之外的异常会中止脚本,因为调用download_many 函数的flags2_common.main 函数中没有 try/except 块
  7. 用户按Ctrl + C 组合键时退出循环
  8. 如果没有异常从 download_one 函数中逃出,则清空错误消息
  9. 如果有错误,则把局部变量 status 设为相应的状态
  10. 递增相应状态的计数器
  11. 如果是详细模式,而且有错误,则显示带有当前国家代码的错误消息
  12. 返回counter,以便main函数能在最终的报告中显示数量

使用 futures.as_completed 函数

为了集成tqdm 进度条,并处理各个请求中的错误,flags2_threadpool.py 脚本用到我们见过的futures.ThreadPoolExector 类 和 futures.as_completed 函数。示例 22-16 是flags2_threadpooy.py 脚本的完整代码请求。这个脚本只实现了 download_many 函数,其他函数都重用 flags2_common.py 和 flags2_sequential.py 脚本里的

# 示例20-16 flags2_threadpool.py:完整的代码清单
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed

import httpx
import tqdm

from flags2_common import main, DownloadStatus
from flags2_sequential import download_one  # 1

DEFAULT_CONCUR_REQ = 30  # 2
MAX_CONCUR_REQ = 1000  # 3


def download_many(cc_list: list[str],
                  base_url: str,
                  verbose: bool,
                  concur_req: int) -> Counter[DownloadStatus]:
    counter: Counter[DownloadStatus] = Counter()
    with ThreadPoolExecutor(max_workers=concur_req) as exector:  # 4
        to_do_map = {}  # 5
        for cc in sorted(cc_list):  # 6
            future = exector.submit(download_one, cc, 
                                    base_url, verbose) # 7 
            to_do_map[future] = cc # 8

        done_iter = as_completed(to_do_map) # 9
        if not verbose:
            done_iter = tqdm.tqdm(done_iter, total=len(cc_list))  # 10
  
        for future in done_iter: # 11
            try:
                status = future.result() # 12
            except httpx.HTTPStatusError as exc: # 13
                error_msg = 'HTTP error {resp.status_code} - resp.reason_phrase'
                error_msg = error_msg.format(resp=exc.response)
            except httpx.RequestError as exc:
                error_msg = f'{exc} {type(exc)}'.strip()
            except KeyboardInterrupt:
                break
            else:
                error_msg = ''

            if error_msg:
                status = DownloadStatus.ERROR
            counter[status] += 1
            if verbose and error_msg:
                cc = to_do_map[future]
                print(f'{cc} error: {error_msg}')
    return counter


if __name__ == '__main__':
    main(download_many, DEFAULT_CONCUR_REQ, MAX_CONCUR_REQ)
  1. 重用flags2_sequential模块中的download_one 函数
  2. 如果没有命令行中指定 -m/--max_req 选项,则使用这个值作为并发请求数的最大值,也就是线程池的大小;真实的数量可能会比较少,例如下载国家的国旗数量较少
  3. 不管要下载多少国旗,也不管 -m/--max_req 命令行选项的值是多少,MAX_CONCUR_REQ 会限制最大的并发请求。这是一项安全措施,免得启动太多线程,消耗过多内存
  4. 把max_workers 设为concur_req,创建exector。main函数会把下面这3个值中最小的那个赋给concur_req:MAX_CONCUR_REQ、cc_list 的长度、-m/--max_req 命令行选项的值。这样能避免创建过多的线程。
  5. 这个字典把各个Futrue 实例(表示一次下载) 映射到相应的国家代码上,在处理错误时使用
  6. 按字母表顺序迭代国家代码的列表。结果的顺序主要由HTTP 相应的时间长短决定,不过,如果线程池的大小(由concur_req 设定) 比len(cc_list) 小得多,那么可能会按字母表顺序批量下载。
  7. 每次调用 extctor.submit 方法排定一个可调用对象的执行时间,返回一个Future实例。第一个参数是可调用对象,余下的参数是传给可调用对象的参数
  8. 把返回的future 和国家代码存储在字典中
  9. future.as_completed 函数返回一个迭代器,在每个任务运行结束后产出future对象。
  10. 如果不是详细模式,则把 as_completed 函数返回的结果传给tqdm 函数,显示进度条;因为done_iter 没有长度,所以我们必须通过total=参数告诉tqdm 函数预期的项数,这样tqdm 才能预计剩余的工作量
  11. 迭代运行结束后的future对象
  12. 在future 对象上调用 result 方法,要么返回可调用对象的返回值,要么抛出可调用共对象在执行过程中捕获的异常。这个方法可能会阻塞,等待确定结果;但是,在这个示例中不阻塞,因为as_completed 函数只返回已经运行结束的future对象
  13. 处理可能出现的异常。这个函数余下的代码与依序下载版download_many 函数一样,唯有下一点除外
  14. 为了给错误消息提供上下文,以当前future 为键,从to_do_map 中获取国家代码。在依序下载版本中无须这么做,因为那一版迭代的是国家代码,知道国家代码是什么,而这里迭代的是future 对象

Python 线程特别适合I/O 密集型应用程序 concurrent.futures 包大大简化了某些使用场景下Python 线程的用法。另外,使ProcessPoolExector 还可以利用多核解决CPU 密集型问题————如果 "高度并行" 计算的话。

posted @ 2022-12-02 23:19  chuangzhou  阅读(312)  评论(0编辑  收藏  举报