【Python】4. Python 并行

并行

1. 基于协程的并行

1.1 基本原理

1.1.1 概述

协程是一种在线程中被调度的函数,也称作微线程。它的调度与进程、线程不同,完全在用户空间中进行,即协程的调度器行为与操作系统无关。

协程可以在等待异步 I/O 时立刻保存当前执行栈帧的上下文,并从调度器处返回,调度器会转而调度和执行其他协程。当 OS 通知调度器异步 I/O 结束后,调度器会带着异步 I/O 的结果调度该协程,并从内存中恢复上下文,从刚才退出协程的位置进入协程继续执行。

由于协程只会在开发者指定的位置被调度,所以几乎可以不考虑对资源的原子性访问的问题。

注意,协程并不会加快 CPU 绑定的 (占用 CPU 时间的) 代码的执行速度,因为协程始终在一个线程中被调度,它真正的威力在于允许下面这种行为,即当一个协程在等待异步 I/O 时,立刻转而调度执行其他协程的 CPU 绑定的代码,这意味着我们可以一次性调度多个协程,在同一时刻迅速发出多个 I/O 请求,并一起等待返回。

所以,其主要应用场景是 I/O 密集程序,例如网络应用 (HTTP Server、Client),Node.js 就是一个基于单线程非阻塞 I/O 的 JavaScript Runtime,被主要用于 HTTP 后端应用的快速开发。

1.1.2 协程的实现
1.1.3 协程的优点
  • 开销小

    • 协程调度的性能开销比线程调度小很多,协程切换迅速
    • 协程调度的内存开销也比线程调度小很多
  • 一般情况下,不需要考虑在并发环境下对资源原子性访问的问题,节省了锁的开销

  • 逻辑更简洁

    • 相比线程来说,协程让从上下文推导代码逻辑变得更加简单

    Threads make local reasoning difficult, and local reasoning is perhaps the most important thing in software development.

    线程使得局部推理变得困难,而局部推理可能是软件开发中最重要的事情。

    —— https://github.com/glyph/

1.2 运行协程

参考文档 -> https://docs.python.org/zh-cn/3/library/asyncio-task.html

Python 协程库实际上维护了一个最近时间最小堆(树),每次调度一个最近的可调度协程。

1.2.1 asyncio.run
asyncio.run(coro, *, debug=False)
coroutine asyncio.sleep(delay, result=None, *, loop=None)

调用一个协程并不会使其被调度执行,而是返回一个内建的 coroutine 对象,我们可以通过 asyncio.run 方法来调度执行该协程。

如果了解协程的实现,应该不难发现,协程的某个行为与生成器函数非常相似,

即协程函数在调用后不会直接执行,而是返回一个协程对象,

实际上 Python 内部的协程函数和生成器函数共用一套实现。

import asyncio

async def asyncfun(time):
    await asyncio.sleep(time)
    print('done')

asyncio.run(asyncfun(1))

一秒后输出 done

1.2.2 await

除了使用 asyncio.run 对协程进行调度执行外,协程对象在其他协程函数的上下文中也可以通过 await 进行调度执行。

await 除了可以对协程进行调度执行,还会返回协程的结果。

import asyncio

async def sleepAndPrintDone(time):
    await asyncio.sleep(time)
    print('done')

async def main():
    for _ in range(3):
        await sleepAndPrintDone(1)
        
asyncio.run(main())

依次打印了三次 done

1.3 并发协程

1.3.1 asyncio.create_task
asyncio.create_task(coro, *, name=None)

事件循环是开始进行协程调度的入口, asyncio.run 负责管理事件循环,将传入的协程绑定到事件循环上。

asyncio.create_task 则是负责直接将所有传入的协程绑定到 当前正在运行的 事件循环上,该协程会被立刻自动调度执行,并返回一个 Task 对象,我们可以在异步上下文中 await 它。

import asyncio
import time

async def sleepAndPrintDone(time):
    await asyncio.sleep(time)
    print('done')

