DEV Community

Cover image for The Async Iterator: When Regular Loops Block the Event Loop
Aaron Rose
Aaron Rose

Posted on

The Async Iterator: When Regular Loops Block the Event Loop

Timothy stared at his screen, puzzled. His async web server was working beautifully for most requests, but whenever someone requested the log file analysis endpoint, the entire server seemed to freeze for several seconds.

"Margaret, I don't understand," he said, showing her the code. "I'm using async/await everywhere, but this one endpoint still blocks everything."

Margaret walked over and looked at his screen.

import asyncio
from pathlib import Path

async def analyze_logs():
    """Analyze a large log file"""
    log_file = Path("server.log")  # 1GB file
    error_count = 0

    print("Starting log analysis...")

    # Read and process the file
    with open(log_file, 'r') as f:
        for line in f:  # Regular for loop
            if 'ERROR' in line:
                error_count += 1

    print(f"Found {error_count} errors")
    return error_count

async def handle_request(request_id):
    """Simulate concurrent requests"""
    print(f"[{request_id}] Request started")
    await asyncio.sleep(0.1)
    print(f"[{request_id}] Request completed")

async def main():
    # Start several requests concurrently
    await asyncio.gather(
        analyze_logs(),
        handle_request("req_1"),
        handle_request("req_2"),
        handle_request("req_3"),
    )

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

"I see the problem," Margaret said with a knowing smile. "You're using a regular for loop to read the file. That blocks the entire event loop."

The Blocking Problem

"But I'm inside an async function," Timothy protested. "Doesn't that make it non-blocking?"

"Common misconception," Margaret said. "Let me show you what's happening."

She drew a diagram:

Event Loop Timeline:

Without blocking (ideal):
Task analyze_logs: [read line]──[read line]──[read line]──...
Task req_1:        [start]──────────[await]──────────[complete]
Task req_2:                    [start]──────[await]──────[complete]
Task req_3:                              [start]──[await]──[complete]

With blocking (actual):
Task analyze_logs: [BLOCKS FOR 5 SECONDS reading entire file]
Task req_1:                                                    [start][await][complete]
Task req_2:                                                                [start][await][complete]
Task req_3:                                                                         [start][await][complete]

The for loop never yields control back to the event loop!
Enter fullscreen mode Exit fullscreen mode

"Just because you put code inside an async def function doesn't make it automatically non-blocking," Margaret explained. "You need to explicitly use await to yield control. A regular for loop never awaits anything, so it holds the event loop hostage."

She typed a demonstration:

import asyncio
import time

async def blocking_loop():
    """This blocks even though it's async"""
    print("Starting blocking loop")

    # This never yields control
    for i in range(5):
        time.sleep(1)  # Blocks!
        print(f"  Blocking iteration {i}")

    print("Blocking loop done")

async def other_task(name):
    """Another task that wants to run"""
    print(f"[{name}] Started")
    await asyncio.sleep(0.5)
    print(f"[{name}] Finished")

async def main():
    start = time.time()

    await asyncio.gather(
        blocking_loop(),
        other_task("Task 1"),
        other_task("Task 2"),
    )

    print(f"\nTotal time: {time.time() - start:.1f}s")

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Timothy ran it:

Starting blocking loop
  Blocking iteration 0
  Blocking iteration 1
  Blocking iteration 2
  Blocking iteration 3
  Blocking iteration 4
Blocking loop done
[Task 1] Started
[Task 1] Finished
[Task 2] Started
[Task 2] Finished

Total time: 5.5s
Enter fullscreen mode Exit fullscreen mode

"The other tasks didn't start until the blocking loop finished!" Timothy exclaimed. "They're supposed to run concurrently!"

"The issue isn't the for loop itself," Margaret clarified. "It's the time.sleep(1) inside it. That's a synchronous blocking operation—it doesn't yield control to the event loop. The same would happen with regular file I/O, network calls without await, or CPU-intensive computation. The for loop just keeps executing these blocking operations one after another without ever giving other tasks a chance to run."

"If you absolutely must run blocking code that doesn't have an async version," Margaret added, "you can use asyncio.to_thread() to run it in a separate thread so it doesn't freeze the event loop. But that's a workaround—whenever possible, use truly async operations like async generators for I/O."

She showed a quick example:

import asyncio

def blocking_work():
    """Unavoidable blocking operation"""
    import time
    time.sleep(2)
    return "Done"

async def workaround_example():
    # Run blocking code in thread pool
    result = await asyncio.to_thread(blocking_work)
    print(result)

