Concurrency in Python: Gevent vs. Asyncio
by Robert Oct 26, 2019 Python concurrency tutorialIf you're writing a Python script which is meant to play a game of hangman with the user, then you probably don't need to worry about concurrency. So when it is useful? When do you need to care? There's certainly a lot of information about concurrency in Python on the web. You'll find discussions about the GIL, multithreading vs. multiprocessing, and in Python 3.4 and up, something called asyncio. There are also other packages that have allowed concurrency like gevent, tornado, and others which even work on Python 2.7.
Concurrency and Parallelism
So what is concurrency? Concurrency is when two tasks are being processed at the same time. This is related to parallelism, but distinct. In a concurrent application, the two tasks may be both ongoing and being processed in the same time frame, but they will not be physically occupying the same time. That is, they will interleave (context switch) in some way to share resources. In a parallel application, the two tasks are being executed at the same time using two different sets of resources (processor cores). These two concepts are very related and in the end it comes down to what is best for your particular use case.
If your application is I/O bound, i.e. it does a little bit of work, then goes out to the network (for example) to fetch a resource and waits before processing the new data, then it is likely you might call the application I/O bound. Another way of saying it is that the application spends most of its time waiting on something else to respond to it. In an I/O bound application, using a form of concurrency may be a big productivity boost. This is because you can have each of the items you are trying to fetch running concurrently. Since the rest of the time the application is waiting around for an answer from one of those tasks, it can spend its CPU cycles processing a task that already came back and is waiting.
A CPU bound application is something of an opposite case. In such an application, most of the work is in crunching numbers, parsing strings, or other local tasks. In these cases, it can be a performance boost to use a mechanism that allows for parallelism. In Python, these can be tricky because the threading model is not like other languages (C, Go, etc.) and while you can have multiple application level threads running at once, they (more or less) only run concurrently and not in parallel. I won't go much deeper here, but if you'd like to know more, I suggest this article from Real Python.
For the rest of the post, I'd like to get into some code examples that will illustrate how to use gevent and asyncio to accomplish concurrency in your application. These are going to be toy examples, but something to get you started. If you're looking for more, there are countless articles out there to get you started. Or let me know below :)
Without Concurrency
Before going into a concurrent application, let's take a look at what it might look like without. Here's a very simple program that does nothing but print some information and sleep:
import time def sleepy_time(sleep_for): print(f"See you in {sleep_for} seconds") time.sleep(sleep_for) print(f"Gooooooood morrrning Vietnam! (slept {sleep_for} seconds)") print(time.ctime()) sleepy_time(3) sleepy_time(5) sleepy_time(1) print(time.ctime())
Here's what the output would look like when it was run:
$ python no_concurrency.py
Sat Oct 26 16:19:20 2019
See you in 3 seconds
Gooooooood morrrning Vietnam! (slept 3 seconds)
See you in 5 seconds
Gooooooood morrrning Vietnam! (slept 5 seconds)
See you in 1 seconds
Gooooooood morrrning Vietnam! (slept 1 seconds)
Sat Oct 26 16:19:29 2019
$
This program took 9 seconds to run because it had to do 9 seconds of sleeping serially.
With asyncio
What if we wanted to do all the same work, but because we know most of the time is spent waiting around, we will do it concurrently. Here's what that might look like when using asyncio (requires Python 3.5 or newer):
import asyncio import time async def sleepy_time(sleep_for): print(f"See you in {sleep_for} seconds") await asyncio.sleep(sleep_for) print(f"Gooooooood morrrning Vietnam! (slept {sleep_for} seconds)") async def main(): print(time.ctime()) await asyncio.gather(sleepy_time(3), sleepy_time(5), sleepy_time(1)) print(time.ctime()) asyncio.run(main())
And here's the output:
$ python concurrent_asyncio.py
Sat Oct 26 16:38:43 2019
See you in 3 seconds
See you in 5 seconds
See you in 1 seconds
Gooooooood morrrning Vietnam! (slept 1 seconds)
Gooooooood morrrning Vietnam! (slept 3 seconds)
Gooooooood morrrning Vietnam! (slept 5 seconds)
Sat Oct 26 16:38:48 2019
$
Yay, we have concurency! So what happened? We used the async keyword to mark functions that should be coroutines. Coroutine objects are like a special kind of object that can be scheduled to run inside of an event loop. The asyncio package provides that event loop to our application by using asyncio.run(). Using the asyncio.gather() function lets us wait on a group of coroutines to complete. You can see that each one started in order and once they reached the await statement, they gave up control back to the loop which tried to find more meaningful work to do until eventually the sleep was over and they each finished. Our total time to execute is now down to 5 seconds whereas it was 9 seconds in the non-concurrent example.
With gevent
What if you don't care for littering your code with async and await? It's probably not as bad as you think and explicit is better than implicit. But another real concern is that you can't (easily) mix async functions with traditional sync functions. You might also need to change some code, like we did in the above example to call asycio.sleep() instead of time.sleep(). Gevent is a library that can help solve some of these concerns. Here's what our concurrent example might look like in gevent:
from gevent import monkey monkey.patch_all() import gevent import time def sleepy_time(sleep_for): print(f"See you in {sleep_for} seconds") time.sleep(sleep_for) print(f"Gooooooood morrrning Vietnam! (slept {sleep_for} seconds)") print(time.ctime()) gevent.joinall([ gevent.spawn(sleepy_time, 3), gevent.spawn(sleepy_time, 5), gevent.spawn(sleepy_time, 1), ]) print(time.ctime())
And here's the output. Notice it matches the asyncio output:
$ python concurrent_gevent.py
Sat Oct 26 16:54:25 2019
See you in 3 seconds
See you in 5 seconds
See you in 1 seconds
Gooooooood morrrning Vietnam! (slept 1 seconds)
Gooooooood morrrning Vietnam! (slept 3 seconds)
Gooooooood morrrning Vietnam! (slept 5 seconds)
Sat Oct 26 16:54:30 2019
$
The main difference between these two examples is that gevent does some magic monkeypatching to make the standard library behave in a concurrent way without needing to use async/await or the asyncio library. Gevent runs an event loop of its own and will deterministically switch between coroutines implicitly when it runs into a patched function call. You can also be explicit if you need by using gevent.sleep(0) or gevent.idle().
Speed
In case you're curios about speed and performance, both of these solutions are pretty performant (and about identical). Here's an enhanced example where we will interleave a less sleepy coroutine with the others. First the asyncio version:
import asyncio import time stop_running = False async def run_inbetween(): run_times = 0 while not stop_running: run_times += 1 await asyncio.sleep(.01) print(f"I was able to run {run_times} times") async def sleepy_time(sleep_for): print(f"See you in {sleep_for} seconds") await asyncio.sleep(sleep_for) print(f"Gooooooood morrrning Vietnam! (slept {sleep_for} seconds)") async def main(): global stop_running print(time.ctime()) asyncio.ensure_future(run_inbetween()) await asyncio.gather(sleepy_time(3), sleepy_time(5), sleepy_time(1)) stop_running = True print(time.ctime()) asyncio.run(main())
$ python concurrent_asyncio.py
Sat Oct 26 17:10:17 2019
See you in 3 seconds
See you in 5 seconds
See you in 1 seconds
Gooooooood morrrning Vietnam! (slept 1 seconds)
Gooooooood morrrning Vietnam! (slept 3 seconds)
Gooooooood morrrning Vietnam! (slept 5 seconds)
Sat Oct 26 17:10:22 2019
I was able to run 252 times
$
Here we using an external variable to control the run_inbetween() function's duration. You can see that it was getting the chance to run while the other sleepy coroutines were not doing anything else. In this run, it got 252 chances, but that can change from run to run a bit (just a few usually). Here's the gevent code for the same example:
from gevent import monkey monkey.patch_all() import gevent import time stop_running = False def run_inbetween(): run_times = 0 while not stop_running: run_times += 1 gevent.sleep(.01) print(f"I was able to run {run_times} times") def sleepy_time(sleep_for): print(f"See you in {sleep_for} seconds") time.sleep(sleep_for) print(f"Gooooooood morrrning Vietnam! (slept {sleep_for} seconds)") gevent.spawn(run_inbetween) print(time.ctime()) gevent.joinall([ gevent.spawn(sleepy_time, 3), gevent.spawn(sleepy_time, 5), gevent.spawn(sleepy_time, 1), ]) print(time.ctime()) stop_running = True gevent.wait()
$ python concurrent_gevent.py
Sat Oct 26 17:14:16 2019
See you in 3 seconds
See you in 5 seconds
See you in 1 seconds
Gooooooood morrrning Vietnam! (slept 1 seconds)
Gooooooood morrrning Vietnam! (slept 3 seconds)
Gooooooood morrrning Vietnam! (slept 5 seconds)
Sat Oct 26 17:14:21 2019
I was able to run 263 times
$
We got basically the same result. Again, it can fluctuate how many times the run_inbetween() function gets executed, but it is getting its chance as long as the other coroutines aren't busy and the event loop is able to give it control.
Conclusion
If your application needs concurrency, I can recommend either asyncio or gevent. There are advantages and disadvantages to each, but overall they are both more than capable of giving you the tools that you need. If you want to not require external dependencies, then asyncio is certainly the way to go. If you want to be a little more terse in your code or just plop something into an existing application, then gevent will be great. Now go out there and give it a try with your code.