原创

asyncio.Semaphore 限住并发后,为什么任务仍然占满内存

给每次请求套上 asyncio.Semaphore(10),同时运行的请求确实少了,内存却仍然随着输入条数增长。问题可能没有发生在许可数量上,而是代码先为全部输入创建了 Task,随后才让这些 Task 在信号量前排队。

信号量限制进入受保护区域的执行者数量,不限制任务对象、闭包参数和输入载荷的数量。十个任务在跑,十万个任务等许可,依然是十万个任务的生命周期。

下面固定 Python 3.12,用一组纯本地异步计算对照两种调度方式。实验只比较确切创建的任务数量与活动工作数,不把计数冒充内存或吞吐实测。然后给出一个滚动提交实现,把在途 Task 数也限制在上限内。

先创建全部任务,许可只管后半段

常见写法是对所有输入调用 create_task(),每个任务进入函数后再 async with semaphore。这样可以控制受保护区并发,但创建任务的循环没有背压。

若输入早已在一个大列表里,任务对象之外还有输入本身;若每项还捕获了响应、文件内容等对象,等待者又会延长它们的寿命。仅调整许可,未必能碰到真正占内存的那一层。

滚动提交的思路更直接:最多保留 N 个在途任务,等待至少一个完成,取走其结果,然后才从输入迭代器取下一项。下面把两种方式放在同一个完整脚本里。

import asyncio


class Gauge:
    def __init__(self):
        self.active = 0
        self.peak = 0

    async def work(self, value):
        self.active += 1
        self.peak = max(self.peak, self.active)
        try:
            await asyncio.sleep(0)
            if value < 0:
                raise ValueError("negative input")
            return value * 2
        finally:
            self.active -= 1


async def create_all(values, limit, gauge):
    semaphore = asyncio.Semaphore(limit)

    async def guarded(value):
        async with semaphore:
            return await gauge.work(value)

    tasks = [asyncio.create_task(guarded(value)) for value in values]
    created_before_wait = len(tasks)
    results = await asyncio.gather(*tasks)
    return results, created_before_wait


async def rolling_map(values, limit, work):
    if limit <= 0:
        raise ValueError("limit must be positive")
    source = iter(enumerate(values))
    pending = {}
    results = {}
    submitted = 0
    peak_tasks = 0
    exhausted = False

    def fill():
        nonlocal submitted, peak_tasks, exhausted
        while not exhausted and len(pending) < limit:
            try:
                index, value = next(source)
            except StopIteration:
                exhausted = True
                break
            task = asyncio.create_task(work(value))
            pending[task] = index
            submitted += 1
            peak_tasks = max(peak_tasks, len(pending))

    try:
        fill()
        while pending:
            done, _ = await asyncio.wait(
                pending, return_when=asyncio.FIRST_COMPLETED
            )
            for task in done:
                index = pending[task]
                results[index] = task.result()
                del pending[task]
            fill()
    except BaseException:
        for task in pending:
            task.cancel()
        await asyncio.gather(*pending, return_exceptions=True)
        raise
    return [results[index] for index in range(submitted)], peak_tasks


async def main():
    first = Gauge()
    values, created = await create_all(range(100), 3, first)
    assert values == [value * 2 for value in range(100)]
    assert created == 100 and first.peak <= 3
    print("create all: 100 tasks created before waiting, active <= 3")

    second = Gauge()
    values, peak = await rolling_map(range(100), 3, second.work)
    assert values == [value * 2 for value in range(100)]
    assert peak <= 3 and second.peak <= 3 and second.active == 0
    print("rolling: in-flight tasks <= 3, active <= 3")

    failed = Gauge()
    try:
        await rolling_map([1, -1, 2, 3, 4], 3, failed.work)
    except ValueError:
        assert failed.active == 0
        print("failure: submitted tasks were cancelled and awaited")
    else:
        raise AssertionError("negative input was accepted")


if __name__ == "__main__":
    asyncio.run(main())

保存为 rolling_tasks.py,执行 python3 rolling_tasks.py。第一种方式在第一次等待之前已经创建 100 个 Task;第二种的在途数量最多为 3。两者活动工作数都不超过 3,这恰好说明活动并发与任务数量是不同指标。

FIRST_COMPLETED 返回的可能不止一个任务

