Python 并发编程

本文深入探讨Python中的并发编程,涵盖多线程、多进程、协程等概念,解析GIL锁的影响,介绍线程安全与可重入,以及Python中创建线程的多种方式。同时,详述线程同步工具,包括锁、信号量、条件变量等,对比多线程与多进程的优劣,指导合理选择。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

本文系统介绍了 Python 中并发编程相关概念。多进程编程或多线程编程或并发编程存在的意义不再赘述,该类问题的核心在于:资源的共享使方式,以及并发任务的协作方式。

Linux 中的程序、进程、线程、协程
  • 程序 VS 进程:程序是一组有序的指令集合,是一种静态的概念。进程是程序及其数据在计算机上的一次运行活动,是一个动态的概念。而进程的运行实体是程序,离开程序的进程没有存在的意义。进程是由程序、数据和进程控制块(PCB)三部分组成的。
  • 进程 VS 线程:线程是进程的一个实体,是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少。而进程间的上下文切换开销(栈、寄存器、虚拟内存、文件句柄等)相对比较大。
  • 线程 VS 协程:协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
  • 调度相关:在传统的操作系统中,拥有资源和独立调度的基本单位都是进程。在引入线程的操作系统中,线程是独立调度的基本单位,进程是资源拥有的基本单位。在同一进程中,线程的切换不会引起进程切换。在不同进程中进行线程切换,如从一个进程内的线程切换到另一个进程中的线程时,会引起进程切换。
  • 系统开销相关:由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、 I/O设备等,因此操作系统所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度到进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。此外,由于同一进程内的多个线程共享进程的地址空间,因此,这些线程之间的同步与通信非常容易实现,甚至无需操作系统的干预。
  • 优势和选择:多进程的优势就是一个子进程崩溃并不会影响其他子进程和主进程的运行,但缺点就是不能一次性启动太多进程,会严重影响系统的资源调度,特别是 CPU 使用率和负载。多线程的优势是切换快,资源消耗低,但一个线程挂掉则会影响到所有线程,所以不够稳定。
第一大锁 GIL
  • GIL 全局解释器锁:GIL 相当于互斥锁,在解释器层面上限制多线程同时执行,保证同一时间只有一个线程在执行。
  • GIL 不是 Python 的特性,它是 Python的 C 解释器在实现的时候引入的特性,不是说我们的 Python 代码写出来就自带了 GIL,而是在执行时,CPython 解释器在解释多线程程序时会受到 GIL 锁的影响。
  • GIL 存在至今的原因:Python 支持多线程,而解决多线程之间数据完整性和状态同步的最简单方法自然就是加锁,于是有了GIL 这把超级大锁,并且越来越多的代码库依赖这种特性(即默认 Python 内部对象是线程安全的,无需在实现时考虑额外的内存锁和同步操作),所以简单的说 GIL 的存在更多的是根深蒂固的历史原因。
  • multiprocess 库的出现很大程度上是为了弥补 thread 库因为 GIL 而低效的缺陷。它完整的复制了一套 thread 所提供的接口方便迁移。唯一的不同就是它使用了多进程而不是多线程。每个进程有自己的独立的 GIL,因此也不会出现进程之间的 GIL 争抢。
线程安全与可重入
  • 多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。
  • 不安全的多线程编程方式主要有:
    • 不保护共享变量的函数
    • 函数状态随着被调用,状态发生变化的函数
    • 返回指向静态变量指针的函数
    • 调用线程不安全函数的函数
  • 可重入函数是线程安全函数的真子集
  • 一个函数称为可重入的充要条件:
    • 不使用任何(局部)静态或全局的非const变量;
    • 不返回任何(局部)静态或全局的非const变量的指针;
    • 仅依赖于调用方提供的参数;
    • 不依赖任何单个资源的锁;
    • 不调用任何不可重入函数;
  • 若一个函数中存在全局变量,那么这个函数既不是线程安全的也不是可重入的。
Python 中创建线程的两种方式
  • 基于函数实现
from threading import Thread
import time
def sayhi(name):
    time.sleep(2)
    print('%s say hello' %name)

if __name__ == '__main__':
    t=Thread(target=sayhi,args=('egon',))
    t.start()
    print('主线程')
  • 基于类实现
from threading import Thread
import time
class Sayhi(Thread):
    def __init__(self,name):
        super().__init__()
        self.name=name
    def run(self):
        time.sleep(2)
        print('%s say hello' % self.name)

if __name__ == '__main__':
    t = Sayhi('egon')
    t.start()
    print('主线程')
