Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Trio: Structured Concurrency for Python by Jeremy Thurgood

Pycon ZA
October 10, 2020

Trio: Structured Concurrency for Python by Jeremy Thurgood

Concurrency has a reputation for being complicated and hard to get right, even in Python. Fortunately, by using the "structured concurrency" programming model, it's possible to avoid many of the pitfalls inherent in more traditional thread-based and callback-based models.

Trio [1] is an async I/O framework that brings the benefits of structured concurrency to Python. Built from the ground up to use async/await natively, Trio makes it possible to write async software that is robust and easy to reason about.

In this talk I'll explain how Trio differs from its predecessors (such as Twisted and asyncio), show that it leads to simpler code with fewer bugs, and hopefully convince you to give it try.

[1] https://trio.readthedocs.io/

Pycon ZA

October 10, 2020
Tweet

More Decks by Pycon ZA

Other Decks in Programming

Transcript

  1. THE ERA OF GOTO 10 I = 1 20 PRINT

    "LINE NUMBER"; I 30 I = I + 1 40 IF I <= 10 GOTO 20 50 PRINT "ALL DONE" 3
  2. MORE COMPLICATED GOTO 10 INPUT "HOW MANY PRIMES"; N :

    DIM P(N-1) 20 PRINT 2 : PRINT 3 30 P(1) = 3 : PI = 1 40 C = P(PI) 50 C = C+2 : I = 0 60 I = I+1 70 IF C/P(I) = INT(C/P(I)) GOTO 50 80 IF I < PI GOTO 60 90 PRINT C : PI = PI+1 : P(PI) = C 100 IF PI < N-2 GOTO 40 4
  3. STRUCTURED PROGRAMMING Ten lines: for i in range(10): print("LINE NUMBER",

    i+1) print("ALL DONE") 1 2 3 Prime Numbers: n = int(input("HOW MANY PRIMES? ")) print(2) primes = [] candidate = 3 while len(primes) < n-1: for p in primes: if candidate % p == 0: break else: # Runs if the loop ends without breaking. primes.append(candidate) print(candidate) candidate += 2 1 2 3 4 5 6 7 8 9 10 11 12 5
  4. CONSEQUENCES OF STRUCTURE Execution ow that humans can reason about

    Call stacks and local variables Exceptions and error handling Context managers and with blocks 6
  5. spawn: goto FOR CONCURRENCY (Don’t worry too much about the

    syntax. Erlang is weird.) %% This is a recursive function with two clauses. %% It counts backwards because that's less code. say(_, 0) -> ok; say(Msg, N) -> io:fwrite("~w ~s~n", [N, Msg]), timer:sleep(1000), say(Msg, N-1). main(_) -> spawn(fun() -> say("task1", 5) end), spawn(fun() -> say("task2", 5) end), timer:sleep(6000). 1 2 3 4 5 6 7 8 9 10 11 12 13 say(_, 0) -> ok; say(Msg, N) -> io:fwrite("~w ~s~n", [N, Msg]), timer:sleep(1000), say(Msg, N-1). %% This is a recursive function with two clauses. 1 %% It counts backwards because that's less code. 2 3 4 5 6 7 8 9 main(_) -> 10 spawn(fun() -> say("task1", 5) end), 11 spawn(fun() -> say("task2", 5) end), 12 timer:sleep(6000). 13 main(_) -> spawn(fun() -> say("task1", 5) end), spawn(fun() -> say("task2", 5) end), timer:sleep(6000). %% This is a recursive function with two clauses. 1 %% It counts backwards because that's less code. 2 say(_, 0) -> 3 ok; 4 say(Msg, N) -> 5 io:fwrite("~w ~s~n", [N, Msg]), 6 timer:sleep(1000), 7 say(Msg, N-1). 8 9 10 11 12 13 %% This is a recursive function with two clauses. %% It counts backwards because that's less code. say(_, 0) -> ok; say(Msg, N) -> io:fwrite("~w ~s~n", [N, Msg]), timer:sleep(1000), say(Msg, N-1). main(_) -> spawn(fun() -> say("task1", 5) end), spawn(fun() -> say("task2", 5) end), timer:sleep(6000). 1 2 3 4 5 6 7 8 9 10 11 12 13 9
  6. ASYNCIO CALLS IT create_task import asyncio async def print_lines(msg, n):

    for i in range(n): print(i+1, msg) await asyncio.sleep(0.2) async def main(): await print_lines("basically sync", 3) t1 = asyncio.create_task(print_lines("task1", 3)) t2 = asyncio.create_task(print_lines("task2", 6)) await t1 await t2 asyncio.run(main()) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 async def print_lines(msg, n): for i in range(n): print(i+1, msg) await asyncio.sleep(0.2) import asyncio 1 2 3 4 5 6 7 async def main(): 8 await print_lines("basically sync", 3) 9 t1 = asyncio.create_task(print_lines("task1", 3)) 10 t2 = asyncio.create_task(print_lines("task2", 6)) 11 await t1 12 await t2 13 14 asyncio.run(main()) 15 async def main(): await print_lines("basically sync", 3) t1 = asyncio.create_task(print_lines("task1", 3)) t2 = asyncio.create_task(print_lines("task2", 6)) await t1 await t2 import asyncio 1 2 async def print_lines(msg, n): 3 for i in range(n): 4 print(i+1, msg) 5 await asyncio.sleep(0.2) 6 7 8 9 10 11 12 13 14 asyncio.run(main()) 15 import asyncio async def print_lines(msg, n): for i in range(n): print(i+1, msg) await asyncio.sleep(0.2) async def main(): await print_lines("basically sync", 3) t1 = asyncio.create_task(print_lines("task1", 3)) t2 = asyncio.create_task(print_lines("task2", 6)) await t1 await t2 asyncio.run(main()) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 10
  7. WHERE DO ERRORS GO? import asyncio async def raise_error(msg): await

    asyncio.sleep(0.1) raise Exception(msg) async def main(): t1 = asyncio.create_task(raise_error("task1")) # Never awaited. t2 = asyncio.create_task(raise_error("task2")) # Awaited later. await asyncio.create_task(raise_error("task3")) # Awaited now. await t2 asyncio.run(main()) 1 2 3 4 5 6 7 8 9 10 11 12 13 t1 = asyncio.create_task(raise_error("task1")) # Never awaited. import asyncio 1 2 async def raise_error(msg): 3 await asyncio.sleep(0.1) 4 raise Exception(msg) 5 6 async def main(): 7 8 t2 = asyncio.create_task(raise_error("task2")) # Awaited later. 9 await asyncio.create_task(raise_error("task3")) # Awaited now. 10 await t2 11 12 asyncio.run(main()) 13 t2 = asyncio.create_task(raise_error("task2")) # Awaited later. await t2 import asyncio 1 2 async def raise_error(msg): 3 await asyncio.sleep(0.1) 4 raise Exception(msg) 5 6 async def main(): 7 t1 = asyncio.create_task(raise_error("task1")) # Never awaited. 8 9 await asyncio.create_task(raise_error("task3")) # Awaited now. 10 11 12 asyncio.run(main()) 13 await asyncio.create_task(raise_error("task3")) # Awaited now. import asyncio 1 2 async def raise_error(msg): 3 await asyncio.sleep(0.1) 4 raise Exception(msg) 5 6 async def main(): 7 t1 = asyncio.create_task(raise_error("task1")) # Never awaited. 8 t2 = asyncio.create_task(raise_error("task2")) # Awaited later. 9 10 await t2 11 12 asyncio.run(main()) 13 import asyncio async def raise_error(msg): await asyncio.sleep(0.1) raise Exception(msg) async def main(): t1 = asyncio.create_task(raise_error("task1")) # Never awaited. t2 = asyncio.create_task(raise_error("task2")) # Awaited later. await asyncio.create_task(raise_error("task3")) # Awaited now. await t2 asyncio.run(main()) 1 2 3 4 5 6 7 8 9 10 11 12 13 11
  8. STRUCTURED CONCURRENCY Tasks spawned in a block must nish in

    that block Execution ow that humans can reason about Nested contexts and task-local state Well-de ned cancellation and error handling No orphaned tasks or missing results 12
  9. FROM THE VERY BEGINNING Async code can call sync code

    Sync code can’t call async code import trio def square(n): return n * n async def cube(n): return n * square(n) trio.run(cube, 2) 1 2 3 4 5 6 7 8 9 … but don’t call anything that blocks … but frameworks like Trio are a special case 15
  10. GETTING IT WRONG Debugging a forgotten await is annoying. Trio

    provides some debugging tools: trio.abc.Instrument import trio async def slow_square(n): await trio.sleep(0.2) return n * n def foo(): print(await slow_square(3)) # SyntaxError async def bar(): print(slow_square(3)) # RuntimeWarning logged 1 2 3 4 5 6 7 8 9 10 11 print(await slow_square(3)) # SyntaxError import trio 1 2 async def slow_square(n): 3 await trio.sleep(0.2) 4 return n * n 5 6 def foo(): 7 8 9 async def bar(): 10 print(slow_square(3)) # RuntimeWarning logged 11 print(slow_square(3)) # RuntimeWarning logged import trio 1 2 async def slow_square(n): 3 await trio.sleep(0.2) 4 return n * n 5 6 def foo(): 7 print(await slow_square(3)) # SyntaxError 8 9 async def bar(): 10 11 16
  11. NURSERIES: ACTUAL CONCURRENCY A nursery block doesn’t exit until all

    child tasks are complete. import trio async def print_lines(msg, n): for i in range(n): print(i+1, msg) await trio.sleep(0.2) print("task finished:", msg) async def main(): print("parent: started!") async with trio.open_nursery() as nursery: nursery.start_soon(print_lines, "task1", 4) nursery.start_soon(print_lines, "task2", 2) print("parent: waiting for tasks to finish...") print("parent: all done!") trio.run(main) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 async def print_lines(msg, n): for i in range(n): print(i+1, msg) await trio.sleep(0.2) print("task finished:", msg) import trio 1 2 3 4 5 6 7 8 async def main(): 9 print("parent: started!") 10 async with trio.open_nursery() as nursery: 11 nursery.start_soon(print_lines, "task1", 4) 12 nursery.start_soon(print_lines, "task2", 2) 13 print("parent: waiting for tasks to finish...") 14 print("parent: all done!") 15 16 trio.run(main) 17 async with trio.open_nursery() as nursery: nursery.start_soon(print_lines, "task1", 4) nursery.start_soon(print_lines, "task2", 2) print("parent: waiting for tasks to finish...") import trio 1 2 async def print_lines(msg, n): 3 for i in range(n): 4 print(i+1, msg) 5 await trio.sleep(0.2) 6 print("task finished:", msg) 7 8 async def main(): 9 print("parent: started!") 10 11 12 13 14 print("parent: all done!") 15 16 trio.run(main) 17 17
  12. TIMEOUTS import trio async def main(): with trio.move_on_after(3): print("Went to

    sleep") await trio.sleep(2) print("Still sleeping") await trio.sleep(2) print("Woke up on my own") print("Nap time is over") try: with trio.fail_after(2): async with open_nursery() as n: n.start_soon(trio.sleep, 1) n.start_soon(trio.sleep, 3) await trio.sleep(4) except trio.TooSlowError as e: print(f"Took too long: {e!r}") trio.run(main) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 with trio.move_on_after(3): print("Went to sleep") await trio.sleep(2) print("Still sleeping") await trio.sleep(2) print("Woke up on my own") print("Nap time is over") import trio 1 2 async def main(): 3 4 5 6 7 8 9 10 11 try: 12 with trio.fail_after(2): 13 async with open_nursery() as n: 14 n.start_soon(trio.sleep, 1) 15 n.start_soon(trio.sleep, 3) 16 await trio.sleep(4) 17 except trio.TooSlowError as e: 18 print(f"Took too long: {e!r}") 19 20 trio.run(main) 21 try: with trio.fail_after(2): async with open_nursery() as n: n.start_soon(trio.sleep, 1) n.start_soon(trio.sleep, 3) await trio.sleep(4) except trio.TooSlowError as e: print(f"Took too long: {e!r}") import trio 1 2 async def main(): 3 with trio.move_on_after(3): 4 print("Went to sleep") 5 await trio.sleep(2) 6 print("Still sleeping") 7 await trio.sleep(2) 8 print("Woke up on my own") 9 print("Nap time is over") 10 11 12 13 14 15 16 17 18 19 20 trio.run(main) 21 import trio async def main(): with trio.move_on_after(3): print("Went to sleep") await trio.sleep(2) print("Still sleeping") await trio.sleep(2) print("Woke up on my own") print("Nap time is over") try: with trio.fail_after(2): async with open_nursery() as n: n.start_soon(trio.sleep, 1) n.start_soon(trio.sleep, 3) await trio.sleep(4) except trio.TooSlowError as e: print(f"Took too long: {e!r}") trio.run(main) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 18
  13. ERRORS AND CANCELLATION import trio async def print_lines(msg, n): for

    i in range(n): print(i+1, msg) await trio.sleep(0.4) async def timebomb(delay): await trio.sleep(delay) raise Exception("KABOOOM!") async def main(): try: async with trio.open_nursery() as n: n.start_soon(print_lines, "child", 5) n.start_soon(timebomb, 1) await print_lines("body", 5) except Exception as e: print(f"Error: {e}") trio.run(main) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 19
  14. OTHER FEATURES Task-local storage Communication between tasks Events Channels Locks,

    semaphores, … Async generators (with caveats) Threads (if you must) Async lesystem operations Subprocesses 20
  15. WHAT HAVE WE LEARNED? goto is bad (but we already

    knew this) spawn is bad, no matter how you spell it Structured Concurrency is great Use Trio for your next async project 22