The Secret Life of Python: Never Block the Asyncio Loop

 

The Secret Life of Python: Never Block the Asyncio Loop

Why CPU-bound code freezes your server and how to fix it with to_thread

#PythonAsync #EventLoop #BlockingCode #ResponsiveCode




Margaret is a senior software engineer. Timothy is her junior colleague. They work in a grand Victorian library in London — the kind of place where code quality is the unspoken objective, and craftsmanship is the only thing that matters.

Episode 41

Timothy was feeling invincible. He had successfully juggled Alice, Bob, and Charlie using asyncio.gather(). But then, he decided to add one more feature to his ranking request: a Security Hash.

"It’s just a small calculation," Timothy told Margaret. "Before I return the rank, I'll run a heavy for loop to generate a secure token. It shouldn't take more than two seconds. Since it's inside an async function, the Juggler will just handle it while he waits for the others, right?"

He added the "Selfish Loop" to Alice's request.

import asyncio
import time

async def check_ranking(player_name, is_selfish=False):
    print(f"Juggler: Starting request for {player_name}...")
    
    if is_selfish:
        # THE TRAP: A heavy CPU task that doesn't use 'await'
        print(f"--- {player_name} is being selfish! Starting heavy math... ---")
        # This is a CPU-bound task. It doesn't throw a ball in the air;
        # it forces the Juggler to stand still and calculate.
        sum(i * i for i in range(10_000_000)) 
        print(f"--- {player_name} finished the math. ---")
    
    await asyncio.sleep(1) 
    print(f"Juggler: Finished request for {player_name}!")

async def main():
    print("--- The Juggler is starting the show ---")
    # Alice is going to be the 'Selfish' one
    await asyncio.gather(
        check_ranking("Alice", is_selfish=True),
        check_ranking("Bob"),
        check_ranking("Charlie")
    )

if __name__ == "__main__":
    asyncio.run(main())

Timothy hit enter. Usually, Bob and Charlie would start their requests immediately after Alice. But this time, the screen stayed dead.

Alice started her math. Two seconds passed. Only after Alice finished her math did Bob and Charlie’s names even appear on the screen.

"Margaret!" Timothy cried. "Alice froze the whole stage! Bob and Charlie were just standing there waiting for her to finish her math. Why didn't the Juggler throw her ball in the air?"


The Juggler’s Hands are Full

Margaret walked over to the whiteboard. "Remember the Juggler's hands, Timothy. The Juggler can only throw a ball in the air when they reach an await point."

"In your code," she explained, "Alice started doing heavy math. That’s not 'waiting'—that’s 'working.' The Juggler had to hold that ball with both hands and focus entirely on the calculation. Because the Juggler was busy, they couldn't move their hands to pick up Bob or Charlie’s balls."

"This is the Cardinal Sin of Asyncio," Margaret said. "If you block the thread with heavy work, the Event Loop stops for everyone. One selfish player just crashed the experience for your other 4,999 users."


The Specialist’s Solution: Offloading

"So I can't do math in Asyncio?" Timothy asked, defeated.

"You can," Margaret said, "but you have to send that ball to a different stage. If you have heavy work, you use a Thread or a Process to do the heavy lifting, and you await the result. In modern Python, we use to_thread to do this easily."

# The correct way to offload heavy math so the Juggler stays free
def heavy_math():
    return sum(i * i for i in range(10_000_000))

# Inside your async function:
result = await asyncio.to_thread(heavy_math)

"That way," Margaret said, "the Juggler’s hands stay free to keep the other 4,999 balls moving."


Margaret’s Cheat Sheet: The Rules of the Loop

The Cardinal Rule

  • Never Block the Loop: Any code that takes a long time to run without an await (like a massive for loop) will freeze the entire program for every user.

Detect the Trap

  • Does it do math? → CPU-bound → Offload it with to_thread.
  • Does it wait for a database? → I/O-bound → Use await.
  • Does it use time.sleep()? → STOP. This is "Selfish." Always use await asyncio.sleep().

The Escape Hatch

  • asyncio.to_thread(): Added in Python 3.9, this is the easiest way to run a blocking function in a separate thread without freezing the Juggler.

Aaron Rose is a software engineer and technology writer at tech-reader.blog

Catch up on the latest explainer videos, podcasts, and industry discussions below.


Comments

Popular posts from this blog

Insight: The Great Minimal OS Showdown—DietPi vs Raspberry Pi OS Lite

Running AI Models on Raspberry Pi 5 (8GB RAM): What Works and What Doesn't

Raspberry Pi Connect vs. RealVNC: A Comprehensive Comparison