第14章 进程与线程

4 阅读12分钟

第14章 进程与线程

14.1 并发与并行

14.1.1 并发

单个 CPU 处理多个任务。各个任务交替执行一段时间。

14.1.2 并行

多个 CPU 同时执行多个任务。

14.2 多进程

14.2.1 什么是进程

  • 进程是操作系统进行资源分配的基本单位。
  • 操作系统中一个正在运行的程序或软件就是一个进程。
  • 每个进程都有自己独立的一块内存空间。
  • 一个进程崩溃后,在保护模式下不会对其他进程产生影响。
  • 多进程是指在操作系统中同时运行多个程序。

14.2.2 使用 multiprocessing.Process 创建进程

Unix/Linux 操作系统提供了一个 os.fork() 系统调用,它非常特殊。普通的函数调用,调用一次,返回一次,但是 fork() 调用一次,返回两次,因为操作系统自动把当前进程(父进程)复制了一份(子进程),然后,分别在父进程和子进程内返回。

Windows 中没有 fork() 调用,不过 Python 提供了一个跨平台的多进程模块 multiprocessingmultiprocessing 模块提供了一个 Process 类来代表一个进程对象。

1)Process 的创建

multiprocessing.Process(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)
  • group:应当始终为 None,它的存在仅是为了与 threading.Thread 兼容。
  • target:由 run() 方法来发起调用的可调用对象,默认为 None
  • name:进程名称,默认为 None 则自动分配。
  • args:针对目标调用的参数元组。
  • kwargs:针对目标调用的关键字参数字典。
  • daemon:是否为守护进程,TrueFalse。默认为 None 则继承父进程。

2)Process 的属性和方法与其他常用方法

  • name:获取进程名称。
  • pid:获取进程号。
  • daemon:判断或设置进程是否为守护进程。
  • exitcode:获取子进程的退出状态码。
  • start():启动进程,调用传入 target 的对象。start() 只能被调用一次。
  • run():默认调用传入 target 的对象,如果子类化了 Process,可以重写此方法来自定义行为。
  • join([timeout]):阻塞主进程,直到子进程结束或超时。timeout 参数可选,意为阻塞多少秒。
  • terminate():强制终止子进程。
  • kill():杀死进程,与 terminate() 类似,但更彻底。
  • is_alive():检查进程是否仍在运行。
  • os.getpid():获取当前进程编号。
  • os.getppid():获取当前进程的父进程编号。

3)案例:同时读写文件

注意:在 Windows 上执行要加上 if __name__ == "__main__"

import time
import multiprocessing

# 向文件中写入数据
def write_file():
    with open("test.txt", "a") as f:
        while True:
            f.write("hello world\n")
            f.flush()
            time.sleep(0.5)

# 从文件中读取数据
def read_file():
    with open("test.txt", "r") as f:
        while True:
            time.sleep(0.1)
            print(f.read(1))

if __name__ == "__main__":
    # 创建一个子进程用于写文件
    p1 = multiprocessing.Process(target=write_file)
    # 创建一个子进程用于读文件
    p2 = multiprocessing.Process(target=read_file)
    # 启动子进程
    p1.start()
    p2.start()

14.2.3 自定义 Process 子类创建进程

import os
import multiprocessing

class Worker(multiprocessing.Process):
    def run(self):
        print("进程id:", os.getpid(), "\t 父进程id:", os.getppid())

if __name__ == "__main__":
    for i in range(5):
        p = Worker()
        p.start()

14.2.4 进程池

当需要启动大量子进程时,可以使用进程池。

1)进程池的创建

multiprocessing.Pool([processes[, initializer[, initargs[, maxtasksperchild[, context]]]]])
  • processes:要使用的工作进程数量。如果 processesNone 则使用 os.cpu_count() 所返回的数值。
  • initializer:如果不为 None,则每个工作进程将会在启动时调用 initializer(*initargs)
  • maxtasksperchild:一个工作进程在它退出或被一个新的工作进程代替之前能完成的任务数量,为了释放未使用的资源。默认的 maxtasksperchildNone,意味着工作进程寿与池齐。
  • context:可被用于指定启动的工作进程的上下文。

使用时一般只指定 processes 参数。

