Your First Async Program

Let’s write your first async program and see the magic of concurrent execution in action.

Basic Async Function

Start with a simple async function:

import asyncio

async def say_hello(name, delay):
    """Async function that greets someone after a delay"""
    print(f"Hello {name}, starting...")
    await asyncio.sleep(delay)  # Non-blocking sleep
    print(f"Hello {name}, finished after {delay}s!")
    return f"Greeted {name}"

# Run a single async function
async def main():
    result = await say_hello("Alice", 2)
    print(f"Result: {result}")

# Execute the async program
asyncio.run(main())

Key points:

  • async def creates an async function (coroutine)
  • await pauses execution until the operation completes
  • asyncio.run() starts the event loop and runs the main coroutine

Concurrent Execution

Now let’s see the real power - running multiple operations simultaneously:

import asyncio
import time

async def fetch_data(source, delay):
    """Simulate fetching data from different sources"""
    print(f"Fetching from {source}...")
    await asyncio.sleep(delay)  # Simulate network delay
    print(f"Got data from {source}")
    return f"Data from {source}"

async def sequential_example():
    """Sequential execution - slow"""
    print("=== Sequential Execution ===")
    start_time = time.time()
    
    result1 = await fetch_data("Database", 2)
    result2 = await fetch_data("API", 1.5)
    result3 = await fetch_data("Cache", 0.5)
    
    total_time = time.time() - start_time
    print(f"Sequential total time: {total_time:.1f}s\n")
    
    return [result1, result2, result3]

async def concurrent_example():
    """Concurrent execution - fast"""
    print("=== Concurrent Execution ===")
    start_time = time.time()
    
    # Start all operations simultaneously
    task1 = asyncio.create_task(fetch_data("Database", 2))
    task2 = asyncio.create_task(fetch_data("API", 1.5))
    task3 = asyncio.create_task(fetch_data("Cache", 0.5))
    
    # Wait for all to complete
    results = await asyncio.gather(task1, task2, task3)
    
    total_time = time.time() - start_time
    print(f"Concurrent total time: {total_time:.1f}s\n")
    
    return results

async def main():
    # Run both examples
    await sequential_example()
    await concurrent_example()

asyncio.run(main())

Output shows the difference:

  • Sequential: ~4 seconds (2 + 1.5 + 0.5)
  • Concurrent: ~2 seconds (longest operation determines total time)

Common Patterns

Pattern 1: Fire and Forget

async def background_task(name):
    """Task that runs in background"""
    await asyncio.sleep(3)
    print(f"Background task {name} completed")

async def fire_and_forget_example():
    """Start tasks without waiting for them"""
    
    # Start background tasks
    asyncio.create_task(background_task("Task-1"))
    asyncio.create_task(background_task("Task-2"))
    
    # Do other work immediately
    print("Main work starting...")
    await asyncio.sleep(1)
    print("Main work finished")
    
    # Wait a bit to see background tasks complete
    await asyncio.sleep(3)

asyncio.run(fire_and_forget_example())

Pattern 2: Timeout Handling

async def slow_operation():
    """Operation that might take too long"""
    await asyncio.sleep(5)
    return "Slow result"

async def timeout_example():
    """Handle operations with timeout"""
    try:
        # Wait maximum 2 seconds
        result = await asyncio.wait_for(slow_operation(), timeout=2.0)
        print(f"Got result: {result}")
    except asyncio.TimeoutError:
        print("Operation timed out!")

### Pattern 3: Error Handling in Concurrent Operations

