Bootstrap

python 异步编程:协程

什么是协程?

协程(Coroutine)是一种用于并发编程的技术,它允许在单个线程内实现多任务调度。协程可以在执行过程中暂停,并在需要时恢复执行,这使得它们非常适合处理I/O密集型任务,如网络请求、文件读写等。

  1. 非抢占式调度:协程由程序显式地控制切换点,不像线程那样由操作系统调度。这意味着你可以精确控制何时暂停和恢复一个协程。
  2. 轻量级:由于不需要创建新的线程或进程,协程的开销更小。
  3. 单线程内并发:所有的任务都运行在一个单独的主线程中,因此避免了多线程编程中的一些复杂性,如竞争条件和死锁。

协程的实现

在 Python 中,协程的实现有多种,其中主要依赖于生成器和 asyncio 库。关于asyncio的具体用法本章仅做实现示例,下章再详细拆解。

greenlet实现

pip3 install greenlet
from greenlet import greenlet


def func1():
    print(1)
    gr2.switch()
    print(2)
    gr2.switch()


def func2():
    print(3)
    gr1.switch()
    print(4)


gr1 = greenlet(func1)
gr2 = greenlet(func2)

gr1.switch()

结果:

1
3
2
4
  • 定义两个函数 func1()func2(),每个函数内部都有多个步骤,并且在某些步骤之后会调用对方的 switch 方法来进行上下文切换。
  • 使用 greenlet() 函数分别将这两个函数包装成 Greenlets:gr1gr2.
  • 最后,通过调用 gr1.switch() 开始执行第一个任务,这会导致程序在不同任务之间来回切换。

生成器实现

什么是yield关键字?

yield 关键字用于定义一个生成器(generator)函数。生成器是一种特殊的迭代器,它允许你逐个产生值,而不是一次性创建并返回一个包含所有值的列表。使用 yield 的函数在执行过程中可以多次 yield 值,每次 yield 都会返回一个值,并在下一次从该点继续执行。

yield 是 Python 中实现协程和异步编程的基础,特别是在 Python 3.3 之前,当时的 asyncio 库依赖于生成器来实现协程。在 Python 3.5 及以后版本中,引入了 asyncawait 语法,提供了更高级的异步编程能力,但 yield 仍然是 Python 编程中一个非常有用的关键字。

  1. 惰性计算:生成器在调用时不会立即计算所有值,而是在每次迭代时计算并产生下一个值。
  2. 状态保存:当生成器函数 yield 一个值时,它的所有本地变量和当前的执行状态都会被保存,以便下次从该点继续执行。
  3. 逐个产生:生成器一次产生一个值,这使得它们在处理大量数据时非常高效,因为不需要一次性将所有数据加载到内存中。
  4. 可迭代对象:生成器本身是可迭代对象,可以被 for 循环或 next() 函数迭代。
def count_up_to(num):
    """
    `count_up_to` 函数是一个生成器,它在每次迭代时 `yield` 当前的 `count` 值,并在下一次迭代时从上次停下的地方继续执行。
    不使用yield的话,使用这段代码可以达到同样的效果:

    result = list()
    count = 1
    while count <= num:
        result.append(count)
        count += 1
    return result

    """

    count = 1
    while count <= num:
        yield count
        count += 1


# 使用生成器
for number in count_up_to(5):
    print(number)

print("分割线".center(30, "="))


def chain_generators(*gens):
    """
    yield from 允许你将一个生成器的输出委托给另一个生成器,简化了生成器的嵌套调用。
    如果不使用yield from来简化:
    for gen in gens:
        # 迭代每个生成器对象
        for value in gen:
            # 逐个 yield 生成器的值
            yield value
    """
    for gen in gens:
        yield from gen


for number in chain_generators(count_up_to(2), count_up_to(3)):
    print(number)

结果输出:

1
2
3
4
5
=============分割线==============
1
2
1
2
3

实现协程示例

def func1():
    yield 1
    yield from func2()
    yield 2


def func2():
    yield 3
    yield 4


f1 = func1()
for item in f1:
    print(item)

结果

1
3
4
2
  • 首先,f1 开始执行,yield 1 被执行,生成器产生值 1
  • 然后,yield from func2() 被执行。func2 产生第一个值 3f1 将其传递给迭代器,所以 3 被打印出来。
  • func2 继续产生第二个值 4f1 同样将其传递给迭代器,所以 4 也被打印出来。
  • func2 没有更多的 yield 语句,所以 yield from 表达式完成,控制权回到 f1
  • f1 继续执行,yield 2 被执行,生成器产生值 2

asyncio实现

旧版本实现

asyncio 是 Python 的一个标准库,用于编写单线程并发代码。

