Mathew K Analytics

Lesson 12 · Python standard library deep dive

Python asyncio Explained: async/await & Event Loops | Standard Library #12

Video twelve of the twenty-five-part series: asyncio, Python's cooperative concurrency model for I/O-bound code. Coroutines, the event loop, gather, tasks,…

⬇ Download notebookOpen in Colab ↗
asyncio

📓 Full notebook

Download .ipynb

Python Standard Library Deep-Dive, Video 12: asyncio#

  • Video twelve of the twenty-five-part series: asyncio, Python's cooperative concurrency model for I/O-bound code.
  • Coroutines, the event loop, gather, tasks, timeouts, and the Jupyter-specific setup this actually needs to run.
  • Let's get into it.

Part 1: What asyncio Offers, and a Jupyter Setup Note#

import asyncio
import time
try:
    import nest_asyncio
    nest_asyncio.apply()
except ImportError:
    pass
print('asyncio ready')
asyncio ready

Part 2: async def and Coroutines#

async def greet(name):
    return f'Hello, {name}!'
result = greet('Ana')
print(type(result))
print(result)
<class 'coroutine'>
<coroutine object greet at 0x000001C59A3DB940>
async def main():
    result = await greet('Sam')
    print(result)
asyncio.run(main())
Hello, Sam!

Part 3: await and Sequential Execution#

async def fetch_data(name, delay):
    await asyncio.sleep(delay)
    return f'{name} fetched'
async def sequential_main():
    start = time.time()
    result1 = await fetch_data('A', 0.3)
    result2 = await fetch_data('B', 0.3)
    elapsed = time.time() - start
    return result1, result2, elapsed
r1, r2, elapsed = asyncio.run(sequential_main())
print(r1, r2)
print(elapsed >= 0.6)
A fetched B fetched
True

Part 4: asyncio.gather() - Running Coroutines Concurrently#

async def concurrent_main():
    start = time.time()
    result1, result2 = await asyncio.gather(
        fetch_data('A', 0.3),
        fetch_data('B', 0.3)
    )
    elapsed = time.time() - start
    return result1, result2, elapsed
r1, r2, elapsed = asyncio.run(concurrent_main())
print(r1, r2)
print(elapsed < 0.6)
A fetched B fetched
True

Part 5: Tasks with asyncio.create_task()#

async def task_main():
    task1 = asyncio.create_task(fetch_data('A', 0.2))
    task2 = asyncio.create_task(fetch_data('B', 0.2))
    print('Both tasks are genuinely already running now')
    result1 = await task1
    result2 = await task2
    return result1, result2
print(asyncio.run(task_main()))
Both tasks are genuinely already running now
('A fetched', 'B fetched')

Part 6: asyncio.sleep() vs time.sleep()#

async def blocking_mistake():
    start = time.time()
    await asyncio.gather(
        blocking_task('A'),
        blocking_task('B')
    )
    return time.time() - start
async def blocking_task(name):
    time.sleep(0.2)
    return name
elapsed = asyncio.run(blocking_mistake())
print(elapsed >= 0.4)
True

Part 7: Timeouts with asyncio.wait_for()#

async def slow_task():
    await asyncio.sleep(2)
    return 'finished'
async def timeout_main():
    try:
        result = await asyncio.wait_for(slow_task(), timeout=0.3)
        return result
    except asyncio.TimeoutError:
        return 'genuinely timed out'
print(asyncio.run(timeout_main()))
genuinely timed out

Part 8: async with and asyncio.Lock#

shared_counter = 0
lock = asyncio.Lock()
async def safe_increment():
    global shared_counter
    async with lock:
        current = shared_counter
        await asyncio.sleep(0.01)
        shared_counter = current + 1
async def lock_main():
    await asyncio.gather(*(safe_increment() for _ in range(10)))
    return shared_counter
print(asyncio.run(lock_main()))
10

Part 9: asyncio.Queue - Producer-Consumer#

async def producer(q, n):
    for i in range(n):
        await q.put(i)
    await q.put(None)
async def consumer(q, results):
    while True:
        item = await q.get()
        if item is None:
            break
        results.append(item * item)
async def queue_main():
    q = asyncio.Queue()
    results = []
    await asyncio.gather(producer(q, 5), consumer(q, results))
    return results
print(asyncio.run(queue_main()))
[0, 1, 4, 9, 16]

Part 10: Common Patterns#

async def fetch_user(user_id):
    await asyncio.sleep(0.1)
    return {'id': user_id, 'name': f'User{user_id}'}
async def fetch_all_users(user_ids):
    tasks = [fetch_user(uid) for uid in user_ids]
    return await asyncio.gather(*tasks)
start = time.time()
users = asyncio.run(fetch_all_users([1, 2, 3, 4, 5]))
elapsed = time.time() - start
print(len(users))
print(elapsed < 0.3)
5
True

Wrap-Up: What You Learned#

  • asyncio runs many I/O-bound coroutines cooperatively on one thread; nest_asyncio fixes running it inside Jupyter's own loop.
  • async def defines a coroutine function; calling it creates a coroutine object, only await actually runs it.
  • asyncio.run starts the event loop and runs a top-level coroutine to completion.
  • Sequential awaits run one at a time; gather runs several coroutines concurrently.
  • create_task starts a coroutine running immediately in the background, to be awaited later.
  • asyncio.sleep yields control back to the event loop; time.sleep blocks it entirely, breaking concurrency.
  • wait_for adds a timeout to any coroutine, raising TimeoutError if it's exceeded.
  • async with asyncio.Lock protects shared state across coroutines; asyncio.Queue coordinates producers and consumers.
  • A real pattern: fetching many independent records concurrently instead of one at a time.
  • That wraps up asyncio. Next up: logging, for genuinely production-grade diagnostics.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.