Asyncio in Python - Full Tutorial

Source

Imagine programming is a journey from point A to D. In traditional synchronous programming, we travel in a straight line, stopping at each point before moving to the next. This means that if there's a delay at any point, everything pauses until we can move on. Now, asynchronous programming changes the game. It allows us to start tasks at B, C, and D, even if the task at A isn't finished yet. This is like sending out scouts to explore multiple paths at once, without waiting for the first scout to return before sending out the next one.

This way, our program can handle multiple tasks simultaneously, making it more efficient, especially when dealing with operations that have waiting times, like loading a webpage. And that's the essence of asynchronous programming: making our code more efficient by doing multiple things at once without unnecessary waiting. Now let's quickly discuss when we should use async I/O. When we build software, choosing the right concurrency model and picking between async I/O, threads, or processes is crucial for performance and efficiency. Async I/O is your choice for tasks that involve a lot of waiting, such as network requests or reading files. It excels in handling many tasks concurrently without consuming much CPU power.

This makes your application more efficient and responsive when you're waiting on a lot of different tasks. Now, threads are suited for tasks that may need to wait but also share data. They can run in parallel within the same application, making them useful for tasks that may need to wait but also involve shared data. They can run in parallel within the same application, making them useful for tasks that are I/O bound but less CPU intensive. I/O stands for input-output.

Now, for CPU-heavy tasks, processes are the way to go. Each process operates independently, maximizing CPU usage by running in parallel across multiple cores. This is ideal for intensive computations. In summary, choose asyncio for managing many waiting tasks efficiently, threads for parallel tasks that share data with minimal CPU use, and processes for maximizing performance on CPU-intensive tasks. Now that we know when to use asyncio, let's dive into the five key concepts that we need to understand.

The first concept is the event loop. In Python's asyncio, the event loop is the core that manages and distributes tasks. Think of it as a central hub with tasks circling around it, waiting for their turn to be executed. Each task takes its turn in the center, where it is either executed immediately or paused if it is waiting for something, like data from the internet. When a task awaits, it steps aside, making room for another task to run, ensuring the loop is always efficiently utilized.

Once the awaited operation is complete, the task will resume, ensuring a smooth and responsive program flow. And that's how the asynchronous I/O event loop keeps your Python program running efficiently, handling multiple tasks asynchronously. Now, just a quick pause here for anyone who is serious about becoming a software developer. If you want to be like Max, who landed a $70,000 per year job in just four months of work, consider checking out my program with Course Careers. This program teaches you the fundamentals of programming and also allows you to choose a specialization taught by an industry expert in front-end, back-end, or DevOps.

Beyond that, we even help you prepare your resume, give you tips to optimize your LinkedIn profile, and provide guidance on how to prepare for interviews. We only succeed if our students actually get jobs; that's the entire goal of the program. If this is of any interest to you, we offer a free introductory course that provides a wealth of value—no obligation, no strings attached. You can check it out for free via the link in the description.

So now that we understand what the event loop is, it's time to look at how we create one and then discuss the next important concept, which is coroutines. Whenever we start writing asynchronous code in Python, we begin by importing the `asyncio` module. This module is built into Python, so you don't need to install it. For the purpose of this video, I'll be referencing all of the features in Python version 3.11 and above.

So, if you're using an older version of Python, just make sure you update it because some things have changed in the recent versions. We begin by importing the Python module. Then, we use the command or the line `asyncio.run`, and we pass something known as a coroutine function, which will return a coroutine object. Now, `asyncio.run` is going to start our event loop, and it will do this by running a coroutine. In our case, there are two types of coroutines we're concerned with.

We have a coroutine function, which is this right here, and we have what's returned when you call a coroutine function. I know it seems a bit strange, but when you call `main` like this, when it's defined using the `async` keyword, it returns something known as a coroutine object. Now, the coroutine object is what we need to pass to `asyncio.run`. It will wait for that to finish and start the event loop for us, where it handles all of our asynchronous programming. To recap, import the module and define some asynchronous functions.

