守护线程、线程间的互斥锁、GIL全局解释器锁(计算密集型、IO密集型)、死锁和递归锁

【一】守护线程

  • Python中的主线程是程序的起始线程,即程序启动时自动创建的第一个线程,它执行程序的主体逻辑。
  • 守护线程则是在后台运行并依赖于主线程或非守护线程的存在。

【1】主线程死亡,子线程未死亡

  • 主线程结束运行后不会马上结束,而是等待其他非守护子线程结束之后才会结束
  • 如果主线程死亡就代表着主进程也死亡,随之而来的是所有子线程的死亡。
import random
import time
from threading import Thread


def work(name):
    print(f'当前 {name} is starting ...')
    time.sleep(random.randint(1, 4))
    print(f'当前 {name} is ending ...')


def main():
    print(f'this is main process start ...')
    task = Thread(target=work, args=('chosen',))
    task.start()
    print(f'this is main process end ...')

if __name__ == '__main__':
    main()
    
# this is main process start ...  主线程启动
# 当前 chosen is starting ... 子线程启动
# this is main process end ... 主线程死亡 --- 子线程应该也随之死亡
# 当前 chosen is ending ... 但是子线程未死亡

【2】优化:主线程死亡,子线程也死亡

import time
from threading import Thread


def work(name):
    print(f'当前 {name} is starting ...')
    time.sleep(4)
    print(f'当前 {name} is ending ...')


def main():
    print(f'this is main process start ...')
    task = Thread(target=work, args=('chosen',))
    # 开启守护进程,主线程结束,子线程也随之结束
    task.daemon = True
    task.start()
    print(f'this is main process end ...')

if __name__ == '__main__':
    main()

# this is main process start ...
# 当前 chosen is starting ...
# this is main process end ...

【3】迷惑性例子

  • 代码示例
import time
from threading import Thread


# 定义一个 opp 函数,模拟耗时操作
def opp():
    # 打印开始信息
    print(f'this is opp starting ...')
    # 模拟耗时操作,暂停5秒
    time.sleep(5)
    # 打印结束信息
    print(f'this is opp ending ...')

# 定义另一个函数 func ,同样模拟耗时操作
def func():
    # 打印开始信息
    print(f'this is func start')
    # 模拟耗时操作,暂定3秒
    time.sleep(3)
    # 打印结束信息
    print(f'this is func end')

# 主函数
def main():
    print(f'this is main start')
    # 创建线程 task_opp,目标函数为opp
    task_opp = Thread(target=opp)
    # 设置 task_opp 为守护进程
    # 意味着当主线程结束时,不论 task_opp 是否执行完毕都会被强制终止
    task_opp.daemon = True
    # 创建线程 task_func,目标函数为 func
    task_func = Thread(target=func)
    # 启动线程 task_opp
    task_opp.start()
    # 启动线程 task_func
    task_func.start()
    print(f'this is main end')

if __name__ == '__main__':
    main()
    
# this is main start
# this is opp starting ...
# this is func start
# this is main end
# this is func end

(1)执行过程

  • 初始化阶段

    • 程序开始执行时,首先会导入所需的模块,并定义两个函数opp()func()
    • 这两个函数分别代表两个需要并发执行的任务。
  • 线程创建与启动

    • main()函数中

    • 首先通过Thread类创建了两个线程实例t1t2

    • 其中 t1 的目标函数是 oppt2 的目标函数是 func

    • 然后将t1设置为守护线程(daemon=True),这意味着当主线程结束时,即使t1尚未执行完毕也会被系统终止。

    • 之后,两个线程通过start()方法启动,这意味着它们将异步地执行各自的目标函数。

(2)原理分析

  • 并发执行

    • t1开始执行,打印出“this is opp starting ...”,随后进入5秒的等待状态。
    • 几乎同时,t2也开始执行,打印出“this is func start”,并进入3秒的等待状态。
    • 由于线程调度机制,实际的打印顺序可能会略有不同,但通常情况下func()会先于opp()结束,因为它的等待时间较短。
  • 主线程执行

    • 主线程继续执行,打印出“this is main start”。

    • 由于t1被设置为守护线程,即便它还在睡眠中,当主线程执行结束后,整个程序也会直接终止,此时t1不论是否完成都会被系统停止。

    • t2作为一个非守护线程,如果在主线程结束前已完成,则正常结束,否则也会随程序终止。

