├── .gitignore ├── cpu_bound.py ├── io_bound.py ├── media ├── asyncio.png ├── concurrency_and_parallelism.png ├── event_loop.png ├── os_thread.png └── synchronous_IO_bound.png └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | # .gitignore 2 | log/ -------------------------------------------------------------------------------- /cpu_bound.py: -------------------------------------------------------------------------------- 1 | import time 2 | import multiprocessing 3 | import os 4 | 5 | import logging 6 | import sys 7 | 8 | logging.basicConfig( 9 | level = logging.INFO, 10 | format = "[%(asctime)s] - [%(levelname)s] - [Process %(process)d, Thread %(thread)d] - %(message)s", 11 | datefmt = "%Y-%m-%d %H:%M:%S", 12 | handlers = [ 13 | logging.StreamHandler(sys.stdout), 14 | logging.FileHandler('log/multiprocessing_log.txt') 15 | ] 16 | ) 17 | 18 | logger = logging.getLogger('log output') 19 | 20 | def is_prime(n): 21 | if n <= 1: 22 | return False 23 | if n <= 3: 24 | return True 25 | if n % 2 == 0 or n % 3 == 0: 26 | return False 27 | i = 5 28 | while i * i <= n: 29 | if n % i == 0 or n % (i + 2) == 0: 30 | return False 31 | i += 6 32 | return True 33 | 34 | def find_primes(start, end): 35 | primes = [] 36 | for number in range(start, end + 1): 37 | 38 | logger.info(f'Processing number {number}') 39 | if is_prime(number): 40 | primes.append(number) 41 | return primes 42 | 43 | def multiprocessing_find_primes(prime_range:list, number_of_processors:int): 44 | 45 | chunk_size = (prime_range[1] - prime_range[0] + 1) // number_of_processors 46 | 47 | with multiprocessing.Pool(processes=number_of_processors) as pool: 48 | results = pool.starmap(find_primes, [ 49 | (prime_range[0] + i * chunk_size, prime_range[0] + (i + 1) * chunk_size - 1) 50 | for i in range(number_of_processors) 51 | ]) 52 | 53 | primes = [prime for sublist in results for prime in sublist] 54 | 55 | if __name__ == "__main__": 56 | num_processes = 3 57 | prime_range = (1, 10100000) 58 | 59 | 60 | start_time = time.time() 61 | find_primes(1, 10100000) 62 | 63 | end_time = time.time() 64 | print(f"Execution time: {end_time - start_time} seconds") -------------------------------------------------------------------------------- /io_bound.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import threading 3 | import concurrent.futures 4 | import asyncio 5 | import aiohttp 6 | import time 7 | 8 | import os 9 | import logging 10 | import sys 11 | 12 | 13 | logging.basicConfig( 14 | level = logging.INFO, 15 | format = "[%(asctime)s] - [%(levelname)s] - [Process %(process)d, Thread %(thread)d] - %(message)s", 16 | datefmt = "%Y-%m-%d %H:%M:%S", 17 | handlers = [ 18 | logging.StreamHandler(sys.stdout) 19 | ] 20 | ) 21 | 22 | logger = logging.getLogger('log output') 23 | 24 | 25 | DELAY_FACTOR = 2 26 | 27 | # Get response from API using 28 | def get_character_data(character_index: int): 29 | logger.info(f'Ingesting character number {character_index}') 30 | response = requests.get(f'https://rickandmortyapi.com/api/character/{character_index}') 31 | 32 | if response.status_code == 200: 33 | logger.info(f"Ingested successfully character number {character_index}") 34 | else: 35 | logger.error(f"Ingestion failed character number {character_index}!") 36 | 37 | time.sleep(DELAY_FACTOR) 38 | return response 39 | 40 | # Synchronous programming 41 | def synchronous_api_call(number_of_apis: int): 42 | for i in range(1, number_of_apis + 1): 43 | response = get_character_data(i) 44 | 45 | # multi-threading 46 | def threading_api_call(number_of_apis: int): 47 | # Create and start multiple threads 48 | threads = [] 49 | for i in range(1, number_of_apis + 1): 50 | thread = threading.Thread(target=get_character_data, args=(i,)) 51 | threads.append(thread) 52 | thread.start() 53 | 54 | # Wait for all threads to finish 55 | for thread in threads: 56 | thread.join() 57 | 58 | # Thread-pool 59 | def thread_pool_api_call(number_of_apis: int, number_of_threads: int): 60 | with concurrent.futures.ThreadPoolExecutor(max_workers=number_of_threads) as executor: 61 | # Use list comprehension to submit API requests to the thread pool 62 | results = [executor.submit(get_character_data, i) for i in range(1, number_of_apis + 1)] 63 | 64 | # Retrieve results from the submitted tasks 65 | for future in concurrent.futures.as_completed(results): 66 | result = future.result() 67 | 68 | # AsyncIO 69 | async def asyncio_get_character_data(character_index: int): 70 | async with aiohttp.ClientSession() as session: 71 | async with session.get(f'https://rickandmortyapi.com/api/character/{character_index}') as response: 72 | 73 | if response.status == 200: 74 | logger.info(f'Ingesting character number {character_index} ') 75 | data = await response.json() 76 | await asyncio.sleep(DELAY_FACTOR) 77 | 78 | logger.info(f"Ingested successfully character number {character_index}") 79 | return data 80 | else: 81 | logger.error(f"Ingestion failed character number {character_index}!") 82 | 83 | async def main(): 84 | list_of_characters = range(1, 11) 85 | 86 | tasks = [asyncio_get_character_data(index) for index in list_of_characters] 87 | 88 | results = await asyncio.gather(*tasks) 89 | 90 | 91 | 92 | if __name__ == "__main__": 93 | start_time = time.time() 94 | 95 | synchronous_api_call(10) # running the synchronous function 96 | # threading_api_call(10) # running the multi-threading function 97 | # thread_pool_api_call(10, 3) # running the thread-pool function 98 | # asyncio.run(main()) # running the async io function 99 | total_execution_time = time.time() - start_time 100 | print(f"Execution time: {total_execution_time} seconds") -------------------------------------------------------------------------------- /media/asyncio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vuthanhhai2302/understand-asynchronous-programming/8f3fe1135f91231b16d73562f0ee2c048458aecf/media/asyncio.png -------------------------------------------------------------------------------- /media/concurrency_and_parallelism.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vuthanhhai2302/understand-asynchronous-programming/8f3fe1135f91231b16d73562f0ee2c048458aecf/media/concurrency_and_parallelism.png -------------------------------------------------------------------------------- /media/event_loop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vuthanhhai2302/understand-asynchronous-programming/8f3fe1135f91231b16d73562f0ee2c048458aecf/media/event_loop.png -------------------------------------------------------------------------------- /media/os_thread.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vuthanhhai2302/understand-asynchronous-programming/8f3fe1135f91231b16d73562f0ee2c048458aecf/media/os_thread.png -------------------------------------------------------------------------------- /media/synchronous_IO_bound.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vuthanhhai2302/understand-asynchronous-programming/8f3fe1135f91231b16d73562f0ee2c048458aecf/media/synchronous_IO_bound.png -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Apply asynchronous programming to speed up your Python code 2 | 3 | I wanted to take a moment to say thank you to [@nguyenanhhao998](https://github.com/nguyenanhhao998), [@chunguyenduc](https://github.com/chunguyenduc), [@hieuwu](https://github.com/hieuwu) and vot zo for the great coffee talks that inspired me to write this article. I'm grateful for your support in my learning journey. 4 | 5 | ## Introduction 6 | Traditionally, your programs run sequentially, which means using a linear order and execution of operations where each operation must complete before the next operation can begin, it can also be called synchronous programming. You can find this type of programming everywhere, from simple projects to more complex systems, because it's easier to write and understand, intuitive to debug, and predictable to run. 7 | 8 | However, this style of programming can lead to long execution times and limit the scalability of your code, especially when dealing with long running tasks that depend on an external source or heavy processing using your CPU. These operations are generally called **I/O bound** and **CPU bound**. 9 | 10 | ### What is I/O Bound and CPU bound? 11 | When we mention an action as either CPU bound or I/O bound, we are referring to the limitation that prevent our programing running faster. If we can increase the performance the operations are bound on, the program can complete in less time. **I/O bound** are when we spend time waiting for a network (transmitting data through the internet,...) or an io divice(searching in our system's hard drive storage,...). **CPU bound** refer to the computation and processing code as looping through a dataset with ten thousand rows and arregating the element or applying the business to it then calculate for reports. 12 | 13 | I/O bound and CPU bound operations live side by side with each other in real life. First, we make an API call from rickandmortyapi.com, once we have the response, we performce an loop through the list of response, get the data we need and then write the strings to our storage as an second IO bound operations. These issue occurs daily, yet how we can tackle these issues? 14 | 15 | ### The key is concurrency and parralelism 16 | the 2 concepts concurrency and parallelism are both used to managing and executing mutiple tasks, but the way they execute is different. Let's take a look at the diagram bellow to see how the 2 paradigm different from each other and compare to synchronous way: 17 |

