守护线程、线程间的互斥锁、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
类创建了两个线程实例t1
和t2
-
其中
t1
的目标函数是opp
,t2
的目标函数是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 睡觉结束
# ....
# 不会卡死 正常运行