【二】线程间的互斥锁

  • 因为线程是共享用一个进程下的数据
  • 限制多个进程或多个线程修改数据,使只有一个进程或一个线程修改数据。
  • 互斥锁其实是用在线程上面的
  • 进程之间数据会进行隔离 ---》所以不需要加锁处理每一个进程间的数据

【1】问题

  • 所有子线程都会进行阻塞操作,导致最后的改变只是改了一次
import time
from threading import Thread

money = 100

def work():
    global money

    # 模拟获取到车票信息
    temp = money
    # 模拟网络延迟
    time.sleep(3)
    #模拟购票
    money = temp - 1

def main():
    task_list = []
    for i in range(100):
        task = Thread(target=work)
        task_list.append(task)
    for task in task_list:
        task.start()
    for task in task_list:
        task.join()
    print(money)

if __name__ == '__main__':
    main()
    
# 99

【2】解决办法

  • 在数据发送变化的地方进行加锁处理
import time
from threading import Thread, Lock

money = 100
# 生成锁
lock = Lock()


def work():
    global money

    # 数据发送改变之前加锁
    lock.acquire()
    # 模拟获取到车票信息
    temp = money
    # 模拟网路延迟
    time.sleep(1)
    # 模拟网络购票
    money = temp - 1
    # 数据发生改变后解锁
    lock.release()

def main():
    task_list = [Thread(target=work) for i in range(100)]
    [task.start() for task in task_list]
    [task.join() for task in task_list]

    # 所有子线程结束后打印 money
    print(money)

if __name__ == '__main__':
    main()
    
# 0

【三】GIL全局解释器锁

【1】官方解释

In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple
native threads from executing Python bytecodes at once. This lock is necessary mainly

because CPython’s memory management is not thread-safe. (However, since the GIL
exists, other features have grown to depend on the guarantees that it enforces.)

结论:在Cpython解释器中,同一个进程下开启的多线程,同一时刻只能有一个线程执行,无法利用多核优势

【2】GIL锁与普通互斥锁的区别

(1)普通版

  • 当睡了1s 后
  • 所有线程都去抢 GIL 锁住的数据,当所有子线程都抢到后再去修改数据就变成了99
import time
from threading import Thread

money = 100


def work():
    global money
    temp = money
    time.sleep(1)
    money = temp - 1

def main():
    task_list = [Thread(target=work) for i in range(100)]
    [task.start() for task in task_list]
    [task.join() for task in task_list]
    print(money)

if __name__ == '__main__':
    main()
   

# 99

(2)升级版

  • 谁先抢到谁就先处理数据
from threading import Thread

money = 100


def work():
    global money
    
    temp = money
    money = temp - 1

def main():
    task_list = [Thread(target=work) for i in range(100)]
    [task.start() for task in task_list]
    [task.join() for task in task_list]
    print(money)

if __name__ == '__main__':
    main()
  
# 0

(3)终极版

  • 自动加锁并解锁
  • 子线程启动 , 后先去抢 GIL 锁 , 进入 IO 自动释放 GIL 锁 , 但是自己加的锁还没解开 ,其他线程资源能抢到 GIL 锁,但是抢不到互斥锁
  • 最终 GIL 回到 互斥锁的那个进程上,处理数据
import time
from threading import Thread, Lock

money = 100
mutex = Lock()


def work():
    global money
    
    # 自动执行 加锁 在解锁操作
    with mutex:
        temp = money
        # 只要进入 IO 会自动释放 GIL锁
        time.sleep(1)
        money = temp - 1


def main():
    task_list = [Thread(target=work) for i in range(100)]
    [task.start() for task in task_list]
    [task.join() for task in task_list]
    print(money)


if __name__ == '__main__':
    main()
  
# 0 

【3】GIL导致多线程无法利用多核优势

(1)Cpython 解释器中 GIL

  • 在 Cpython 解释器中 GIL 是一把互斥锁,用来阻止同一个进程下的多个线程的同时进行

    • 同一个进程下的多个线程无法利用这一优势?
    • Python的多线程是不是一点用都没有?
  • 因为在 Cpython 中的内存管理不是线程安全的

    • ps:内存管理(垃圾回收机制)
      • 应用计数
      • 标记清除
      • 分代回收

