♾️

Asynchronous Programming

What is asynchronous programming?

Think of asynchronous programming like a chef in a kitchen. If the chef puts a pot of water on the stove and stands there staring at it until it boils, doing nothing else, that’s synchronous programming.

If the chef turns on the stove, then starts chopping onions while the water heats up, that’s asynchronous programming. You aren't necessarily doing two things at the exact same millisecond (parallelism), but you aren't wasting time waiting for "blocking" tasks to finish.

Async programming paradigm allows writing a code that doesn’t block the execution of program while waiting for certain operation to complete.

The core of asynchronous programming in Python is a single-threaded event loop. Instead of a program waiting for a slow operation to finish, it can hand off the task to the event loop and move on to other work. When the slow operation is complete, the event loop notifies the program, which can then resume the original task from where it left off.

AND WHERE EVENT LOOP EXECUTES TASKS? In single thread, and executes many tasks by rapidly switching between them.

So, we can work on multiple tasks at the same time.

  • Non-blocking
  • Concurrency
  • Efficiency

Why NOT multithreading or multiprocessing instead?

  • Multithreading
  • Because of GIL. It ensures that only one thread executes Python bytecode at a time.

    Multithreading runs within a single process. It shares the same memory space. The Operating System decides when to "pause" Thread A and "start" Thread B. Because of the GIL, only one thread can actually execute Python code at a time. However, when a thread starts a "system call" (like downloading a file), it releases the GIL, allowing another thread to work.

    Even if you have a processor with 16 cores and you create 16 threads, the GIL acts like a narrow doorway. Only one thread can pass through to use the CPU at any given moment.

    Multithreading: The OS constantly "context switches" it stops Thread A, saves its state, starts Thread B, stops it, and goes back to A. This switching has a "tax" (overhead).

  • Multiprocessing creates entirely separate instances of the Python interpreter, each with its own memory space and its own GIL.
  • Multiprocessing spreads the work across multiple CPU cores. Unlike threads or async, this is the only way to achieve true parallelism in Python for heavy calculations.

    PROBLEM with multiprocessing: It’s "heavy." Starting a new process takes more time and memory than a thread, and sharing data between processes is complicated

Async: There is only one thread. It doesn't switch unless the code explicitly says, "I'm waiting for something, go ahead and do other work" (the await keyword). This is much more efficient for thousands of connections.

Feature
Multiprocessing
Multithreading
Asyncio
Bottleneck
CPU (Heavy Math)
I/O (Waiting)
I/O (Waiting + Scale)
Parallelism
True Parallelism
Shared (One at a time)
Cooperative (One at a time)
Max Capacity
Hundreds of threads
Tens of thousands of tasks
CPU usage
Limited by the GIL
One CPU core only
Memory Cost
High (New Process)
Medium (Stack memory), High (MBs per thread)
Low (Object in memory), Very Low (KBs per task)
Safety/problem
Isolated memory
Race conditions
Explicit switches with await

Asyncio: The "Single-Threaded" Manager

Async is like a high-speed traffic controller on a single thread. Tasks are never interrupted by the OS. Instead, they yield control voluntarily when they hit an await point. It is much lighter than threads.

You can have 100,000 async tasks (coroutines) running on the same amount of RAM that would only support 100 threads. igh-concurrency I/O (Web servers, chat apps, scraping thousands of sites).

Best For: High-concurrency I/O (Web servers, chat apps, scraping thousands of sites).

The Catch: "One bad apple spoils the bunch." If you put a heavy math calculation inside an async function without awaiting, it blocks the entire program for everyone.

async and await

async

  • This keyword is used to define a coroutine, which is a function that can be paused and resumed. When you put async before def, you aren't creating a regular function anymore.
  • When you call an async function, it doesn't execute immediately, it returns a coroutine object that needs to be "scheduled" to run.

await

  • This keyword is used inside an async function to pause its execution and "await" the result of another awaitable object (like another coroutine or a future).
  • await tells Python: "This task is going to take a while (like an API call). Pause this specific function here, let the CPU go do other work, and come back when this task is finished." The program can then switch to another task until the awaited operation is done.

Forgetting to await: If you call an async function without await, it won't run its body; it will just return a "coroutine object" and a warning.

What are the coroutines?

Asynchronous functions are also called coroutines. They don't execute immediately when called but return a coroutine object. You must schedule them in the event loop to run.

Because coroutines can pause and resume execution context, they’re well suited to concurrent processing, as they enable the program to determine when to ‘context switch’ from one point of the code to another.