# This keeps the event loop responsive
Enter fullscreen mode Exit fullscreen mode

"But for file reading and other I/O, async generators are the proper solution—cleaner, more efficient, and more scalable."

Enter async for

"So how do I fix my log analyzer?" Timothy asked.

"You need async for," Margaret said. "It's like a regular for loop, but it cooperates with the event loop."

She typed:

import asyncio

async def async_number_generator():
    """An async generator that yields numbers"""
    for i in range(5):
        print(f"  Generating {i}")
        await asyncio.sleep(0.5)  # Simulate async work
        yield i

async def process_numbers():
    """Process numbers using async for"""
    print("Starting async iteration")

    async for num in async_number_generator():
        print(f"  Received {num}")
        # The event loop can switch to other tasks here!

    print("Async iteration done")

async def other_task(name):
    """Another concurrent task"""
    for i in range(3):
        print(f"[{name}] Working {i}")
        await asyncio.sleep(0.7)

async def main():
    await asyncio.gather(
        process_numbers(),
        other_task("Background"),
    )

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Timothy ran it:

Starting async iteration
  Generating 0
[Background] Working 0
  Received 0
  Generating 1
  Received 1
[Background] Working 1
  Generating 2
  Received 2
  Generating 3
[Background] Working 2
  Received 3
  Generating 4
  Received 4
Async iteration done
Enter fullscreen mode Exit fullscreen mode

"They're interleaved!" Timothy said. "The tasks are actually running concurrently now."

"Right. Every time async for gets a value, it gives other tasks a chance to run. The await inside the generator yields control to the event loop."

Creating Async Generators

Margaret opened a new file. "Let me show you how to create async generators. It's simpler than you might think."

import asyncio

# Regular generator (synchronous)
def regular_generator():
    """Regular generator with yield"""
    for i in range(3):
        yield i

# Async generator
async def async_generator():
    """Async generator with yield"""
    for i in range(3):
        await asyncio.sleep(0.1)  # Can await!
        yield i

async def demo():
    # Regular for loop with regular generator
    print("Regular generator:")
    for value in regular_generator():
        print(f"  {value}")

    # Async for loop with async generator
    print("\nAsync generator:")
    async for value in async_generator():
        print(f"  {value}")

asyncio.run(demo())
Enter fullscreen mode Exit fullscreen mode

Output:

Regular generator:
  0
  1
  2

Async generator:
  0
  1
  2
Enter fullscreen mode Exit fullscreen mode

"The syntax is almost identical," Margaret pointed out. "The key differences:"

She wrote:

Regular Generator vs Async Generator:

Regular Generator:
- def function_name():
- yield values
- Used with: for item in generator():
- Cannot use await inside

Async Generator:
- async def function_name():
- yield values
- Used with: async for item in generator():
- CAN use await inside
- Each iteration can be async
Enter fullscreen mode Exit fullscreen mode

Practical Example: Streaming File Reader

"Now let's fix your log analyzer," Margaret said. She typed:

import asyncio
from pathlib import Path

async def read_lines_async(filepath):
    """Read a file line by line asynchronously"""
    # Simulate reading from a file
    # In real code, you'd use aiofiles or similar
    lines = [
        "INFO: Server started",
        "ERROR: Connection failed",
        "INFO: Request processed",
        "ERROR: Database timeout",
        "INFO: Cache hit",
        "ERROR: Invalid input",
    ]

    for line in lines:
        # Yield control to event loop periodically
        await asyncio.sleep(0.01)  # Simulate I/O delay
        yield line

async def analyze_logs_async():
    """Analyze logs without blocking"""
    print("Starting async log analysis...")
    error_count = 0
    line_count = 0

    async for line in read_lines_async("server.log"):
        line_count += 1
        if 'ERROR' in line:
            error_count += 1
            print(f"  Found error: {line.strip()}")

    print(f"Analyzed {line_count} lines, found {error_count} errors")
    return error_count

async def handle_request(request_id):
    """Concurrent requests that won't be blocked"""
    print(f"[{request_id}] Request started")
    await asyncio.sleep(0.02)
    print(f"[{request_id}] Request completed")

async def main():
    # Now these actually run concurrently!
    await asyncio.gather(
        analyze_logs_async(),
        handle_request("req_1"),
        handle_request("req_2"),
        handle_request("req_3"),
    )

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Output:

Starting async log analysis...
[req_1] Request started
  Found error: ERROR: Connection failed