(2)Python的多线程是不是一点用都没有?

  • 同一个进程下的多线程无法利用多核优势,是不是就没用了

  • 多线程是否有用要看情况

    • 单核
      • 四个任务(IO密集型/计算密集型)
    • 多核
      • 四个任务(IO密集型/计算密集型)

【1】计算密集型

  • 一直处在计算运行中

  • 每个任务都需要 10s

    • 单核
      • 多进程:额外消耗资源
      • 多线程:减少开销
    • 多核
      • 多进程:总耗时 10s
      • 多线程:总耗时 40s+
import time
from threading import Thread
from multiprocessing import Process
import os

def work_calculate(n=1000):
    result = 1
    for i in range(1,n+1):
        result *= 1
    # 为了确保计算确实发生并防止编译器优化,可以考虑使用结果
    _ = sum(int(digit) for digit in str(result))

def timer(func):
    def inner(*args, **kwargs):
        # 定义起始时间
        start_time = time.time()
        func(*args, **kwargs)
        print(f'总耗时:>>>{time.time() - start_time}')
    return inner


@timer
def main(cls):
    # 获取当前 CPU 运行的个数
    print(f'当前正在使用的CPU个数:>>>{os.cpu_count()}')

    # 创建任务列表
    task_list = [cls(target=work_calculate) for i in range(400)]
    # 启动任务
    [task.start() for task in task_list]
    # 阻塞任务
    [task.join() for task in task_list]

if __name__ == '__main__':
    # 计算密集型
    # 多进程下的耗时
    main(cls=Process)
    # 当前正在使用的CPU个数:>>>12
    # 总耗时:>>>6.356434106826782

    # 多线程下的耗时
    main(cls=Thread)
    # 当前正在使用的CPU个数:>>>12
    # 总耗时:>>>0.06648612022399902

【2】IO 密集型

  • 存在多个 IO 阻塞切换操作
  • 每个任务都需要 10 s
    • 多核
      • 多进程:相对浪费资源
      • 多线程:更加节省资源
import time
import os
from multiprocessing import Process
from threading import Thread


def work_io():
    time.sleep(2)


def work_calculate(n=1000):
    result = 1
    for i in range(1, n + 1):
        result *= 1
    # 为了确保计算确实发生并防止编译器优化,可以考虑使用结果
    _ = sum(int(digit) for digit in str(result))


def timer(func):
    def inner(*args, **kwargs):
        # 定义起始时间
        start_time = time.time()
        func(*args, **kwargs)
        print(f'总耗时:>>>{time.time() - start_time}')

    return inner


@timer
def main(cls):
    # 获取当前 CPU 运行的个数
    print(f'当前正在使用的CPU个数:>>>{os.cpu_count()}')

    # 创建任务列表
    task_list = [cls(target=work_io) for i in range(400)]
    # 启动任务
    [task.start() for task in task_list]
    # 阻塞任务
    [task.join() for task in task_list]


if __name__ == '__main__':
    # 计算密集型
    # 多进程下的耗时
    main(cls=Process)
    # 当前正在使用的CPU个数:>>>12
    # 总耗时:>>>7.833707332611084

    # 多线程下的耗时
    main(cls=Thread)
    # 当前正在使用的CPU个数:>>>12
    # 总耗时:>>>2.064544439315796

【3】总结

  • 计算是消耗cpu的:

    • 代码执行,算术,for都是计算
  • io不消耗cpu:

    • 打开文件,写入文件,网络操作都是io
    • 如果遇到io,该线程会释放cpu的执行权限,cpu转而去执行别的线程
  • 由于python有gil锁,开启多条线程,统一时刻,只能有一条线程在执行

  • 如果是计算密集型,开了多线程,同一时刻,只有一个线程在执行

  • 多核cpu,就会浪费多核优势

  • 如果是计算密集型,我们希望,多个核(cpu),都干活,同一个进程下绕不过gil锁

  • 所以我们开启多进程,gil锁只能锁住某个进程中得线程,开启多个进程,就能利用多核优势

  • io密集型---》只要遇到io,就会释放cpu执行权限

  • 进程内开了多个io线程,线程多半都在等待,开启多进程是不能提高效率的,反而开启进程很耗费资源,所以使用多线程即可