Python 中的线程同步工具
  • 原始锁(互斥锁) Lock

    • 原始锁是一个在锁定时不属于特定线程的同步基元组件。在 Python 中,它是能用的最低级的同步基元组件, 由_thread 扩展模块直接实现。
    • 原始锁处于‘锁定’或者‘非锁定’两种状态之一。它被创建时为非锁定状态。它有两个基本方法,acquire() 和release() 。
    • 当状态为非锁定时,acquire() 将状态改为锁定并立即返回。
    • 当状态是锁定时,acquire() 将阻塞至其他线程调用 release() 将其改为非锁定状态,然后 acquire() 调用重置其为锁定状态并返回。
    • release() 只在锁定状态下调用;它将状态改为非锁定并立即返回。如果尝试释放一个非锁定的锁,则会引发RuntimeError 异常。
  • 递归锁(重入锁) RLock

    • 重入锁是一个可以被同一个线程多次获取的同步基元组件。
    • 在内部,它在基元锁的锁定/非锁定状态上附加了‘所属线程’ 和‘递归等级’的概念。
    • 在锁定状态下,某些线程拥有锁;在非锁定状态下,没有线程拥有它。
    • 若要锁定锁,线程调用其 acquire() 方法;一旦线程拥有了锁,方法将返回。
    • 若要解锁,线程调用 release() 方法。
    • acquire()/release() 对可以嵌套;只有最终release() (最外面一对的release() ) 将锁解开,才能让其他线程继续处理 acquire() 阻塞。
  • 条件变量 Condition

    • 条件变量总是与某种类型的锁对象相关联,锁对象可以通过传入获得,或者在缺省的情况下自动创建(RLock),因此 Condition 同样支持 release()/acquire() 操作。当多个条件变量需要共享同一个锁时,传入一个锁很有用。
    • wait() 方法释放锁,然后阻塞直到其它线程调用notify() 方 法或notify_all() 方法唤醒它。一旦被唤醒,wait() 方法重新获取锁并返回。它也可以指定超时时间。
    • 使用条件变量时,那些对状态的某些特定改变感兴趣的线程,它们重复调用 wait() 方法,直到看到所期望的改变发生;而对于修改状态的线程,它们将当前状态改变为可能是等待者所期待的新状态后,调用 notify() 方法或者 notify_all() 方法。
    • 使用 while 循环检查所要求的条件成立与否是有必要的,因为wait() 方法可能要经过不确定长度的时间后才会返回,而此时导致notify() 方法调用的那个条件可能已经不再成立。这是多线程编程所固有的问题。 wait_for() 方法可自动化条件检查,并简化超时计算。
from threading import Thread,Condition,Lock
from time import sleep
from random import randint

def supplier(res_con, res_desk):
    print("Supplier Started", flush=True)
    # notify 前锁必须 acquire(),notify() 后必须 release() 才能让其他 wait() 返回
    res_con.acquire()
    while True:
        res = randint(1, 100)
        res_desk.append(res)
        print("Supplier Data Ready: " + str(res), flush=True)
        res_con.notify() 
        res_con.wait()
        
def nicotian(res_con, res_desk):
    print("Nicotian Started", flush=True)
    res_con.acquire()
    while True:
        # 进入时释放锁,然后阻塞,被唤醒后获取锁,然后返回
        res_con.wait() 
        res = res_desk.pop()
        sleep(1)
        print("Nicotian Dealed Data: " + str(res), flush=True)
        res_con.notify()
        
def main():
    # 材料状态控制
    res_con = Condition(Lock())
    # 材料桌
    res_desk = []
    # 吸烟者,消费者线程应该先准备好等待
    thread_nic = Thread(target=nicotian, args=(res_con, res_desk), daemon=True)
    thread_nic.start()
    sleep(1)
    # 供应者
    thread_supp = Thread(target=supplier, args=(res_con, res_desk), daemon=True)
    thread_supp.start()
    input("\n\n===Enter Any Key To Exit===\n\n")
    
if __name__ == '__main__':
    main()
  • 信号量 Semaphore

    • 信号量是计算机科学史上最古老的同步原语之一,早期的荷兰科学家 Edsger W. Dijkstra 发明了它。(他使用名称 P() 和 V() 而不是acquire() 和release() )。
    • 一个信号量管理一个内部计数器,该计数器因acquire() 方法的调用而递减,因release() 方法的调用而 递增。
    • 计数器的值永远不会小于零;当acquire() 方法发现计数器为零时,将会阻塞,直到其它线程调 用release() 方法。
  • 事件 Event

    • 这是线程之间通信的最简单机制之一:一个线程发出事件信号,而其他线程等待该信号。
    • 一个事件对象管理一个内部标志,调用set() 方法可将其设置为 true,调用 clear() 方法可将其设置为 false, 调用 wait() 方法将进入阻塞直到标志为 true。
  • 栅栏 Barrier

    • 栅栏类提供一个简单的同步原语,用于应对固定数量的线程需要彼此相互等待的情况。
    • 线程调用 wait() 方 法后将阻塞,直到所有线程都调用了 wait() 方法。此时所有线程将被同时释放。
  • 定时器子类 Timer

    • 此类表示一个操作应该在等待一定的时间之后运行 — 相当于一个定时器。Timer 类是 Thread 类的子类,因此可以像一个自定义线程一样工作。