async def main():
    task1 = asyncio.create_task(
        sleepAndPrintDone(1)
    )

    task2 = asyncio.create_task(
        sleepAndPrintDone(2)
    )

    print(f"started at {time.strftime('%X')}")

    await task1
    await task2

    print(f"finished at {time.strftime('%X')}")
    
asyncio.run(main())

这个示例下进行了两个协程的调度,一个将阻塞 1s,另一个是 2s,但实际上这个程序只需要 2s 就会执行完毕。

这是因为,当我们调用 asyncio.create_tesk 后,该协程将会立刻被绑定到事件循环进行调度,而不必等到 await 时才绑定(对于 Task 对象的 await task 并不是开始调度,而是等待结束),所以前一秒两个协程都在等待,第一秒时第一个协程结束,第二秒时第二个协程结束。

这种调用方式也被成为并发。

1.3.2 asyncio.gather
awaitable asyncio.gather(*aws, loop=None, return_exceptions=False)

awaitable 被称为 可等待对象,指的是在协程函数中允许跟在 await 关键字后的对象,协程对象(协程函数的返回值)、Task、以及一些低层级对象(Future) 都是可等待对象。

如果对 Drincann/py-coro-impl 的实现有所了解,应该知道 await 后跟的对象类型与协程执行器是强耦合的,它们共同配合着自动执行一个协程(或者生成器迭代器)。

Drincann/py-coro-impl 中,yield from 后必须跟一个 Promise 对象,在 Python 内建的协程支持中,则是 awaitable 对象,它可以看做是一个接口,代表一类对象。

asyncio.gather 接收多个可等待对象,并返回一个可等待对象,当对返回的可等待对象进行 await 时,将并发等待所有传入的可等待对象。

注意,与 Task 不同,Task 被创建时就立刻开始调度执行,而该接口返回的对象只有在开始等待时才开始调度执行。

它的实现类似:

async def gather(*awaitableObjs):
    return [await task for task in [asyncio.create_task(obj) for obj in awaitableObjs]]

即首先并发调度所有协程,然后等待所有协程直到全部执行完毕后返回结果列表。

下面给出一个使用示例:

import asyncio

async def sleepAndPrintDone(time):
    await asyncio.sleep(time)
    print('done')

async def main():
    await asyncio.gather(
        sleepAndPrintDone(1),
        sleepAndPrintDone(1),
        sleepAndPrintDone(1),
        sleepAndPrintDone(1),
    )

asyncio.run(main())

一秒后立刻依次打印四个 done

1.3.3 asyncio.wait

该接口将在 3.10 移除,所以更建议使用 asyncio.gather 代替它。

coroutine asyncio.wait(aws, *, loop=None, timeout=None, return_when=ALL_COMPLETED)

这个接口与 asyncio.gather 类似,它们在使用上唯一的区别是,asyncio.wait 接收的可等待对象们将不再是位置参数,而是一个 iterable

import asyncio

async def sleepAndPrintDone(time):
    await asyncio.sleep(time)
    print('done')
    
async def main():
    await asyncio.wait([
        sleepAndPrintDone(1),
        sleepAndPrintDone(1),
        sleepAndPrintDone(1),
        sleepAndPrintDone(1),
    ])
    
asyncio.run(main())

1.4 同步和异步 I/O

我们刚才提到:

它真正的威力在于允许下面这种行为,即当一个协程在等待异步 I/O 时,立刻转而调度执行其他协程的 CPU 绑定的代码,这意味着我们可以一次性调度多个协程,在同一时刻迅速发出多个 I/O 请求,并一起等待返回。

这里提到了 异步 I/O,实际上异步 I/O 是协程调度在 I/O 密集的程序中效率非常高的主要原因,可以这么讲,高效率协程 = 异步 I/O 接口 + 协程调度

什么是异步 I/O 接口(也称非阻塞式 I/O 接口)?

有异步就有同步,同步 I/O 接口(也称阻塞式 I/O 接口)指的是,该接口被调用后,将阻塞直到等待 I/O 结束后返回,在 Python 中这样的接口比比皆是,例如:

import requests

for _ in range(100):
    res = requests.get(url='http://gaolihai.cool/doc/README.md')
    print(res.text)