(1)计算密集型任务(多进程)
  • 计算密集型任务主要是指需要大量的CPU计算资源的任务,其中包括执行代码、进行算术运算、循环等。

    • 在这种情况下,使用多线程并没有太大的优势。
    • 由于Python具有全局解释器锁(Global Interpreter Lock,GIL),在同一时刻只能有一条线程执行代码,这意味着在多线程的情况下,同一时刻只有一个线程在执行计算密集型任务。
  • 但是,如果使用多进程,则可以充分利用多核CPU的优势。

    • 每个进程都有自己独立的GIL锁,因此多个进程可以同时执行计算密集型任务,充分发挥多核CPU的能力。
    • 通过开启多个进程,我们可以将计算密集的任务分配给每个进程,让每个进程都独自执行任务,从而提高整体的计算效率。
(2)IO密集型任务(多线程)
  • IO密集型任务主要是指涉及大量输入输出操作(如打开文件、写入文件、网络操作等)的任务。

    • 在这种情况下,线程往往会因为等待IO操作而释放CPU执行权限,不会造成太多的CPU资源浪费。
    • 因此,使用多线程能够更好地处理IO密集型任务,避免了频繁切换进程的开销。
  • 当我们在一个进程中开启多个IO密集型线程时,大部分线程都处于等待状态,开启多个进程却不能提高效率,反而会消耗更多的系统资源。

    • 因此,在IO密集型任务中,使用多线程即可满足需求,无需开启多个进程。
(3)总结
  • 计算密集型任务使用多进程可以充分利用多核CPU的优势,而IO密集型任务使用多线程能够更好地处理IO操作,避免频繁的进程切换开销。

    • 根据任务的特性选择合适的并发方式可以有效提高任务的执行效率。

【四】死锁和递归锁

【1】死锁

  • 死锁是指两个或多个进程,在执行过程中,因争夺资源而造成了互相等待的一种现象。
  • 即两个或多个进程持有各自的锁并试图获取对方持有的锁,从而导致被阻塞,不能向前执行,最终形成僵局。
  • 在这种情况下,系统资源利用率极低,系统处于一种死循环状态。
  • 例如:
    • 要吃饭,必须具备盘子和筷子
    • 但是一个人拿着盘子等着筷子,另一个人拿着筷子等盘子

【2】解决办法

  • 锁不要有多个,一个足够
  • 如果真的发生了死锁的问题,必须迫使一方先交出锁
from threading import Thread,Lock
import time
import random

lockA = Lock()
lockB = Lock()

# 类只要加括号多次,产生的肯定是不同的对象
# 如果想要实现多次加括号等到的是相同的对象 - 单例模式

class MyThread(Thread):
    def run(self):
        self.func1()
        self.func2()


    def func1(self):
        print(f'func1 {self.name} :>>> 开始加锁A')
        lockA.acquire()
        # self.name:获取当前线程名
        print(f'{self.name} 抢到了A锁')
        print(f'func1 {self.name} :>>> 开始加锁B')
        lockB.acquire()
        print(f'{self.name} 抢到了B锁')
        lockB.release()
        print(f'func1 {self.name} :>>> 开始释放 B 锁')
        lockA.release()
        print(f'func1 {self.name} :>>> 开始释放 A 锁')

    def func2(self):
        print(f'func2 {self.name} :>>> 开始加锁B')
        lockB.acquire()
        # self.name:获取当前线程名
        print(f'{self.name} 抢到了B锁')
        sleep_time = random.randint(1,3)
        print(f'func2 {self.name} 开始睡觉 :>>> {sleep_time}s')
        time.sleep(sleep_time)
        print(f'func2 {self.name} 睡觉结束')
        print(f'func2 {self.name} :>>> 开始加锁A')
        lockA.acquire()
        print(f'{self.name} 抢到了A锁')
        lockA.release()
        print(f'func2 {self.name} :>>> 释放 A 锁')
        lockB.release()
        print(f'func2 {self.name} :>>> 释放 B 锁')