So, `async` is used, and then you write the function name out. This is known as a coroutine function. You then call the function and pass that to `asyncio.run`, which starts your event loop and allows you to run asynchronous code starting from this entry point. Now, to illustrate this a bit further, let's look at the difference between an asynchronous function (something defined with the `async` keyword) and a normal function. Watch what happens if I go here and simply call this function. Some of you may assume that it's simply going to print out "Start of main coroutine," but you'll see that this is actually not the case.

I know that my terminal is a little bit messy here, but it says "coroutine 'main' was never awaited." The reason we encounter that issue is that when we call the function, we are actually generating a coroutine object. This coroutine object needs to be awaited in order for us to obtain the result of its execution. If we want to see this even more visually, we can print out what we get when we call this main function. So, let's call it here and notice that we actually get this coroutine object.

So, when you call a function defined with the `async` keyword, it returns a coroutine object, and that coroutine object needs to be awaited in order for it to execute. This is why we use the `asyncio.run` syntax, as this will handle awaiting the coroutine and allow us to write more asynchronous code. Next, we need to look at the `await` keyword. The `await` keyword is what we use to await a coroutine, allowing it to execute and enabling us to get the result. However, we can only use this `await` keyword inside an asynchronous function or inside a coroutine.

So, let's write another coroutine and see how we would await it and how we get its result. I have included a slightly more complex example where we're actually waiting on a different coroutine to observe how that works. Notice that we have a coroutine here, and its purpose is to simulate some input/output-bound operation. This could involve going to the network to retrieve some data or trying to read a file—something that is not controlled by our program that we will be waiting on the result from. In this case, you can see that we fetch some data, then we introduce a delay, effectively sleeping for a certain number of seconds to simulate that input/output-bound operation.

We then get the data, and we return it. Now we know this is a coroutine because we've defined it as an asynchronous function. Remember that in order for a coroutine to actually be executed, it needs to be awaited. In this case, we create a task, and this task is the coroutine. At this point in time, the coroutine object is not yet being executed. The reason it's not being executed yet is that it hasn't been awaited.

What I'm trying to show you is that when you call an asynchronous function, it returns a coroutine. That coroutine needs to be awaited before it will actually start executing. So in this case, we now await the task. When we await it, it will start executing, and we'll wait for it to finish before we move on to the rest of the code in our program. So let's run the code and see what the output is. You can see it says "Start of main coroutine"; data fetched, and then it receives the results.

And it says the end of the main coroutine. Now let's clear that. Let's look at a slightly different example. So let's take this result code right here. Let me just get rid of this comment.

Let's place this at the end of this function. What we have now is: "Print start of main coroutine." We create the coroutine object, then print "end of main coroutine." Next, we await the coroutine. I want to demonstrate the difference in the results we will get. So, let's run the code and notice that we get "start of main coroutine," "end of main coroutine," followed by "fetching data," "data fetched," and finally the result.

Now, the reason we got this is because we only created the coroutine object here; we didn't yet await it. So, it wasn't until we hit this line right here that we waited for the execution of this to finish before moving on to the next line. It's really important to understand that a coroutine doesn't start executing until it's awaited or until we wrap it in something like a task, which we're going to look at later. I've made a slight variation to the last example, and you can see that we are now creating two different coroutine objects and then awaiting them. Now, I want you to pause the video and take a guess at what you think the output is going to be and how long you think it will take for this to execute.

Go ahead and pause the video. I'm going to be explaining how long you think it will take for this to execute. Go ahead, pause the video. I'm going to run the code now and explain what happens. When I run this, if we move this up here, you'll see that we get "Fetching data for ID 1," followed by "Data fetched for ID 1." We then receive the result, and then we go ahead and fetch it for ID 2.