requests.get 就是一个典型的同步 I/O 接口,如果服务端需要 200ms 才响应,那么主线程就会阻塞 200ms 直到操作系统通知 I/O 结束并恢复线程,继续执行。

这种接口的调用即便写在协程中进行并发调用,也不会有任何效率的提升,因为协程本身是在一个线程中通过一个调度器进行调度执行的。如果将同步 I/O 接口的调用放在协程中调度,I/O 要花费 1s,那么所有协程的执行将都延后 1s,我们后面将介绍一个标准库接口 asyncio.to_thread 来解决这个问题。

说回来,异步 I/O 接口只在必须占用 CPU 时间的代码上使用 CPU,一旦进行等待 I/O 则立刻转而执行后面的代码,直到 I/O 结束后由操作系统回调和通知主线程结果,于是协程调度器就可以在异步 I/O 接口开始等待时立刻拿到 CPU 控制权并立即调度其他需要 CPU 的协程,这就实现了并发。

一个典型的异步 I/O 接口就是非阻塞 socket,或者一些第三方的异步 HTTP 请求库,例如 aiohttp

HTTP 接口当然也有异步版本,我们以一个基于非阻塞 socket 实现的异步 HTTP/1.0 GET 接口为例:

代码在这里,如果加载较慢请访问 镜像

get(*, url, callback)

该接口占用 CPU 的部分仅仅是向服务端发送请求的过程,一旦发送就立即开始等待 I/O,此时它不再像 requests.get 那样阻塞,而是立刻返回并执行后面的代码。

当 I/O 结束时,将由操作系统通知程序,协程调度器在收到通知后某一刻将回调callback 参数,回调函数中将被传入异步 I/O 的结果,即响应结果(HTTP 响应报文)。

于是,通过包装,可以将该接口包装成 awaitable 对象并在协程中等待,参考 3. 数据模型#awaitable-objects — Python 3.9.7 文档

同时也可以参考在我的协程实现中,它是如何基于生成器迭代器将异步 I/O 接口对接到协程执行器中的,参考 Drincann/py-coro-impl

这里使用成熟的异步 HTTP 请求库 aiohttp 作演示:

接口文档 -> https://docs.aiohttp.org/en/stable/

import aiohttp
import asyncio
import time
import requests

async def asyncRequest():
    session = aiohttp.ClientSession()
    response = await session.get('http://gaolihai.cool/doc/README.md')
    print(await response.text())
    await session.close()


asyncio.run(asyncio.gather(
    *[asyncRequest() for i in range(10)]
))

1.5 同步 I/O 接口的协程包装器

我们提到:

这种接口的调用即便写在协程中进行并发调用,也不会有任何效率的提升,因为协程本身是在一个线程中通过一个调度器进行调度执行的。如果将同步 I/O 接口的调用放在协程中调度,I/O 要花费 1s,那么所有协程的执行将都延后 1s。

同步 I/O 接口将会阻塞协程调度所在的线程,在这种情况下,如果该同步接口无可替代,可以尝试将它放在另一个线程中运行,这样它就会阻塞住另一个线程,这样主线程的协程调度就不受影响。

这里可以使用 threading 包创建新线程,但在使用协程的程序中再使用线程时,将两者进行结果汇总的复杂性会增加,我们需要自己管理细粒度的锁,以维护线程安全。

不过标准库提供了线程安全的接口 asyncio.to_thread,它已经为我们处理好了这些复杂度带来的问题,该接口将一个同步任务放在新线程中执行,并包装为一个可等待对象返回,这样一个同步接口就被包装成了一个异步接口的可等待对象。

注意,使用该接口包装同步 I/O 接口是下下策。

协程相较于线程的优点就在于它上下文资源少,恢复快,协程间调度时切换迅速。但使用该接口包装后,反而又回头使用了基于线程的并行。所以这种方式最好仅在没有替代方案、或对效率要求不敏感的情况下使用。

在下面的示例中,我们将在使用异步接口的同时,使用 asyncio.to_thread 来包装一个同步接口,并像使用一个协程一样 await 它:

注意,该接口在 Python 3.9 中加入

