Get AI summaries of any video or article — Sign up free
Python Tutorial: AsyncIO - Complete Guide to Asynchronous Programming with Animations thumbnail

Python Tutorial: AsyncIO - Complete Guide to Asynchronous Programming with Animations

Corey Schafer·
5 min read

Based on Corey Schafer's video on YouTube. If you like this content, support the original creators by watching, liking and subscribing to their content.

TL;DR

AsyncIO improves performance by overlapping I/O waits; it doesn’t inherently make CPU work faster.

Briefing

AsyncIO in Python delivers real concurrency only when code yields control to the event loop—meaning I/O-bound work must use awaitable operations (like asyncio.sleep or async HTTP/file libraries), and CPU-bound work must move to processes. The tutorial’s core message is practical: asynchronous syntax alone doesn’t make programs faster; correct task scheduling and non-blocking behavior are what unlock speed.

The walkthrough starts by separating synchronous execution from concurrency using a restaurant analogy: synchronous code finishes one “customer” at a time, while concurrent code overlaps waiting periods. AsyncIO is then framed as single-threaded, single-process cooperative multitasking: tasks run until they hit an awaitable, then voluntarily pause so the event loop can run other ready tasks. That’s why “async” doesn’t automatically mean faster—only I/O-bound waits benefit, since the program can do other useful work while network requests, database queries, or file operations are pending.

A major early focus is terminology and mechanics. The event loop is the engine started via asyncio.run, responsible for scheduling and resuming tasks. await works only inside async functions and pauses the current coroutine until an awaitable completes. The tutorial distinguishes three awaitable types: coroutines (created by calling async def functions), tasks (wrappers created with asyncio.create_task that schedule coroutines on the event loop), and futures (lower-level state/result objects, used mostly in framework-level code). Animations reinforce a key pitfall: creating coroutine objects doesn’t schedule them. If coroutines are awaited directly one after another, they run to completion sequentially and provide no concurrency.

That pitfall is corrected in the “right way” example: wrap coroutines with asyncio.create_task before awaiting. Once both tasks are scheduled, the event loop can interleave their execution during awaits, so total runtime becomes roughly the duration of the slowest task rather than the sum of all task durations.

The tutorial then tackles ordering and correctness. Awaiting a particular task doesn’t guarantee it runs next; it only guarantees the program won’t proceed past that await until the awaited operation finishes. Another critical failure mode is blocking the event loop with synchronous calls like time.sleep inside async code. Even if tasks exist, a blocking call prevents the event loop from switching contexts, collapsing concurrency and turning a supposed async workload back into a sequential one.

When no async alternative exists for blocking I/O, the tutorial shows how to offload work using threads (asyncio.to_thread) or processes (loop.run_in_executor with ProcessPoolExecutor). Threads help with blocking I/O; processes help with CPU-bound computation. This distinction is validated in a real-world refactor: a synchronous script that downloads 12 images and performs edge-detection processing. Profiling with scalene identifies download time as mostly system/IO waiting and processing time as mostly Python time (CPU-bound). The refactor then uses HTTPX and aiofiles for async downloads, threads for blocking fallbacks, and processes for CPU-heavy image processing—cutting total runtime from about 23 seconds to roughly 5 seconds.

Finally, the tutorial adds production-minded controls: semaphores to cap concurrent downloads (e.g., 4 at a time) and process worker limits based on CPU count. It closes with common async mistakes—forgetting to await tasks, letting the script exit before tasks complete, and accidentally using blocking calls—plus guidance on choosing asyncio vs threads vs multiprocessing based on whether work is I/O-bound or CPU-bound.

Cornell Notes

AsyncIO speeds up programs by overlapping waiting time, not by making code run “faster” in general. It works via a single-threaded event loop that resumes coroutines only when awaited awaitables complete; tasks must be scheduled (e.g., with asyncio.create_task) before concurrency appears. Directly awaiting coroutine objects one-by-one often yields sequential behavior because coroutines aren’t scheduled until the await happens. Blocking the event loop with synchronous calls like time.sleep inside async code destroys concurrency. For CPU-bound work, processes are the right tool; for blocking I/O without async libraries, threads (asyncio.to_thread) can preserve responsiveness. Profiling (scalene) helps decide where to apply async, threads, or processes in real codebases.

Why doesn’t “async/await” automatically make a program faster?