def main():
    task_list = [MyThread() for i in range(5)]
    [task.start() for task in task_list]

if __name__ == '__main__':
    main()
  
# func1 Thread-1 :>>> 开始加锁A
# Thread-1 抢到了A锁
# func1 Thread-1 :>>> 开始加锁B
# Thread-1 抢到了B锁
# func1 Thread-1 :>>> 开始释放 B 锁
# func1 Thread-2 :>>> 开始加锁A
# func1 Thread-1 :>>> 开始释放 A 锁
# func2 Thread-1 :>>> 开始加锁B
# Thread-1 抢到了B锁
# func2 Thread-1 开始睡觉 :>>> 1s
# Thread-2 抢到了A锁
# func1 Thread-2 :>>> 开始加锁B
# func1 Thread-3 :>>> 开始加锁A
# func1 Thread-4 :>>> 开始加锁A
# func1 Thread-5 :>>> 开始加锁A
# func2 Thread-1 睡觉结束
# func2 Thread-1 :>>> 开始加锁A
# 线程卡死
# 第一个线程走完第一圈 回到原地抢 A,结果第二个线程已经拿到了A 导致卡死

【3】递归锁

  • 递归锁(也叫可重入锁)是一种特殊的锁,它允许一个线程多次请求同一个锁,称为“递归地”请求锁
  • 在该线程释放锁之前,会对锁计数器进行累加操作,线程每成功获得一次锁时,都要进行相应的解锁操作,直到锁计数器清零才能完全释放该锁。
  • 递归锁能够保证同一线程在持有锁时能够再次获取该锁,而不被自己所持有的锁所阻塞,从而避免死锁的发生。
  • 但是注意要正常使用递归锁,避免过多地获取锁导致性能下降。

【示例】

from threading import Thread,RLock
import time
import random

lockA = lockB = RLock()

# 类只要加括号多次,产生的肯定是不同的对象
# 如果想要实现多次加括号等到的是相同的对象 - 单例模式

class MyThread(Thread):
    def run(self):
        self.func1()
        self.func2()


    def func1(self):
        print(f'func1 {self.name} :>>> 开始加锁A')
        lockA.acquire()
        # self.name:获取当前线程名
        print(f'{self.name} 抢到了A锁')
        print(f'func1 {self.name} :>>> 开始加锁B')
        lockB.acquire()
        print(f'{self.name} 抢到了B锁')
        lockB.release()
        print(f'func1 {self.name} :>>> 开始释放 B 锁')
        lockA.release()
        print(f'func1 {self.name} :>>> 开始释放 A 锁')

    def func2(self):
        print(f'func2 {self.name} :>>> 开始加锁B')
        lockB.acquire()
        # self.name:获取当前线程名
        print(f'{self.name} 抢到了B锁')
        sleep_time = random.randint(1,3)
        print(f'func2 {self.name} 开始睡觉 :>>> {sleep_time}s')
        time.sleep(sleep_time)
        print(f'func2 {self.name} 睡觉结束')
        print(f'func2 {self.name} :>>> 开始加锁A')
        lockA.acquire()
        print(f'{self.name} 抢到了A锁')
        lockA.release()
        print(f'func2 {self.name} :>>> 释放 A 锁')
        lockB.release()
        print(f'func2 {self.name} :>>> 释放 B 锁')

def main():
    for i in range(3):
        t = MyThread()
        t.start()

if __name__ == '__main__':
    main()
    
# Thread-1 抢到了A锁
# func1 Thread-1 :>>> 开始加锁B
# Thread-1 抢到了B锁
# func1 Thread-1 :>>> 开始释放 B 锁
# func1 Thread-1 :>>> 开始释放 A 锁
# func2 Thread-1 :>>> 开始加锁B
# Thread-1 抢到了B锁
# func2 Thread-1 开始睡觉 :>>> 2s
# func1 Thread-2 :>>> 开始加锁A
# func1 Thread-3 :>>> 开始加锁A
# func2 Thread-1 睡觉结束
# ....
# 不会卡死 正常运行
posted @ 2024-05-29 08:48  光头大炮  阅读(7)  评论(0编辑  收藏  举报