MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Python多线程与多进程的异同

2023-09-083.9k 阅读

1. 多线程与多进程的基本概念

1.1 线程的概念

线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一个进程可以包含多个线程,这些线程共享进程的资源,比如内存空间、文件描述符等。

在 Python 中,我们可以使用 threading 模块来创建和管理线程。下面是一个简单的示例:

import threading


def print_numbers():
    for i in range(10):
        print(f"Thread {threading.current_thread().name} is printing {i}")


if __name__ == '__main__':
    thread = threading.Thread(target=print_numbers)
    thread.start()
    thread.join()

在上述代码中,我们首先定义了一个函数 print_numbers,它会打印数字 0 到 9。然后,我们使用 threading.Thread 创建了一个线程,并将 print_numbers 函数作为目标函数传递给线程。通过 start() 方法启动线程,join() 方法等待线程执行完毕。

1.2 进程的概念

进程是计算机中已运行程序的实体。每个进程都有自己独立的地址空间、内存、数据栈以及其他记录其运行状态的辅助数据。进程间相互独立,它们之间的通信需要通过特定的机制,如管道、套接字等。

在 Python 中,multiprocessing 模块用于创建和管理进程。以下是一个简单的进程示例:

import multiprocessing


def print_numbers():
    for i in range(10):
        print(f"Process {multiprocessing.current_process().name} is printing {i}")


if __name__ == '__main__':
    process = multiprocessing.Process(target=print_numbers)
    process.start()
    process.join()

这里,我们定义了和线程示例中类似的 print_numbers 函数,然后使用 multiprocessing.Process 创建一个进程,并将 print_numbers 函数作为目标函数。同样通过 start() 启动进程,join() 等待进程结束。

2. 资源共享与隔离

2.1 线程的资源共享

线程最大的特点就是共享进程的资源。这意味着多个线程可以访问相同的变量、文件描述符等。例如:

import threading

data = []


def append_data():
    global data
    for i in range(5):
        data.append(i)


if __name__ == '__main__':
    threads = []
    for _ in range(3):
        thread = threading.Thread(target=append_data)
        threads.append(thread)
        thread.start()

    for thread in threads:
        thread.join()

    print(data)

在这个例子中,我们定义了一个全局变量 data,三个线程都对这个变量进行操作,向其中追加数据。由于线程共享资源,所以最终 data 列表中会包含所有线程追加的数据。

然而,这种资源共享也带来了一些问题,比如数据竞争。当多个线程同时访问和修改共享资源时,可能会导致数据不一致。例如,如果两个线程同时读取一个变量的值,然后各自对其加 1 并写回,就可能导致最终结果比预期少 1。为了解决这个问题,我们可以使用锁机制。

import threading

data = 0
lock = threading.Lock()


def increment():
    global data
    with lock:
        for _ in range(100000):
            data += 1


if __name__ == '__main__':
    threads = []
    for _ in range(2):
        thread = threading.Thread(target=increment)
        threads.append(thread)
        thread.start()

    for thread in threads:
        thread.join()

    print(data)

这里我们引入了一个锁 lock,在对共享变量 data 进行操作前,先获取锁,操作完成后释放锁,这样就保证了同一时间只有一个线程能修改 data,避免了数据竞争。

2.2 进程的资源隔离

与线程不同,进程之间是资源隔离的。每个进程都有自己独立的内存空间,一个进程无法直接访问另一个进程的变量。例如:

import multiprocessing


def increment(data):
    for _ in range(100000):
        data.value += 1


if __name__ == '__main__':
    shared_data = multiprocessing.Value('i', 0)
    processes = []
    for _ in range(2):
        process = multiprocessing.Process(target=increment, args=(shared_data,))
        processes.append(process)
        process.start()

    for process in processes:
        process.join()

    print(shared_data.value)

在这个例子中,我们想要实现两个进程对一个共享数据进行递增操作。由于进程间资源隔离,我们不能直接共享普通变量,需要使用 multiprocessing.Value 来创建共享数据。multiprocessing.Value 提供了一种在进程间共享数据的方式,它使用了操作系统的共享内存机制。同时,为了保证数据的一致性,在对共享数据进行操作时,也需要使用锁机制(这里 multiprocessing.Value 内部已经实现了一定的同步机制)。

3. 并发与并行

3.1 线程的并发执行

在 Python 中,由于全局解释器锁(Global Interpreter Lock,GIL)的存在,多线程在 CPython 解释器下实际上是并发执行,而不是并行执行。GIL 是 CPython 解释器中的一个互斥锁,它保证在同一时刻只有一个线程能够执行 Python 字节码。这意味着,即使在多核 CPU 上,多个线程也不能真正同时执行,而是轮流执行。