AsyncIO only improves throughput when tasks can yield control during waiting. If a coroutine never hits an awaitable that suspends execution (or if it blocks with time.sleep), the event loop can’t switch to other ready tasks. In the tutorial’s “first attempt,” coroutines were created but not scheduled; awaiting them directly caused them to run to completion one after another, producing the same total runtime as synchronous code.

What’s the difference between a coroutine object, a task, and a future in practical terms?

A coroutine function (async def) produces a coroutine object when called; that object is awaitable but isn’t scheduled until awaited or wrapped. A task (created with asyncio.create_task) schedules the coroutine on the event loop so multiple tasks can run concurrently. Futures are lower-level state containers (pending/cancelled/finished with result or exception) and are mostly used in framework internals; typical application code works with coroutines and tasks instead.

How does awaiting affect execution order on the event loop?

await guarantees the program won’t proceed until the awaited operation completes, but it doesn’t guarantee which task runs next. The event loop runs whichever tasks are ready, often based on internal queue ordering (the tutorial mentions FIFO behavior). That’s why swapping which task is awaited first can change when “fully complete” prints happen without changing the overall concurrency runtime.

What exactly goes wrong when synchronous blocking code is used inside async functions?

Blocking calls like time.sleep prevent the event loop from regaining control. Even if tasks are scheduled, the event loop can’t interleave execution because the running task never suspends at an await point. The tutorial demonstrates this collapse: total runtime returns to the sum of blocking durations (e.g., ~3 seconds instead of ~2 seconds), showing concurrency was effectively lost.

When should developers use asyncio vs threads vs processes?

Use asyncio for I/O-bound work when async libraries exist (e.g., HTTPX for HTTP, aiofiles for file I/O). Use threads (asyncio.to_thread) when I/O is blocking but there’s no async alternative. Use processes (ProcessPoolExecutor via loop.run_in_executor) for CPU-bound computation, since Python’s CPU work doesn’t benefit from cooperative multitasking in a single process.

How did profiling guide the real-world refactor of the image pipeline?

scalene was used to generate an HTML report showing where time was spent across Python time, native time, and system time. System time dominated downloads (waiting on I/O), while Python time dominated image processing (CPU-bound edge detection). That mapping led to async HTTP/file I/O for downloads and process-based parallelism for processing, producing a large end-to-end speedup.

Review Questions

  1. In what scenario would awaiting coroutine objects directly fail to produce concurrency, even if the functions are written with async def?
  2. What observable symptom indicates that the event loop has been blocked by synchronous code inside an async function?
  3. How would you decide between using asyncio.gather, a task group, threads, or processes for a workload with mixed I/O and CPU work?

Key Points

  1. 1

    AsyncIO improves performance by overlapping I/O waits; it doesn’t inherently make CPU work faster.

  2. 2

    Coroutines don’t schedule themselves—wrap them with asyncio.create_task (or otherwise schedule) to get true concurrency.

  3. 3

    await pauses the current coroutine and yields control, but the event loop chooses which ready task runs next.

  4. 4

    Never call blocking functions like time.sleep inside async code; it freezes the event loop and removes concurrency.

  5. 5

    Use threads (asyncio.to_thread) for blocking I/O without async libraries, and processes for CPU-bound computation (ProcessPoolExecutor).

  6. 6

    Profiling with tools like scalene helps identify whether time is spent waiting on I/O or doing CPU work, guiding where to apply async vs threads vs processes.

  7. 7

    Add safety controls for scale: cap concurrency with semaphores and limit process workers based on CPU count to avoid overwhelming machines and servers.

Highlights

The tutorial’s central scheduling lesson: creating coroutine objects isn’t enough—concurrency appears only after tasks are scheduled on the event loop (e.g., via asyncio.create_task).
Blocking the event loop with time.sleep inside async code collapses concurrency back into sequential timing.
Awaiting a specific task controls when the program continues, not which task the event loop runs next.
A real refactor used scalene to separate I/O waiting from CPU processing, then applied HTTPX/aiofiles for async downloads and processes for edge-detection processing.
Semaphores and CPU-based worker limits prevent “blast everything at once” behavior that can bog down systems or hammer external servers.

Topics

  • AsyncIO Fundamentals
  • Event Loop Scheduling
  • Tasks vs Coroutines
  • Avoiding Blocking Calls
  • Threads vs Processes
  • Profiling and Refactoring
  • Concurrency Limits