This article was first published on:Walker AI

Why use coroutines, usually we concurrent programming in Python are implemented using multi-threaded or multi-process, for computational tasks as a result of the existence of GIL we usually use multiple processes, and for the IO type task we can let the thread by thread scheduling in performing the duty of IO release GIL, so as to realize the concurrent of the surface.

Coroutines are the “concurrency” running in a single thread. Compared with multithreading, coroutines have the advantage of eliminating the overhead of switching between multiple threads and achieving greater running efficiency. This article does not discuss the implementation mechanisms of Python coroutines, but rather shows simple examples of the most common uses of coroutines, and gives you a quick start on some of the high-performance Web frameworks based on coroutines, such as FastAPI.

1. The household chores

Let’s say I have to do three chores, namely boiling water, washing clothes, and sweeping the floor. As a programmer, I always have a very organized plan before starting work. Here are my specific tasks:

  • Put the kettle on
  • Wait for the kettle to boil
  • The washing machine puts clothes and hot water in
  • Wait for the washing machine
  • Drying clothes
  • Sweep the floor

Think of me like that industrious CPU, but I have a lot of machines can help me do these jobs, perhaps for CPU, network card like kettle, hard disk like washing machine. To analyze again, boiling water and washing clothes are the same kind of work, what we need to do is to connect the water to the kettle or put clothes into the washing machine and then turn on the switch, the specific details are done by the machine for us. Sweeping is another kind of work, because there is no robot to help me do, so I need to sweep by myself.

If you think of boiling water and washing clothes as IO tasks, sweeping the floor is computationally intensive.

2. Program description

Let’s simulate the chores from the previous section:

The program housework
1 + 2 Put the kettle on
Read an addend saved on another computer over the network Wait for the kettle to boil
The sum yields the file name on disk where the multiplier is stored The washing machine puts clothes and hot water in
Reads a saved multiplier from a disk file Wait for the washing machine
Multiply the result of the summation by the multiplier Drying clothes
Compute the sum from 0 to 10000 Sweep the floor
def get_network_number() - >int:
	""" Get an integer over the network """.def get_file_number(filename: str) - >int: 
	""" Reads disk file to get an integer """.def cumulative_sum(start: int, end: int) - >int: 
	""" "Summation """ 
	sum = 0 
	for number in range(start, end): 
		sum += number 
		return sum 

def task() : 
	"" "task "" " 
	result = 1 + 2 
	network_number = get_network_number() 
	result += network_number 
	file_number = get_file_number(f"{result}.txt") 
	result *= file_number 
	sum = cumulative_sum(0.10000) task() 
Copy the code

3. Problem analysis and procedure improvement

Mother as our housekeeper to see not bottom go to, a see I was lazy, sweep the floor and the other two work no relationship, the kettle boil water and washing machine work time can also go to sweep the floor, so he started to command the I work, such as in water to half the time arranged for me to sweep the floor, haven’t finished cleaning and arrange for me to boil water.

Under the arrangement of the housekeeper, I, as a human resource, was efficiently utilized, and it was difficult to have the opportunity to relax.

The butler acts like our operating system, resulting in the following optimized code:

from threading import Thread 

def get_network_number() - >int: 
	""" Get an integer over the network """.def get_file_number(filename: str) - >int: 
	""" Reads disk file to get an integer """.def cumulative_sum(start: int, end: int) - >int: 
	""" "Summation """ 
	sum = 0 
	for number in range(start, end): 
		sum += number 
		return sum 

def task1() :
	 ""task1""" result = 1 + 2 network_number = get_network_number() result += network_number file_number = get_file_number() result  *= file_number def task2(): """task2""" sum = cumulative_sum(0, 10000) t1 = Thread(target=task1) t2 = Thread(target=task2) t1.start() t2.start() t1.join() t2.join()Copy the code

Sweeping the floor has nothing to do with boiling water and washing clothes. It is a task that needs us to perform separately. The two tasks are concurrent, so we can arrange this task to another thread. The CPU then switches back and forth between the two threads, performing both tasks simultaneously.

One of the major drawbacks of this operating system-directed approach is that it requires frequent task switching, which wastes a lot of time.

4. Introduce coroutines

Smart people like me do not need the butler command, after boiling the kettle to open the switch, I directly picked up the broom and began to sweep the floor, no longer silly waiting, so there is the following operation logic:

Task 1 Task 2
Put the kettle on
Wait for the kettle to boil
Complete clean the floor
The washing machine puts clothes and hot water in
Wait for the washing machine
Drying clothes

This work is much better than the housekeeper command, also do not waste the time of task switching back and forth, according to their own arrangements, the following is the latest coroutine code implementation:

import asyncio 

IO task changed to coroutine
async def get_network_number() - >int:
	""" Get an integer over the network """.IO task changed to coroutine
async def get_file_number(filename) - >int:
	""" Reads disk file to get an integer """.Computationally intensive tasks do not need to be coroutines modified
def cumulative_sum(start: int, end: int) - >int: 
	""" "Summation """ 
	sum = 0 
	for number in range(start, end): 
		sum += number 
		return sum 

async def task1() : 
	"" "task 1 "" " 
	result = 1 + 2 
	network_number = await get_network_number() 
	result += network_number 
	file_number = await get_file_number(f'{result}.txt') 
	result *= file_number 

async def task2() : 
	Task 2 "" "" "" 
	sum = cumulative_sum(0.10000) 