Now, let's clear this and run it one more time. You can see that it takes two seconds to fetch the first result, and then it takes another two seconds to fetch the second result. This might seem counterintuitive because you may have guessed that when we created these two coroutine objects, they would start running concurrently. This means that it should only take a total of two seconds, and we would immediately receive both results. However, remember that a coroutine doesn't start running until it's awaited. So in this case, we are actually waiting for the first coroutine to finish.

And only once this has finished do we even start executing the second coroutine, meaning that we haven't really gained any performance benefit here. We've just created a way to wait for a task to be finished. That's all we've really learned at this point in time. Now that we understand this concept, we can move on to discussing tasks and see how we can actually speed up an operation like this and run both of these tasks or coroutines simultaneously. So now we're moving on to the next important concept, which is a task.

Now, a task is a way to schedule a coroutine to run as soon as possible and to allow us to run multiple coroutines simultaneously. The issue we saw previously was that we needed to wait for one coroutine to finish before we could start executing the next one. With a task, we don't have that issue. As soon as a coroutine is sleeping or waiting on something that's not in control of our program, we can move on and start executing another task. We're never going to execute these tasks at the exact same time; we're not using multiple CPU cores. However, if one task isn't doing something—if it's idle, blocked, or waiting on something—we can switch over and start working on another task. The whole goal here is to optimize our program's efficiency, so we're always attempting to do something. When we're waiting on something that isn't in control of our program, we switch over to another task and start working on that.

Here's a quick example that shows you how we would optimize the previous example we looked at. What we do here is use the simple `create_task` function. There are a few other ways to create tasks, which I will show you in a moment, but this is the simplest method. We set `task_one` equal to `asyncio.create_task`, and then we pass in a coroutine object. It’s a coroutine object because this is a coroutine function.

We call the function, and it returns a coroutine. In this case, we pass an ID and then a time delay. If this were running synchronously, we would have to wait for each of these tasks to complete, which would take 2 seconds plus 3 seconds plus 1 second, totaling 6 seconds for the code to execute. However, as you'll see, we can execute this code in just 3 seconds because as soon as one of the tasks is idle and we're waiting on this sleep, we can start another task. I still need to await these tasks to finish, so I await them all in sequence here and then collect all of their different results.

So let's bring up the terminal and run this code to ensure it works. Notice that it starts all three coroutines almost immediately, and then we receive all of the data back at once in about three seconds. This differs from using standard coroutines without creating a task, as we would have to wait for each one to finish before moving on to the next. To recap, when we create a task, we can initiate all the coroutines simultaneously rather than waiting for each to complete individually.

So, as a quick recap, when we create a task, we're essentially scheduling a coroutine to run as quickly as possible, and we're allowing multiple coroutines to run at the same time. As soon as one coroutine isn't doing something and is waiting on some operation, we can switch to another one and start executing that. Now, all of that is handled by the event loop; it's not something we need to manually take care of. However, if we do want to wait for one task to finish before moving to the next one, we can use the `await` syntax.

So it would be possible for me to go here and write some code like this. And now we will see if we execute the code, and we can go ahead and do that. Then we'll start the first and the second coroutine, but we won't start the third one until the first and the second ones are done. Using asynchronous programming gives us that control and allows us to synchronize our code in whatever manner we see fit. Now we move on to a quick example where I'm going to show you something known as the gather function.

Now the `gather` function is a quick way to concurrently run multiple coroutines, just like we did manually before. Instead of creating a task for every single coroutine using the `create_task` function, we can simply use `gather`, which automatically runs these concurrently for us and collects the results in a list. The way it works is that we pass multiple coroutines as arguments. These coroutines are automatically scheduled to run concurrently, so we don't need to wait for them to finish before we start executing the next one. Afterward, we will gather all of the results in a list in the order in which we provided the coroutines.

The result of this operation will be the first element in the list, the second element in the list, the third element in the list, and so on. It will wait for all of them to finish when we use the `await` keyword, which simplifies this process for us. This allows us to have all of the results in one place so we can parse through them using a `for` loop. Now, let's go ahead and run this code. You will see that it starts all three of our coroutines. We wait three seconds, and then we receive all of our different results.

