Python学习之多线程、多进程

前言:

在了解本章之前,我们先来了解下什么是线程和进程:
在计算机科学中,进程和线程是执行程序的基本单元,它们在操作系统的管理下运作,但它们之间有着本质的区别。理解进程和线程的概念对于进行有效的程序设计和系统管理非常重要。
进程(Process)
进程可以被理解为一个运行中的程序的实例。它是系统资源分配和执行的基本单位。每个进程都有自己独立的内存空间(包括代码段、数据段和堆栈等),进程间的通信(IPC,Inter-process communication)需要特定的机制(如管道、消息队列、共享内存等)来实现。
操作系统管理的任务,比如执行一个应用程序,通常都是在一个独立的进程中完成的。进程具有以下特性:
独立性:每个进程都有自己独立的地址空间和系统资源。
并发性:多个进程可以同时运行在多核或单核CPU系统上。
隔离性:一个进程崩溃通常不会影响到其他进程。
线程(Thread)
线程,有时被称为轻量级进程,是进程的执行单元。一个进程中可以包含一个或多个线程,所有线程共享该进程的地址空间和资源,但每个线程有自己的执行路径和状态(比如程序计数器、寄存器和栈)。线程之间的通信和数据共享更为容易,因为它们共享相同的进程空间,但这也意味着需要额外的注意来避免资源竞争和同步问题。
线程具有以下特性:
轻量级:创建和销毁线程比进程更快,线程间的切换开销也更小。
共享数据:线程之间可以直接访问相同的数据和资源,这使得数据共享和通信更方便。
多线程:一个单独的进程可以并发执行多个线程,实现程序的并行处理。
进程与线程的对比
资源分配:进程是资源分配的基本单位,有独立的地址空间;线程是CPU调度的基本单位,是进程中的一个实体,是比进程更小的能独立运行的基本单位,线程自身基本上不拥有系统资源(除了必要的少量资源外),它与进程内的其他线程共享进程所拥有的全部资源。
通信方面:由于线程共享相同的地址空间,线程之间的通信相对简单,但需要处理同步和互斥;进程间通信则需要通过IPC机制。
独立性:进程之间相互独立,一个进程的崩溃不会影响到其他进程;而线程之间共享进程资源,一个线程的错误可能影响到整个进程的其他线程。
效率:线程的创建、销毁和切换的开销小于进程。
概括来说,进程和线程都是操作系统中的并发执行的单位,但线程是进程的一部分,二者在资源管理、通信机制、开销和设计上都有差异。理解这些差异对于设计高效、稳定的并发程序至关重要。

1. 多线程 (Threading)

多线程是操作系统能够在同一进程中并行处理多个任务的能力。在纯Python代码中,由于全局解释器锁(Global Interpreter Lock, GIL)的存在,同一时刻只允许一个线程执行Python字节码。因此,在CPU密集型任务中,Python的多线程并不会带来太大的性能提升,但在I/O密集型任务中,多线程可以在一个线程等待外部响应时允许其他线程执行,这样可以提高程序的整体效率。

1.1 多线程例子:

import threading
import time

def print_numbers():
    for i in range(1, 6):
        time.sleep(1)
        print(i)

def print_letters():
    for letter in 'abcde':
        time.sleep(1.5)
        print(letter)

# 创建线程
t1 = threading.Thread(target=print_numbers)
t2 = threading.Thread(target=print_letters)

# 启动线程
t1.start()
t2.start()

# 等待线程完成
t1.join()
t2.join()

start() 方法:
用于启动一个Thread(线程)实例。在创建Thread实例后,线程并不会立即执行,直到调用了它的start()方法,该线程才真正被操作系统调度,开始运行对应的target函数。

join() 方法:
用来等待线程结束的。当在某个线程A中调用另外一个线程B的join()方法时,线程A将被阻塞,直到线程B完成执行后才继续执行线程A之后的操作。join()方法对于控制程序流程和确保线程按期望执行完成非常有用。

1.2 多线程中的Queue:

