异步上下文管理器是否需要保护其清理代码免受取消操作的影响?

15

问题(我想)

contextlib.asynccontextmanager 文档提供以下示例:

@asynccontextmanager
async def get_connection():
    conn = await acquire_db_connection()
    try:
        yield conn
    finally:
        await release_db_connection(conn)

在我看来,这段代码可能会泄露资源。如果此代码的任务在其await release_db_connection(conn)行上时被取消,则释放可能会被中断。 asyncio.CancelledError将从finally块中的某个位置传播上来,阻止后续清理代码运行。

因此,在实际应用中,如果您正在实现一个带有超时处理的Web服务器,并且在恰当的时间发生超时,可能会导致数据库连接泄漏。

完整可运行示例

import asyncio
from contextlib import asynccontextmanager

async def acquire_db_connection():
    await asyncio.sleep(1)
    print("Acquired database connection.")
    return "<fake connection object>"

async def release_db_connection(conn):
    await asyncio.sleep(1)
    print("Released database connection.")

@asynccontextmanager
async def get_connection():
    conn = await acquire_db_connection()
    try:
        yield conn
    finally:
        await release_db_connection(conn)

async def do_stuff_with_connection():
    async with get_connection() as conn:
        await asyncio.sleep(1)
        print("Did stuff with connection.")

async def main():
    task = asyncio.create_task(do_stuff_with_connection())

    # Cancel the task just as the context manager running
    # inside of it is executing its cleanup code.
    await asyncio.sleep(2.5)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        pass

    print("Done.")

asyncio.run(main())

Python 3.7.9 的输出结果:

Acquired database connection.
Did stuff with connection.
Done.

请注意,Released database connection 从未被打印。

我的问题

  • 这是一个问题,对吗?直觉告诉我,我希望 .cancel() 的意思是“优雅地取消,清理沿途使用的任何资源。”(否则,为什么他们要将取消实现为异常传播呢?)但是我可能错了。也许,例如,.cancel() 应该是快速而不是优雅的。是否有权威来源澄清了在此处 .cancel() 应该做什么?
  • 如果这确实是个问题,我该如何解决?

1
看起来你能捕获 CancelledError 的事实意味着你仍然可以自己清理 -- 但是你必须在某个可访问代码其他部分的地方缓存类似于 conn 状态的等价物。 - Andrew Jaffe
2个回答

2
专注于保护取消清理是一个转移注意力的问题。有很多问题可能会出现,上下文管理器无法知道:
  • 哪些错误可能发生
  • 哪些错误必须受到保护。
正确处理错误是资源处理实用程序的责任。
  • 如果release_db_connection不能被取消,则必须自我保护以防止取消。
  • 如果需要运行acquire/release对,它必须是一个单一的async with上下文管理器。还可能涉及其他保护,例如防止取消。
async def release_db_connection(conn):
    """
    Cancellation safe variant of `release_db_connection`

    Internally protects against cancellation by delaying it until cleanup.
    """
    # cleanup is run in separate task so that it
    # cannot be cancelled from the outside.
    shielded_release = asyncio.create_task(asyncio.sleep(1))
    # Wait for cleanup completion – unlike `asyncio.shield`,
    # delay any cancellation until we are done.
    try:
        await shielded_release
    except asyncio.CancelledError:
        await shielded_release
        # propagate cancellation when we are done
        raise
    finally:
        print("Released database connection.")

注意:异步清理很棘手。例如,如果事件循环不等待受保护任务,则简单的asyncio.shield是不够的。避免发明自己的保护措施,依赖基础框架来做正确的事情。


任务的取消是一个优雅的关闭,可以 a) 仍然允许异步操作,并且 b) 可能会被延迟/抑制。协程准备好处理清理的CancelledError是明确允许的。

Task.cancel

然后协程有机会通过使用try …… except CancelledError … finally块来清理或拒绝请求而抑制异常。[...] Task.cancel()不能保证任务将被取消,尽管完全抑制取消并不常见,也被积极地反对。

强制关闭是coroutine.close/GeneratorExit。这对应于立即、同步关闭,并禁止通过awaitasync forasync with进行挂起。

