Concurrency
Asynchronous Programming
Multithreading
Software Development
Async/Await

Async/Await vs Threads

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

async and threads both help a program handle multiple tasks, but they solve different problems. People often compare them as if one replaces the other, when in practice they are different concurrency tools with different tradeoffs.

The short version is that async is excellent for waiting on slow I/O without blocking a thread, while threads are useful when you must run blocking code or truly overlap work across operating system threads. Picking the wrong model usually leads to complexity, poor performance, or both.

What async and await Actually Do

An async function does not magically create a new thread. It creates work that can pause at await points and let an event loop run something else in the meantime. That makes it ideal for network calls, timers, and file or socket operations that spend most of their time waiting.

Here is a small Python example using asyncio:

python
1import asyncio
2import time
3
4async def fetch(name, delay):
5    print(f"start {name}")
6    await asyncio.sleep(delay)
7    print(f"done {name}")
8    return name
9
10async def main():
11    start = time.perf_counter()
12    results = await asyncio.gather(
13        fetch("A", 1),
14        fetch("B", 1),
15        fetch("C", 1),
16    )
17    elapsed = time.perf_counter() - start
18    print(results)
19    print(f"elapsed: {elapsed:.2f}s")
20
21asyncio.run(main())

Even though each task waits one second, the whole program finishes in about one second because the event loop switches between tasks while they are suspended.

What Threads Do

A thread is an operating system execution unit inside a process. Threads can run blocking functions without freezing the rest of the program, and in many languages they can run truly in parallel on multiple CPU cores.

In Python, threads are still useful for blocking I/O, but pure Python CPU-bound work is limited by the Global Interpreter Lock. That means Python threads are not the best default for heavy numeric computation unless native extensions release the lock.

A simple thread example:

python
1import threading
2import time
3
4def worker(name, delay):
5    print(f"start {name}")
6    time.sleep(delay)
7    print(f"done {name}")
8
9threads = [
10    threading.Thread(target=worker, args=("A", 1)),
11    threading.Thread(target=worker, args=("B", 1)),
12    threading.Thread(target=worker, args=("C", 1)),
13]
14
15for thread in threads:
16    thread.start()
17
18for thread in threads:
19    thread.join()

This overlaps the three blocking sleep calls on separate threads. The model is simple, but shared state becomes your problem.

The Real Difference

The cleanest way to think about the comparison is this:

  • 'async is cooperative. Tasks give control back when they hit await.'
  • Threads are preemptive. The scheduler can switch threads at many points.

That difference affects how you write code. Async code works well when your dependencies are already non-blocking and the whole call path is designed for async. Threads work better when you must call blocking libraries, old SDKs, or APIs that do not expose async interfaces.

Async programs often scale to many concurrent I/O operations with less memory overhead than one thread per task. Threads are easier to mix into older code but usually need locks, queues, or other coordination primitives when data is shared.

How to Choose

Use async when:

  • Most of the time is spent waiting on I/O.
  • Your framework already supports async, such as asyncio, FastAPI, or Node.js.
  • You want thousands of lightweight concurrent tasks.

Use threads when:

  • You must call blocking code.
  • A library is not async-aware.
  • You need background workers that should not block the main thread.

In Python specifically, use processes rather than threads for pure CPU-bound parallelism if the code does not release the GIL.

Mixing Both Models

You do not always have to choose only one. A common pattern is running an async application that offloads blocking calls to a thread pool:

python
1import asyncio
2import time
3
4def blocking_task():
5    time.sleep(2)
6    return "finished"
7
8async def main():
9    result = await asyncio.to_thread(blocking_task)
10    print(result)
11
12asyncio.run(main())

This lets the event loop stay responsive while legacy blocking code runs on a worker thread.

Common Pitfalls

The biggest mistake is assuming async means parallel CPU execution. It usually does not. It gives concurrency for waiting tasks, not automatic speedups for heavy computation.

Another common mistake is calling blocking functions inside async code. If an async endpoint uses time.sleep instead of await asyncio.sleep, the whole event loop can stall.

With threads, the classic problem is shared mutable state. Race conditions appear when two threads update the same value without proper synchronization. Even if the code works in a quick test, timing bugs often show up later in production.

Finally, teams sometimes choose async because it sounds modern, then discover their database driver, cloud SDK, or internal library stack is mostly blocking. In that case, threads may be the more pragmatic choice.

Summary

  • 'async is best for non-blocking I/O and high-concurrency waiting tasks.'
  • Threads are useful for blocking APIs and, in many languages, true parallel execution.
  • 'async does not create new threads by itself.'
  • Python threads are not the best default for pure CPU-bound work because of the GIL.
  • You can mix async and threads when an async app needs to call blocking code safely.

Course illustration
Course illustration

All Rights Reserved.