[req_2] Request started
[req_1] Request completed
  Found error: ERROR: Database timeout
[req_3] Request started
[req_2] Request completed
  Found error: ERROR: Invalid input
[req_3] Request completed
Analyzed 6 lines, found 3 errors
Enter fullscreen mode Exit fullscreen mode

"Perfect!" Timothy said. "The requests complete while the log analysis is still running."

"This is a simulation," Margaret clarified. "In real production code, you'd use a library like aiofiles because Python's built-in open() is a synchronous system call—it blocks the entire event loop when reading from disk."

She pulled up a real example:

# Real async file reading with aiofiles
# pip install aiofiles

import aiofiles
import asyncio

async def read_file_truly_async(filepath):
    """Real async file reading"""
    async with aiofiles.open(filepath, 'r') as f:
        async for line in f:
            yield line.strip()

async def analyze_real_logs():
    """Analyze actual log file asynchronously"""
    error_count = 0

    async for line in read_file_truly_async("server.log"):
        if 'ERROR' in line:
            error_count += 1

    return error_count

# This ACTUALLY doesn't block the event loop
Enter fullscreen mode Exit fullscreen mode

"The key is that aiofiles.open() returns an async context manager, and iterating over the file uses async for, which yields control to the event loop between reads. Python's standard library doesn't include async file I/O, so you need external libraries for this."

"So my original code with regular open() was blocking because it's a synchronous system call," Timothy said.

"Exactly. The async for loop yields control at each iteration, allowing other tasks to make progress."

The Async Iterator Protocol

"How does async for actually work under the hood?" Timothy asked.

Margaret smiled. "Just like regular for uses the iterator protocol, async for uses the async iterator protocol."

She drew a comparison:

Regular Iterator Protocol:
┌─────────────────┐
│ __iter__()      │ → Returns iterator object
│ __next__()      │ → Returns next value or raises StopIteration
└─────────────────┘

Async Iterator Protocol:
┌─────────────────┐
│ __aiter__()     │ → Returns async iterator object
│ __anext__()     │ → Coroutine that returns next value or raises StopAsyncIteration
└─────────────────┘
Enter fullscreen mode Exit fullscreen mode

She typed an example:

import asyncio

class CountdownIterator:
    """Async iterator that counts down"""
    def __init__(self, start):
        self.current = start

    def __aiter__(self):
        """Return self as the async iterator"""
        return self

    async def __anext__(self):
        """Get the next value asynchronously"""
        if self.current <= 0:
            raise StopAsyncIteration

        await asyncio.sleep(0.1)  # Simulate async work
        self.current -= 1
        return self.current + 1

async def demo():
    print("Countdown:")
    async for num in CountdownIterator(5):
        print(f"  {num}")
    print("Done!")

asyncio.run(demo())
Enter fullscreen mode Exit fullscreen mode

Output:

Countdown:
  5
  4
  3
  2
  1
Done!
Enter fullscreen mode Exit fullscreen mode

"So __aiter__ and __anext__ are like __iter__ and __next__, but async," Timothy summarized.

"Right. And notice __anext__ is a coroutine—it uses async def and can await things."

When to Use Async Generators vs Classes

"Do I always need to write the full protocol with __aiter__ and __anext__?" Timothy asked.

"Rarely," Margaret said. "Most of the time, async generators are simpler and cleaner."

She compared:

import asyncio

# Method 1: Async Generator (Simple!)
async def countdown_generator(start):
    """Simple async generator"""
    for i in range(start, 0, -1):
        await asyncio.sleep(0.1)
        yield i

# Method 2: Full Protocol (More control)
class CountdownIterator:
    """Full async iterator with more control"""
    def __init__(self, start):
        self.current = start

    def __aiter__(self):
        return self

    async def __anext__(self):
        if self.current <= 0:
            raise StopAsyncIteration
        await asyncio.sleep(0.1)
        value = self.current
        self.current -= 1
        return value

async def demo():
    print("Generator approach:")
    async for num in countdown_generator(3):
        print(f"  {num}")

    print("\nIterator protocol approach:")
    async for num in CountdownIterator(3):
        print(f"  {num}")

asyncio.run(demo())
Enter fullscreen mode Exit fullscreen mode

Output:

Generator approach:
  3
  2
  1

Iterator protocol approach:
  3
  2
  1
Enter fullscreen mode Exit fullscreen mode

"When should I use the full protocol?" Timothy asked.

Margaret explained:

Use Async Generators when:
- Simple iteration logic
- Stateless or simple state
- Don't need multiple iterators from one object
- Example: Reading lines, processing batches

