How is asyncio different from multiprocessing and multithreading?
Execution model
- Multithreading: Multiple threads share the same memory space, but only one thread runs at a time in Python (because of the GIL). It is good for I/O-bound tasks.
- Multiprocessing: Multiple processes, each with its own memory and Python interpreter. It gives true parallelism. It is good for CPU-bound tasks.
- Asyncio: Single thread, single process. It uses an event loop to run many tasks concurrently by switching between them when one task is waiting (for example, waiting for network or file I/O). It is best for I/O-bound workloads along with high-concurrency workloads.
Memory and overhead
- Threads: Light, but still need stack memory.
- Processes: Heavy, separate memory, higher overhead.
- Coroutines (asyncio): Ultra-light. Thousands of coroutines can run inside one thread because they do not need OS threads.
Rule of thumb
- If the need is parallelism, use multiprocessing (for example, image/video processing).
- If the need is to handle blocking I/O with limited concurrency, use threads ****.
- If the need is to handle huge concurrency with I/O, use asyncio (for example, chat servers, web scraping, APIs).
Difference between Threads and Coroutines
Scheduling
- Thread: Scheduling is done by the operating system, so the context switches are expensive.
- Coroutine: Scheduling is done by the event loop (Python-level), so the switching is cheap because it is just jumping between Python functions.
Blocking vs Non-blocking
-
Thread: A thread can block (for example,
time.sleep(5)
blocks it). -
Coroutine: A coroutine must use
await asyncio.sleep(5)
which pauses only that coroutine, not the whole thread.
Parallelism
- Threads: (in Python) No true parallel execution of Python code due to GIL.
-
Coroutines: No parallel execution. Just cooperative multitasking. Each coroutine yields control explicitly with
await
.
Coroutine
It is a special kind of function that can pause its execution and later resume from the same point. It is Python’s way of expressing cooperative concurrency, the code that can yield control so other tasks can run without using extra threads or processes.
A coroutine is created by using the async def
keyword. For example:
async def my_coroutine():
await asyncio.sleep(1)
return "done"
-
async def
tells Python that this function does not execute like a normal function. - Calling it does not run it immediately.
- Instead, it returns a coroutine object, which is a promise of work that can be scheduled.
Example:
cor = my_coroutine()
print(cor) # <coroutine object my_coroutine at 0x...>
At this point, nothing has happened yet.
Running a Coroutine
A coroutine does not execute immediately when called; it must be attached to an event loop to run. There are three common ways to do that:
1. Await inside another coroutine
When operating within an asynchronous function, one can directly await another coroutine. This pauses the current coroutine until the awaited one completes.
Example:
import asyncio
async def greet():
print("Hello...")
await asyncio.sleep(1)
print("...World!")
return "Done!"
async def main():
result = await greet()
print(result)
asyncio.run(main())
Output:
Hello...
...World!
Done!
Flow:
- The event loop starts via
asyncio.run(main())
. -
main()
begins and awaitsgreet()
. - Control passes to
greet()
, which pauses for 1 second atawait asyncio.sleep(1)
. - After finishing,
greet()
returns"Done!"
, andmain()
resumes to print it.
Use when: Already inside another coroutine and want sequential execution.
2. Top-level entry with asyncio.run()
When running from normal synchronous code (like a main
block), use asyncio.run()
.
It creates a new event loop, runs the coroutine to completion, and then closes the loop.
Example:
import asyncio
async def greet():
print("Starting task...")
await asyncio.sleep(1)
print("Task finished!")
asyncio.run(greet())
Output:
Starting task...
Task finished!
Flow:
-
asyncio.run()
creates an event loop. - The loop executes
greet()
, pausing for 1 second atawait asyncio.sleep(1)
. - Once done, it prints “Task finished!” and the loop closes automatically.
Use when: Need to run async code at the top level (for example, in a script).
3. Schedule concurrent execution with asyncio.create_task()
asyncio.create_task()
schedules coroutines to run concurrently within an existing event loop. It returns a Task
object that starts running immediately in the background.
Example:
import asyncio
async def task_one():
await asyncio.sleep(2)
print("Task One Complete")
async def task_two():
await asyncio.sleep(1)
print("Task Two Complete")
async def main():
t1 = asyncio.create_task(task_one())
t2 = asyncio.create_task(task_two())
print("Both tasks started...")
await t1
await t2
print("All tasks done!")
asyncio.run(main())
Output:
Both tasks started...
Task Two Complete
Task One Complete
All tasks done!
Flow:
- Both coroutines are scheduled and start running in parallel.
-
task_two
completes first (after 1s), followed bytask_one
(after 2s). - Once both are done, the main coroutine prints “All tasks done!”.
Use when: Want to run multiple coroutines concurrently on the same event loop.
Note: The event loop drives these coroutines forward, suspending them whenever an
await
is reached and resuming when the awaited operation is complete.
Coroutine vs normal function
Normal Function (def ) |
Coroutine (async def ) |
---|---|
Runs top to bottom at once | Can pause at await and resume later |
Returns a value directly | Returns a coroutine object (must be awaited) |
Blocking | Non-blocking (gives control back to the event loop) |
Event Loop, Tasks, and Futures
Event loop
The event loop is the central scheduler for asyncio. It runs in a single thread and repeatedly does three jobs:
- Monitor OS I/O events (sockets, pipes, etc.).
- Run ready callbacks.
- Resume coroutines whose awaited operations have completed.
Starting a loop the modern way
The easiest entry point is asyncio.run()
:
import asyncio
async def main():
print("Hello from asyncio!")
asyncio.run(main())
Output:
Hello from asyncio!
Note:
asyncio.run()
creates an event loop, runs the coroutine until it finishes, and closes the loop.
Important properties
- An event loop is like a scheduler that keeps track of all asyncio tasks. Only one loop can run inside a given thread. Normally, the main thread hosts one loop. If another thread is created, it can have its own loop if explicitly started. Most asyncio programs just use a single loop in the main thread.
-
asyncio.run()
is the recommended top-level entry point; it creates a new loop, runs the coroutine until completion, and then closes the loop. So, there is no need for manual loop handling. -
Sometimes, more control is required (for example, libraries or advanced servers), then the event loop object can be accessed and managed directly. Its key methods are:
-
new_event_loop()
: It creates a new event loop. -
set_event_loop(loop)
: It assigns a specific event loop object as the current loop for the running thread. -
create_task()
: It schedules a coroutine to run concurrently as a Task on the current event loop and returns a Task object immediately (schedule + run and returns Task object). -
loop.run_forever()
: It starts the loop and keeps it alive untilloop.stop()
is called. -
loop.run_until_complete(future)
: It runs the loop until a specific coroutine/future is complete. -
loop.stop()
: It requests the loop to exit. - Example:
import asyncio async def say_hi(): await asyncio.sleep(1) print("Hi!") loop.stop() # stop the loop after task completes loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.create_task(say_hi()) # schedule a coroutine loop.run_forever() # manually keep running loop.close() # Output: # Hi!
Note: The
loop.run_forever()
call blocks the main thread while the loop keeps spinning. It does not return untilloop.stop()
is triggered from inside the loop. Whenloop.stop()
is finally called bysay_hi()
:- The loop finishes the current iteration.
-
run_forever()
returns control to the main code. - The next Python statement (
loop.close()
) executes.
-
Inside a coroutine, the active loop can be retrieved with
asyncio.get_running_loop()
. This is safer than the olderasyncio.get_event_loop()
, because it ensures a loop is already running.-
The loop can also schedule normal functions to run later. Some common helpers for this are:
-
loop.call_soon(callback, *args)
: It runs callback as soon as possible on the next loop iteration. -
loop.call_later(delay, callback, *args)
: It runs callback after delay seconds. -
loop.time()
: It returns the loop’s internal monotonic clock (useful for scheduling). -
loop.create_task(coroutine)
orasyncio.create_task(coroutine)
: It converts a coroutine into a running Task. - Example:
import asyncio def plain_callback(msg): print("Callback says:", msg) async def main(): loop = asyncio.get_running_loop() loop.call_soon(plain_callback, "soon") loop.call_later(1, plain_callback, "after 1 second") await asyncio.sleep(1.5) # keep loop alive long enough asyncio.run(main())
Output:
Callback says: soon Callback says: after 1 second
-
Tasks
A Task is a wrapper that schedules a coroutine to run on the event loop. Creating a Task immediately schedules execution (it does not need an explicit call to "start"). Tasks are awaitable (awaiting a task yields its return value).
Coroutine vs Task
A coroutine is just the recipe (the object returned by calling an async def
function). But a task is a wrapper that tells the event loop to start running that coroutine in the background.
Example:
coroutine = my_coroutine() # just a coroutine object
task = asyncio.create_task(coroutine) # now scheduled to run
task
starts immediately on the event loop.
await task
waits for the result.
Creating Tasks
Coroutines by themselves are not scheduled. Coroutines are wrapped in Tasks so that they can run. For example:
import asyncio
async def say_after(delay, msg):
await asyncio.sleep(delay)
print(msg)
async def main():
t1 = asyncio.create_task(say_after(1, "One"))
t2 = asyncio.create_task(say_after(2, "Two"))
await t1
await t2
asyncio.run(main())
Output:
One
Two
Important properties
- Tasks are instances of
asyncio.Task
, which is a subclass ofasyncio.Future
. - Tasks can be created with
asyncio.create_task(coroutine)
(preferred inside a running loop) orloop.create_task(coroutine)
. It can also be created usingasyncio.ensure_future(coroutine_or_future)
, which accepts either a coroutine or a Future and ensures a Task if needed. - The Task runs until the coroutine yields (via
await
) or completes.
Important functions
-
task.cancel()
: It requests cancellation. Awaiting a cancelled task raisesasyncio.CancelledError
. -
task.done()
: It returnsTrue
if the task is complete. -
task.cancelled()
: It returnsTrue
if the task is cancelled. -
task.result()
: It returns the value or raises the original exception (if the task finished with an exception). It should be called only iftask.done()
isTrue
. -
task.exception()
: It returns the exception instance (if any). -
task.add_done_callback(cb)
: It registers a callback called with the task as a single argument when completed.
Example:
import asyncio
async def worker(name, delay):
await asyncio.sleep(delay)
return f"{name} done"
async def main():
t1 = asyncio.create_task(worker("A", 1))
t2 = asyncio.create_task(worker("B", 2))
def on_done(t):
try:
print("Callback result:", t.result())
except Exception as e:
print("Callback exception:", e)
t1.add_done_callback(on_done)
# Cancel t2 before it finishes
await asyncio.sleep(0.5)
t2.cancel()
try:
await t2
except asyncio.CancelledError:
print("t2 was cancelled")
res1 = await t1
print("res1:", res1)
asyncio.run(main())
Output:
t2 was cancelled
Callback result: A done
res1: A done
Flow:
- Program starts:
asyncio.run(main())
creates an event loop and starts running themain()
coroutine. - Inside
main()
: Two coroutines (worker("A", 1)
andworker("B", 2)
) are created and immediately scheduled usingasyncio.create_task()
.-
t1
starts working onworker("A", 1)
-
t2
starts working onworker("B", 2)
- Both are now running concurrently inside the event loop.
-
- Callback added: The
on_done()
callback is attached tot1
. This means, whent1
finishes, the callback will automatically be called and print the result. - Pause for 0.5 seconds: The line
await asyncio.sleep(0.5)
pauses themain()
coroutine for half a second.- During this time, both
t1
andt2
get to run. - They both enter their own
await asyncio.sleep(delay)
calls (1s
and2s
respectively). - So, both are currently “sleeping” asynchronously (waiting).
- During this time, both
- Cancel task t2: After 0.5 seconds,
main()
wakes up and callst2.cancel()
.- Task
t2
is cancelled while it’s still sleeping for 2 seconds. - Its coroutine (
worker("B", 2)
) receives aCancelledError
.
- Task
- Handle cancellation:
await t2
tries to wait fort2
to finish, but since it was cancelled, it raises aCancelledError
.- The
try-except
block catches this and prints:t2 was cancelled
.
- The
- Wait for
t1
to finish: By now, around 0.5 seconds have passed.-
t1
is still sleeping but will complete soon (after a total of 1 second from start). - When
t1
finishes:- The
on_done()
callback is triggered first, it prints:Callback result: A done
- Then,
res1 = await t1
receives the result"A done"
and prints:res1: A done
- The
-
- End of program: After both tasks are done (or cancelled),
main()
finishes.asyncio.run()
closes the event loop.
Futures
A Future is a promise of a value that will be available later. The asyncio.Future
is a low-level building block used by asyncio. Normally, high-level code uses await on coroutines or tasks. But sometimes, custom code needs a raw Future to signal that some event or computation finished.
A Future:
- Starts as pending (no result yet).
- Later becomes done with either:
- a result (success), or
- an exception (failure).
Creating a Future
Inside a running event loop:
loop = asyncio.get_running_loop()
future = loop.create_future()
(or asyncio.Future()
, but using the loop is safer).
At this point, the future is empty. If a coroutine awaits it, it will pause until someone sets its result.
Completing a Future
The result can be supplied later:
future.set_result(value) # Finish successfully
future.set_exception(error) # Finish with an error
Once a result or exception is set:
- Any coroutine waiting with
await future
resumes immediately. - The result can be retrieved with
future.result()
. - If an exception was set,
future.result()
raises that exception.
Important functions
-
future.set_result(value)
: Marks the future as finished successfully and storesvalue
as the result. -
future.set_exception(exc)
: Marks the future as finished with an error and storesexc
as the exception. -
future.result()
: Retrieves the stored value if the future has completed successfully.- Raises an exception if the future finished with an error.
- Raises
InvalidStateError
if the future is not done yet.
-
future.exception()
: Returns the exception object if the future finished with an error, orNone
if it succeeded. -
future.add_done_callback(cb)
: Registers a functioncb(future)
that is automatically called once the future completes, whether it succeeds or fails. - Thread-safety:
set_result
andset_exception
must be called from the event loop’s own thread.- If the result must be set from another thread, use
loop.call_soon_threadsafe(future.set_result, value)
to safely schedule the update inside the loop.
- If the result must be set from another thread, use
Example (creating and awaiting a future):
import asyncio
async def waiter(future):
print("Waiting for future...")
result = await future # Pause until fut gets a result
print("Future done, result:", result)
async def main():
loop = asyncio.get_running_loop()
# Creating future
future = loop.create_future()
# After 1 second, set the result to "done"
loop.call_later(1.0, future.set_result, "done")
await waiter(future)
asyncio.run(main())
Output:
Waiting for future...
Future done, result: done
Flow:
Time →
│
│ 0.0s ─► main() starts
│ ├── future created (empty)
│ ├── call_later(1s, set_result)
│ └── waiter(future) started → "Waiting for future..."
│
│ 0.0–1.0s → event loop idle (waiting)
│
│ 1.0s ─► future.set_result("done")
│ ├── waiter resumes
│ └── prints "Future done, result: done"
│
│ 1.1s ─► main() done → event loop stops
Example (setting future result from a different thread):
import asyncio
from threading import Thread
import time
async def main():
loop = asyncio.get_running_loop()
future = loop.create_future()
def other_thread():
time.sleep(0.5)
loop.call_soon_threadsafe(future.set_result, "from thread")
Thread(target=other_thread).start()
print(await future)
asyncio.run(main())
Output:
from thread
Relationship between task, future, and awaitable
- A coroutine function returns a coroutine object (an awaitable). A coroutine object itself does nothing until scheduled.
- A Task wraps and schedules a coroutine. It is a Future (so
isinstance(task, asyncio.Future)
isTrue
). - A Future is a placeholder for a result. Tasks are Futures with an attached coroutine and scheduling behaviour.
-
asyncio.create_task(coroutine)
is the usual way to turn a coroutine into a running Task. -
asyncio.ensure_future
accepts either a coroutine or a Future and returns a Future or Task accordingly.
We have covered a lot in this article - from coroutines and tasks to futures and event loops. In the upcoming articles, we will go even deeper into asyncio, exploring:
- How cooperative multitasking compares with threads and processes
- Awaitable objects and running concurrent I/O in a single thread
- Running multiple tasks concurrently with
asyncio.gather
andcreate_task
- Async context managers (
async with
) and async iterators (async for
) - The difference between blocking and non-blocking calls
- Integrating asyncio with libraries like
aiohttp
andaiomysql
- Using async context managers effectively
- When to use
await
versusasyncio.run()
Stay tuned to unlock the full power of Python’s asynchronous programming!
Top comments (0)