What is and isn’t atomic is indeed quite subtle. (You mention asyncio, that’s a little different and easier to reason about because it’s cooperative. Unless you await, nothing else will run)
I personally think reasoning about it in terms of bytecodes is more confusion than its worth. Sure, if an operation spans multiple bytecodes, certainly don’t treat it as atomic. But CALL is a single bytecode and that’s clearly not atomic! (Also the details of when bytecodes can drop the GIL changes, e.g. in Python 3.10 bpo-29988: Only check evalbreaker after calls and on backwards egdes. by markshannon · Pull Request #18334 · python/cpython · GitHub means a lot of trivial attempts to demonstrate non-atomicity will no longer reproduce in Python 3.10 and newer, but this isn’t guaranteed by the language)
I think a way to think about it is in terms of segments of uninterrupted C code. If you call into C and don’t give up the GIL and don’t call into Python code and don’t decref (since reference count hitting zero can call __del__
), whatever you do will (probably) be atomic with respect to all other Python code.
See also delete misleading faq entry about atomic operations · Issue #89598 · python/cpython · GitHub and PEP 703 – Making the Global Interpreter Lock Optional in CPython | peps.python.org