```python
async def risky_operation(name, should_fail=False):
    """Operation that might fail"""
    await asyncio.sleep(1)
    if should_fail:
        raise ValueError(f"Operation {name} failed!")
    return f"Success from {name}"

async def error_handling_example():
    """Handle errors in concurrent operations"""
    tasks = [
        asyncio.create_task(risky_operation("Task-1", False)),
        asyncio.create_task(risky_operation("Task-2", True)),  # This will fail
        asyncio.create_task(risky_operation("Task-3", False))
    ]
    
    # Use return_exceptions=True to get both results and exceptions
    results = await asyncio.gather(*tasks, return_exceptions=True)
    
    for i, result in enumerate(results):
        if isinstance(result, Exception):
            print(f"Task {i+1} failed: {result}")
        else:
            print(f"Task {i+1} succeeded: {result}")

asyncio.run(error_handling_example())

Real-World Example: Web Scraper

Here’s a practical example that demonstrates async power:

import asyncio
import aiohttp
import time

async def fetch_url(session, url):
    """Fetch a single URL"""
    try:
        async with session.get(url) as response:
            content = await response.text()
            return f"Success {url}: {len(content)} characters"
    except Exception as e:
        return f"Failed {url}: Error - {str(e)}"

async def scrape_websites():
    """Scrape multiple websites concurrently"""
    urls = [
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/2", 
        "https://httpbin.org/delay/1",
        "https://httpbin.org/status/200",
        "https://httpbin.org/json"
    ]
    
    start_time = time.time()
    
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, url) for url in urls]
        results = await asyncio.gather(*tasks, return_exceptions=True)
    
    total_time = time.time() - start_time
    
    print("Scraping Results:")
    for result in results:
        print(f"  {result}")
    print(f"\nTotal time: {total_time:.1f}s")
    print(f"Average per URL: {total_time/len(urls):.1f}s")

# Run the scraper
asyncio.run(scrape_websites())

Common Mistakes and Solutions

Mistake 1: Forgetting await

# Wrong - creates coroutine but doesn't execute it
async def wrong_way():
    result = fetch_data("API", 1)  # Missing await!
    print(result)  # Prints: <coroutine object>

# Correct - actually executes the coroutine
async def right_way():
    result = await fetch_data("API", 1)  # With await
    print(result)  # Prints: "Data from API"

Mistake 2: Using time.sleep() instead of asyncio.sleep()

# Wrong - blocks the entire event loop
async def blocking_sleep():
    time.sleep(2)  # Blocks everything!

# Correct - allows other tasks to run
async def non_blocking_sleep():
    await asyncio.sleep(2)  # Other tasks can run

Mistake 3: Not handling exceptions in concurrent tasks

# Wrong - one failure stops everything
async def fragile_approach():
    results = await asyncio.gather(
        risky_operation("A"),
        risky_operation("B", should_fail=True),  # This stops everything
        risky_operation("C")
    )

# Correct - handle exceptions gracefully
async def robust_approach():
    results = await asyncio.gather(
        risky_operation("A"),
        risky_operation("B", should_fail=True),
        risky_operation("C"),
        return_exceptions=True  # Continue despite failures
    )

Debugging Async Code

Enable Debug Mode

# Add this to see detailed async debugging info
import asyncio
asyncio.run(main(), debug=True)

Check for Unawaited Coroutines

Python will warn you about coroutines that weren’t awaited:

RuntimeWarning: coroutine 'fetch_data' was never awaited

Use asyncio.current_task() for debugging

async def debug_example():
    current = asyncio.current_task()
    print(f"Current task: {current.get_name()}")
    
    all_tasks = asyncio.all_tasks()
    print(f"Total running tasks: {len(all_tasks)}")

When Async Makes Sense (And When It Doesn’t)

After writing your first async programs, you might wonder when to use these patterns: Async shines when you’re waiting for things:

  • Network requests (APIs, web scraping)
  • Database queries
  • File operations (reading/writing large files)
  • User input or external events

Skip async for:

  • CPU-intensive tasks (mathematical calculations, image processing)
  • Simple scripts that do one thing at a time
  • Legacy code where integration complexity outweighs benefits

The async functions you’ll use most:

  • asyncio.run() - Start the event loop
  • asyncio.create_task() - Schedule coroutine for execution
  • asyncio.gather() - Wait for multiple operations
  • asyncio.wait_for() - Add timeout to operations
  • await - Pause until operation completes

Next Steps

In Part 4, we’ll dive deeper into the event loop - the engine that makes all this concurrency possible. You’ll learn how it schedules tasks, handles I/O, and coordinates your async operations.