注意:进程池对象的方法只有创建它的进程能够调用。

2)进程池的常用方法

  • apply(func[, args[, kwds]]):使用 args 参数以及 kwds 命名参数同步调用 func,在返回结果前阻塞。
  • apply_async(func[, args[, kwds[, callback[, error_callback]]]]):使用 args 参数以及 kwds 命名参数异步调用 func,并立即返回一个 AsyncResult 对象,不会阻塞。
  • close():阻止后续任务提交到进程池,当所有任务执行完成后,工作进程会退出。
  • terminate():不必等待未完成的任务,立即停止工作进程。
  • join():阻塞主进程,等待工作进程结束。调用 join() 前必须先调用 close() 或者 terminate()

3)案例

import os
import time
import multiprocessing

# 打印10个数字,每次间隔0.5秒
def func():
    for i in range(10):
        print(os.getpid(), i)
        time.sleep(0.5)

if __name__ == "__main__":
    # 指定进程池大小
    process_num = 5
    pool = multiprocessing.Pool(process_num)
    for p in range(process_num):
        # 阻塞式
        # pool.apply(func)
        # 非阻塞式
        pool.apply_async(func)
    pool.close()
    pool.join()
    print("end")

14.2.5 进程间通信

1)进程间不共享全局变量

子进程向传入的列表中添加元素,最终发现主进程与子进程之间的列表结果不同:

import os
import multiprocessing

# 向list1中添加10个元素
def func(list1):
    for i in range(10):
        list1.append(i)
        print(os.getpid(), list1)

if __name__ == "__main__":
    list1 = []
    p1 = multiprocessing.Process(target=func, args=(list1,))
    p2 = multiprocessing.Process(target=func, args=(list1,))
    p1.start()
    p2.start()
    p1.join()
    p2.join()
    print(os.getpid(), list1)

2)使用 Queue 通信

Python 的 multiprocessing 模块包装了底层的机制,提供了 QueuePipes 等多种方式来交换数据。

multiprocessing.Queue([maxsize]) 返回一个使用一个管道和少量锁和信号量实现的共享队列(先进先出)实例。默认队列是无限大小的,可以通过 maxsize 参数限制。

(1)Queue 的常用方法

  • qsize():返回队列的大致长度。
  • empty():如果队列是空的返回 True
  • full():如果队列是满的返回 True
  • put(obj[, block[, timeout]]):将 obj 放入队列。
  • put_nowait(obj):相当于 put(obj, False)
  • get([block[, timeout]]):从队列中取出并返回对象。
  • get_nowait():相当于 get(False)

(2)案例:两个进程分别读写 Queue

import time
import random
import multiprocessing

# 间隔随机时间向queue中放入随机数
def func1(queue):
    while True:
        queue.put(random.randint(1, 50))
        time.sleep(random.random())

# 从queue中取出数据
def func2(queue):
    while True:
        print("=" * queue.get())

if __name__ == "__main__":
    queue = multiprocessing.Queue()
    p1 = multiprocessing.Process(target=func1, args=(queue,))
    p2 = multiprocessing.Process(target=func2, args=(queue,))
    p1.start()
    p2.start()
    p1.join()
    p2.join()

注意:multiprocessing.Queue 存在兼容性问题,如果要使用进程池,可以使用 Manager().Queue

3)进程池之间使用 Manager().Queue 通信

import time
import random
import multiprocessing

# 间隔随机时间向queue中放入随机数
def func1(queue):
    while True:
        queue.put(random.randint(1, 50))
        time.sleep(random.random())

# 从queue中取出数据
def func2(queue):
    while True:
        print("=" * queue.get())

if __name__ == "__main__":
    queue = multiprocessing.Manager().Queue()
    pool = multiprocessing.Pool(2)
    pool.apply_async(func1, (queue,))
    pool.apply_async(func2, (queue,))
    pool.close()
    pool.join()

14.3 多线程

14.3.1 什么是线程

  • 线程是处理器任务调度和执行的基本单位。
  • 一个进程至少有一个线程,也可以运行多个线程。
  • 多个线程之间可共享数据。
  • 线程运行出错异常后,如果没有捕获,会导致整个进程崩溃。
  • 多线程是指在同一进程中同时执行多个任务。