Use Full Protocol when:
- Complex state management
- Need to separate iterator from iterable
- Want multiple independent iterators
- Need special initialization/cleanup
- Example: Database cursors, connection pools
Enter fullscreen mode Exit fullscreen mode

Real-World Pattern: Batching Data

Margaret showed a practical example. "Here's a common pattern: processing data in batches."

import asyncio
from typing import List, TypeVar

T = TypeVar('T')

async def batch_iterator(items: List[T], batch_size: int):
    """Yield items in batches"""
    for i in range(0, len(items), batch_size):
        batch = items[i:i + batch_size]
        # Simulate async processing time
        await asyncio.sleep(0.1)
        yield batch

async def process_users():
    """Process users in batches"""
    users = [f"user_{i}" for i in range(10)]

    print(f"Processing {len(users)} users in batches of 3")

    async for batch in batch_iterator(users, batch_size=3):
        print(f"  Processing batch: {batch}")
        # Simulate processing
        await asyncio.sleep(0.05)

    print("All users processed")

asyncio.run(process_users())
Enter fullscreen mode Exit fullscreen mode

Output:

Processing 10 users in batches of 3
  Processing batch: ['user_0', 'user_1', 'user_2']
  Processing batch: ['user_3', 'user_4', 'user_5']
  Processing batch: ['user_6', 'user_7', 'user_8']
  Processing batch: ['user_9']
All users processed
Enter fullscreen mode Exit fullscreen mode

Async Generators Can Be Infinite

"One powerful pattern," Margaret said, "is infinite async generators."

import asyncio
from datetime import datetime

async def event_stream():
    """Infinite stream of events"""
    event_id = 0
    while True:  # Infinite!
        await asyncio.sleep(0.5)
        event_id += 1
        timestamp = datetime.now().strftime('%H:%M:%S')
        yield {
            'id': event_id,
            'timestamp': timestamp,
            'type': 'heartbeat'
        }

async def monitor_events():
    """Monitor events with a limit"""
    count = 0
    max_events = 5

    print("Monitoring event stream...")

    async for event in event_stream():
        print(f"  Event {event['id']}: {event['type']} at {event['timestamp']}")
        count += 1

        if count >= max_events:
            print("Stopping after 5 events")
            break  # Exit the async for loop

asyncio.run(monitor_events())
Enter fullscreen mode Exit fullscreen mode

Output:

Monitoring event stream...
  Event 1: heartbeat at 14:30:45
  Event 2: heartbeat at 14:30:45
  Event 3: heartbeat at 14:30:46
  Event 4: heartbeat at 14:30:46
  Event 5: heartbeat at 14:30:47
Stopping after 5 events
Enter fullscreen mode Exit fullscreen mode

"The generator never ends, but we can break out of the async for loop whenever we want," Margaret explained.

Generator Cleanup and Resource Management

"One important detail," Margaret added. "When you break out of an async for loop, Python automatically calls aclose() on the generator to clean up any resources. If you're using an async generator directly without async for, you need to close it manually."

She typed a quick example:

import asyncio

async def resource_generator():
    """Generator that needs cleanup"""
    print("Opening resource")
    try:
        for i in range(10):
            await asyncio.sleep(0.1)
            yield i
    finally:
        print("Cleaning up resource")

async def demo_cleanup():
    # With async for - automatic cleanup
    print("Using async for:")
    async for value in resource_generator():
        if value == 2:
            break  # Automatically calls aclose()

    print("\nManual usage:")
    gen = resource_generator()
    try:
        print(await gen.__anext__())
        print(await gen.__anext__())
    finally:
        await gen.aclose()  # Must close manually!

asyncio.run(demo_cleanup())
Enter fullscreen mode Exit fullscreen mode

Output:

Using async for:
Opening resource
Cleaning up resource

Manual usage:
Opening resource
0
1
Cleaning up resource
Enter fullscreen mode Exit fullscreen mode

"So async for handles cleanup automatically, but if I'm calling __anext__ directly, I need to call aclose()?" Timothy asked.

"Exactly. It's like context managers—most of the time you use the with statement and don't think about cleanup. But if you need manual control, you're responsible for cleanup."

Combining Multiple Async Iterators

"Can I iterate over multiple async sources at once?" Timothy asked.

"You can, but you need to be careful," Margaret said. She showed two approaches:

import asyncio

async def source_a():
    """First data source"""
    for i in range(3):
        await asyncio.sleep(0.2)
        yield f"A-{i}"