coroutine.close

[...] 它在挂起点引发GeneratorExit,导致协程立即清理自己。


你能否澄清一下你所说的“这是资源处理实用程序的责任”和“避免发明自己的保护”是什么意思?如果你正在编写上下文管理器来管理资源的生命周期,那么你就是在编写资源处理实用程序。 - Maxpm
@Maxpm,“contextlib”示例是关于使用现有的资源处理程序并为它们提供更好的外观。在这种情况下,除非明确说明不需要,否则应该相信处理程序是健壮的-否则您根本无法知道需要哪种恢复(取消、重试、超时等)。这也涉及到不要尝试添加保护,除非您知道如何添加-“asyncio.shield”的天真情况会在关闭时泄漏,答案中显示的方法可以抑制任务引发异常时的取消-因为它们取决于资源处理程序的详细信息。 - MisterMiyagi
1
@Maxpm 现在,如果我们谈论创建自己的安全资源处理程序,那只是与“contextlib.contextmanager”略有关联(尽管这绝对是可取的)。最重要的是,它涉及到a)您正在处理什么类型的资源-例如,它是否也可以同步清理-以及您正在使用哪种事件循环-例如,“asyncio”有许多棘手的问题。无论如何,如果单个获取/释放操作不安全,则外部上下文管理器很难使它们安全-至少不能以更好地适合获取/释放的方式。 - MisterMiyagi
这种方法让我感觉不对:协程是否“不得被取消”取决于具体情况。例如,文件删除协程可以作为上下文管理器的清理函数调用(不应该被取消),也可以作为程序主要操作的一部分调用(应该可以被取消)。你的方法难道不会强制所有可能在清理函数中使用的协程都使用 try: await x; except CancelledError: await x 这样的结构吗?这样做会使它们在本应该是可取消的情况下变得无法使用。 - Arno
@Arno 在某些操作开始后就“绝不能取消”,这些通常是多步骤清理过程,在部分清理过程中,可能会使系统处于未定义状态(例如完成日志的压缩)。如果你的清理有时可以被取消,那么它本身就不是“绝不能取消”的一种,也不需要这样的保护。如果有调用代码要求不要取消它(例如一次删除多个文件,以避免局部构建/清理看起来相同),则调用代码必须保护其。 - MisterMiyagi

1
你可以使用 asyncio.shield 保护任务,以确保上下文管理器的优雅关闭。我只在 main() 中做了更改:
async def main():
    task = asyncio.create_task(do_stuff_with_connection())
    # shield context manager from cancellation
    sh_task = asyncio.shield(task)
    # Cancel the task just as the context manager running
    # inside of it is executing its cleanup code.
    await asyncio.sleep(2.5)
    sh_task.cancel()  # cancel shielded task
    try:
        await sh_task
    except asyncio.CancelledError:
        pass

    await asyncio.sleep(5)  # wait till shielded task is done

    print("Done.")

但这仍然没有调用release_db_connection(conn),是吗? - Andrew Jaffe
@AndrewJaffe 当我运行代码时,我得到了以下输出:获取数据库连接。 使用连接执行操作。 释放数据库连接。 完成。因此看起来它运行了release_db_connection - Artiom Kozyrev
2
糟糕,我忘记了最后的 await asyncio.sleep(5),这意味着代码在运行 release_db_connection 之前就结束了(但这也是一个有趣的故障模式!)。 - Andrew Jaffe
是的,你必须让循环忙于一些工作,否则它不会等待"任务"。 - Artiom Kozyrev
你可以使用 await task(而不是 await sh_task)来代替 await asyncio.sleep(5),这样可以使它更加确定性,对吧?这也会确保任务中的异常能够正确地传播。 - Maxpm
1
@Maxpm 我想要展示的是,尽管任务被取消了,它仍然可以优雅地停止,而应用程序(例如某些服务器)可以继续处理其他任务。由于这段代码片段不是真正的服务器,不能一直监听某些连接,因此我使用了 asyncio.sleep 来模拟一些服务器工作。 - Artiom Kozyrev

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接