pyforjs home

Asynchronous runtimes & concurrency

Node's event loop is always running; Python's is not. Lazy coroutines, explicit scheduling, and the GIL's effect on which concurrency primitive you actually want.

The syntax is nearly identical. async def, await, the same shape of code. The runtime model is not, and that is where the bugs come from.

Nothing runs until you start a loop

Node boots an event loop before your first line executes. Python has none until you ask for one, and a coroutine that is never awaited never runs at all.

entry.ts
const p = fetchUser(); // already running
async function main() {
return await p;
}
main();
entry.py
Starting Python…

The silent no-op

Calling an async def and discarding the result does nothing, no request, no error, just a RuntimeWarning: coroutine was never awaited if you are lucky. In JavaScript the equivalent fires the request and swallows the rejection. Both are bugs; Python's is quieter at runtime and louder at review time.

Eager Promises, lazy coroutines

JavaScript PromisePython coroutine
Starts executingon constructionon await, or when wrapped in a task
Awaiting twicereturns the cached resultRuntimeError: cannot reuse already awaited
Fire and forgetvoid pasyncio.create_task(coro)

To get Promise-like eagerness, schedule the coroutine explicitly:

scheduling.py
Starting Python…

Combinators

JavaScriptPythonBehaviour
Promise.all([p1, p2])asyncio.gather(t1, t2)concurrent; raises on the first failure
Promise.allSettled([...])asyncio.gather(..., return_exceptions=True)collects results and exceptions
Promise.race([...])asyncio.wait(..., return_when=FIRST_COMPLETED)returns when the first finishes
AbortSignal.timeout(ms)async with asyncio.timeout(s)3.11+, raises TimeoutError
combinators.py
Starting Python…

Structured concurrency with TaskGroup

Promise.all fails fast but abandons its siblings, they keep running, their results discarded, and nothing awaits them again. Python 3.11+ has a stronger contract. It is called structured concurrency. An asyncio.TaskGroup scope does not exit until every task started inside it is finished, or cancelled, if one of them failed first.

task_groups.py
Starting Python…
Promise.all + AbortSignalasyncio.TaskGroup
First failurerejects; siblings keep runningcancels every sibling, then raises
Error shapethe single first rejectionExceptionGroup, all failures, collected
Cancellation wiringyou thread the signal through by handautomatic within the scope
After the blockmay still have in-flight requestsguaranteed: no task survives the block

except* is a new keyword shape

A TaskGroup failure surfaces as an ExceptionGroup, and you catch it with except*, a separate syntax that unwraps the group and matches each member exception type independently. Plain except ValueError will not catch it, because a group of ValueErrors is not itself a ValueError. Also note return is illegal inside an except* block, assign a result and return after, as the snippet does.

The rule of thumb is this. Use gather for quick fan-outs where abandonment is fine (or where you asked for return_exceptions=True), TaskGroup whenever sibling tasks share a lifetime, which is most request handlers.

The GIL

CPython holds a global lock around bytecode execution, so threads do not give you CPU parallelism. They do give you concurrency during blocking I/O, because the lock is released while waiting.

WorkloadReach forWhy
Network / disk I/Oasynciothousands of waits, one thread, no lock contention
Blocking library with no async APIasyncio.to_thread / threadingthe GIL is released during the blocking call
CPU-bound number crunchingmultiprocessingseparate interpreters, separate GILs
CPU-bound array mathsnumpythe heavy loops release the GIL in C

PEP 703 makes the GIL optional. In 3.14 the free-threaded build is supported but still optional, the GIL remains the default, and whether it ever becomes the default build is undecided. It does not change the advice above. Most C extensions still assume the lock exists.

Kata

The pattern you will write in your first week on any Python service. Fan out, but not too far.

kata_03.py
Starting Python…

On this page