第七章 基于线程的并行 -- 信号量对象、事件对象、定时器对象、栅栏对象

第零章 学前准备
第一章 数据结构 – 基本数据类型
第一章 数据结构 – 字符串
第一章 数据结构 – 列表、元组和切片
第一章 数据结构 – 字典
第一章 数据结构 – 集合
第一章 – 数组、队列、枚举
第一章 数据结构 – 序列分类
第二章 控制流程
第三章 函数也是对象 – 函数定义以及参数
第三章 函数也是对象 – 高阶函数以及装饰器
第三章 函数也是对象 – lambda 表达式、可调用函数及内置函数
第四章 面向对象编程 – 自定义类、属性、方法和函数
第四章 面向对象编程–魔术方法1
第四章 面向对象编程 – 魔术方法2
第四章 面向对象编程 – 可迭代的对象、迭代器和生成器
第四章 面向对象编程 – 继承、接口
第四章 面向对象编程 – 对象引用
第四章 面向对象编程 – 案例
第五章 文件操作
第六章 异常
第七章 基于线程的并行 – threading
第七章 基于线程的并行 – 锁对象、递归锁对象、条件对象
第七章 基于线程的并行 – 信号量对象、事件对象、定时器对象、栅栏对象


第七章 基于线程的并行 – 信号量对象、事件对象、定时器对象、栅栏对象

7.6 信号量对象

一个信号量管理一个内部计数器,该计数器因 acquire() 方法的调用而递减,因 release() 方法的调用而递增。 计数器的值永远不会小于零;当 acquire() 方法发现计数器为零时,将会阻塞,直到其它线程调用 release() 方法。信号量对象也支持 上下文管理协议 。

  1. class threading.Semaphore(value=1)
    该类实现信号量对象。信号量对象管理一个原子性的计数器,代表 release() 方法的调用次数减去 acquire() 的调用次数再加上一个初始值。如果需要, acquire() 方法将会阻塞直到可以返回而不会使得计数器变成负数。在没有显式给出 value 的值时,默认为 1 。可选参数 valu e 赋予内部计数器初始值,默认值为 1 。如果 value 被赋予小于 0 的值,将会引发 ValueError 异常。
  2. acquire(blocking=True, timeout=None)
    获取一个信号量。在不带参数的情况下调用时:如果在进入时内部计数器的值大于零,则将其减一并立即返回 True . 如果在进入时内部计数器的值为零,则将会阻塞直到被对 release() 的调用唤醒。 一旦被唤醒(并且计数器的值大于 0 ),则将计数器减 1 并返回 True 。 每次对 release() 的调用将只唤醒一个线程。 线程被唤醒的次序是不可确定的。当发起调用时将 blocking 设为假值,则不进行阻塞。 如果一个无参数调用将要阻塞,则立即返回 False ;在其他情况下,执行与无参数调用时一样的操作,然后返回 True 。当发起调用时如果 timeout 不为 None ,则它将阻塞最多 timeout 秒。 请求在此时段时未能成功完成获取则将返回 False。 在其他情况下返回 True。
  3. release(n=1)
    释放一个信号量,将内部计数器的值增加 n 。 当进入时值为零且有其他线程正在等待它再次变为大于零时,则唤醒那 n 个线程。
  4. class threading.BoundedSemaphore(value=1)
    该类实现有界信号量。有界信号量通过检查以确保它当前的值不会超过初始值。如果超过了初始值,将会引发 ValueError 异常。在大多情况下,信号量用于保护数量有限的资源。如果信号量被释放的次数过多,则表明出现了错误。没有指定时, value 的值默认为 1
  5. Semaphore 例子
    信号量通常用于保护数量有限的资源,例如数据库服务器。在资源数量固定的任何情况下,都应该使用有界信号量。在生成任何工作线程前,应该在主线程中初始化信号量。
maxconnections = 5
# ...
pool_sema = BoundedSemaphore(value=maxconnections) # 工作线程生成后,当需要连接服务器时,这些线程将调用信号量的 acquire 和 release 方法:

