I've been researching when I work in ASGI environments (single-threaded) whether it is safe to mutate singletons or global variables. I thought initially that it would be safe, but AI tells me it's not safe and I'm having a tough time believing it because I cannot reproduce it. Look at this code here.
import asyncio
import random
counter = 0
async def increment(name: str):
global counter
await asyncio.sleep(random.uniform(0, 5)) # yield control to event loop
counter = counter + 1 # write (based on possibly stale value)
print(f"{name}: counter → {counter}")
async def main():
await asyncio.gather(*[increment(f"{i}") for i in range(10000)])
print(f"Final counter (should be 10000): {counter}")
if __name__ == "__main__":
asyncio.run(main())
This retuns correctly at the end. Here is an example of the last lines of input:
7825: counter → 9995
8403: counter → 9996
6039: counter → 9997
5887: counter → 9998
9942: counter → 9999
8631: counter → 10000
Final counter (should be 10000): 10000
This is safe right ?
The interesting statement is this one:
counter = counter + 1
On general principles this not thread-safe. Thread switches are controlled by the operating system and can occur at any time. If a thread switch happens just after reading the current value of counter
, before writing the new value, this statement is a potential bug.
However, your example program is not multi-threaded. Asyncio Task switches are not under control of the system and can only occur at await
expressions. In a single-threaded asyncio program, the statement above is not a potential bug; the design of asyncio guarantees that it will work, as your results prove.
Threading is pre-emptive. Asyncio is cooperative. The two approaches have different strengths and weaknesses.