在多线程编程中,queue模块中的Queue类是一个线程安全的队列实现,它提供了一种安全的方式来交换信息或数据,使得在不同线程间通信变得简单而且不易出错。Queue的作用主要有以下几点:
线程间的数据交换:
Queue允许多个线程放入元素和取出元素,这些操作内部是自动加锁的,因此线程在这些操作中不会彼此干扰,也不会造成数据结构的损坏。
任务调度:
Queue常被用来分发任务,其中一个线程(通常称作生产者)负责将任务放到队列中,然后多个处理线程(通常称作消费者)可以同时从队列中取出任务并执行。
同步与顺序控制:
Queue可以用来协调线程的执行,例如可以用它来确保任务按照放入队列的顺序来处理;或在pipeline中,Queue可以用作各阶段中的缓存,同步不同阶段的处理速度。
缓解生产者和消费者速度不匹配问题:
如果生产者线程的生产速度快于消费者线程的处理速度,那么Queue可以作为缓冲区,暂存任务避免生产者直接阻塞等待。
资源池管理:
Queue同时也可以用来管理资源池,比如数据库连接池,线程可以从队列中获取资源进行操作,操作完成后再放回队列供其他线程使用。
Queue提供了多种方法(如put(), get(), qsize(), empty(), full()以及join()和task_done())来支持其上述作用:
put(item): 将item放入队列中。
get(): 从队列中移除并返回一个元素。若队列为空,调用该方法的线程会被阻塞,直到有元素可以返回。
qsize(): 返回队列中大致的元素数量(由于多线程的原因,这个数量可能不准确)。
empty(): 检查队列是否为空。
full(): 检查队列是否已满。
join(): 阻塞调用线程,直到队列中所有元素都被处理(task_done() 被每个元素调用一次)。
task_done(): 告诉队列,之前排队的一个元素的处理已完成,当队列中的所有元素都被处理完成后,调用join()的线程才会被解锁继续执行。

1.2.1 多线程中带参数的使用和Queue的应用
import threading
import queue
import time

# 线程要执行的函数,计算平方并将结果放入队列
def calc_square(numbers, results_queue):
    for number in numbers:
        time.sleep(0.5)  # 模拟耗时操作
        square = number * number
        results_queue.put(square)
        print(f"Square of {number} is {square}")

# 创建一个queue来存放结果
results_queue = queue.Queue()

# 定义一组数字
numbers = [2, 4, 6, 8]

# 创建线程,带参数
thread = threading.Thread(target=calc_square, args=(numbers, results_queue))

# 启动线程
thread.start()

# 等待线程完成
thread.join()

# 从队列中获取计算结果
while not results_queue.empty():
    square = results_queue.get()  # 通过queue.get获取计算结果
    print(f"Square result from queue: {square}")

输出:
# Square of 2 is 4
# Square of 4 is 16
# Square of 6 is 36
# Square of 8 is 64
# Square result from queue: 4
# Square result from queue: 16
# Square result from queue: 36
# Square result from queue: 64

在这个例子中,我们定义了一个calc_square函数,它接收一组数字和一个队列。对每个数字计算平方,并将结果放入队列。我们通过args参数给线程传递了需要处理的数字列表和用于存储结果的队列。线程启动后会执行calc_square函数,主线程通过join()等待线程完成。最后,主线程从队列中取出并打印了平方计算的结果。

2. 多进程(multiprocessing)

多进程是指操作系统能够运行多个进程,为每个进程分配独立的内存空间,每个进程中可能有一个或多个线程。多进程可以绕过GIL的限制,在Python中实现真正的并行计算,尤其适合CPU密集型任务。

2.1 多进程例子:

import multiprocessing
import time


def calculate_square(numbers):
    for n in numbers:
        time.sleep(0.5)
        print('Square:', n * n)


def calculate_cube(numbers):
    for n in numbers:
        time.sleep(0.5)
        print('Cube:', n * n * n)

#  多进程需要在if __name__ == '__main__' 中调用, 这是因为Windows没有fork()调用,因此Python解释器在Windows上需要通过“引导(bootstrapping)”的方式来启动新的Python进程,这意味着它会重新导入主模块
if __name__ == '__main__':
    numbers = [2, 3, 4, 5]

    # 创建进程
    p1 = multiprocessing.Process(target=calculate_square, args=(numbers,))
    p2 = multiprocessing.Process(target=calculate_cube, args=(numbers,))

    # 启动进程
    p1.start()
    p2.start()

    # 等待进程完成
    p1.join()
    p2.join()

# 输出:
# Square: 4
# Cube: 8
# Square: 9
# Cube: 27
# Square: 16
# Cube: 64
# Square: 25
# Cube: 125

2.2 多进程中Queue的使用

from multiprocessing import Process, Queue
import time

def worker(task_queue, result_queue):
    """
    工作进程,用于处理任务并将结果放入结果队列
    """
    while not task_queue.empty():
        task = task_queue.get()
        print(f'Process {task}...')
        processed_result = task * task  # 假设任务为计算数值的平方
        time.sleep(1)  # 模拟工作负载
        result_queue.put(processed_result)

if __name__ == '__main__':
    # 创建任务队列和结果队列
    task_queue = Queue()
    result_queue = Queue()

    # 填充任务队列
    tasks = [2, 3, 5, 7, 11]
    for t in tasks:
        task_queue.put(t)

    # 创建并启动多个工作进程
    num_processes = 3
    processes = [Process(target=worker, args=(task_queue, result_queue)) for _ in range(num_processes)]

    for p in processes:
        p.start()

    for p in processes:
        p.join()  # 等待所有进程完成

    # 收集结果
    results = []
    while not result_queue.empty():
        results.append(result_queue.get())

    print(f"Results: {results}")