18 | concurrency vs parallelism 19 |

20 | 21 | **Concurrency** doesn't necessary mean that multiple tasks must run at the same time, they can run for some time, then paused then let other tasks run and maybe it can get to run in the future. **Parllelism** on the other hands, do tasks simultaneously. Each tasks will have its own swimlane, allowing tasks to run at the same time with others. 22 | 23 | ### Process, thread, multithreading and multi processing 24 | 25 | 26 | ## I/O bound 27 | In computer science, I/O bound refers to a condition in which the time it takes to complete a computation is determined principally by the period spent waiting for input/output operations to be completed. This circumstance arises when the rate at which data is requested is slower than the rate it is consumed or, in other words, more time is spent requesting data than processing it. 28 | 29 | For example, when we are calling an API, we need to wait for the server to send us a response or when the application need to search for a file in your hard drive, ... All of this cause a delay in your code as the program will have to wait for the resource and continue to process. 30 | 31 | Let's take a look at the example bellow, where we are trying to call for 10 character apis from rickandmortyapi.com and let the response delay for 2 seconds to mimic the deplay of the server: 32 | 33 | ```python 34 | import requests 35 | import time 36 | import logging 37 | import sys 38 | 39 | logging.basicConfig( 40 | level = logging.INFO, 41 | format = "[%(asctime)s] - [%(levelname)s] - [Process %(process)d, Thread %(thread)d] - %(message)s", 42 | datefmt = "%Y-%m-%d %H:%M:%S", 43 | handlers = [ 44 | logging.StreamHandler(sys.stdout), 45 | logging.FileHandler('log/multiprocessing_log.txt') 46 | ] 47 | ) 48 | 49 | logger = logging.getLogger('log output') 50 | 51 | DELAY_FACTOR = 2 52 | 53 | # Get response from API using 54 | def get_character_data(character_index: int): 55 | logger.info(f'Ingesting character number {character_index}') 56 | response = requests.get(f'https://rickandmortyapi.com/api/character/{character_index}') 57 | 58 | if response.status_code == 200: 59 | logger.info(f"Ingested successfully character number {character_index}") 60 | else: 61 | logger.error(f"Ingestion failed character number {character_index}!") 62 | 63 | time.sleep(DELAY_FACTOR) 64 | return response 65 | 66 | # Synchronous programming 67 | def synchronous_api_call(number_of_apis: int): 68 | for i in range(1, number_of_apis + 1): 69 | response = get_character_data(i) 70 | 71 | if __name__ == "__main__": 72 | start_time = time.time() 73 | 74 | synchronous_api_call(10) 75 | total_execution_time = time.time() - start_time 76 | print(total_execution_time) 77 | ``` 78 | 79 | And we can check out the log for understanding the execution of the synchonous code: 80 | 81 |