Now, one thing you should know about `gather` is that it's not that great at error handling, and it's not going to automatically cancel other coroutines if one of them were to fail. The reason I'm bringing that up is that the next example I show you does actually provide some built-in error handling, which means it's typically preferred over `gather`. However, it's worth noting that if an error occurs in one of these different coroutines, it won't cancel the other coroutines. This means you could encounter some weird state in your application if you're not manually handling the different exceptions and errors that could occur. Now we're moving on to the last example in the topic of tasks, where we're discussing something relatively new known as a task group. This is a slightly more preferred way to create multiple tasks and organize them together. The reason for this is that it provides built-in error handling; if any of the tasks inside our task groups were to fail, it will automatically cancel all of the other tasks. This is typically preferable when dealing with advanced errors or larger applications where we want to be a bit more robust. Now, the `fetch_data` function has not changed at all; all we've done here is start using `asyncio.TaskGroup`. Notice that what I'm using here is the `async with` statement. This is what's known as an asynchronous context manager.

You don't need to understand that. You don't have to have seen context managers before, but what this does is give us access to this variable, `tg`. We create a task group as `tg`, and now, to create a task, we can say `tg.create_task()`, just like we did before in that first example. We can create an individual task and then add this to something like our tasks list if we care about the result of it. Once we get past this asynchronous `with` statement, all of these tasks will have already been executed. The idea is that this is a little bit cleaner.

It's automatically going to execute all of the tasks that we add inside of the task group. Once all of those tasks have finished, then this will stop blocking. When I say "stop blocking," that means we can move down to the next line of code. At this point, we can retrieve all of the different results from our tasks. Now, there are various ways to go about writing this type of code, but the idea is that you simply create a task here.

As soon as it's created, inside of the task group, we now need to wait for that and all the other tasks to finish before we unblock from this block of code. Once they're all finished, we move on to the next lines of code. Similarly to any other tasks we've looked at before, these will all run concurrently, meaning if one task is sleeping, we can start another task and work on something else. So those are tasks.

Obviously, there's a lot more you can do here, but understand that you run tasks when you want to execute code concurrently, and you want multiple different operations to be happening at the same time. So now we're moving on to the fourth important concept, which is a Future. It's worth noting that a Future is not something that you are expected to write on your own; it's typically utilized in lower-level libraries. However, it's good to be familiar with the concept in case you encounter it in asynchronous programming. I'll go through this fairly quickly, but essentially, a Future is a promise of a future result.

So, all it's saying is that a result is to come in the future. You don't know exactly when that's going to be. That's all 'future' is. In this case, you can see that we actually create a future and await its value. What we do is utilize the event loop.

You don't need to do this. You'll probably never write this type of code. We create our own future. We then have a new task that we create using `asyncio`, and you can see the task is set to have a future result inside here. We wait for two seconds.

So, this is a blocking operation, and then we set the result of the future and wait for two seconds. After that, we print out the result. Here, we await the future, and then we print the result. Now, notice that we didn't actually await the task to finish.

We awaited the future object. Inside the task, we set the value of the future and awaited it, which means that as soon as we get the value of the future, the task may or may not actually be complete. This is slightly different from using a task. When we use a future, we're just waiting for some value to be available. We're not waiting for an entire task or coroutine to finish. I really want to show you this without getting into too many details.

That's a future, really just a promise of an eventual result. So now we're moving on and talking about synchronization primitives. These are tools that allow us to synchronize the execution of various coroutines, especially when we have larger, more complicated programs. Let's look at this example to understand how we use the first synchronization tool, which is a lock. Let's say that we have some shared resource.

Maybe this is a database, maybe it's a table, maybe it's a file. It doesn't matter what it is, but the idea is that it might take a fair amount of time for us to actually modify or perform some operations on this shared resource. We want to ensure that no two coroutines are working on it at the same time. The reason for this is that if two coroutines, for example, were modifying the same file or writing to the database, we could encounter errors such as a mutated state or other unexpected results. This occurs because different operations might be happening simultaneously, and we really want to ensure that one entire operation finishes before the next one begins. While this might seem a little confusing, the core idea is that we have a resource we want to lock and permit access from only one coroutine at a time.