with pool_sema:
    conn = connectdb()
    try:
        # ... use connection ...
    finally:
        conn.close()

使用有界信号量能减少这种编程错误:信号量的释放次数多于其请求次数。

7.7 事件对象

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

  1. class threading.Event
    实现事件对象的类。事件对象管理一个内部标识,调用 set() 方法可将其设置为 true 。调用 clear() 方法可将其设置为 false 。调用 wait() 方法将进入阻塞直到标识为 true 。这个标识初始时为 false
  2. is_set()
    当且仅当内部标识为 true 时返回 True
  3. set()
    将内部标识设置为 true 。所有正在等待这个事件的线程将被唤醒。当标识为 true 时,调用 wait() 方法的线程不会被被阻塞。
  4. clear()
    将内部标识设置为 false 。之后调用 wait() 方法的线程将会被阻塞,直到调用 set() 方法将内部标识再次设置为 true
  5. wait(timeout=None)
    阻塞线程直到内部变量为 true 。如果调用时内部标识为 true ,将立即返回。否则将阻塞线程,直到调用 set() 方法将标识设置为 true 或者发生可选的超时。当提供了 timeout 参数且不是 None 时,它应该是一个浮点数,代表操作的超时时间,以秒为单位(可以为小数)。
import threading
import time
event = threading.Event()


def thread_one(seconds):
    for i in range(seconds):
        if i == 2:
            print("Event", "wait")
            event.wait()
        time.sleep(1)
        print("total", seconds, "秒", "thread_one", "runned", i+1, "秒")


def thread_two(seconds):
    for i in range(seconds):
        if i == 3:
            print("Event", "wait")
            event.wait()
        time.sleep(1)
        print("total", seconds, "秒", "thread_two", "runned", i+1, "秒")


if __name__ == "__main__":
    threading.Thread(target=thread_one, args=(20,)).start()
    threading.Thread(target=thread_two, args=(20,)).start()

    time.sleep(15)
    print("Event", "set")
    event.set()

7.8 定时器对象

此类表示一个操作应该在等待一定的时间之后运行。相当于一个定时器。 Timer 类是 Thread 类的子类,因此可以像一个自定义线程一样工作。与线程一样,通过调用 start() 方法启动定时器。而 cancel() 方法可以停止计时器(在计时结束前), 定时器在执行其操作之前等待的时间间隔可能与用户指定的时间间隔不完全相同。

  1. class threading.Timer(interval, function, args=None, kwargs=None)
    创建一个定时器,在经过 interval 秒的间隔事件后,将会用参数 args 和关键字参数 kwargs 调用 function。如果 argsNone (默认值),则会使用一个空列表。如果 kwargsNone (默认值),则会使用一个空字典。
  2. cancel()
    停止定时器并取消执行计时器将要执行的操作。仅当计时器仍处于等待状态时有效。
import threading


def thread_timer(seconds, loop):
    print(f"do {
      
      loop} something every {
      
      seconds}...")
    if loop >= 1:
        threading.Timer(interval=seconds, function=thread_timer,
                        args=(seconds, loop-1,)).start()


if __name__ == "__main__":
    threading.Timer(interval=3, function=thread_timer, args=(3, 10,)).start()

7.9 栅栏对象

栅栏类提供一个简单的同步原语,用于应对固定数量的线程需要彼此相互等待的情况。线程调用 wait() 方法后将阻塞,直到所有线程都调用了 wait() 方法。此时所有线程将被同时释放。栅栏对象可以被多次使用,但进程的数量不能改变。
这是一个使用简便的方法实现客户端进程与服务端进程同步的例子:

b = Barrier(2, timeout=5)

def server():
    start_server()
    b.wait()
    while True:
        connection = accept_connection()
        process_server_connection(connection)