82 | Execution log of Synchronous program 83 |

84 | 85 | We can describe this through a diagram: 86 | 87 |

88 | Execution diagram of Synchronous program 89 |

90 | 91 | we can see the idle time between every time the application sending the requests and receive the response from the API. this casause unnecessary wait time and this we usually call **I/O bound**. 92 | 93 | ### How to tackle I/O bound with concurrency? 94 | 95 | #### Operating System's Threads 96 | 97 | A **thread** represents **a sequential execution flow of tasks within a process**, which is also referred to as a thread of execution. Each operating system provides a mechanism for executing threads within a process and a process can contain multiple threads. So, how can we use thread to execute tasks concurrently? 98 | 99 | ##### Multi-Threading Executions 100 | Reusing the code block above, we can add a minor change in the code to change the process into multi-threading execution 101 | 102 | ```python 103 | import threading 104 | 105 | # OS threads 106 | def threading_api_call(number_of_apis: int): 107 | # Create and start multiple threads 108 | threads = [] 109 | for i in range(1, number_of_apis + 1): 110 | thread = threading.Thread(target=get_character_data, args=(i,)) 111 | threads.append(thread) 112 | thread.start() 113 | 114 | # Wait for all threads to finish 115 | for thread in threads: 116 | thread.join() 117 | ``` 118 | 119 | we can see the significant change in the execution times: 120 |

121 | Execution log of OS thread 122 |

123 | 124 | the diagram bellow indicates how the code actually run with this type of concurrent programming: 125 | 126 |

127 | Execution diagram in OS thread 128 |

