#+TITLE: AsyncIO In Depth

# Outline: https://kinopio.club/asyncio-outline-IVepJwzB8cuYaVvA-HeVQ

# Deep dive into asyncio for reasonably good Python engineers
# who might not really understand it yet. Help build intuition
# around how, why it works; and even go into deep details
# around the idea.

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 and the basics of application; this
article is for understanding how it works.

[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 keyword 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:
: Hello, world!

* 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