事件循环恢复主任务时,可能已经有多项完成,因此 done 是集合,不是一份单独结果。代码处理这一批完成项,再补足容量,不能只拿出一个任务就忽略其余完成者。

结果根据输入编号存放,最终按编号返回。完成顺序不稳定,并不应该让输出与输入失去对应关系。若调用方本来就接受完成顺序,可以改成逐项产出,进一步减少结果积累;若必须严格输入顺序流式输出,则还要为前面的慢任务保留后续已完成结果。

后一种保序缓冲仍可能增长。限制任务数,并不会让所有保序需求免费变成恒定内存。最早一项迟迟不结束,后面即使不断有结果,也不能在不改变合同的情况下越过它。

示例返回列表,因此明确保留了全部结果。它解决的是任务对象的数量,不宣称整批内存完全有界。输入与输出都很大时,应继续设计迭代结果、受限存储或分批提交。

首错停止以后,还要把已提交任务等完

task.result() 可能抛出业务错误或编程错误,输入迭代器也可能在 next() 时失败。退出路径会取消仍登记在 pending 里的任务,再 gather 它们,最后重新抛出原异常。

这里捕获 BaseException 是为了清理以后继续传播,不是吞掉取消。若只调用 cancel 而不等待,函数已经向上报告失败,工作者还可能在 finally 里释放资源;调用方很难判断它们究竟结束了没有。

完成集合中也可能有另一项已经失败。清理阶段的 gather 会收集剩余结果,避免把已完成异常遗弃成未检索任务错误。对业务需要保留多个失败原因的场景,可以增加明确的错误结果账本,但不应随手让第二个错误覆盖最初触发终止的那个。

取消不会撤销外部写入,线程或扩展中的阻塞工作也未必随协程立即停止。示例的计算通过 await 提供协作点,才适合用这条取消链验证退出。把同样代码外面包一个阻塞函数,并不会自动获得同样的响应性。

输入迭代本身也可能成为阻塞点

滚动实现从同步迭代器逐项取值,适合range、普通容器或每次next很快的本地生成器。如果next内部读取慢文件、等待网络或执行大量CPU计算,fill仍会堵住事件循环。限制Task数改变不了同步输入函数的执行方式,这时需要异步输入协议或明确的线程桥接,而不是再给next外面套一层信号量。

输入生成器若自己拥有打开的文件,谁关闭它也要事先决定。当前函数只消费调用方传入的可迭代对象,不声明接管任意迭代器的close责任。调用方可以在外层with里建立输入资源;如果将函数改成自己打开文件,就应在函数退出时负责关闭,不能把取消下游任务当成关闭上游资源的替代动作。

同一类边界也作用于结果消费。现在每个结果都留在字典里,改成边算边写时,写入失败应进入同一条取消和等待路径。若在成功结果回调中私自创建后台任务,又不把它登记进在途集合,滚动窗口就管不到那些任务了。容量合同必须覆盖真正提交工作的入口,而不是只覆盖其中一层漂亮的API。

单项超时可以放进work自己的作用域,让到期错误按同样路径触发整批退出,或转换成逐项失败。但超时发生以后是否允许其他项目继续,要保持一贯政策。一次超时并不总说明远端没有处理,不应在窗口补位时自动生成无限重试链。

信号量仍然有它合适的位置

多个独立流程共享同一类稀缺资源时,信号量很有用。比如不同批次共用最多十个远程连接,可以共享一个许可池;单批滚动任务数各自限制,并不能天然表达跨批次总量。

也可以同时使用两层限制:任务提交层控制对象数量,资源访问层控制实际占用。但要明确获取顺序,避免任务拿着一种许可又无限等另一种许可,造成资源互相占住。

许可数还不是请求速率。十个并发任务可能每秒完成很多次,也可能长时间一项都不结束。若下游合同是每秒最多多少请求,需要单独的速率限制,而不是继续降低 Semaphore 的值直到“感觉差不多”。

生产程序还应限制单项尺寸、失败后的重试数量和总处理预算。一次重试若绕过原来的提交入口,仍可能让在途数量失控;重试任务也必须算进同一份容量账目。

选择结构时,先定位自己想约束的是等待任务、活动工作、资源租约还是单位时间请求数。它们可以同时存在,却不是同一件事。用活动并发指标证明任务数量受控,正是这类内存问题最容易走偏的一步。

正文到此结束
评论插件初始化中...
Loading...
本文目录