Coroutines or async are a different way to execute functions concurrently in Python, by way of special programming constructs rather than system threads. Coroutines created with async def are implemented using the more recent __await__ dunder method (see documentation here).

While generator based coroutines are using a legacy ‘generator’ based implementation.

Types of Coroutines

This has led to the term ‘coroutine’ meaning multiple things in different contexts. We now have:

  • simple coroutines: traditional generator coroutine (no async io).
  • generator coroutines: async io using legacy asyncio implementation.
  • native coroutines: async io using latest async/await implementation.

Coroutines declared with the async/await syntax is the preferred way of writing asyncio applications. For example, the following snippet of code (requires Python 3.7+) prints “hello”, waits 1 second, and then prints “world”:

>>> import asyncio

>>> async def main():
...     print('hello')
...     await asyncio.sleep(1)
...     print('world')

>>> asyncio.run(main())
hello
world

Examples

The Synchronous Way (slow)

import time

def brew_coffee():
    print("Starting coffee...")
    time.sleep(2)  # Blocks the whole program
    print("Coffee ready!")

def toast_bread():
    print("Starting toast...")
    time.sleep(3)  # Blocks the whole program
    print("Toast ready!")

# Total time: 5 seconds
brew_coffee()
toast_bread()

The Asynchronous Way (fast)

Another example:

How/where the awaited task is executed if CPU will be busy with next lines of code in program?

In an async program, there is only one thread and one CPU core involved.

But everything is controlled in event loop. Event Loop to put that specific task in a "Waiting Room" and move on to the next task on its list.

Where is the "Waiting" actually executed?

If the CPU isn't doing the waiting, who is? The Operating System (OS).

  • When you await a network request, Python tells the OS: "Let me know when data arrives on Socket #1234."
  • The OS has high-efficiency tools (like epoll on Linux or kqueue on macOS) that watch thousands of connections at once without using much CPU.
  • The CPU is then 100% free to run other Python code until the OS taps it on the shoulder.

The Golden Rule of Async: If the CPU is busy executing actual code (calculating, sorting, moving variables), it cannot check if an awaited task is finished. So when it checks?

  1. The "Pause and Drop"
    1. When the CPU hits await task_a(), it doesn't "check" anything yet. Instead:

    2. Task A says: "I'm going to be waiting on the network. I'm pausing now."
    3. The CPU immediately exits Task A and jumps back to the Event Loop (the manager).
  2. The Manager’s To-Do List
    1. The Event Loop looks at its list of tasks and asks two questions:

    2. "Are any new tasks ready to start?"
    3. "Are any old tasks (that were waiting) now finished?"
  3. The "Busy" Gap
    1. If Task B is next on the list, the CPU starts running Task B.

    2. Crucial Point: While the CPU is running Task B, it is blind to Task A.
    3. Even if Task A’s data arrives from the internet 1 millisecond after Task B starts, the CPU won't know. It is busy with Task B.
  4. The Loop Back
  5. As soon as Task B finishes (or hits its own await), the CPU jumps back to the Event Loop. Now the Event Loop notices: "Oh! Task A's data arrived while I was busy with Task B. I can now resume Task A."

Question
Answer
Does hitting await check the status?
No. Hitting await just gives up control.
When is the status checked?
Continuously by the Event Loop whenever the CPU isn't inside a function.
Can a task be "finished" but ignored?
Yes. If the CPU is stuck in a long loop elsewhere, the "finished" task sits in the lobby waiting for the Event Loop to notice it.

Race condition

Performing several operations over same shared resource (data) at the same time.

Making result unpredictable, depends on order which thread or process executed first.

In multithreading, the OS can interrupt your code at any line.

Imagine two threads trying to decrease a bank balance:

  1. Thread A reads balance = 100.
  2. The OS pauses Thread A and moves to Thread B.
  3. Thread B reads balance = 100, subtracts 50, and saves balance = 50.
  4. The OS moves back to Thread A.
  5. Thread A (which still thinks the balance is 100) subtracts 50 and saves balance = 50.
  6. Result: You spent 100, but the balance only dropped by 50.

In Async, this is much harder to mess up. Because you only "switch" tasks at an await point, you know exactly where your code might pause. It’s "Cooperative Multitasking"—the code cooperates rather than being interrupted forcefully.

Dead lock

Two or more threads waiting for the result of each other. So, here they stuck.

References

SuperMade with Super