import aiohttp
import asyncio
import requests

def syncRequest():
    for _ in range(10):
        requests.get(url='http://gaolihai.cool/doc/README.md')
        print('sync')

async def asyncRequest():
    session = aiohttp.ClientSession()
    await session.get('http://gaolihai.cool/doc/README.md')
    print('async')
    await session.close()

async def main():
    await asyncio.gather(
        *[asyncRequest() for _ in range(10)]
    )
    await asyncio.to_thread(syncRequest)

asyncio.run(main())

输出:

async
async
async
async
async
async
async
async
async
async
sync
sync
sync
sync
sync
sync
sync
sync
sync
sync

1.6 同步异步的效率对比

这段代码分别使用同步和异步 I/O 接口访问了 http://gaolihai.cool/doc/README.md,并计算了两者用时:

import aiohttp
import asyncio
import time
import requests

def syncRequest():
    start = time.time()
    for _ in range(10):
        res = requests.get(url='http://gaolihai.cool/doc/README.md')
        # print(res.text)
    print(f'{time.time() - start}s')
    
async def asyncRequest():
    session = aiohttp.ClientSession()
    response = await session.get('http://gaolihai.cool/doc/README.md')
    # print(await response.text())
    await session.close()
    
async def main():
    start = time.time()
    await asyncio.gather(
        *[asyncRequest() for i in range(10)]
    )    
    print(f'{time.time() - start}s')

syncRequest()
asyncio.run(main())

0.24110913276672363s
0.03390836715698242s

结果指出,异步接口相比同步接口的效率大致高了一个数量级。

所以,对于爬虫这种高 I/O 的程序,使用协程调度异步接口带来的效率提升还是非常可观的。

1.7 低层级接口

到此为止,常用的用来调度协程的高层级接口已经介绍完了,低层级的接口以及其他工具介绍见 https://docs.python.org/zh-cn/3/library/asyncio.html,包含了以下内容。

  • 协程锁
  • 进程的协程包装器
  • 协程同步容器
  • 异常

高层级指面向普通开发者用户的,包装粒度大,使用方便简单的接口。

低层级指的是较底层的接口,一般面向一些第三方库,或标准库的开发者,例如 aiohttp 的开发就必须使用低层级的协程接口,将实现的异步 api 对接到标准库的协程执行器中。

如果有兴趣学习这些低层级接口,建议在此之前先阅读开头提到的参考资料,熟悉协程的基本实现方式。

在将一种技术应用在开发中时,应该首先理解它的工作原理,这一学习技巧也被称为费曼原理。

What I cannot create, I do not understand.

—— 费曼

实际上一些看似复杂的技术都有它的 “玩具” 版本,我们只需要很少的代码就可以实现它们的核心功能。而当我们真的能够理解这些技术的底层原理,并手写出了一个 “玩具” 版本,这就代表着我们真正掌握了它的核心。

那么在使用这些技术时将更加如鱼得水,很轻松就能写出优雅、简洁、高性能的代码,这显然得益于我们基本上了解了它的实现细节。

参考资料中提到的 500 Lines or Less 就是这样一本书,它每一章都用 500 行或更少的代码为读者展示一个优秀的开源技术的实现细节,每个章节的作者都是开源项目的核心贡献者,读完一章就可以动手实现一个麻雀虽小、五脏俱全的 “玩具” 实现。

2. 基于线程的并行

2.1 概述

参考文档 -> https://docs.python.org/zh-cn/3/library/threading.html

threading 是基于低层级接口 _thread 包装的高层级线程接口。

注意,Python (准确的说是 Python 解释器的 CPython 实现,下文同) 中存在一个被称为 GIL (Global Interpreter Lock) 即全局解释器锁的机制。

GIL 确保同一时刻只有一个线程在运行 Python 字节码,这导致 Python 失去了多核上的并发性能,而且由于线程的上下文切换(线程调度)会引起额外的性能开销,这导致多线程性能大部分情况下反而不如单线程。

还要注意,GIL 几乎只是简化了 CPython 的实现,它并不能保证 Python 是线程安全的。

