Why async/await Doesn’t Give You Concurrency – And How to Make It Work
Although Python’s async/await syntax lets you pause and resume functions, it alone doesn’t run tasks concurrently; you must explicitly schedule coroutines with tools like asyncio.create_task(), gather(), or TaskGroup, and understand when to use sequential versus concurrent execution to avoid common pitfalls.
When I first encountered Python async / await, I thought I had unlocked instant concurrency and sprinkled await everywhere, expecting a huge performance boost.
Reality hit hard: my "asynchronous" code still executed step‑by‑step. No speedup, no magic—just sequential execution with extra keywords.
The truth is that async and await only provide the ability to pause and resume . To truly run multiple tasks at the same time you need tools such as asyncio.create_task(), gather() or TaskGroup to schedule them.
First: What Is a Coroutine?
A coroutine is a special function in Python that can pause and resume .
You define it with async def.
You can pause it with await.
The event loop decides when to resume it.
Example:
import asyncio
async def greet():
print("Hello")
await asyncio.sleep(1)
print("World")Calling greet() returns a coroutine object and does nothing until you give it to the event loop: asyncio.run(greet()) You can think of a coroutine as a TV show you can pause and resume at any time, whereas a normal function is like a movie that plays straight through.
This pause‑and‑resume ability makes async programming powerful: when a coroutine is waiting (e.g., for network or disk I/O) Python can switch to another coroutine instead of staying idle.
Common Misunderstandings
Consider this naïve code:
import asyncio, time
async def fetch_data(url):
print(f"Fetching {url}")
await asyncio.sleep(2)
return f"Data from {url}"
async def main():
start = time.time()
result1 = await fetch_data("https://api1.com")
result2 = await fetch_data("https://api2.com")
result3 = await fetch_data("https://api3.com")
end = time.time()
print(f"Total time: {end - start:.2f}s")
asyncio.run(main())This takes about 6 seconds . Even though we used async and await, each call waits for the previous one to finish.
Solution: Use create_task() for Scheduling
To truly run independent tasks concurrently you must schedule them:
import asyncio, time
async def fetch_data(url):
print(f"Fetching {url}")
await asyncio.sleep(2)
return f"Data from {url}"
async def main():
start = time.time()
task1 = asyncio.create_task(fetch_data("https://api1.com"))
task2 = asyncio.create_task(fetch_data("https://api2.com"))
task3 = asyncio.create_task(fetch_data("https://api3.com"))
results = await asyncio.gather(task1, task2, task3)
end = time.time()
print(f"Total time: {end - start:.2f}s")
print("Results:", results)
asyncio.run(main())Now all tasks overlap and the program finishes in about 2 seconds , the duration of the slowest task.
Mental Model: Async ≠ Concurrent
async→ defines a coroutine that can be paused/resumed. await → tells the coroutine to pause until something completes. create_task() → actually puts multiple coroutines into the event loop simultaneously.
Analogy: await is like standing in a single‑file line, while create_task() is like buying several tickets at once and waiting together.
Alternatives to create_task()
asyncio.gather()(the simplest way to run many tasks):
results = await asyncio.gather(
fetch_data("https://api1.com"),
fetch_data("https://api2.com"),
fetch_data("https://api3.com"),
) asyncio.as_completed()(process results as they arrive):
for task in asyncio.as_completed([
fetch_data("https://api1.com"),
fetch_data("https://api2.com"),
fetch_data("https://api3.com"),
]):
result = await task
print("Got:", result) TaskGroup(Python 3.11+ for structured concurrency):
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch_data("https://api1.com"))
tg.create_task(fetch_data("https://api2.com"))
tg.create_task(fetch_data("https://api3.com"))Structured concurrency makes the code clearer and safer.
Real‑World Case: Handling API Rate Limits
Concurrency also helps control load. For an API that allows only 10 concurrent requests, you can use a semaphore:
import asyncio, aiohttp
from asyncio import Semaphore
class APIClient:
def __init__(self, max_concurrent=5):
self.sem = Semaphore(max_concurrent)
async def fetch(self, session, uid):
async with self.sem:
async with session.get(f"https://api.com/users/{uid}") as resp:
return await resp.json()
async def fetch_all(self, uids):
async with aiohttp.ClientSession() as s:
tasks = [asyncio.create_task(self.fetch(s, uid)) for uid in uids]
return await asyncio.gather(*tasks)
client = APIClient(max_concurrent=10)
results = asyncio.run(client.fetch_all(range(50)))This keeps the program fast while protecting the server.
Common Pitfalls
❌ Sequential awaiting
res1 = await op1()
res2 = await op2()✅ Concurrent scheduling
res1, res2 = await asyncio.gather(op1(), op2())❌ Creating tasks too late
t1 = asyncio.create_task(op1())
res1 = await t1✅ Create all tasks first, then await them together
t1 = asyncio.create_task(op1())
t2 = asyncio.create_task(op2())
res1, res2 = await asyncio.gather(t1, t2)When to Use Sequential vs. Concurrent
Sequential : when tasks depend on each other.
user = await fetch_user()
settings = await fetch_settings(user.id)Concurrent : when tasks are independent and I/O‑bound.
user_task = asyncio.create_task(fetch_user())
settings_task = asyncio.create_task(fetch_settings())
user, settings = await asyncio.gather(user_task, settings_task)Key Takeaways
Coroutines are functions that can pause and resume. async / await ≠ concurrency. await waits; create_task() schedules.
Use gather() or TaskGroup to combine tasks.
Control concurrency with semaphores.
For CPU‑bound work, use threads or processes instead of async.
Final Thoughts
Understanding coroutines and scheduling was a turning point for me. Once I started using create_task() (and later TaskGroup), my async code became truly concurrent, delivering dramatically better performance and clarity.
Next time you sprinkle await everywhere, ask yourself: “Do these tasks really need to run sequentially, or should I schedule them together?” In most cases, create_task() is your best friend. 🚀
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Code Mala Tang
Read source code together, write articles together, and enjoy spicy hot pot together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