不过,对于 I/O 密集型任务,多线程仍然是非常有效的。因为在进行 I/O 操作(如文件读写、网络请求等)时,线程会释放 GIL,其他线程可以利用这个时间片执行。例如:

import threading
import time


def io_bound_task():
    time.sleep(2)
    print(f"Thread {threading.current_thread().name} finished I/O task")


if __name__ == '__main__':
    start_time = time.time()
    threads = []
    for _ in range(5):
        thread = threading.Thread(target=io_bound_task)
        threads.append(thread)
        thread.start()

    for thread in threads:
        thread.join()

    end_time = time.time()
    print(f"Total time: {end_time - start_time} seconds")

在这个例子中,io_bound_task 模拟了一个 I/O 密集型任务,通过 time.sleep(2) 模拟 I/O 操作的等待时间。由于在 time.sleep 期间线程会释放 GIL,其他线程可以继续执行,所以总的执行时间会接近 2 秒,而不是 10 秒(如果是顺序执行的话)。

3.2 进程的并行执行

进程没有 GIL 的限制,在多核 CPU 上,多个进程可以真正并行执行。每个进程都有自己独立的 Python 解释器实例,因此可以充分利用多核 CPU 的性能。对于 CPU 密集型任务,使用多进程通常能获得更好的性能提升。例如:

import multiprocessing
import time


def cpu_bound_task():
    result = 0
    for i in range(100000000):
        result += i
    print(f"Process {multiprocessing.current_process().name} finished CPU task")


if __name__ == '__main__':
    start_time = time.time()
    processes = []
    for _ in range(4):
        process = multiprocessing.Process(target=cpu_bound_task)
        processes.append(process)
        process.start()

    for process in processes:
        process.join()

    end_time = time.time()
    print(f"Total time: {end_time - start_time} seconds")

这里 cpu_bound_task 模拟了一个 CPU 密集型任务,通过一个大循环进行计算。在多核 CPU 上,四个进程可以并行执行这个任务,大大缩短了总的执行时间。

4. 创建与管理开销

4.1 线程的创建与管理开销

线程的创建和管理开销相对较小。因为线程共享进程的资源,创建线程时不需要重新分配大量的系统资源,只需要为线程分配少量的栈空间和线程控制块(TCB)等数据结构。例如,在前面的线程示例中,创建一个线程只需要执行 thread = threading.Thread(target=print_numbers) 这一行代码,开销很小。

线程之间的切换也比较快,因为线程共享内存空间,切换时不需要像进程那样进行地址空间的切换等复杂操作。然而,由于 GIL 的存在,线程切换可能会受到 GIL 的影响,特别是在 CPU 密集型任务中,频繁的线程切换可能会增加额外的开销。

4.2 进程的创建与管理开销

进程的创建和管理开销相对较大。创建一个新进程时,操作系统需要为其分配独立的地址空间、内存、文件描述符等资源,这涉及到大量的系统调用和资源分配操作。例如,在前面的进程示例中,创建一个进程需要执行 process = multiprocessing.Process(target=print_numbers),相比线程创建,这个过程更为复杂。

进程之间的切换也比线程切换慢,因为进程有独立的地址空间,切换时需要进行地址空间的切换,保存和恢复寄存器等操作,这些操作都需要消耗更多的时间和系统资源。

5. 错误处理与稳定性

5.1 线程的错误处理与稳定性

由于线程共享进程的资源,一个线程出现未处理的异常可能会影响整个进程。例如,如果一个线程中发生了除零错误并且没有捕获这个异常,整个进程可能会崩溃,因为所有线程共享相同的运行环境和内存空间。

在多线程编程中,错误处理需要特别小心。可以使用 try - except 语句在每个线程的目标函数中捕获异常,以避免异常传播导致进程崩溃。例如:

import threading


def divide_numbers():
    try:
        result = 1 / 0
    except ZeroDivisionError as e:
        print(f"Thread {threading.current_thread().name} caught an error: {e}")


if __name__ == '__main__':
    thread = threading.Thread(target=divide_numbers)
    thread.start()
    thread.join()

在这个例子中,divide_numbers 函数中故意进行除零操作,并使用 try - except 捕获异常,这样即使发生错误,也不会导致整个进程崩溃。

5.2 进程的错误处理与稳定性

进程之间相互独立,一个进程出现错误不会影响其他进程。如果一个进程发生崩溃,其他进程仍然可以正常运行。这使得多进程程序在稳定性方面更具优势。

在多进程编程中,错误处理相对简单。每个进程都有自己独立的运行环境,一个进程内部的错误不会传播到其他进程。例如:

import multiprocessing


def divide_numbers():
    result = 1 / 0