测试下面的代码可以发现,几乎不会发生 count 的值停在 4,000,000 的情况:

import threading

count = 0
def task():
    global count
    for i in range(1000000):
        count += 1

threading.Thread(target=task).start()
threading.Thread(target=task).start()
threading.Thread(target=task).start()
threading.Thread(target=task).start()

# 用于等待除当前线程(主线程)外的所有线程结束
for thread in threading.enumerate():
    thread.join() if thread is not threading.current_thread() else 'ignore'

print(count)

所以,面对多线程的性能限制,以及几乎一定存在的额外的性能开销(为解决线程安全问题加锁而导致的),在 Python 中使用协程代替线程是一个在实践中被广泛采用的方案。只有在少数情况下,我们推荐在实践中使用线程,例如上文提到的 asyncio.to_thread 接口。

2.2 构造 Thread

class threading.Thread(
    group=None,  # 忽略该参数
    target=None, # callable 对象
    name=None,   # 线程的名称
    args=(),     # 传入线程的位置参数
    kwargs={},   # 传入线程的关键字参数    
    *,
    daemon=None, # 是否是守护线程
)

Thread 是一个用于描述线程的类,每一个实例都抽象了一个独立的线程实体。

构造函数的参数:

  • target 参数是一个可调用对象,即 callable,例如函数对象是一个可调用对象。
  • name 可以为线程设置一个名称,默认以 'Thread-1' 的方式命名,可以从对象的 name 属性上获得
  • argskwargs 分别是传入线程的位置参数和关键字参数
  • daemon 是守护线程的标志位,后面会解释什么是守护线程,可以从对象的 daemon 属性上获得

虽然构造函数的参数很多,但基本上我们只需要关注 targetargskwargs

使用它创建和允许线程非常简单:

Thread 对象上调用 start 方法,它会在一个独立的控制进程中运行该线程。

from threading import Threadimport time

def task(sec):
    time.sleep(sec)
    print('done')

Thread(target=task, args=(1,)).start()
Thread(target=task, args=(1,)).start()
Thread(target=task, args=(1,)).start()

一秒后立刻依次输出三个 done

注意,这里的 time.sleep 是同步接口,他将会阻塞当前线程,而 “基于协程的并行” 一节中的 asyncio.sleep 是异步接口,不会阻塞当前线程,而是位于由协程标准库维护的最近时间最小堆中,由协程调度器在时间结束时进行调度。

2.3 运行线程

  • run() 方法将会在当前线程中直接运行构造对象时传入的 target 对象,不会创建新线程。
  • start() 方法用于在一个独立的控制进程中调用,即创建一个线程进行调度。

2.4 等待线程完成

在某个线程对象 thread1 上调用 join(timeout=None) 可以阻塞调用该方法的线程,直到 thread1 代表的线程执行结束或超时(由 timeout 参数确定)后,调用该方法的线程将被唤醒,从调用处返回。

在下面这个例子中,主线程将在每次循环的 thread1.join() 调用处阻塞,等待一秒并输出 done,然后继续下一次循环:

from threading import Threadimport time
def task(sec):
    time.sleep(sec)    
    print('done')
    for _ in range(3):    
        thread1 = Thread(target=task, args=(1,))    thread1.start()    thread1.join()

三秒内每秒末输出一个 done

我们还可以通过 is_alive() 来判断从 join() 调用处返回的原因是超时还是线程退出,is_alive() 调用返回一个布尔值,指明线程是否真正运行。

我们在 join() 后调用 thread1.is_alive(),若线程正在运行,则是由于超时而返回。

概述一节中的例子展示了如何在主线程中等待其他线程结束,其中 enumerate() 方法返回所有活动线程的可迭代对象,current_thread() 返回当前线程对象(在这个例子中是主线程)。

for thread in threading.enumerate():    
    thread.join() if thread is not threading.current_thread() else 'ignore'

2.5 守护模式线程

守护线程的含义是 为其他非守护模式的线程服务的线程,可以在构造对象时设置 daemon 参数为 TrueFalse 来决定对象是否为守护模式,或直接修改对象的 daemon 属性。

