Why does an event loop want a lock at all?
For the same reasons you might want locks in threaded code: to protect critical sections of code which might lead to a corrupted state otherwise.
Here is a (somewhat long) example where an async lock is useful.
In the output, you can see how publishing of the create
message completes after publishing of the delete
message when no lock is used. When a lock is used, the messages are published serially.
import asyncio
# lets say we have a collection of resources and a pub/sub system
# which we want to send all signals related to those resources to
# lets also say that we want all messages (created, deleted, updated, etc.)
# to be sequential for each resource so that, for example a deleted message
# does not arrive to some subscribers before the created message.
# to do that, we use a lock for each resource.
async def publish(msg, delay):
print(f"starting publish: {msg}")
await asyncio.sleep(delay)
print(f"ending publish: {msg}")
async def create_resource(resource_lock):
print("create - wait lock")
await resource_lock.acquire()
print("create - acquire lock")
try:
print("create - resource")
# once we have created the resource, we have to finish publishing
# or the other parts of the system wont know about it
# we have to hold the lock or publishing of the created message
# may interleave with publishing of the deleted message
await publish("create - resource", delay=3)
finally:
resource_lock.release()
print("create - release lock")
async def delete_resource(resource_lock):
# the lock is outside the shield so we can still cancel waiting on it
# if there is contention with the lock. maybe we come back later.
print("delete - wait lock")
await resource_lock.acquire()
print("delete - acquire lock")
try:
print("delete - resource")
await publish("delete - resource", delay=2)
finally:
resource_lock.release()
print("delete - release lock")
class DummyLock:
async def acquire(self):
...
def release(self):
...
async def main():
print("\nwith lock...")
resource_lock = asyncio.Lock()
creator = asyncio.create_task(create_resource(resource_lock))
await asyncio.sleep(0)
deleter = asyncio.create_task(delete_resource(resource_lock))
await asyncio.wait([creator, deleter])
print("\nwith no lock...")
resource_lock = DummyLock()
creator = asyncio.create_task(create_resource(resource_lock))
await asyncio.sleep(0)
deleter = asyncio.create_task(delete_resource(resource_lock))
await asyncio.wait([creator, deleter])
asyncio.run(main())
Output:
with lock...
create - wait lock
create - acquire lock
create - resource
starting publish: create - resource
delete - wait lock
ending publish: create - resource
create - release lock
delete - acquire lock
delete - resource
starting publish: delete - resource
ending publish: delete - resource
delete - release lock
with no lock...
create - wait lock
create - acquire lock
create - resource
starting publish: create - resource
delete - wait lock
delete - acquire lock
delete - resource
starting publish: delete - resource
ending publish: delete - resource
delete - release lock
ending publish: create - resource
create - release lock
Surely that will block the event loop defeating the purpose of async?
All the asyncio
primitives are specially designed using futures to avoid blocking the event loop. When one of them would block, a future is created in the background and the event loop moves on to other tasks and comes back to the task when the future is done (cancelled or result is set).
If you were to use threading
primitives in asyncio
code, that would block the event loop, but that is why asyncio
has its own version of almost every primitive from threading
.