The core difference between TCP threading and async isn’t about which is "faster," but rather how they manage waiting.
Let’s see this in action. Imagine a web server that needs to fetch data from two external APIs, then combine and return the result.
Here’s a simplified look at how a threaded model might handle this:
import threading
import requests
def fetch_data(url, results, key):
try:
response = requests.get(url, timeout=5)
results[key] = response.json()
except requests.exceptions.RequestException as e:
results[key] = {"error": str(e)}
def process_request_threaded():
api1_url = "http://example.com/api1"
api2_url = "http://example.com/api2"
results = {}
thread1 = threading.Thread(target=fetch_data, args=(api1_url, results, 'data1'))
thread2 = threading.Thread(target=fetch_data, args=(api2_url, results, 'data2'))
thread1.start()
thread2.start()
thread1.join() # Wait for thread1 to finish
thread2.join() # Wait for thread2 to finish
return {"data1": results.get('data1'), "data2": results.get('data2')}
# In a real server, this would be called within a request handler
# print(process_request_threaded())
Notice thread1.join() and thread2.join(). The main thread stops and waits for each API call to complete before moving on. While thread1 is waiting for api1, the CPU core it’s running on is essentially idle, doing nothing useful. The operating system can schedule thread2 onto another core, but if you have many concurrent requests and limited cores, you quickly run out of efficient processing. Threads are like giving each task its own dedicated worker, who might just be standing around waiting for their turn.
Now, let’s look at an async model using asyncio and aiohttp:
import asyncio
import aiohttp
async def fetch_data_async(session, url, key, results):
try:
async with session.get(url, timeout=5) as response:
results[key] = await response.json()
except aiohttp.ClientError as e:
results[key] = {"error": str(e)}
async def process_request_async():
api1_url = "http://example.com/api1"
api2_url = "http://example.com/api2"
results = {}
async with aiohttp.ClientSession() as session:
task1 = asyncio.create_task(fetch_data_async(session, api1_url, 'data1', results))
task2 = asyncio.create_task(fetch_data_async(session, api2_url, 'data2', results))
await asyncio.gather(task1, task2) # Wait for both tasks to complete
return {"data1": results.get('data1'), "data2": results.get('data2')}
# To run this:
# async def main():
# data = await process_request_async()
# print(data)
# asyncio.run(main())
The key is await. When await session.get(...) is called, the fetch_data_async function yields control back to the event loop. The event loop, which is a single thread managing many concurrent operations, can then immediately switch to running task2 (or any other ready task). When api1’s response arrives, the event loop picks up fetch_data_async right where it left off. Async is like one extremely efficient manager who juggles many tasks, only ever doing something when there’s actual work to be done, and switching instantly to another task when the current one is blocked.
The problem these models solve is I/O-bound concurrency. Most applications spend a huge amount of time waiting for external resources: network requests, database queries, disk reads/writes. Traditional threading uses CPU-bound concurrency primitives (threads) to manage I/O-bound tasks, which is inefficient. Async I/O is designed specifically for I/O-bound tasks, allowing a single thread to manage thousands of concurrent operations without blocking.
The core lever you control in asyncio is the event loop. This is the central scheduler. When you await something, you’re telling the event loop, "I’m waiting for this I/O operation to complete. While I’m waiting, feel free to run any other task that’s ready." The event loop is responsible for keeping track of all these waiting tasks and resuming them when their I/O operation signals completion.
The one thing most people don’t grasp is that async/await doesn’t magically make your code run faster on a single CPU core. It makes your program more efficient by not wasting CPU cycles while waiting for I/O. A single-threaded async application can outperform a multi-threaded application on I/O-bound workloads because it avoids the overhead of thread creation, context switching, and synchronization primitives, and it uses the CPU core 100% of the time it’s not actively waiting for I/O.
The next hurdle is understanding how to properly manage shared state and avoid blocking operations within your async code.