129 | 130 | Right now we can see the usage of OS's thread, when we are processing and run into an IO operation, the thread will switch to another one until the IO operation occurs again. this process keep happen until we handle the whole program. the purple arrow indicates the **context switching** in multi-threading execution. 131 | 132 | However, this approach have some problems. Thread is a kind of expensive resource interms of memory, an os have limited number of threads. So, the one-api-call-per-thread doesn't scale well and we will soon run out of thread. This can resulted in the server will not only work poorly under heavy workload, is there anyway we can improve this? 133 | 134 | ##### Thread Pool Executions 135 | 136 | Thread pools offer a solution to the issue of uncontrolled thread creation. Instead of creating a new thread for each task, we employ a queue to which we submit tasks, and a group of threads, forming a thread pool, takes and processes these tasks from the queue. This approach allows us to set a predetermined maximum number of threads in the pool, preventing the server from spawning an excessive number of threads. Below is an example of how we can implement a thread pool version of the server using the Python standard concurrent.futures module: 137 | 138 | ```python 139 | import concurrent.futures 140 | 141 | # Thread-pool 142 | def thread_pool_api_call(number_of_apis: int, number_of_threads: int): 143 | with concurrent.futures.ThreadPoolExecutor(max_workers=number_of_threads) as executor: 144 | # Use list comprehension to submit API requests to the thread pool 145 | results = [executor.submit(get_character_data, i) for i in range(1, number_of_apis + 1)] 146 | 147 | # Retrieve results from the submitted tasks 148 | for future in concurrent.futures.as_completed(results): 149 | result = future.result() 150 | ``` 151 | 152 | How is this different from the threading way? Well, you can see the thread pool executor is now set as 5, this mean there are only 5 api call happened at the same time: 153 | 154 |

155 | Execution diagram in thread-pool 156 |

157 | 158 | when a call api action is finished, the pool let another execution happen, but only 5 thread is open in the pool. This way, we can assure control over the os's resource. 159 | 160 | > **Conclusion**: Using a thread pool is a practical and uncomplicated method. However, it is essential to tackle the problem of slow executions monopolizing the thread pool. This can be handled in several ways, including **terminating long-living connections**, **enabling task prioritization**. Archieving efficient concurrent server performance with OS threads is more intricate than it seems at first, urging us to explore alternative concurrency strategies. 161 | 162 | #### Asyncio 163 | 164 | Asyncio is a paradigm allows non-blocking execution of your code, enable the program to handle concurrent operation efficiently. To see how Asyncio work in python, we can try running the block of code below: 165 | 166 | ```python 167 | import asyncio 168 | import aiohttp 169 | 170 | # AsyncIO 171 | async def asyncio_get_character_data(character_index: int): 172 | async with aiohttp.ClientSession() as session: 173 | async with session.get(f'https://rickandmortyapi.com/api/character/{character_index}') as response: 174 | 175 | if response.status == 200: 176 | logger.info(f'Ingesting character number {character_index}') 177 | data = await response.json() 178 | await asyncio.sleep(DELAY_FACTOR) 179 | 180 | logger.info(f"Ingested successfully character number {character_index}") 181 | return data 182 | else: 183 | logger.error(f"Ingestion failed character number {character_index}!") 184 | 185 | async def main(): 186 | list_of_characters = range(1, 11) 187 | 188 | tasks = [asyncio_get_character_data(index) for index in list_of_characters] 189 | 190 | results = await asyncio.gather(*tasks) 191 | ``` 192 | When you check out the execution logs, you can see the similarity to multi-threading when you allow the unlimited generation of thread. 193 |

194 | Execution log of asyncio function 195 |

196 | 197 | but the this is how asyncIO code really run: 198 |

199 | Execution diagram of asyncio function 200 |

201 | 202 | We can see there is only one thread is used, but how can asyncIO allow us to do this? 203 | 204 |

205 | Event loop 206 |

207 | 208 | From the bottom up, asyncio start with 209 | 1. **Coroutines**: these are asynchronous functions that allow you to paused and resumed a function, defining the asynchronous logic and they are defined with the `async def` syntax. The keyword `await` is used inside these coroutines to pause the execution of the coroutine until the awaited function is complete. the usage of `async` and `await` allow you to write the concurrent code without using any threads or processes. 210 | 2. **Tasks**: these tasks refer to a unit of work that is run on the event loops, it allow a way to coordinate the executions of corroutines. 211 | 3. **Event Loops**: it primary function is to manage the concurrent execution of asynchronous code included in tasks and coroutine in a non blocking way. 212 | 213 | > **Notes**: we can schedule directly coroutine on the event loop but it would be a bit messy. 214 | 215 | 216 | ## CPU bound 217 | --------------------------------------------------------------------------------