def client():
    b.wait()
    while True:
        connection = make_connection()
        process_client_connection(connection)
  1. class threading.Barrier(parties, action=None, timeout=None)
    创建一个需要 parties 个线程的栅栏对象。如果提供了可调用的 action 参数,它会在所有线程被释放时在其中一个线程中自动调用。 timeout 是默认的超时时间,如果没有在 wait() 方法中指定超时时间的话。

  2. wait(timeout=None)
    冲出栅栏。当栅栏中所有线程都已经调用了这个函数,它们将同时被释放。如果提供了 timeout 参数,这里的 timeout 参数优先于创建栅栏对象时提供的 timeout 参数。函数返回值是一个整数,取值范围在0parties - 1,在每个线程中的返回值不相同。可用于从所有线程中选择唯一的一个线程执行一些特别的工作。例如:

    i = barrier.wait()
    if i == 0:
        # Only one thread needs to print this
        print("passed the barrier")
    

    如果创建栅栏对象时在构造函数中提供了 action 参数,它将在其中一个线程释放前被调用。如果此调用引发了异常,栅栏对象将进入损坏态。如果发生了超时,栅栏对象将进入破损态。如果栅栏对象进入破损态,或重置栅栏时仍有线程等待释放,将会引发 BrokenBarrierError 异常。

  3. reset()
    重置栅栏为默认的初始态。如果栅栏中仍有线程等待释放,这些线程将会收到 BrokenBarrierError 异常。请注意使用此函数时,如果存在状态未知的其他线程,则可能需要执行外部同步。 如果栅栏已损坏则最好将其废弃并新建一个。

  4. abort()
    使栅栏处于损坏状态。 这将导致任何现有和未来对 wait() 的调用失败并引发 BrokenBarrierError。 例如可以在需要中止某个线程时使用此方法,以避免应用程序的死锁。更好的方式是:创建栅栏时提供一个合理的超时时间,来自动避免某个线程出错。

  5. parties
    冲出栅栏所需要的线程数量。

  6. n_waiting
    当前时刻正在栅栏中阻塞的线程数量。

  7. broken
    一个布尔值,值为 True 表明栅栏为破损态。

  8. exception threading.BrokenBarrierError
    异常类,是 RuntimeError 异常的子类,在 Barrier 对象重置时仍有线程阻塞时和对象进入破损态时被引发。

7.10 GIL – 全局解释器锁

7.10.1 介绍

全局解释器锁(global interpreter lock)是CPython 解释器所采用的一种机制,它确保同一时刻只有一个线程在执行 Python bytecode。此机制通过设置对象模型(包括 dict 等重要内置类型)针对并发访问的隐式安全简化了 CPython 实现。给整个解释器加锁使得解释器多线程运行更方便,其代价则是牺牲了在多处理器上的并行性。

However, some extension modules, either standard or third-party, are designed so as to release the GIL when doing computationally intensive tasks such as compression or hashing. Also, the GIL is always released when doing I/O.

7.10.2 出现的原因

因为C语言的内存管理不是线程安全的,所以CPythonGILJPython没有。

7.10.3 GIL优点

  • 在单线程任务中更快;在多线程任务中,对于I/O密集型程序运行更快;
  • 在多任务中,对于用C语言包来实现CPU密集型任务的程序运行更快;
  • 在写C扩展的时候更加容易,因为除非你在扩展中允许,否则Python解释器不会切换线程;
  • 在打包C库时更加容易。我们不用担心线程安全性。因为如果该库不是线程安全的,则只需在调用GIL时将其锁定即可。

7.10.3 带来的影响

因为有GIL的存在,由CPython做解释器的多线程Python程序只能利用多核处理器的一个核来运行。

7.10.4 如何避免GIL的影响

有两个建议:

  1. 在以IO操作为主的IO密集型应用中,多线程和多进程的性能区别并不大,原因在于即使在Python中有GIL锁的存在,由于线程中的IO操作会使得线程立即释放GIL,切换到其他非IO线程继续操作,提高程序执行效率。相比进程操作,线程操作更加轻量级,线程之间的通讯复杂度更低,建议使用多线程。
  2. 如果是计算密集型的应用,尽量使用多进程或者协程来代替多线程。

Guess you like

Origin blog.csdn.net/qq_31654025/article/details/132781122