So what we can do for that is create a lock. Now, when we create a lock, we have the ability to acquire it, and we do that with this code right here, which is `async with lock`. Now, this again is an asynchronous context manager. What this will do is check if any other coroutine is currently using the lock. If it is, it will wait until that coroutine is finished. If it's not, it will enter this block of code.

Now, the idea is that whatever we put inside this context manager needs to finish executing before the lock is released. This means we can perform some critical modifications without interruption. We can have code here that we know will complete all at once before we move on to a different task or coroutine. The reason this is important is that if we have something like an `await`, perhaps we're awaiting a network operation to save something else that could trigger another task to start running. In this case, we're stating that, within this lock, we need to wait for all of this to finish before we release the lock. This means that even though another task could potentially be executing when the sleep occurs, it can't start executing this critical part of the code until everything is finished and the lock is released. So, the purpose of the lock is to synchronize our different coroutines, ensuring that they cannot use or execute this block of code while another coroutine is executing it.

That's all it's doing. It's locking off access to, in this case, a critical resource that we only want to be accessed one at a time. So, in this instance, you can see that we create five different instances of this coroutine. We then access the lock, and once we reach this point, we will release it.

So, if we bring up the terminal here and start executing this, you'll see that we have the resource before modification and the resource after modification: before, after, before, after. The idea is that even though we've executed these coroutines concurrently, we're gating them off and locking their access to this resource so that only one can access it at a time. Moving on, the next synchronization primitive to cover is known as the semaphore. A semaphore works very similarly to a lock; however, it allows multiple coroutines to have access to the same object at the same time.

But we can decide how many we want that to be. So, in this case, we create a semaphore and give it a limit of two. That means that only two coroutines can access some resource at the exact same time. The reason we would do that is to make sure that we throttle our program and don't overload any particular resource. It's possible that we are going to send a bunch of different network requests. We can handle a few of them at the same time, but we can't send maybe a thousand or ten thousand requests simultaneously.

So, in that case, we would create a semaphore. We'd say, "Okay, our limit is maybe five at a time." This way, we have the event loop automatically handle this. We throttle our code intentionally to only send a maximum of five requests at a time. Anyway, let's bring up our terminal here and run this code.

So, run `python3 semaphore.py`, and you can see that we can access the resource about two at a time and modify it, but we can't have any more than that. Now, moving on to the last primitive we're going to discuss, this is the event. The event is something that's a bit more basic and allows us to perform simpler synchronization. In this case, we can create an event, and we can await the event to be set, as well as set the event.

This acts as a simple Boolean flag, and it allows us to block other areas of our code until we've set this flag to true. So it's really just like setting a variable to true or false. In this case, it's just being done in an asynchronous way. As you can see, we have a setter function that might take two seconds to set some result.

We then set the result. As soon as that result has been set, we can come up here and await it. We wait for this to finish, and then we can go ahead and print the event that has been set and continue execution. So we can bring this up here and quickly take a look at it.

So, Python 3—if we spell that correctly—is event.py. You'll see it says, "Awaiting the event to be set." Then it states, "Event has been set; continuing the execution." Okay, pretty straightforward! It's just a boolean flag that allows us to wait at certain points in our program. There are many different situations when you would want to use this, but I just wanted to quickly show you that we do have something like this that exists.

Now, there is another type of primitive here that’s a bit more complicated—the condition. I’m not going to get into that in this video. In fact, I’m going to leave the video here. If you guys enjoyed this, make sure you leave a like, subscribe to the channel, and consider checking out my premium software development course with Course Careers. If you enjoy this teaching style and you’re serious about becoming a developer, this could be a great opportunity for you.

Anyways, I will see you guys in another YouTube video.