14.3.2 使用 threading.Thread 创建线程

Python 的标准库提供了两个模块:_threadthreading_thread 是低级模块,threading 是高级模块,对 _thread 进行了封装。绝大多数情况下,我们只需要使用 threading 这个高级模块。

1)Thread 的创建

threading.Thread(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)
  • group:应为 None,保留给将来扩展使用。
  • target:用于 run() 方法调用的可调用对象。
  • name:线程名称。默认以 "Thread-N" 的形式构造唯一名称。
  • args:用于发起调用目标函数的参数列表或元组。默认为 ()
  • kwargs:用于调用目标函数的关键字参数字典。默认是 {}
  • daemonTrueFalse 来设置该线程是否为守护模式。

2)Thread 的属性和方法与其他常用方法

  • name:线程的名称。
  • daemon:线程是否为守护线程。
  • ident:线程标识符。
  • native_id:此线程的线程 id(tid),由 OS(内核)分配。
  • start():启动线程,调用线程的 run() 方法。
  • run():定义线程的行为,默认调用传入的 target 对象。
  • join([timeout=None]):阻塞主线程,直到当前线程运行完成或达到超时时间。
  • is_alive():线程是否在运行。
  • threading.enumerate():查看都有哪些线程。
  • threading.current_thread():返回当前线程实例。

3)案例:两线程分别交替打印

import time
import threading

# 交替打印 00000 和 11111
def func():
    flag = 0
    while True:
        print(threading.current_thread().name, f"{flag}" * 5)
        flag = flag ^ 1  # 替换0和1
        time.sleep(0.5)

if __name__ == "__main__":
    t1 = threading.Thread(target=func, name="线程1")
    t2 = threading.Thread(target=func, name="线程2")
    t1.start()
    t2.start()

14.3.3 自定义 Thread 子类创建线程

import time
import threading

class Worker(threading.Thread):
    def __init__(self, name):
        super().__init__()
        self.name = name

    def run(self):
        flag = 0
        while True:
            print(f"\r{self.name}:{str(flag)*5}", end="")
            flag = flag ^ 1  # 替换0和1
            time.sleep(0.2)

if __name__ == "__main__":
    t1 = Worker("线程1")
    t2 = Worker("线程2")
    t1.start()
    t2.start()

14.3.4 线程池

ThreadPoolExecutorconcurrent.futures 模块中的线程池实现,它允许我们轻松地提交任务到线程池,并管理任务的执行和结果。

1)线程池的创建

concurrent.futures.ThreadPoolExecutor(max_workers=None, thread_name_prefix="", initializer=None, initargs=())
  • max_workers:线程池的最大线程数(默认取决于系统资源)。
  • thread_name_prefix:线程名称前缀。
  • initializer:可选的初始化函数。
  • initargs:传递给初始化函数的参数。

2)线程池的常用方法

  • submit(fn, *args, **kwargs):提交一个任务到线程池,返回一个 Future 对象。可使用 Future.result() 获取任务结果。
  • map(func, *iterables, timeout=None, chunksize=1):类似于内置的 map() 函数,但在线程池中并行执行。
  • shutdown(wait=True, cancel_futures=False):关闭线程池,等待所有任务完成。

3)案例

3 个线程,每个线程都将字符列表中的每个字符与 1 异或。

import concurrent.futures

def func(tname):
    global word
    for i, char in enumerate(word):
        word[i] = chr(ord(char) ^ 1)
        print(f"{tname}: {word}\n", end="")
    return word

if __name__ == "__main__":
    word = list("idmmn!vnsme")
    # 使用 with 语句来确保线程被迅速清理
    with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
        future1 = executor.submit(func, "线程1")
        future2 = executor.submit(func, "线程2")
        future3 = executor.submit(func, "线程3")
        word = future1.result()
        word = future2.result()
        word = future3.result()
    print("".join(word))  # hello world

14.3.5 互斥锁

1)线程安全问题

线程之间共享数据会存在线程安全的问题。

比如下面这段代码,3 个线程,每个线程都将 g_num +1 十次:

import time
import threading

def func():
    global g_num
    for _ in range(10):
        tmp = g_num + 1
        # time.sleep(0.01)
        g_num = tmp
        print(f"{threading.current_thread().name}: {g_num}\n", end="")