if __name__ == '__main__':
    process = multiprocessing.Process(target=divide_numbers)
    process.start()
    process.join()
    print("Main process continues even if the child process crashed")

在这个例子中,divide_numbers 函数中发生除零错误,导致子进程崩溃,但主进程仍然可以继续执行后续的代码。

6. 应用场景

6.1 线程的应用场景

  • I/O 密集型任务:如网络爬虫、文件读写、数据库操作等。由于在进行 I/O 操作时,线程会释放 GIL,其他线程可以利用这个时间片执行,所以多线程可以显著提高这类任务的执行效率。例如,一个网络爬虫程序需要同时发起多个 HTTP 请求获取网页内容,使用多线程可以在等待一个请求响应的同时,发起其他请求,从而减少总的等待时间。
  • 图形用户界面(GUI)编程:在 GUI 应用中,需要在主线程中处理用户界面的绘制和事件响应,同时可能需要在后台执行一些任务,如数据加载、计算等。使用多线程可以将这些后台任务放到其他线程中执行,避免阻塞主线程,保证用户界面的流畅性。例如,在一个图片处理的 GUI 应用中,可以使用一个线程在后台进行图片的处理,而主线程继续响应用户的操作,如缩放、旋转等。

6.2 进程的应用场景

  • CPU 密集型任务:如科学计算、数据挖掘、机器学习中的模型训练等。由于进程没有 GIL 的限制,在多核 CPU 上可以真正并行执行,所以多进程可以充分利用多核 CPU 的性能,大大提高这类任务的执行效率。例如,在进行大规模数据的矩阵运算时,使用多进程可以将数据分块,每个进程处理一块数据,最后将结果合并,从而加速整个计算过程。
  • 需要资源隔离的场景:当需要确保不同任务之间的资源相互隔离,避免相互干扰时,多进程是更好的选择。例如,在一个服务器应用中,不同的用户请求可能需要在相互隔离的环境中处理,以防止一个用户的错误操作影响其他用户。使用多进程可以为每个用户请求创建一个独立的进程,保证资源的隔离性。

7. 通信与同步

7.1 线程的通信与同步

线程之间由于共享资源,通信相对简单,可以直接通过共享变量进行数据交换。然而,为了保证数据的一致性,需要使用同步机制,如锁、信号量、条件变量等。

  • 锁(Lock):前面已经介绍过,锁用于保证同一时间只有一个线程能访问共享资源,避免数据竞争。例如:
import threading

counter = 0
lock = threading.Lock()


def increment():
    global counter
    with lock:
        counter += 1


  • 信号量(Semaphore):信号量可以控制同时访问共享资源的线程数量。例如,假设有一个资源只能同时被 3 个线程访问,可以使用信号量来实现:
import threading

semaphore = threading.Semaphore(3)


def access_resource():
    with semaphore:
        print(f"Thread {threading.current_thread().name} is accessing the resource")


  • 条件变量(Condition):条件变量用于线程之间的复杂同步,它允许线程在满足特定条件时进行等待和唤醒。例如,生产者 - 消费者模型中,消费者线程需要等待生产者线程生产数据后才能消费:
import threading

condition = threading.Condition()
data = None


def producer():
    global data
    with condition:
        data = "Some data"
        condition.notify()


def consumer():
    with condition:
        condition.wait()
        print(f"Thread {threading.current_thread().name} consumed {data}")


7.2 进程的通信与同步

进程之间由于资源隔离,不能直接共享变量,需要使用专门的通信机制,如管道、队列、共享内存等,同时也需要同步机制来保证数据的一致性。

  • 管道(Pipe):管道是一种简单的进程间通信方式,它可以在两个进程之间实现单向或双向的数据传输。例如:
import multiprocessing


def send_data(pipe):
    pipe.send('Hello from sender')
    pipe.close()


def receive_data(pipe):
    print(f"Received: {pipe.recv()}")
    pipe.close()


if __name__ == '__main__':
    parent_pipe, child_pipe = multiprocessing.Pipe()
    sender_process = multiprocessing.Process(target=send_data, args=(child_pipe,))
    receiver_process = multiprocessing.Process(target=receive_data, args=(parent_pipe,))

    sender_process.start()
    receiver_process.start()

    sender_process.join()
    receiver_process.join()


  • 队列(Queue)multiprocessing.Queue 是一个线程和进程安全的队列,可用于在多个进程之间传递数据。例如:
import multiprocessing


def put_data(queue):
    queue.put('Data from process 1')


def get_data(queue):
    print(f"Received: {queue.get()}")


