Concurrency Basics
Multithreading Programming
Hi everyone! Today we're going to talk about concurrent programming in Python. Concurrent programming is an important means to improve program execution efficiency, especially in the current era of widespread multi-core CPUs. Python, as a high-level programming language, also provides us with various concurrent programming models, including multithreading, multiprocessing, and coroutines. Let's take a look at each of them.
Let's start with the most basic multithreading programming. The core idea of the multithreading programming model is to distribute tasks within a process to different threads to run simultaneously, thereby improving the execution efficiency of the program. However, it should be noted that due to the existence of Python's Global Interpreter Lock (GIL), the efficiency improvement of multithreading on CPU-intensive tasks is not significant. But for IO-intensive tasks, multithreading can play a big role.
So how do we implement simple multithreading programming in Python? It's actually very simple, we can use the built-in threading
module:
import threading
import time
def worker():
print(f"Worker thread starts: {threading.current_thread().name}")
time.sleep(2)
print(f"Worker thread ends: {threading.current_thread().name}")
print(f"Main thread starts: {threading.current_thread().name}")
thread = threading.Thread(target=worker, name="WorkerThread")
thread.start()
thread.join()
print(f"Main thread ends: {threading.current_thread().name}")
In the above example, we created a worker
function, then used threading.Thread
to create a new thread, and specified the worker
function as the task to be executed by that thread. We start the new thread with thread.start()
, and thread.join()
waits for the new thread to finish executing.
Can you notice an interesting phenomenon? The execution order of the new thread and the main thread is uncertain. This is a feature of concurrent programming, multiple threads are executed in parallel, and who executes first depends entirely on the CPU scheduling situation.
However, using multithreading is not without cost. Since threads share the same memory space of a process, if not operated properly, it's easy to cause data race problems. For example:
import threading
count = 0
def increment():
global count
for _ in range(100000):
count += 1
threads = []
for _ in range(2):
thread = threading.Thread(target=increment)
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
print(f"The final value of Count is: {count}") # Expected output 200000
Can you guess the output? Because two threads are reading and writing the count
variable at the same time, data race situations are likely to occur, resulting in a final result less than 200000.
To solve this problem, we can use thread locks to protect shared resources:
import threading
count = 0
lock = threading.Lock()
def increment():
global count
for _ in range(100000):
# Acquire the lock
lock.acquire()
count += 1
# Release the lock
lock.release()
By acquiring the lock before the increment operation and releasing the lock after the operation is completed, we can prevent multiple threads from accessing the count
variable simultaneously, thus solving the data race problem.
However, the use of locks also needs to be very careful. If you request another lock while holding a lock, it may lead to a deadlock. To avoid deadlocks, we need to:
- Sort resources and request locks in order
- Set a timeout for lock requests
- Avoid nested locking
By properly using the lock mechanism, we can write safe and efficient multithreaded programs.
Multiprocessing Programming
In addition to multithreading, Python also supports the multiprocessing programming model. Compared to multithreading, the advantage of multiprocessing is that it can better utilize the computing power of multi-core CPUs, as it is not limited by the GIL. At the same time, processes have independent memory spaces, which also avoids the problem of data sharing.
Python's multiprocessing
module provides cross-platform multiprocessing support, which is also very easy to use:
import multiprocessing
def worker(num):
"""The task of the worker function is to print the input number and its square"""
print(f"Worker process starts: {num}")
print(f"Worker process result: {num**2}")
print(f"Worker process ends: {num}")
if __name__ == "__main__":
# Create a process pool, initialize 4 processes
pool = multiprocessing.Pool(processes=4)
# Use processes to execute target tasks
inputs = [1, 2, 3, 4, 5, 6, 7, 8]
pool.map(worker, inputs)
# Close the process pool
pool.close()
pool.join()
Here we first created a process pool containing 4 processes. Then we use the pool.map()
method to assign a process to execute the worker
function for each input number. The final output result is unordered, because the execution order of each process is uncertain.
However, there are some things to note when using multiprocessing. First, the overhead of creating and destroying processes is much larger than threads. Second, data sharing and communication between processes is also more troublesome, requiring mechanisms such as pipes, queues, or shared memory.
Coroutine Programming
Finally, let's look at the coroutine programming model in Python. Coroutines are essentially also a concurrent model, but unlike threads and processes, they achieve concurrent operations through a single-threaded approach.
The core idea of coroutines is based on an event loop, achieving cooperative execution of tasks by manually switching execution contexts. When each coroutine encounters an IO operation, it actively gives up the use of the CPU, thus avoiding long blocking waits.
Python 3.4 introduced the asyncio
library, which provides powerful support for coroutine programming. asyncio
executes coroutines through an event loop and provides communication and synchronization between coroutines.
Here's a simple example:
import asyncio
async def hello_world():
print("Hello World!")
await asyncio.sleep(1) # Simulate asynchronous IO operation
print("Hello again!")
loop = asyncio.get_event_loop()
loop.run_until_complete(hello_world())
loop.close()
Here we used the async
keyword to define a coroutine function hello_world
. In the function, we use the await
statement to "pause" the execution of the coroutine, switching to the event loop to handle other matters. asyncio.sleep(1)
is used to simulate an IO operation that takes 1 second.
Executing a coroutine function needs to be done through an event loop. We first obtained an event loop instance, then used the run_until_complete
method to execute the coroutine function.
It's worth noting that coroutine functions can only be called in other coroutine functions or in an event loop, they cannot be executed directly.
A major advantage of coroutines is that they can efficiently handle a large number of IO-intensive tasks without frequent context switching like multithreading. However, for CPU-intensive tasks, the multiprocessing model might be more efficient.
Performance and Choice
Coroutines VS Threads
We have briefly introduced three concurrent programming models in Python: multithreading, multiprocessing, and coroutines. So in practical applications, how should we choose?
First, let's compare the performance of coroutines and threads. Generally speaking, the context switching overhead of threads is slightly smaller than that of coroutines. According to some data circulating on the internet, the context switching time of threads is about 3-4 microseconds, while the switching time of coroutines is between 10-20 microseconds.
So if your application scenario requires a lot of context switching, using threads might be more appropriate. But for IO-intensive tasks, coroutines are also a good choice because they can efficiently utilize single-thread resources.
In addition, we need to consider the type of task. Due to the existence of the GIL, Python's multithreading cannot exert very good parallel capabilities on CPU-intensive tasks. If you need to fully utilize the computing power of multi-core CPUs, you might want to consider using the multiprocessing model.
However, using multiprocessing also requires more resource overhead. Each process has an independent memory space, and inter-process communication and data sharing require special mechanisms, such as pipes, queues, or shared memory.
Therefore, when choosing a concurrent model, we need to weigh the following factors:
- Type of task (CPU-intensive or IO-intensive)
- Whether frequent context switching is required
- Whether it's necessary to fully utilize multi-core CPUs
- Requirements for memory and communication overhead
By comprehensively analyzing these factors, we can choose the most suitable concurrent programming model for our application scenario.
Choosing Concurrent Models
Alright, through the above explanation, I believe you now have a certain understanding of the concurrent programming models in Python. Now, let's look at a practical case and see how to choose an appropriate concurrent model.
Suppose we need to develop a web crawler program to crawl a large number of page contents from a certain website. This kind of task obviously belongs to the IO-intensive type, because the crawler will be blocked for a long time while waiting for network responses.
If we use the ordinary single-threaded method, the execution efficiency of the program will be very low. So, we need to use some kind of concurrent model to improve efficiency.
First, we can consider using the multithreading model. Since the task is IO-intensive, the context switching overhead of threads will not be too large, and threads can also conveniently share the crawled data.
However, we need to pay attention to thread safety issues. When multiple threads read and write shared variables simultaneously, data races can easily occur. Therefore, we need to use thread locks or thread-safe data structures (such as queues) to protect shared resources.
Another option is to use the coroutine model. Through the asyncio
library, we can easily write asynchronous crawler programs based on event loops. Each coroutine will automatically give up the CPU while waiting for network responses, thus avoiding blocking waits. This method can also efficiently utilize single-thread resources.
However, the programming model of coroutines is relatively more complex. We need to use the async/await
syntax to define coroutine functions and arrange their execution through event loops. If your team is not very familiar with coroutine programming, the learning cost might be a bit higher.
Finally, if our target website has certain restrictions on crawler traffic, the crawling efficiency of a single machine may not meet the requirements. At this time, we need to consider using the multiprocessing model. By starting multiple processes on multiple machines, we can further improve crawling efficiency.
Of course, using multiprocessing also needs to solve the problems of inter-process communication and data sharing. We can use message queues or distributed storage systems (such as Redis) to achieve data exchange between processes.
In summary, for this web crawler program, if the efficiency requirements are not too high, we can choose the multithreading model as the first choice, because it is relatively simple and can meet basic needs. If the requirements are higher, we can consider using coroutines or multiprocessing models. The former has a significant improvement in single-machine efficiency, while the latter can infinitely improve overall efficiency by expanding the number of machines, but the implementation difficulty will also be greater.
Summary
Through the above introduction, I believe you now have a certain understanding of the concurrent programming models in Python. We started from the most basic multithreading programming, learned how to create and manage threads, and how to use locks to avoid data race problems.
Then, we introduced the multiprocessing programming model, which can fully utilize the computing power of multi-core CPUs, but at the cost of higher resource overhead.
Finally, we discussed the principles and usage of coroutine programming. Coroutines can efficiently handle a large number of IO-intensive tasks by manually switching execution contexts, which is a brand new concurrent programming paradigm.
In practical applications, we need to choose an appropriate concurrent programming model based on the characteristics of the task and the requirements for efficiency. Regardless of which model is used, we need to pay attention to concurrent safety issues to ensure the correctness of the program.
Alright, I hope that through today's sharing, you can have a comprehensive understanding of Python concurrent programming. If you have any questions, feel free to ask me at any time. The road of programming is long and arduous, let's learn and progress together in practice! Keep going!