async def source_b():
    """Second data source"""
    for i in range(3):
        await asyncio.sleep(0.3)
        yield f"B-{i}"

# Approach 1: Sequential (one after another)
async def sequential():
    print("Sequential approach:")
    async for item in source_a():
        print(f"  {item}")
    async for item in source_b():
        print(f"  {item}")

# Approach 2: Concurrent (with gather)
async def concurrent():
    print("\nConcurrent approach:")

    async def consume_a():
        async for item in source_a():
            print(f"  {item}")

    async def consume_b():
        async for item in source_b():
            print(f"  {item}")

    await asyncio.gather(consume_a(), consume_b())

async def main():
    await sequential()
    await concurrent()

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Output:

Sequential approach:
  A-0
  A-1
  A-2
  B-0
  B-1
  B-2

Concurrent approach:
  A-0
  B-0
  A-1
  A-2
  B-1
  B-2
Enter fullscreen mode Exit fullscreen mode

"Sequential waits for each source to finish," Margaret pointed out. "Concurrent processes them in parallel."

The Key Insight: Cooperative Yielding

They were approaching the afternoon break. Margaret summarized the core concept with a final diagram:

The Core Difference:

Regular for loop:
┌─────────────────────────────┐
│ for item in generator():    │
│     # Never yields control  │ → Blocks event loop
│     process(item)           │
└─────────────────────────────┘

Async for loop:
┌──────────────────────────────┐
│ async for item in gen():     │
│     # Yields at each await   │ → Cooperates with event loop
│     await process(item)      │
└──────────────────────────────┘

The "async for" gives other tasks chances to run between iterations.
Enter fullscreen mode Exit fullscreen mode

The Takeaway

Timothy closed his laptop, understanding how async iteration enables non-blocking loops in async code.

Regular for loops block the event loop: When they contain synchronous blocking operations (I/O, sleep, CPU work).

async for enables cooperative iteration: Allows the event loop to switch between tasks during iteration.

Async generators use async def + yield: Combine async def with yield to create async generators.

Can await inside async generators: Unlike regular generators, async generators can use await.

Syntax is familiar: async for item in generator(): mirrors regular for loops.

The async iterator protocol: Uses __aiter__() and __anext__() instead of __iter__() and __next__().

anext is a coroutine: Returns an awaitable that produces the next value.

StopAsyncIteration ends iteration: Like StopIteration but for async iterators.

Async generators are usually simpler: Prefer them over full protocol implementation for most cases.

Use full protocol for complex state: When you need fine-grained control over iteration.

Perfect for streaming data: Reading large files, API responses, database results without blocking.

Python's built-in open() blocks: Use libraries like aiofiles for true async file I/O.

Async file I/O requires external libraries: Python's standard library doesn't include async file operations.

asyncio.to_thread() is a workaround: For unavoidable blocking code, but async operations are better.

Batching pattern is common: Process data in chunks asynchronously.

Infinite generators are useful: Create endless streams that consumers can break out of.

Can combine with gather for concurrency: Process multiple async iterators simultaneously.

Each iteration yields control: The event loop can switch tasks between iterations.

Break works normally: Exit async for loops with break just like regular loops.

async for handles cleanup automatically: Calls aclose() when exiting the loop.

Manual usage requires manual cleanup: Call aclose() if using __anext__ directly.

Resource management is important: Use try/finally in generators for proper cleanup.

Next time: Async comprehensions, streaming APIs, database cursors, and advanced patterns for real-world async iteration.

Understanding Async Iteration

Timothy had discovered how to iterate over data in async code without blocking the event loop.

He learned that regular for loops can block when they contain synchronous operations that never yield control, that async for solves this by cooperating with the event loop at each iteration, and that async generators combine async def with yield to create non-blocking iteration.

Margaret showed him that the async iterator protocol mirrors the regular iterator protocol but with coroutines, that async generators are usually simpler than implementing the full protocol, and that proper cleanup with aclose() is important for resource management.

Most importantly, Timothy understood that Python's built-in file operations are synchronous and require libraries like aiofiles for truly async I/O, that asyncio.to_thread() can be used as a workaround for unavoidable blocking code, but that async generators are the proper, scalable solution for I/O operations.

The library was quiet in the afternoon. As Timothy packed up, his blocking log analyzer was now a streaming async iterator, and his web server could handle requests smoothly even during long-running operations.


Next in this series: The Async Iterator: Streaming Data and Real-World Patterns


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (0)