#+TITLE: AsyncIO In Depth

Python's async / await and asyncio can be magical and opaque. At the
same time it enables concurrency with a few minor adjustments to
otherwise synchronous and simple code [fn:conc]. 

This article is a depth-first-traversal into the Python's event loop
implementation. After several aborted attempts at writing about
asyncio, I think this is the best way to understand the system.

This is not a way to quickly become productive with asyncio: that
function is better fulfilled by numerous other articles and tutorials
already published. This is the article you should read after becoming vaguely familiar with what it is; when you want to understand the how.

[fn:conc]: Concurrency would be the ability to interleave multiple
computations, parallelism is the ability to run them on multiple
cores. I would recommend
Parallel and Concurrent Programming in Haskell for a much better description.

# TODO Potentially insert a discussion on event loops here, just to
# set context and create the same mental image.

* Hello, world!
Let's start the DFS by looking at the classical "Hello, World":

#+begin_src python :results output :exports both :session hello
import asyncio

async def hello_world():
    await asyncio.sleep(1)
    print("Hello, world!")

asyncio.run(hello_world())
#+end_src

#+RESULTS:
: Hello, world!

** async ramifications

*** Compiling
The Python lexer has to deal with a new keyword to parse this variant
of hello_world: we can look at it using the ast module.

(To keep the output focused, I'm eliding the call to await; async has
the spotlight for the moment.)

#+begin_src python :results output :exports both 
import ast
import pprint
pprint.pp(ast.dump(ast.parse("""
async def hello_world():
    print("Hello, world!")
""")))
#+end_src

#+RESULTS:
: ("Module(body=[AsyncFunctionDef(name='hello_world', "
:  'args=arguments(posonlyargs=[], args=[], vararg=None, kwonlyargs=[], '
:  'kw_defaults=[], kwarg=None, defaults=[]), '
:  "body=[Expr(value=Call(func=Name(id='print', ctx=Load()), "
:  "args=[Constant(value='Hello, world!', kind=None)], keywords=[]))], "
:  'decorator_list=[], returns=None, type_comment=None)], type_ignores=[])')

The function is explicitly detected as an AsyncFunctionDef to mark the
addition of the async tag. [fn:functions]

We can also look at the disassembly of the function definition to see
what happens after parsing. I've also included a plain function for comparison.

#+begin_src python :results output :exports both 
import ast
import dis

code = dis.Bytecode(compile("""
async def async_hello_world():
    print("Async Hello, world!")

def hello_world():
    print("Hello, world!")
""", filename='<string>', mode='exec'))

print(code.dis())
#+end_src

#+RESULTS:
#+begin_example
  2           0 LOAD_CONST               0 (<code object async_hello_world at 0x7f07b95252f0, file "<string>", line 2>)
              2 LOAD_CONST               1 ('async_hello_world')
              4 MAKE_FUNCTION            0
              6 STORE_NAME               0 (async_hello_world)

  5           8 LOAD_CONST               2 (<code object hello_world at 0x7f07b9525450, file "<string>", line 5>)
             10 LOAD_CONST               3 ('hello_world')
             12 MAKE_FUNCTION            0
             14 STORE_NAME               1 (hello_world)

             16 LOAD_CONST               4 (None)
             18 RETURN_VALUE
#+end_example

Surprisingly enough, the generated opcodes for defining the functions
are exactly the same. Looking inside the functions themselves also
gives the same result.

#+begin_src python :results output :exports both :session hello
import dis

async def async_hello_world():
    print("Hello, world!")

def hello_world():
    print("Hello, world!")

print("Async")
dis.dis(async_hello_world)

print("Function")
dis.dis(hello_world)
#+end_src

#+RESULTS:
#+begin_example
Async
  4           0 LOAD_GLOBAL              0 (print)
              2 LOAD_CONST               1 ('Hello, world!')
              4 CALL_FUNCTION            1
              6 POP_TOP
              8 LOAD_CONST               0 (None)
             10 RETURN_VALUE
Function
  7           0 LOAD_GLOBAL              0 (print)
              2 LOAD_CONST               1 ('Hello, world!')
              4 CALL_FUNCTION            1
              6 POP_TOP
              8 LOAD_CONST               0 (None)
             10 RETURN_VALUE
#+end_example

The only difference is a flag set on the defined code: CO_COROUTINE
which makers a function as a coroutine. The actual compiler code is
somewhere around here; but it's worth explicitly looking at the flags.

Once more into the breach:
#+begin_src python :results output :exports both 
import dis

async def async_hello_world():
    print("Hello, world!")

def hello_world():
    print("Hello, world!")

async_flags = async_hello_world.__code__.co_flags
standard_flags = hello_world.__code__.co_flags

only_async_flags = async_flags & (~standard_flags)
print(f"{only_async_flags=}, aka {dis.COMPILER_FLAG_NAMES[only_async_flags]}")

only_standard_flags = standard_flags & (~async_flags)
print(f"{only_standard_flags=}")
#+end_src

#+RESULTS:
: only_async_flags=128, aka COROUTINE
: only_standard_flags=0

And that's the literally the magic bit that makes the difference in
how coroutines are evaluated.

[fn:functions] Just for contrast:
#+begin_src python :results output :exports both 
import ast
import pprint

pprint.pprint(ast.dump(ast.parse("""
def hello_world():
    print("Hello, world!")
""")))
#+end_src

#+RESULTS:
: ("Module(body=[FunctionDef(name='hello_world', args=arguments(posonlyargs=[], "
:  'args=[], vararg=None, kwonlyargs=[], kw_defaults=[], kwarg=None, '
:  "defaults=[]), body=[Expr(value=Call(func=Name(id='print', ctx=Load()), "
:  "args=[Constant(value='Hello, world!', kind=None)], keywords=[]))], "
:  'decorator_list=[], returns=None, type_comment=None)], type_ignores=[])')

Normal functions are parsed, unsurprisingly, as FunctionDef.

*** Execution
Executing an async function doesn't directly run the inner function anymore,
and instead returns a a coroutine object that wraps the computation.

#+begin_src python :results output :exports both :session hello
hello_world()
#+end_src

#+RESULTS:
: <coroutine object hello_world at 0x7fc6d58186c0>

* Coroutine Objects
The object maintains a link to the actual code to be executed, as well
as the state of execution by keeping the frame around. This is what
makes it possible to "pause" and "resume" a coroutine when out-of-band
mechanisms return.

Listing out the non-dunder methods to see what's available with the object.

#+begin_src python :results output :exports both :session hello
instance = hello_world()
print([(type(getattr(instance, attribute)), attribute) for attribute in instance.__dir__() if not attribute.startswith("__")])
#+end_src

#+RESULTS:
: [(<class 'builtin_function_or_method'>, 'send'), (<class 'builtin_function_or_method'>, 'throw'), (<class 'builtin_function_or_method'>, 'close'), (<class 'frame'>, 'cr_frame'), (<class 'bool'>, 'cr_running'), (<class 'code'>, 'cr_code'), (<class 'NoneType'>, 'cr_origin'), (<class 'NoneType'>, 'cr_await')]

Comparing the attributes to confirm that the code is shared

#+begin_src python :results output :exports both :session hello
instance_a = hello_world()
instance_b = hello_world()
#+end_src