Python 程序将在所有非守护模式的线程结束后退出,而当所有的非守护模式的线程结束后,所有守护线程将被终止。

  • 构造出的线程将默认继承当前线程的守护模式,而主线程不是守护线程,所以在主线程中构造出的线程对象默认不是守护线程。
  • 当线程开始执行后,daemon 属性不允许被修改。

2.6 线程锁

线程的并发是一个无法避开的问题,这一节中我们讲解 Python 标准库对常用线程锁的支持。

2.6.1 原始锁
class threading.Lock
  • acquire(blocking=True, timeout=-1)

    该方法将锁锁定,并返回一个布尔值表示是否成功获得锁,如果锁已经被锁定,则阻塞直到其他线程释放锁。

    • blocking 默认为 True,表示是阻塞模式,设置为 False 时即便无法获得锁也不会阻塞。
    • timeout 可以设置阻塞超时时间,默认为 -1,表示不超时。
  • release()

    释放锁。

  • locked()

    检查当前线程是否获得锁,返回一个布尔值。

下面这段代码将会发生死锁:

import threading
lk = threading.Lock()
lk.acquire()
lk.acquire()
2.6.2 重入锁
class threading.RLock

重入锁允许一个线程多次获得锁而不会阻塞,且锁从属于某一个获得它的线程,一个线程的重入锁不能被另一个线程释放。

必须释放与加锁次数相等的次数,才能将一个重入锁转变为未锁定状态。

重入锁在为复杂业务逻辑扩展功能时非常有用:

例如我们使用了一个使用原始锁实现的线程安全的容器类。如果后来需要对多次容器操作加锁,那么直接在外层加锁就会导致在内层容器操作时发生死锁(获得了两次锁),这时候就必须修改容器类的实现,但将业务逻辑与工具类耦合在实践中通常是不可接受的,只会徒增复杂度。这时候使用重入锁就可以对任意扩展的锁定行为开放,而不必修改工具类的实现。

  • acquire(blocking=True, timeout=-1)

    该方法将锁锁定,并返回一个布尔值表示是否成功获得锁,如果锁已经被锁定,则阻塞直到其他线程释放锁。

  • release()

    释放锁。

2.6.3 信号量
class threading.Semaphore(value=1)

# 有界信号量, 当释放的信号量超过初始值将抛出异常
class threading.BoundedSemaphore(value=1)

这是计算机科学史上最古老的同步原语之一,早期的荷兰科学家 Edsger W. Dijkstra 发明了它。

构造时传入的 value 参数就是信号量的值。

  • acquire(blocking=True, timeout=None)

    获取信号量。

  • release(n=1)

    释放信号量,可以令信号量的值 + n,默认为 1。

这些是比较常用的基本锁,此外还有条件对象、事件对象等线程同步机制,见本节开始处的文档链接。

2.7 多线程请求示例

import threading
import requests

resultHTML = []

def syncRequest():
    resultHTML.append(requests.get(
        url='http://gaolihai.cool/doc/README.md').text)

for _ in range(10):
    threading.Thread(target=syncRequest).start()

for thread in threading.enumerate():
    thread.join() if thread is not threading.current_thread() else 'ignore'

print(resultHTML)

3. 基于进程的并行

参考文档 -> https://docs.python.org/zh-cn/3/library/multiprocessing.html

实际上在爬虫这种 I/O 密集的程序上使用进程并不是一种明智的行为,多进程在资源和进程调度上要比线程的开销更大,而且涉及到进程间通信,不管是在开发的复杂度上,还是在通信的额外开销上,都要花费更大的代价,而换来的仅仅是能够避免 GIL 对多核性能的限制。

然而这个限制只是对 CPU 限制,爬虫的大部分时间都花在了等待 I/O 上,也就是说,爬虫的性能瓶颈在 I/O 上而不是 CPU 上,只有瓶颈在 CPU 上的 Python 程序,使用多进程释放多核 CPU 性能后才能有效提升执行效率。

Python 多进程库 multiprocessing 的 api 与多线程库 threading 形式几乎一样,见上面的参考文档。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

高厉害

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值