线程同步 for Python
在多线程程序中,当多个线程同时去访问修改一个资源,就会引发线程安全问题。例如两个线程同时做累加操作时,两个线程拿的都是增加前的值,并且增加后同时或先后进行覆盖,其中一个线程的操作就被覆盖了。这在程序设计中是相当危险的,往往会导致得到一个错误的结果。
Lock
同步锁,即阻塞锁,是线程同步比较常用的手段。同一个锁同一时间只有一个线程能够获取,只要有一个线程获取了锁,其他线程请求时就会阻塞,等待锁被释放。只要控制一个资源同时只有一个线程访问,就不会引发安全问题。
threading.Lock
在Python的threading模块中使用了Lock类来实现锁。
| 方法 | 用法 |
|---|---|
| acquire | 获取锁,如果锁被占用,则阻塞。参数blocking为Flase则不阻塞。如果timeout或者非阻塞未获得锁都会返回False |
| release | 释放锁 |
| locked | 锁是否已被获取 |
threading.RLock
用法与Lock类似,但是在同一线程中可以多次获取锁,其他线程需要等到该线程所有锁都释放才能获取到。
代价
使用锁会使程序运行速度下降,只要一个线程获取了锁,其他线程只能阻塞直到锁被释放。
但是使用锁很多时候都是不可避免的,一个快速得出的错误结果是没有意义的。
恰当的使用锁,在保证程序正确的情况下使用锁的时间越短越好。只要多个线程没有在同一时间去段去获取锁,锁几乎没有代价。
原子操作需要上锁,只要多步操作不可分割就需要上锁。例如自增操作,从获取数据,增加到覆盖,这个过程是不可分割的。
Event
事件event可以用一个红绿灯模型来说明。
当红灯亮时,所有车辆都需要在线后等待。直到切换到绿灯,所有车辆齐步前进。
threading.Event
在Event内部有一个flag标记,当flag为False时,所有wait的线程都会阻塞,直到timeout或者flag被设为Ture。该标记默认值为False。
| 方法 | 作用 |
|---|---|
| set | 将标记设为True |
| clear | 将标记设为False |
| isSet | 返回标记的值 |
| wait | 如果标记为False,当前线程阻塞直到标记为True或者timeout |
Condition
条件对象,类似于Event事件,但是条件对象可以决定每次唤醒多少个线程,Event类似固定唤醒所有等待线程的condition。
threading.Condition
class threading.Condition(lock=None)
实现条件变量对象的类。一个条件变量对象允许一个或多个线程在被其它线程所通知之前进行等待。
如果给出了非 None 的 lock 参数,则它必须为 Lock 或者 RLock 对象,并且它将被用作底层锁。否则,将会创建新的 RLock 对象,并将其用作底层锁。
threading.Condition使用wait时会为每个等待线程创建一个锁,并将其存放在一个双向队列并上锁。notify会从队列中取出对应数量的锁来release。
| 方法 | 用法 |
|---|---|
| wait | 进入等待队列并阻塞直到被notify或者timeout |
| notify | 唤醒指定数量的等待线程 |
| notify_all | 唤醒所有等待线程 |
| wait_for | 等待,直到传入的可调用对象返回值为真或者timeout |
注意:Condition中存在队列,所以每个等待的线程都是有机会被唤醒的。由于Condition实例对象存在一把大锁,必须在调用上述方法前进行acquire,否则将引发RuntimeError异常。
Barrier
class threading.Barrier(parties, action=None, timeout=None)
创建一个需要 parties 个线程的栅栏对象。如果提供了可调用的 action 参数,它会在所有线程被释放时在其中一个线程中自动调用。 timeout 是默认的超时时间,如果没有在 wait() 方法中指定超时时间的话。
Barrier又被称为栅栏对象,用于应对固定数量的线程需要彼此相互等待的情况。线程在调用wait()方法后将会阻塞,直到指定的数量的线程都调用了wait()方法。此时所有线程都会被释放。
| 方法 | 用法 |
|---|---|
| wait | 阻塞直到指定数量的线程调用该方法或者timeout,timeout将会导致barrier处于broken破损状态并使所有wait线程引发BrokenBarrierError异常 |
| reset | 使barrier恢复初始状态。如果栅栏中仍有线程等待释放,这些线程将会收到 BrokenBarrierError 异常。 |
| abort | 使栅栏进入破损态。这将导致所有已经调用和未来调用的 wait() 方法中引发 BrokenBarrierError 异常。使用这个方法的一种情况是需要中止程序以避免死锁。 |
parties
冲出栅栏所需要的线程数量。
n_waiting
当前时刻正在栅栏中阻塞的线程数量。
broken
一个布尔值,值为 True 表明栅栏为破损态。
Semaphore
class threading.Semaphore(value=1)
该类实现信号量对象。信号量对象管理一个原子性的计数器,代表 release() 方法的调用次数减去 acquire() 的调用次数再加上一个初始值。如果需要, acquire() 方法将会阻塞直到可以返回而不会使得计数器变成负数。在没有显式给出 value 的值时,默认为1。
可选参数 value 赋予内部计数器初始值,默认值为 1 。如果 value 被赋予小于0的值,将会引发 ValueError 异常。
| 方法 | 用法 |
|---|---|
| acquire | 将内部计数器减1并返回True,如果计数器当前值为0则阻塞直到数值大于0。timeout和non-bloking于lock效果一致 |
| release | 使内部计数加1 |
Semaphore计数器必须大于等于0,但是没有上限,通过release能使计数器的值可以大于实例化时给的初始值。
如果需要限制其上限可以使用BoundedSemaphore
class threading.BoundedSemaphore(value=1)
该类实现有界信号量。有界信号量通过检查以确保它当前的值不会超过初始值。如果超过了初始值,将会引发 ValueError 异常。在大多情况下,信号量用于保护数量有限的资源。如果信号量被释放的次数过多,则表明出现了错误。没有指定时, value 的值默认为1。
应用
semaphore通常用于保护数量有限的资源,比如数据库服务器。在资料数量固定的情况下,应该使用有界的信号量对连接数进行控制。
GIL
GIL作为CPython中进程级的一把大锁,对于Python编程影响相当巨大。
在同一个Python解释器中,要求所有进程都要获取GIL锁才能运行。这意味着CPython不存在真正的并行,即便在多核心cpu计算机上。
要注意的是,并非有了GIL就无需考虑线程同步问题,GIL只能保证单字节码是原子性的,多个字节码的操作依旧会从在中途被打断。
CPU密集型和IO密集型
- CPU密集型指程序的大部分时间都用于计算,花费的时间基本来源于CPU的运算时间
- IO密集型指程序的大部分时间都用于等待IO设备的输入输出,用户、文件、数据库等。
Python为什么要使用GIL
- Python使用了引用计数,记录每个变量的引用次数,当引用数量为0时将其释放内存。而当多个进程并行时,则可能会导致出现一个错误的数值,因此导致内存泄漏甚至导致变量错误地被释放。想要解决这个问题可以使用多个锁来解决,但是同时使用多个锁又有可能导致死锁。这会导致Python的开发变得复杂。
- Python出现的时候还没有诞生多核心CPU,GIL的缺点并没有暴露出来。
- Python作为解释器语言,需要将线程切换转移到解释器层次,让Python能够在节码指令之间切换线程。也使得内置类型的很多操作都是原子的,不必再为这些操作考虑线程安全。
- 如果使用小粒度的锁,虽然能够支持多核并行,但是频繁地取得和释放锁。会使得单个线程的效率降低。
缺点
在CPU密集型的程序中,GIL的缺点十分明显。在单个进程中,同时只有一个进程在运行,也就是同一时间只有一个核心的CPU在运行这个进程,这使得多核心CPU的优势无法被利用。真正到了一核工作多核旁观。而在IO密集型中,反正大部分线程都在等待,几乎没有影响。
由于GIL是进程级别的锁,所以可以使用多进程来缓解这个问题。但是,多进程间的数据共享代价比起进程间大得多,并且进程启动是有代价的。
如何绕过GIL
可以使用C/C++来实现性能要求高的部分代码,生成对应的dll文件,使用Python的ctypes来调起。比如Numpy就使用了这种方式,能达到很高的效率。
本文深入探讨Python中线程同步的重要性和各种同步机制,包括Lock、RLock、Event、Condition、Barrier、Semaphore以及GIL的工作原理和应用场景,帮助读者理解如何避免线程安全问题。
923

被折叠的 条评论
为什么被折叠?