async def main() : 
	task1 = asyncio.create_task(task1()) 
	task2 = asyncio.create_task(task2()) 
	await task1 
	await task2 
	asyncio.run(main()) 
Copy the code

One problem we find is that sweeping is a computationally intensive task, so we can’t stop working on it. The water may be burning, but we have to finish sweeping before we can go back to washing.

In order to solve this problem, you can take the initiative to stop on the way to sweep the floor, you can do the work several times, so you can go to see if there is any other work to do it. In Python coroutines we can use asyncio.sleep to make us stop what we’re doing and do something else. Here is a modification of computation-intensive tasks:

async def cumulative_sum(start: int, end: int) : 
	result = 0 
	for i in range(start, end): 
		if i % 100= =0: 
		await asyncio.sleep(1) 
		result += i 
		return result 

async def task2() : 
	Task 2 "" "" "" 
	sum = await cumulative_sum(0.10000) 
Copy the code

We check every 100 times to see if there is anything else we can do, just like when the water is boiling we can do the laundry, put the laundry in the washing machine we come back, and if there is nothing else to do we take a rest, and then when it is time to continue.

The idea is that we divide the task of sweeping the floor into several tasks.

5. Tasks and events

Through the analysis of the previous sections, we have found two important concepts from housework:

  • task
  • things

We find that tasks are made up of many sequential things, and that we are doing one thing at a time as we complete various tasks.

Looking back at the Python program, let’s see which ones correspond to everyday things.

For task1

async def task1() : 
	"" "task 1 "" " 
	result = 1 + 2 
	network_number = await get_network_number() 
	result += network_number 
	file_number = await get_file_number(f"{result}.txt") 
	result *= file_number 
Copy the code

We found these CPU things to do (ignoring network requests and CPU usage when reading files for simplicity, and also ignoring netwok_number and file_number assignments):

  1. result = 1 + 2
  2. result += network_number
  3. result *= file_number

For task2

async def task2() : 
	Task 2 "" "" "" 
	sum = await cumulative_sum(0.10000) 
Copy the code

Since the logic executed by Task2 is in cumulative_sum, we will continue to analyze the events generated by the cumulative_sum coroutine.

async def cumulative_sum(start: int, end: int) : 
	result = 0 
	for i in range(start, end): 
		if i % 100= =0: 
			await asyncio.sleep(1) 
	result += i 
	return result 
Copy the code

Consider a task as a single task, and task2 consists of a number of tasks that add up to 100 times. As we can see from the above analysis, things in life are events in Python coroutines, and await is an obvious point of event segmentation. Our program can be composed of many concurrent tasks, which contain a large number of events. The smallest unit in the actual execution of the program is these events.

6. Event loops

We complete the whole execution process according to the idea of executing events, how should it be realized?

We can create a loop that executes events. Started the cycle is nothing inside, after we create a task, the tasks there are several events, so we put the first event on the event loop in this task, so the event loop execution we put in this event, when the end of this event we will need to be performed after the event on the event loop again, So after an orderly sequence of multiple events added, the event loop completes all the events in our task, and the task is over.

Since the event loop can only execute one event at a time, when we have several tasks, events are queued up for execution.

7. Discuss details

Let’s look at the file read integer operation, normal read like this:

async def get_file_number(filename) : 
	with open(filename) as f: 
		number = int(f.read()) 
		return number 
Copy the code

We find that there is no await in the read operation, and its execution is the same as that of cumulative_sum without adding asyncio. Sleep, so the main program is in the waiting state even when doing disk IO and will not execute other events. We need to modify the disk IO operation as well. To maximize CPU resources.

This is where threads come in handy, and python provides this modification:

import asyncio 

def _read_file(fd) : 
	return fd.read() 

async def get_file_number(filename) : 
	loop = asyncio.get_event_loop() 
	with open(filename) as f: 
	number = await loop.run_in_executor(None, _read_file, f) 
	return int(number) 
Copy the code

Through the scheduling of operating system threads, we separate the disk IO operations out and give certain execution rights to other events, just like two events can seize CPU resources, the specific execution is decided by the operating system. Sleep also blocks the event loop, so use asyncio.sleep when using coroutines. Cumulative_sum can be used to replace asyncio.sleep with asyncio.to_thread. Python3.9 has a more useful asyncio.to_thread. Again, the details of coroutine use should be read carefully in python’s official documentation.

8. Where coroutines come in

Through the detailed discussion in the last section, two questions are raised:

1) Why does disk IO need thread scheduling but network IO does not?

2) Won’t frequent task switching waste CPU time after introducing thread transformation coroutine, and will it be more efficient than multithreading?

Understanding these two questions is the key to using Python coroutines in a flexible way. The following points are my own, without analyzing the source code.

  • There are synchronous and asynchronous approaches to network programming, and the asynchronous approach is IO multiplexing.
  • The file descriptor types supported by IO multiplexing depend on the operating system.
  • Switching tasks in Python coroutines relies on IO multiplexing.
  • In Windows, disk I/O does not support I/O multiplexing. If the STANDARD library is not encapsulated, you need to encapsulate it yourself.
  • If network IO is not involved in the program, the use of coroutines can not effectively reduce the overhead of task switching, but the good synchronous programming of coroutines can still be used.
  • Different programming languages implement coroutines in different ways and application scenarios.

PS: more dry technology, pay attention to the public, | xingzhe_ai 】, and walker to discuss together!