Description
The following observations are made about AsyncRLock. These observations are intended to highlight a potential set of improvements, to harden code. The reason I'm looking into this is because we need an RLock for asyncio and discovered that python plans not to support it.
Treatment of asyncio.CancelledError
In the _acquire_non_blocking
method, if the asyncio.sleep(0)
call gets cancelled, the CancelledError
exception is re-raised, potentially leading to improper cleanup.
The re-raising of the CancelledError
will interrupt the execution flow, leaving no chance for the lock to be released if it was previously acquired by this task. However, I do note that the logic surrounding this operation seems to consider this case: if the lock is owned by the current task (hence the cancellation can impact the lock's state), the _count
counter will be incremented, meaning the lock is not released. The code seems designed to avoid silent cancellation.
That said, this logic can indeed be risky, especially when it comes to proper handling of resources in case of cancellation. So, the concurrency control might benefit from a more robust way of handling cancellation that ensures resources (locks) are properly released.
Simple fix
To prevent this, we could modify the exception handling to check if the lock was acquired and release it before re-raising the exception.
try:
await asyncio.sleep(0)
except asyncio.CancelledError:
# Check if the lock was acquired and release it if necessary
if self.is_owner(task=me) and self._count > 0:
self._release(me)
# This is emulating non-blocking. There is no cancelling this!
# Still, we don't want to silently swallow the cancellation.
# Hence, we flag this task as cancelled again, so that the next
# `await` will raise the CancelledError.
asyncio.current_task().cancel()
Missing error handling in _acquire_non_blocking
In _acquire_non_blocking
, if the task.done()
condition is met but the task completed with an exception (task.exception() is not None
), it seems that neither the lock gets acquired nor the exception is handled in any way, which might lead to lost exceptions and the lock never being acquired. If task.exception() is not None
, there's no fallback or handling for this scenario. The exception that occurred during the task is neither logged nor re-raised, which can make debugging difficult. Furthermore, this scenario will result in False
being returned, indicating that the lock was not acquired, but without any further explanation or information.
Simple fix
To fix this, we can add an else clause to the condition checking task.done() and task.exception() is None
. This clause would handle the case where an exception has occurred.
if task.done() and task.exception() is None:
self._owner = me
self._count = 1
return True
elif task.done() and task.exception() is not None:
# handle the case where the task has an exception
raise task.exception()