if __name__ == '__main__':
    queue = multiprocessing.Queue()
    process1 = multiprocessing.Process(target=put_data, args=(queue,))
    process2 = multiprocessing.Process(target=get_data, args=(queue,))

    process1.start()
    process2.start()

    process1.join()
    process2.join()


  • 共享内存(Shared Memory):通过 multiprocessing.Valuemultiprocessing.Array 可以在进程间共享数据。例如前面提到的 multiprocessing.Value 的例子,多个进程可以通过它来操作共享数据,同时需要使用锁来保证数据的一致性。

在进程同步方面,同样可以使用锁、信号量等机制。例如,使用 multiprocessing.Lock 来保证多个进程对共享资源的互斥访问:

import multiprocessing


def increment_shared_data(shared_data, lock):
    with lock:
        shared_data.value += 1


if __name__ == '__main__':
    shared_data = multiprocessing.Value('i', 0)
    lock = multiprocessing.Lock()
    processes = []
    for _ in range(5):
        process = multiprocessing.Process(target=increment_shared_data, args=(shared_data, lock))
        processes.append(process)
        process.start()

    for process in processes:
        process.join()

    print(shared_data.value)


8. 性能对比实验

为了更直观地了解多线程和多进程在不同任务类型下的性能差异,我们进行一些简单的性能对比实验。

8.1 CPU 密集型任务性能对比

import threading
import multiprocessing
import time


def cpu_bound_task():
    result = 0
    for i in range(100000000):
        result += i
    return result


def run_threads():
    start_time = time.time()
    threads = []
    for _ in range(4):
        thread = threading.Thread(target=cpu_bound_task)
        threads.append(thread)
        thread.start()

    for thread in threads:
        thread.join()

    end_time = time.time()
    print(f"Threads total time: {end_time - start_time} seconds")


def run_processes():
    start_time = time.time()
    processes = []
    for _ in range(4):
        process = multiprocessing.Process(target=cpu_bound_task)
        processes.append(process)
        process.start()

    for process in processes:
        process.join()

    end_time = time.time()
    print(f"Processes total time: {end_time - start_time} seconds")


if __name__ == '__main__':
    run_threads()
    run_processes()


在这个实验中,cpu_bound_task 模拟了一个 CPU 密集型任务。我们分别使用 4 个线程和 4 个进程来执行这个任务,并记录执行时间。在多核 CPU 上,通常可以看到多进程的执行时间明显短于多线程,因为多线程受 GIL 限制,不能真正并行执行,而多进程可以充分利用多核 CPU 的性能。

8.2 I/O 密集型任务性能对比

import threading
import multiprocessing
import time


def io_bound_task():
    time.sleep(2)
    return "Task completed"


def run_threads():
    start_time = time.time()
    threads = []
    for _ in range(5):
        thread = threading.Thread(target=io_bound_task)
        threads.append(thread)
        thread.start()

    for thread in threads:
        thread.join()

    end_time = time.time()
    print(f"Threads total time: {end_time - start_time} seconds")


def run_processes():
    start_time = time.time()
    processes = []
    for _ in range(5):
        process = multiprocessing.Process(target=io_bound_task)
        processes.append(process)
        process.start()

    for process in processes:
        process.join()

    end_time = time.time()
    print(f"Processes total time: {end_time - start_time} seconds")


if __name__ == '__main__':
    run_threads()
    run_processes()


这里 io_bound_task 模拟了一个 I/O 密集型任务,通过 time.sleep(2) 模拟 I/O 操作的等待时间。在这种情况下,多线程的执行时间通常会比多进程短,因为线程的创建和管理开销小,并且在 I/O 等待期间线程会释放 GIL,其他线程可以利用这个时间片执行。而多进程由于创建和管理开销大,在处理 I/O 密集型任务时相对劣势。

通过这些性能对比实验,可以更清楚地看到多线程和多进程在不同应用场景下的优势和劣势,从而在实际编程中做出更合适的选择。

9. 总结对比

通过以上对 Python 多线程和多进程的详细分析,我们可以总结出它们在各个方面的异同:

对比项多线程多进程
资源共享共享进程资源,存在数据竞争问题资源隔离,相互独立,需要专门通信机制
并发与并行在 CPython 下受 GIL 限制,为并发执行,适合 I/O 密集型任务无 GIL 限制,多核 CPU 上可并行执行,适合 CPU 密集型任务
创建与管理开销开销较小开销较大
错误处理与稳定性一个线程出错可能影响整个进程一个进程出错不影响其他进程,稳定性更好
应用场景I/O 密集型任务、GUI 编程CPU 密集型任务、需要资源隔离的场景
通信与同步通过共享变量通信,使用锁等同步机制使用管道、队列、共享内存等通信,同样使用锁等同步机制

在实际编程中,应根据具体任务的特点和需求,合理选择使用多线程还是多进程,以充分发挥 Python 的并发编程能力,提高程序的性能和稳定性。