def hello(): 
    print("hello, world")

t = Timer(30.0, hello) 
# after 30 seconds, "hello, world" will be printed
t.start() 
  • 死锁不是一种锁,是指两个或两个以上的线程或进程在执行程序的过程中,因争夺资源而相互等待的一个现象。死锁的原因:
    • 同一线程,嵌套获取同把锁,造成死锁
    • 多个线程,不按顺序同时获取多个锁,造成死锁
Python 中的多进程编程
  • multiprocessing 是一个用与threading 模块相似 API 的支持产生进程的包。multiprocessing 包同 时提供本地和远程并发,使用子进程代替线程,有效避免Global Interpreter Lock 带来的影响。因此,multiprocessing 模块允许程序员充分利用机器上的多个核心。
from multiprocessing import Process

def f(name): 
    print('hello', name)

if __name__ == '__main__': 
    p = Process(target=f, args=('bob',)) 
    p.start() 
    p.join()
  • 上下文启动方式:
    • spawn 父进程启动一个新的 Python 解释器进程。子进程只会继承那些运行进程对象的run() 方法所需的资源。特别是父进程中非必须的文件描述符和句柄不会被继承。相对于使用 fork 或者 forkserver,使用这个方法启动进程相当慢。
    • fork 父进程使用 os.fork() 来产生 Python 解释器分叉。子进程在开始时实际上与父进程相同。 父进程的所有资源都由子进程继承。请注意,安全分叉多线程进程是棘手的。
    • forkserver 程序启动并选择 * forkserver * 启动方法时,将启动服务器进程。从那时起,每当需 要一个新进程时,父进程就会连接到服务器并请求它分叉一个新进程。分叉服务器进程是单 线程的,因此使用os.fork() 是安全的。没有不必要的资源被继承。
  • 在进程之间交换对象
    • 队列,Queue 类是一个近似queue.Queue的克隆,队列是线程和进程安全的。
    • 管道,Pipe() 函数返回一个由管道连接的连接对象;返回的两个连接对象Pipe() 表示管道的两端。每个连接对象都有 send() 和 recv() 方法(相互 之间的);请注意,如果两个进程(或线程)同时尝试读取或写入管道的 同一端,则管道中的数 据可能会损坏;当然,同时使用管道的不同端的进程不存在损坏的风险。
  • 在进行并发编程时,通常最好尽量避免使用共享状态,使用多个进程时尤其如此,但是,如果你真的需要使用一些共享数据,那么有两种方法:
    • 共享内存,可以使用Value 或Array 将数据存储在共享内存映射中。
    • 服务器进程,由 Manager() 返回的管理器对象控制一个服务器进程,该进程保存 Python 对象并允许其他进 程使用代理操作它们。
    • 服务器进程管理器比使用共享内存对象更灵活,因为它们可以支持任意对象类型。
    • 此外,单个管理器可以通过网络由不同计算机上的进程共享。但是,它们比使用共享内存慢。
  • 信号量 Semaphore,参考多线程相关概念
  • 锁 Lock,参考多线程相关概念
多线程 or 多进程
  • Python 多线程情况下:
    • 计算密集型操作:效率低,因为 GIL 锁
    • IO 操作: 效率高
  • Python 多进程的情况下:
    • 计算密集型操作:效率高,但浪费系统资源,不得已而为之
    • IO 操作: 效率高,但浪费资源
  • 如何选择:
    • IO 密集型用多线程: 文件/输入输出/socket网络通信
    • 计算密集型用多进程,最好别用 Python 或嵌套使用
系统编程其他概念
  • 内核态:CPU 可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,CPU 也可以将自己从一个程序切换到另一个程序。
  • 用户态:只能受限的访问内存,且不允许访问外围设备,占用 CPU 的能力被剥夺,CPU 资源可以被其他程序获取。
  • 用户态和内核态区分原因:需要限制不同的程序之间的访问能力,防止他们获取别的程序的内存数据,或者获取外围设备的数据,并发送到网络。
  • 系统调用:当用户态进程发起一个系统调用, CPU 将切换到内核态并开始执行一个内核函数。 内核函数负责响应应用程序的要求,例如操作文件、进行网络通讯或者申请内存资源等。
  • 同步和异步
    • 所谓同步就是一个任务的完成需要依赖另外一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成,这是一种可靠的任务序列。要么成功都成功,失败都失败,两个任务的状态可以保持一致。
    • 所谓异步是不需要等待被依赖的任务完成,只是通知被依赖的任务要完成什么工作,依赖的任务也立即执行,只要自己完成了整个任务就算完成了。至于被依赖的任务最终是否真正完成,依赖它的任务无法确定,所以它是不可靠的任务序列。
参考资料

https://blog.youkuaiyun.com/yushuaigee/article/details/86537474
https://www.cnblogs.com/zwq-/p/9621275.html
https://www.cnblogs.com/hei-ma/articles/9939477.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值