在python3.4及之后,asyncio 库可以使用 @asyncio.coroutine 装饰器来定义协程。这个装饰器是编写协程的早期方法,它允许你在函数定义之前添加 @asyncio.coroutine,以便使用 yield from 来暂停和恢复协程的执行。

import asyncio


# 在3.8版本后这种装饰器的写法就废弃了 ,让你用async def来定义函数替代。运行会提示警告:
# DeprecationWarning: "@coroutine" decorator is deprecated since Python 3.8, use "async def" instead
@asyncio.coroutine
def func1():
    print(1)
    # 模拟网络IO请求
    yield from asyncio.sleep(2)  # 遇到IO耗时操作,自动化切换到tasks中的其他任务
    print(2)


@asyncio.coroutine
def func2():
    print(3)
    # 模拟网络IO请求
    yield from asyncio.sleep(2)  # 遇到IO耗时操作,自动化切换到tasks中的其他任务
    print(4)


tasks = [
    asyncio.ensure_future(func1()),
    asyncio.ensure_future(func2())
]

loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))

输出:

1
3
2
4
  1. 首先,它将运行 func1,直到遇到 asyncio.sleep(2)
  2. 由于 asyncio.sleep(2) 是一个异步等待调用,事件循环将挂起 func1 并检查是否有其他任务可以运行。
  3. 然后,事件循环切换到 func2 并执行它,直到遇到 asyncio.sleep(2),同样被挂起。
  4. 事件循环再次检查任务队列,发现没有其他任务可以运行,将等待一段时间(由 asyncio.sleep 的参数决定)。
  5. 2 秒后,func1func2 中的 asyncio.sleep 都完成,事件循环将恢复它们的执行。

新版本实现

在3.7版本后,推荐使用 async def 定义协程,并用 await 来等待异步操作。

import asyncio


async def func1():
    """
    相比上面的使用装饰器,推荐新的写法:
        将装饰器@asyncio.coroutine去掉,def方法加上async前缀
        yield from 改为使用await
    """
    print(1)
    # 模拟网络IO请求
    await asyncio.sleep(2)  # 遇到IO耗时操作,自动化切换到tasks中的其他任务
    print(2)


async def func2():
    print(3)
    # 模拟网络IO请求
    await asyncio.sleep(2)  # 遇到IO耗时操作,自动化切换到tasks中的其他任务
    print(4)


tasks = [
    asyncio.ensure_future(func1()),
    asyncio.ensure_future(func2())
]

loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))

协程与多线程、多进程的区别联系

协程(Coroutines)

协程是一种程序组件,能够在等待操作完成时挂起执行,而无需阻塞整个线程。它们通常用于 I/O 密集型任务,因为 I/O 操作(如网络请求、文件读写)通常涉及等待。协程的优点包括:

  • 轻量级:协程的创建和切换开销远小于线程。
  • 非抢占式:协程的执行顺序由程序逻辑控制,而不是由操作系统强制抢占。
  • 易于编写:使用 async/await 语法可以使异步代码的逻辑更清晰。

Python 中的 asyncio 库提供了对协程的支持。

多线程(Multithreading)

多线程允许在同一时间内在同一个进程中并行运行多个线程。线程共享进程的内存和资源,这使得线程间通信和数据共享变得容易,但也引入了同步和竞态条件的问题。多线程的优点包括:

  • 资源共享:线程可以共享进程的内存和文件描述符等资源。
  • 简化编程:线程的创建和管理相对简单。
  • 适用性:适合计算密集型和 I/O 密集型任务。

Python 的 threading 模块提供了多线程的支持。

多进程(Multiprocessing)

多进程是指在操作系统级别上同时运行多个进程。每个进程有自己的内存空间,这意味着进程间通信需要通过明确的 IPC(进程间通信)机制。多进程的优点包括:

  • 隔离性:进程间相互独立,一个进程的崩溃不会直接影响到其他进程。
  • 资源分配:操作系统可以有效地管理不同进程的资源分配。
  • CPU 密集型任务:由于每个进程有自己的内存空间,多进程适合处理 CPU 密集型任务。

Python 的 multiprocessing 模块提供了多进程的支持。

区别与联系

  • 资源占用:协程是单线程内的并发,资源占用最少;多线程共享进程资源,资源占用适中;多进程有最大的资源占用。
  • 上下文切换开销:协程上下文切换的开销最小;多线程次之;多进程的上下文切换开销最大。
  • 编程复杂度:协程编程模型最简单;多线程需要处理线程安全和同步问题;多进程由于进程间隔离,编程复杂度介于协程和多线程之间。
  • 适用场景:协程适合 I/O 密集型任务;多线程适合计算和 I/O 混合型任务;多进程适合 CPU 密集型任务和需要高隔离性的场景。
;