if __name__ == "__main__":
    g_num = 0
    threads = [threading.Thread(target=func, name=f"线程{i}") for i in range(3)]
    [t.start() for t in threads]
    [t.join() for t in threads]
    print(g_num)  # 30

结果为 30,看似没有问题。但添加 0.01 秒的延迟时间后:

import time
import threading

def func():
    global g_num
    for _ in range(10):
        tmp = g_num + 1
        time.sleep(0.01)
        g_num = tmp
        print(f"{threading.current_thread().name}: {g_num}\n", end="")

if __name__ == "__main__":
    g_num = 0
    threads = [threading.Thread(target=func, name=f"线程{i}") for i in range(3)]
    [t.start() for t in threads]
    [t.join() for t in threads]
    print(g_num)  # 10(不是30!)

这是因为在修改 g_num 前,有 0.01 秒的休眠时间,某个线程延时后,CPU 立即分配计算资源给其他线程,导致其他线程获取到的并不是最新值。

2)互斥锁的概念

某个线程要更改共享数据时,先将其锁定,此时其他线程不能更改。直到该线程释放资源,将资源的状态变成"非锁定",其他的线程才能再次锁定该资源。互斥锁保证了每次只有一个线程进行写入操作,从而保证了多线程情况下数据的正确性。

3)互斥锁的使用

可以通过 threading.Lock() 创建互斥锁。

  • 使用 lock.acquire() 来获取锁。
  • 使用 lock.release() 释放锁。
import time
import threading

def func():
    global g_num
    for _ in range(10):
        lock.acquire()  # 获取锁
        tmp = g_num + 1
        time.sleep(0.01)
        g_num = tmp
        lock.release()  # 释放锁
        print(f"{threading.current_thread().name}: {g_num}\n", end="")

if __name__ == "__main__":
    g_num = 0
    lock = threading.Lock()  # 创建锁
    threads = [threading.Thread(target=func, name=f"线程{i}") for i in range(3)]
    [t.start() for t in threads]
    [t.join() for t in threads]
    print(g_num)  # 30

14.3.6 GIL

Python 全局解释器锁(Global Interpreter Lock,简称 GIL)是一个锁,同一时间只允许一个线程保持 Python 解释器的控制权,这意味着在任何时间点都只能有一个线程处于执行状态。

GIL 并不是 Python 的特性,它是在实现 Python 解析器(CPython)时所引入的一个概念。

GIL 的影响:

  • 当你的程序需要进行大量的 CPU 计算时,GIL 会成为性能的瓶颈。即使你有多个线程,GIL 也会阻止它们在多个 CPU 核心上并行执行。
  • 对于涉及 I/O 操作(如文件读写、网络请求等)的程序,GIL 的影响较小。因为在 I/O 操作时,线程会释放 GIL,其他线程可以在此时执行,这使得多线程在 I/O 密集型任务中能更有效地并发。

14.4 进程和线程对比

14.4.1 区别

对比项进程线程
资源分配拥有独立的内存空间和系统资源共享所属进程的内存空间和资源
开销创建需要分配独立的内存,开销较大只需在所属进程内进行少量资源分配,开销较小
并发性可在不同核心上同时执行(真正的并行)受 GIL 限制,CPython 中并非真正的并行
独立性相互独立,一个进程崩溃不影响其他进程相互影响,一个线程出问题可能导致整个进程崩溃
通信相对复杂,需要管道、消息队列、共享内存等相对简单,可直接访问共享变量

14.4.2 使用场景

适合使用多线程的情况:

  • I/O 密集型任务:如网络请求、文件读写等。线程共享内存,切换开销小,在等待 I/O 操作完成的时间内可以切换到其他线程执行,提高整体效率。
  • 对资源共享要求高:线程间共享内存,方便数据共享和通信。

适合使用多进程的情况:

  • CPU 密集型任务:多进程可以利用多核心 CPU 实现真正的并行计算,充分发挥硬件性能。例如进行复杂的科学计算、数据处理等任务。
  • 需要隔离的任务:进程相互独立,一个进程崩溃不会影响其他进程。对于一些可能出现异常或不稳定的任务,使用多进程可以保证系统的稳定性。