1. Processes
A process is an instance of a running program, and it is the most basic unit by which the kernel allocates resources. A process has its own independent heap and stack, its own address space, and its own resource handles. Processes are scheduled by the OS, the scheduling overhead is relatively large, and the efficiency of switching during concurrency is low.
Python provides a cross-platform multiprocessing module, which uses the Process class to represent a process object.
1.1 Multiprocessing Example
| |
Parent process 274.
Child process will start.
Run child process test (298)...
Child process end.
1.2 Process Pool Example
| |
Parent process 274.
Run task 1 (431)...
Run task 0 (430)...
Waiting for all subprocesses done...
Task 1 runs 3.00 seconds.
Run task 2 (431)...
Task 0 runs 3.00 seconds.
Run task 3 (430)...
Task 2 runs 3.00 seconds.
Task 3 runs 3.00 seconds.
Run task 4 (431)...
Task 4 runs 3.00 seconds.
All subprocesses done.
1.3 Inter-Process Communication
The multiprocessing module wraps the underlying communication mechanisms and provides a variety of ways to exchange data, such as Queue and Pipes. Taking Queue as an example, we create two child processes in the parent process: one writes data into the Queue, and the other reads data from the Queue.
| |
Process to write: 211
Put A to queue...
Process to read: 212
Get A from queue.
Put B to queue...
Get B from queue.
Put C to queue...
Get C from queue.
2. Threads
A thread is a lightweight process and the basic unit of CPU scheduling and dispatch. A thread does not create a new address space or resource descriptor table; it reuses those of its parent process. A thread only has a program counter, a set of registers, and a stack, while threads in the same process share all other resources.
Threads are scheduled by the OS, and compared with processes the cost of thread scheduling is very small. Communication between threads is mainly through shared memory; context switching is fast and resource overhead is low, but compared with processes, threads are less stable and more prone to losing data.
2.1 Interpreters
Before discussing Python’s threads, let us first get to know several interpreter versions of Python:
- CPython, the official version of Python, implemented in C. It is the most widely used, and most people use this version.
- Jython, the Java implementation of Python. Compared with CPython, its interoperability with the Java language is far higher than the interoperability between CPython and the C language.
- Python for .NET, a .NET managed version of the CPython implementation, with good interoperability with .NET libraries and program code.
- IronPython, unlike Python for .NET, is the C# implementation of Python, and it compiles Python code into C# intermediate code (similar to Jython), with very good interoperability with .NET languages as well.
- PyPy, the Python implementation of Python. PyPy runs on top of CPython (or another implementation), and user programs run on top of PyPy. Its goal is to become a testing ground for the Python language itself, allowing easy modification of the PyPy interpreter’s implementation (because it is written in Python).
- Stackless. Stackless Python is an enhanced version of CPython; it lets programmers benefit from thread-based programming while avoiding the performance and complexity problems brought by traditional threads.
2.2 The Global Interpreter Lock (GIL)
The GIL is a global interpreter lock unique to CPython (other Python interpreter versions have their own thread scheduling mechanisms and no GIL mechanism). In essence, the GIL is one enormous lock in a Python process, and it is globally effective within the interpreter process. The GIL mainly locks the CPU execution resource, achieving exclusive access for a thread.
In the CPython interpreter, when a thread needs to use CPU resources, it must first acquire the GIL, and it releases the GIL only when it encounters an I/O operation.
For I/O-bound threads, multithreading can significantly improve performance over a single thread; for CPU-bound threads, multithreading cannot improve performance, because waiting for the GIL means multiple threads can only execute in sequence.
On a single-core CPU, only one thread occupies the CPU at any moment, so the GIL has no effect on CPU utilization. But on a multi-core CPU, because of the GIL, threads on different cores compete for the GIL at the same moment. The thread that acquires the GIL can occupy the CPU, while other threads remain idle, even if those threads have idle CPU resources available.
The GIL was not removed in Python 3 either, because a large number of third-party libraries depend on the GIL. Removing the GIL would require introducing a complex locking mechanism to protect numerous global states.
2.3 Multithreading Example
Python’s standard library provides two modules: thread and threading. thread is a low-level module, and threading is a high-level module that wraps thread.
| |
Thread-98
Parent process 426.
0
Thread-99
Parent process 426.
2
Thread-100
Parent process 426.
4
thread run 2.00 s end
thread run 2.01 s end
thread run 2.01 s end
Because the threads executed sleep, the CPU resources were released, allowing other threads to run. If we add the commented-out code my_thread.join(), the threads will execute serially:
Thread-101
Parent process 426.
0
thread run 2.01 s end
Thread-102
Parent process 426.
2
thread run 4.01 s end
Thread-103
Parent process 426.
4
thread run 6.02 s end
2.4 multiprocessing.dummy
The difference between the multiprocessing.dummy module and the multiprocessing module: the dummy module is multithreaded, while multiprocessing is multiprocess, and the calling convention is the same.
| |
Similar to multiprocessing, the dummy module provides a thread pool, making it very convenient to switch code between multithreading and multiprocessing. The dummy module is used in a large number of open-source projects and is highly recommended.
3. Coroutines
A coroutine is a lightweight thread. A coroutine has its own independent register context and stack, and within the same thread it shares the heap. Coroutines are not scheduled by the OS; the OS knows nothing about coroutines, and they are entirely controlled by the programmer’s code.
Concretely, while executing function A, execution can be interrupted at any time to run function B, then B is interrupted and execution of function A continues. These switches are entirely controlled by the program itself. Coroutine scheduling is in effect switching between program functions within the same thread, without the overhead of switching threads.
Coroutines are better suited to handling I/O-bound tasks.
3.1 Gevent
Gevent is a third-party library that implements coroutines through Greenlet. Its basic implementation principle is:
When a Greenlet encounters an I/O operation, such as accessing the network, it automatically switches to another Greenlet, and once the I/O operation completes, it switches back at an appropriate time to continue execution. Because I/O operations are very time-consuming and often leave the program in a waiting state, having Gevent automatically switch coroutines for us guarantees that some Greenlet is always running instead of waiting on I/O.
| |
Parent process 871.
0
Parent process 871.
2
Parent process 871.
4
run 2.00 s end
run 2.00 s end
run 2.00 s end
From the results, multithreading and multiple coroutines in Python have similar effects: when the current execution blocks, the execution flow switches. The difference is that multithreading switches threads, while coroutines switch the context of the function currently executing.
Using Gevent, you can achieve extremely high concurrency performance, but Gevent can only run under Unix/Linux, and installation and operation under Windows are not guaranteed.
3.2 Django
Gevent is also used in Django to enhance concurrency, especially when there are many I/O-bound requests:
| |
3.3 Celery
Celery supports several concurrency modes: prefork, threading, and coroutines (gevent, eventlet). Using a concurrency mode in Celery can significantly improve processing efficiency, especially when there are many I/O operations.
| |
The -P option specifies the pool; the default is prefork, and here it is set to gevent. -c sets the concurrency level.
4. Best Practices
- Use threads and coroutines for I/O-bound tasks (for example, network calls).
- For CPU-bound tasks, use multiple processes to bypass the GIL limitation and make full use of multi-core CPUs to improve efficiency.
- To make full use of the CPU, you can deploy with a combination of multiple processes plus coroutines, with multiple processes and multiple coroutines in each process.