# 输出:
# Process 2...
# Process 3...
# Process 5...
# Process 7...
# Process 11...
# Results: [9, 4, 25, 121, 49]

在这个示例中,我们定义了一个工作进程函数worker,它从task_queue中获取任务,处理这些任务(在这里是计算数值的平方),然后将结果放入result_queue。task_queue和result_queue都是通过multiprocessing.Queue创建的队列,它们可以在不同的进程间安全地传输Python对象。
我们首先将任务放入task_queue,然后创建了几个工作进程并启动,每个工作进程都接收task_queue和result_queue作为参数(使用args参数传递)。工作进程都完成后,主进程将从result_queue中收集所有结果,并打印出来。

3. 全局解释器锁GIL

全局解释器锁(Global Interpreter Lock,简称GIL)是Python解释器中一个用来保护Python对象防止多个线程同时访问的机制。GIL确保同一时刻只有一个线程可以执行Python字节码(即CPython的一部分),因此即使在多核处理器上,CPython的多线程程序也不能实现真正的并行执行(至少不是在执行Python字节码时)。

这个锁是Python的内部细节,特别是在CPython解释器中,因为这是目前最流行的Python实现。GIL并不是Python语言的固有特性,而是CPython解释器的设计选择。一些其他的Python解释器,比如Jython或IronPython,因为它们依赖于Java Virtual Machine(JVM)或.NET的CLR(Common Language Runtime),它们并没有像CPython那样的GIL。
GIL是为了简化CPython中多线程操作的复杂性而引入的,因为内存管理并不是线程安全的。因为GIL的存在,使得CPython的垃圾收集器,尤其是引用计数这一部分,不需要额外的同步机制,从而在单线程情况下有更好的性能。
然而,GIL也有不少缺点:
多核处理器的利用率低:在多核处理器上运行的多线程程序,由于GIL的原因,不能完全利用多核的优势来并行执行任务。
性能问题:在多线程程序中频繁地获取和释放GIL会引起性能瓶颈,这个过程中会产生额外的开销,尤其是在线程数量较多时。
编程复杂性:开发者要想在CPython中编写真正并行的多线程程序时需要格外考虑GIL,并可能采用其他方法(例如多进程)来规避。
在这里插入图片描述

为了绕过GIL的限制,Python的开发者通常会使用以下方法:
使用多进程(而非多线程),因为每个进程有自己的Python解释器和内存空间,因此GIL不会成为限制。
使用基于C语言的扩展来执行计算密集的任务,在C语言的扩展中可以释放GIL。
使用其他实现的Python解释器,例如PyPy,它也有GIL,但是用了一些技术来减少GIL的影响;或使用完全没有GIL的Jython或IronPython(这些解释器有其他限制)。
虽然GIL在是Python社区中常被诟病的话题,但Python仍然非常流行,这表明GIL并不是对大多数Python程序来说绝对的障碍。对于一些高并发处理的需求,通常会有其他语言或架构层面的解决方案。

4. 多进程和多线程的比较

在这里插入图片描述
线程在进程下行进(单纯的车厢无法运行)
一个进程可以包含多个线程(一辆火车可以有多个车厢)
不同进程间数据很难共享(一辆火车上的乘客很难换到另外一辆火车,比如站点换乘)
同一进程下不同线程间数据很易共享(A车厢换到B车厢很容易)
进程要比线程消耗更多的计算机资源(采用多列火车相比多个车厢更耗资源)
进程间不会相互影响,一个线程挂掉将导致整个进程挂掉(一列火车不会影响到另外一列火车,但是如果一列火车上中间的一节车厢着火了,将影响到该趟火车的所有车厢)
进程可以拓展到多机,进程最多适合多核(不同火车可以开在多个轨道上,同一火车的车厢不能在行进的不同的轨道上)
进程使用的内存地址可以上锁,即一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存。(比如火车上的洗手间)-”互斥锁(mutex)”
进程使用的内存地址可以限定使用量(比如火车上的餐厅,最多只允许多少人进入,如果满了需要在门口等,等有人出来了才能进去)-“信号量(semaphore)” (看的某篇博客的总结,就抄录在此了)

总结

如果任务是I/O密集型的,比如网络操作或磁盘读写,多线程能够提高效率。
如果任务是CPU密集型的,比如复杂计算,可以考虑多进程,以并行地利用CPU资源。
在实际应用中,适当选择多线程或多进程,甚至两者结合使用,可以优化程序的性能。然而,在使用多线程和多进程时,需要考虑线程或进程间的通信、数据共享、同步和死锁等问题。

  • 32
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

夏天Aileft

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值