This page looks best with JavaScript enabled

Processes, Threads, and Coroutines in Python

 ·  ☕ 5 min read

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import os
from multiprocessing import Process

# 子进程执行的代码
def run_proc(name):
    print('Run child process %s (%s)...' % (name, os.getpid()))

if __name__=='__main__':
    print('Parent process %s.' % os.getpid())
    p = Process(target=run_proc, args=('test',))  # target 指定要执行的函数,args 指定参数
    print('Child process will start.')
    p.start() #启动 Process 实例
    p.join() #等待子进程结束后,继续往下执行
    print('Child process end.')
Parent process 274.
Child process will start.
Run child process test (298)...
Child process end.

1.2 Process Pool Example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import os, time
from multiprocessing import Pool

def long_time_task(name):
    print('Run task %s (%s)...' % (name, os.getpid()))
    start = time.time()
    time.sleep(3)
    end = time.time()
    print('Task %s runs %0.2f seconds.' % (name, (end - start)))

if __name__=='__main__':
    print('Parent process %s.' % os.getpid())
    p = Pool(2) # 创建对象池,并设置进程池大小,默认大小是 CPU 核数
    for i in range(5):
        p.apply_async(long_time_task, args=(i,)) # 设置每个进程要执行的函数和参数,异步执行
    print('Waiting for all subprocesses done...')
    p.close() # 关闭进程池,不允许继续添加新的 Process
    p.join() # 等待全部子进程执行完毕
    print('All subprocesses done.')
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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import os, time, random
from multiprocessing import Process, Queue

def write(q): # 写数据进程执行的代码
    print("Process to write: %s" % os.getpid())
    for value in ['A', 'B', 'C']:
        print("Put %s to queue..." % value)
        q.put(value)
        time.sleep(random.random())

def read(q): # 读数据进程执行的代码
    print("Process to read: %s" % os.getpid())
    while True:
        value = q.get(True)
        print("Get %s from queue." % value)

if __name__ == '__main__':
    q = Queue() # 父进程创建Queue,并传给各个子进程
    pw = Process(target=write, args=(q,))
    pr = Process(target=read, args=(q,))
    pw.start() # 启动子进程pw,写入
    pr.start() # 启动子进程pr,读取
    pw.join()  # 等待pw结束
    pr.terminate() # pr进程里的死循环,无法等待结束,只能强制终止
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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import time, os, threading

start = time.time()
def doubler(number):
    print(threading.currentThread().getName())
    print('Parent process %s.' % os.getpid())
    print(number * 2)
    time.sleep(2)# 或者 IO 请求
    print('thread run %0.2f s end'% (time.time() - start))

if __name__ == '__main__':

    for i in range(3):
        my_thread = threading.Thread(target=doubler, args=(i,))
        my_thread.start()
        #my_thread.join()
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.

1
2
from multiprocessing import Pool
from multiprocessing.dummy import Pool

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import gevent
import time, os, threading
from gevent import monkey;
monkey.patch_all() # 将默认阻塞的模块替换成非阻塞

start = time.time()
def doubler(number):
    print('Parent process %s.' % os.getpid())
    print(number * 2)
    time.sleep(2)
    print('run %0.2f s end'% (time.time() - start))

if __name__ == '__main__':
    tasks=[gevent.spawn(doubler,i) for i in range(3)] # gevent.spawn 启动协程,参数为函数名称和参数名称
    gevent.joinall(tasks) # gevent.joinall 等待执行完毕
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:

1
2
3
4
# 使用 uwsgi 部署
uwsgi --gevent 100 --gevent-monkey-patch --http :8000 -M  --processes 4 --wsgi-file wsgi.py
# 使用 gunicorn 部署
gunicorn --worker-class=gevent wsgi:application -b 0.0.0.0:8000

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.

1
celery worker -A celery_worker.celery -P gevent -c 10 -l INFO

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.

5. References


微信公众号
WRITTEN BY
微信公众号