29. GIL全局解释器锁、信号量、线程池进程池

1. GIL全局解释器锁

 1.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解释器中,同一个进程下开启的多线程,同一时刻只能有一个线程运行,无法利用多核优势
'''

GIL是一个纯理论知识,编程过程中无需考虑它的存在。

怎样理解互斥锁的存在:不加互斥锁时开启多线程,第一个线程里面的代码运行完毕才能去运行第二个线程里面的代码(在子线程功能函数没有休眠的前提下)

1.2 GIL锁的代码理解:多线程不加互斥锁、无休眠

这个例子验证了GIL锁存在,一个线程结束之后才能运行下一个线程,即证明有锁存在

from threading import Thread

num = 6

def work():
    global num
    temp = num
    print(f'休眠之前为{num}')
    num = temp - 1
    print(f'休眠之后为{num}')

def create_thread():
    print(f'修改之前为{num}')
    task_list = [Thread(target=work) for i in range(6)]
    [task.start() for task in task_list]
    [task.join() for task in task_list]
    print(f'修改之后为{num}')

if __name__ == '__main__':
    create_thread()

 1.3 其它情况对照:多线程不加互斥锁、有休眠

import time
from threading import Thread

num = 6

def work():
    global num
    temp = num
    print(f'休眠之前为{num}')
    time.sleep(0.001)  # 休眠起到切换线程的作用
    num = temp - 1
    print(f'休眠之后为{num}')

def create_thread():
    print(f'修改之前为{num}')
    task_list = [Thread(target=work) for i in range(6)]
    [task.start() for task in task_list]
    [task.join() for task in task_list]
    print(f'修改之后为{num}')

if __name__ == '__main__':
    create_thread()

 1.4 其它情况对照:多线程互斥加锁、有休眠

import time
from threading import Thread, Lock

num = 6

def work(lock):
    lock.acquire()
    global num

    temp = num
    print(f'休眠之前为{num}')
    time.sleep(0.001)  # 休眠起到切换线程的作用;加了锁之后休眠已经无法切换线程
    num = temp - 1
    print(f'休眠之后为{num}')
    lock.release()

def create_thread():
    lock = Lock()
    print(f'修改之前为{num}')
    task_list = [Thread(target=work, args=(lock,)) for i in range(6)]
    [task.start() for task in task_list]
    [task.join() for task in task_list]
    print(f'修改之后为{num}')

if __name__ == '__main__':
    create_thread()

 1.5 其它情况对照:加自动互斥锁

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

with lock利用上下文管理器协议自动处理锁的获取和释放,避免忘记释放锁导致的死锁问题。

import time
from threading import Thread, Lock

num = 6
lock = Lock()

def work():
    with lock:  # 加上自动锁
        global num
        temp = num
        print(f'休眠之前为{num}')
        time.sleep(0.001)  # 休眠起到切换线程的作用;加锁之后休眠已经无法切换线程
        num = temp - 1
        print(f'休眠之后为{num}')

def create_thread():
    print(f'修改之前为{num}')
    task_list = [Thread(target=work) for i in range(6)]
    [task.start() for task in task_list]
    [task.join() for task in task_list]
    print(f'修改之后为{num}')

if __name__ == '__main__':
    create_thread()

 1.6 GIL全局解释器锁导致多线程无法利用多核优势

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

在Cpython中的内存管理(垃圾回收机制:引用计数、标记清除、分代回收)不是线程安全的。

同一个进程下的多线程无法利用多核优势,是不是就没用了?多线程是否有用要看情况。

(1)计算密集型

计算密集型多进程下的耗时

import time
import os
from multiprocessing import Process

def work_calculate():
    result = 1
    for i in range(1, 90):
        result *= i
    # print(f'计算结果为{result}')

def cal_time(func):
    def inner(*args, **kwargs):
        start = time.time()
        res = func(*args, **kwargs)
        end = time.time()
        print(f'函数{func.__name__}总耗时: {end - start}')
        return res

    return inner

@cal_time
def create_process():
    print(f'正在使用的CPU个数{os.cpu_count()}')
    task_list = [Process(target=work_calculate) for i in range(90)]
    [task.start() for task in task_list]
    [task.join() for task in task_list]

if __name__ == '__main__':
    create_process()

 计算密集型多线程下的耗时

import time
import os
from threading import Thread

def work_calculate():
    result = 1
    for i in range(1, 90):
        result *= i
    # print(f'计算结果为{result}')

def cal_time(func):
    def inner(*args, **kwargs):
        start = time.time()
        res = func(*args, **kwargs)
        end = time.time()
        print(f'函数{func.__name__}总耗时: {end - start}')
        return res

    return inner

@cal_time
def create_thread():
    print(f'正在使用的CPU个数{os.cpu_count()}')
    task_list = [Thread(target=work_calculate) for i in range(90)]
    [task.start() for task in task_list]
    [task.join() for task in task_list]

if __name__ == '__main__':
    create_thread()

以上work_calculate 是纯计算(CPU‑bound),没有 I/O。

对比项Thread(线程)Process(进程)
创建成本 只分配线程栈、寄存器状态等,约数 KB ~ 数十 KB。操作系统只需在进程地址空间内新增一个执行上下文。 需要 fork(或 spawn)出一个全新的进程,复制父进程的地址空间(写时拷贝),加载新的 Python 解释器、重新初始化模块、导入代码。内存占用 数十 MB(与主程序相同),耗时 约数毫秒到数十毫秒(取决于系统负载和模块大小)。
上下文切换 只切换寄存器、栈指针等,开销约 1‑2 µs。 需要切换页表、刷新 TLB、切换进程上下文的全部资源,开销约 10‑100 µs(在大量进程竞争时更高)。
GIL(全局解释器锁) 所有线程共享同一个 GIL,同一会话里同一时刻只有一个线程在执行 Python 字节码。对 CPU‑bound 任务不能实现真正的并行。 每个进程拥有独立的 GIL,可以实现 真正的多核并行。
内存共享 线程之间共享全局变量,通信成本低(直接读写)。 进程之间默认隔离,通信需要 IPC(队列、管道、共享内存),额外的序列化/反序列化开销。

work_calculate 本身只运行约 0.01 ms 左右(在普通机器上 90次整数乘法几乎瞬间完成),因此 创建与销毁的开销占比极高。

线程的创建/销毁几乎可以忽略不计,而每个进程都要“重启一次 Python”,这本身就耗时数毫秒到数十毫秒。90 个进程的总建立时间很快就会把整体计时撑大。

结论:对大量 短时、轻量 的任务,Thread 的 低开销 使其在 总耗时(含启动、等待)上表现优于 Process。

 

GIL 与 CPU‑bound 任务的真实并行性
线程:由于 GIL 的存在,即使有 1000 个线程,它们也只能 交替执行(在 CPython 中,每执行约 1000 字节码或 IO 操作就会释放一次 GIL),所以在 CPU‑bound 场景下,多线程并不提速,甚至可能略慢。在以上例子中,每个线程只做了极少量的计算,GIL 的切换开销反而比实际计算时间还小,整体看起来还 “快”。
进程:每个进程拥有独立的 GIL,因此可以在不同 CPU 核心上 真正并行 运行。若任务足够重(例如每个任务需要几秒甚至更长),进程版本会把总耗时压到接近 max(task时间) / cpu_count。但正因为任务是 轻量 的,进程的 启动/调度开销 会把并行优势抵消。
一句话概括:线程没有真正的并行,但 启动快;进程有真正的并行,但 启动慢。对短时任务,启动时间的差距占主导,所以线程在整体计时上表现更好。

场景推荐方式原因
大量短时、轻量的任务(如你的 1000 次简单计算) Thread 或 ThreadPoolExecutor 启动/销毁开销最小,GIL 对性能影响不显著,整体耗时最低。
CPU‑bound、任务量较大(每个任务耗时 > 10 ms) Process / ProcessPoolExecutor 可以利用多核并行,GIL 不再是瓶颈。
I/O‑bound(网络、文件读写) Thread / asyncio I/O 操作会释放 GIL,线程足以实现并发,且开销更低。
需要共享大量数据或状态 Thread(加锁)或 multiprocessing.shared_memory 线程天然共享内存,进程需要显式的 IPC,成本高。
需要隔离、避免崩溃影响 Process

 

创建/销毁开销:进程要比线程“重”得多(需要 fork/ 新解释器、地址空间、模块重新加载),1000 个进程的启动时间远大于 1000 个线程的启动时间。
GIL 导致线程实际上是串行执行,对每个短时任务的执行时间本身差异不大;而进程的并行优势在每个任务只有微秒级的情况下被启动开销所掩盖。
以上两个代码装饰器测量的是完整生命周期(创建、启动、等待),因此包含了上述额外的开销。

(2)IO密集型

模仿IO密集型:多个IO切换操作

IO密集型多进程下的耗时

import os
import time
from multiprocessing import Process

def io_switch():
    # print('子进程开始运行')
    time.sleep(1)
    # print('子进程结束运行')

def cal_time(func):
    def inner(*args, **kwargs):
        start = time.time()
        res = func(*args, **kwargs)
        end = time.time()
        print(f'函数{func.__name__}运行总耗时{end - start}')
        return res

    return inner

@cal_time
def create_process():
    print(f'正在运行的CPU个数是{os.cpu_count()}')
    task_list = [Process(target=io_switch) for i in range(90)]
    [task.start() for task in task_list]
    [task.join() for task in task_list]

if __name__ == '__main__':
    create_process()

 IO密集型多线程下的耗时

import os
import time
from threading import Thread

def io_switch():
    # print('子线程开始运行')
    time.sleep(1)
    # print('子线程结束运行')

def cal_time(func):
    def inner(*args, **kwargs):
        start = time.time()
        res = func(*args, **kwargs)
        end = time.time()
        print(f'函数{func.__name__}运行总耗时{end - start}')
        return res

    return inner

@cal_time
def create_thread():
    print(f'正在运行的CPU个数是{os.cpu_count()}')
    task_list = [Thread(target=io_switch) for i in range(90)]
    [task.start() for task in task_list]
    [task.join() for task in task_list]

if __name__ == '__main__':
    create_thread()

 (3)计算密集型与IO密集型的理论

[1]计算密集型任务(多进程)
  计算密集型任务主要是指需要大量的CPU计算资源的任务,其中包括执行代码、进行算术运算、循环等。
  在这种情况下,使用多线程并没有太大的优势。
  由于Python具有全局解释器锁(Global Interpreter Lock,GIL),在同一时刻只能有一条线程执行代码,这意味着在多线程的情况下,同一时刻只有一个线程在执行计算密集型任务。
  但是,如果使用多进程,则可以充分利用多核CPU的优势。
  每个进程都有自己独立的GIL锁,因此多个进程可以同时执行计算密集型任务,充分发挥多核CPU的能力。
  通过开启多个进程,我们可以将计算密集的任务分配给每个进程,让每个进程都独自执行任务,从而提高整体的计算效率。
[2]IO密集型任务(多线程)
  IO密集型任务主要是指涉及大量输入输出操作(如打开文件、写入文件、网络操作等)的任务。
  在这种情况下,线程往往会因为等待IO操作而释放CPU执行权限,不会造成太多的CPU资源浪费。
  因此,使用多线程能够更好地处理IO密集型任务,避免了频繁切换进程的开销。
  当我们在一个进程中开启多个IO密集型线程时,大部分线程都处于等待状态,开启多个进程却不能提高效率,反而会消耗更多的系统资源。
  因此,在IO密集型任务中,使用多线程即可满足需求,无需开启多个进程。
[3]总结
  计算密集型任务:使用多进程可以充分利用多核CPU的优势,CPU越多越好

  IO密集型任务:使用多线程能够更好地处理IO操作,避免频繁的进程切换开销。
  根据任务的特性选择合适的并发方式可以有效提高任务的执行效率。

计算消耗cpu多:代码执行,算术,for都是计算
io消耗cpu少:打开文件,写入文件,网络操作都是io
如果遇到io,该线程会释放cpu的执行权限,cpu转而去执行别的线程
由于python有gil锁,开启多条线程,同一时刻,只能有一个线程在执行
如果是计算密集型,开了多线程,同一时刻,只有一个线程在执行
多核cpu,就会浪费多核优势
如果是计算密集型,我们希望,多个核(cpu),都干活,同一个进程下绕不过gil锁
所以我们开启多进程,gil锁只能锁住某个进程中得线程,开启多个进程,就能利用多核优势
io密集型:只要遇到io,就会释放cpu执行权限
进程内开了多个io线程,线程多半都在等待,开启多进程是不能提高效率的,反而开启进程很耗费资源,所以使用多线程即可

1.7 GIL特点总结

GIL不是python的特点,而是Cpython解释器的特点

GIL作用面很窄,仅限于保证解释器级别的数据安全;

自定义锁用来保证更大范围的数据安全。

GIL会导致同一个进程下的多个线程无法同时进行,即无法利用多核优势

针对不同的数据需要加不同的锁处理

解释性语言共同的短板:同一个进程下的多个线程无法利用多核优势

2. 死锁

2.1 概念

死锁是指两个或多个进程(线程),在运行的过程中,因争夺资源而造成的互相等待的现象。

两个或多个进程(线程)持有各自的锁并试图获取对方持有的锁,从而导致阻塞,不能向后运行代码。

解决办法:如果发生了死锁问题,必须使一方先交出锁。

2.2 代码示例

import time
from threading import Thread, Lock

# 类加括号多次,每次产生的都是不同的对象   如果想要实现多次加括号得到的是相同的对象---单例模式
lock_a = Lock()
lock_b = Lock()

class NewThread(Thread):
    def run(self):
        self.work_one()
        self.work_two()  # 必须等work_one运行完

    def work_one(self):
        lock_a.acquire()  # 先拿锁a
        print(f'{self.name}拿到了锁a')  # Thread类里面有name属性,获取线程名
        lock_b.acquire()  # 再拿锁b
        print(f'{self.name}拿到了锁b')

        lock_b.release()  # 先释放锁b
        print(f'{self.name}释放了锁b')
        lock_a.release()  # 再释放锁a
        print(f'{self.name}释放了锁a')

    def work_two(self):
        lock_b.acquire()  # 先拿锁b
        print(f'{self.name}拿到了锁b,开始睡觉0.001秒')
        time.sleep(0.001)
        print(f'{self.name}睡觉结束')
        lock_a.acquire()  # 再拿锁a
        print(f'{self.name}拿到了锁a')

        lock_a.release()  # 释放锁a
        print(f'{self.name}释放了锁a')
        lock_b.release()
        print(f'{self.name}释放了锁b')

# 定义产生子线程函数
def create_thread():
    st_list = [NewThread() for i in range(1, 4)]
    [st.start() for st in st_list]

if __name__ == '__main__':
    create_thread()

 分析:

函数的运行顺序为:先运行完一个函数里面所有的代码,再去运行第二个函数(针对run函数体代码)

线程1运行到获取锁a,将切换到线程2,线程2由于获取不到锁a,将切换到线程3,线程3获取不到锁a,切换到线程1,线程1执行获取锁a之后的代码

获取锁b---释放锁b---释放锁a(锁a锁b均处于释放状态)

---work_two中:线程1获取锁b(锁b由线程1获取)

---切换到线程2---线程2获取锁a(锁a由线程2获取)

---切换到线程3---线程3获取不到锁a

---切换到线程1(work_two的代码向后运行)---打印“线程1拿到锁b,开始睡觉0.001秒”

---休眠,切换到线程2(work_one的代码向后运行)---打印“线程2拿到了锁a”---线程2获取不到锁b

---切换到线程3---线程3获取不到锁a

---切换到线程1---打印“线程1睡觉结束”---线程1获取不到锁a

---切换到线程2---线程2仍然获取不到锁b---切换到线程3---线程3仍然获取不到锁a---程序进入阻塞状态

 

import time
from threading import Thread, Lock

# 类加括号多次,每次产生的都是不同的对象   如果想要实现多次加括号得到的是相同的对象---单例模式
lock_a = Lock()
lock_b = Lock()

class NewThread(Thread):
    def run(self):
        self.work_one()  # 运行子线程函数
        self.work_two()

    def work_one(self):
        lock_a.acquire()  # 先拿锁a
        print(f'{self.name}拿到了锁a')  # Thread类里面有name属性,获取线程名
        lock_b.acquire()  # 再拿锁b
        print(f'{self.name}拿到了锁b')

        lock_b.release()  # 先释放锁b
        print(f'{self.name}释放了锁b')
        lock_a.release()  # 再释放锁a
        print(f'{self.name}释放了锁a')

    def work_two(self):
        lock_b.acquire()  # 先拿锁b
        print(f'{self.name}拿到了锁b,开始睡觉0.001秒')
        time.sleep(0.001)
        print(f'{self.name}睡觉结束')
        lock_a.acquire()  # 再拿锁a
        print(f'{self.name}拿到了锁a')

        lock_a.release()  # 释放锁a
        print(f'{self.name}释放了锁a')
        lock_b.release()
        print(f'{self.name}释放了锁b')

# 定义产生子线程函数
def create_thread():
    st_list = [NewThread() for i in range(1, 3)]
    [st.start() for st in st_list]

if __name__ == '__main__':
    create_thread()

 

步骤 1:CPU 调度t1运行work_one

def work_one(self):
    lock_a.acquire()  # t1拿到lock_a(锁a变为「锁定态」,持有者t1)
    print(f'{self.name}拿到了锁a')  # 输出:Thread-1拿到了锁a
    lock_b.acquire()  # t1拿到lock_b(锁b变为「锁定态」,持有者t1)
    print(f'{self.name}拿到了锁b')  # 输出:Thread-1拿到了锁b

    lock_b.release()  # t1释放lock_b(锁b回到「未锁定态」)
    print(f'{self.name}释放了锁b')  # 输出:Thread-1释放了锁b
    lock_a.release()  # t1释放lock_a(锁a回到「未锁定态」)
    print(f'{self.name}释放了锁a')  # 输出:Thread-1释放了锁a

t1的work_one执行完毕,锁 a/b 均回到未锁定状态。
t1继续执行run()的下一步:调用work_two()。

步骤 2:t1执行work_two,触发睡眠与锁持有

def work_two(self):
    lock_b.acquire()  # t1拿到lock_b(锁b变为「锁定态」,持有者t1)
    print(f'{self.name}拿到了锁b,开始睡觉0.001秒')  # 输出:Thread-1拿到了锁b,开始睡觉0.001秒
    time.sleep(0.001)  # t1进入「睡眠态」,CPU会切换调度其他线程(t2)
----------------------------------------------------------------------------------- print(f'{self.name}睡觉结束') # 0.001秒后执行 lock_a.acquire() # 尝试拿锁a(后续会阻塞) ... # 释放锁的逻辑(无法执行)

t1持有lock_b,并进入睡眠(CPU 会立刻调度其他就绪线程,即t2)。

步骤 3:CPU 调度t2执行work_one,触发锁阻塞

def work_one(self):
    lock_a.acquire()  # t2拿到lock_a(锁a变为「锁定态」,持有者t2)
    print(f'{self.name}拿到了锁a')  # 输出:Thread-2拿到了锁a
    lock_b.acquire()  # 尝试拿lock_b(但lock_b被t1持有)→ t2阻塞!
    ...  # 后续释放锁的逻辑(无法执行)

t2持有lock_a,但卡在lock_b.acquire(),进入「等待态」(等待lock_b释放)。

步骤 4:t1睡眠结束,触发死锁

0.001 秒后,t1的sleep结束,执行后续逻辑:

print(f'{self.name}睡觉结束')  # 输出:Thread-1睡觉结束

lock_a.acquire()  # 尝试拿lock_a(但lock_a被t2持有)→ t1阻塞!

t1持有lock_b,卡在lock_a.acquire();

t2持有lock_a,卡在lock_b.acquire();

双方互相等待对方持有的锁,且都无法释放自己的锁(因为阻塞在acquire,没机会执行release),形成死锁。

 3. 递归锁

3.1 概念

递归锁(也称可重入锁)是一种特殊的锁,允许一个线程多次请求同一个锁,称为“递归的”请求锁。

在该线程释放锁之前,会对锁计数器进行累加操作,线程每成功获得一次锁时,都要进行相应的解锁操作,直到锁计数器清零才能完全释放该锁。

递归锁能够保证同一线程在持有锁时能够再次获取该锁,而不被自己所持有的锁阻塞,从而避免死锁。

但是注意要正常使用递归锁,避免过多的获取锁导致性能下降。

 

可以被连续的acquire和release,但是只能被第一个获取到这把锁的线程执行该操作

内部有计数器,acquire一次计数+1,release一次计数-1

只要计数不为0,其它线程都无法获取该锁

3.2 代码示例

 在死锁的基础上,将Lock模块改为RLock模块,并且两把锁都指向同一个锁对象即可

import time
from threading import Thread, RLock

lock_a = lock_b = RLock()  # 两个变量名指向同一把锁

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

    def func1(self):
        lock_a.acquire()
        print(f'{self.name}获取到了锁a')  # self.name获取线程名
        lock_b.acquire()
        print(f'{self.name}获取到了锁b')

        lock_b.release()
        print(f'{self.name}释放了锁b')
        lock_a.release()
        print(f'{self.name}释放了锁a')

    def func2(self):
        lock_b.acquire()
        print(f'{self.name}拿到了锁b,开始睡觉0.001秒')
        time.sleep(0.001)
        print(f'{self.name}睡觉结束')
        lock_a.acquire()
        print(f'{self.name}拿到了锁a')

        lock_a.release()  #
        print(f'{self.name}释放了锁a')
        lock_b.release()
        print(f'{self.name}释放了锁b')

# 定义产生子线程函数
def create_thread():
    st_list = [NewThread() for i in range(1, 3)]
    [st.start() for st in st_list]

if __name__ == '__main__':
    create_thread()

 分析:

根据递归锁的定义:一个线程可以多次请求同一个锁

线程1获取锁a---切换到线程2---线程2获取不到锁a---切换到线程1---线程1获取锁b---线程1释放锁b---线程1释放锁a(所有锁释放完毕)

线程1获取锁b(未释放)---切换到线程2---线程2获取不到锁a(锁a锁b为同一个锁对象)---切换到线程1

线程1休眠---切换线程2--线程2仍然获取不到锁a---切换到线程1---线程1获取锁a---线程1释放锁a---线程1释放锁b---线程1功能函数所有代码运行完毕

因此,该代码的运行顺序是先将一个线程中的代码运行完毕,再去运行另外一个线程中的代码

4. 信号量(了解知识点)

 4.1 概念

进程与线程中都有信号量模块Semaphore

GIL锁同一时刻只允许一个线程运行,而信号量同一时刻允许一定数量的线程运行

举例:有一条非常长的铁路,GIL锁是同一时刻该铁路上只能运行1辆列车;信号量是可以运行指定数量的列车,当有1辆列车运行到站时,等待中的列车可以发1辆车。

信号量与进程池的概念比较像,但是要区分开,信号量涉及到获取和释放的概念。

4.2 进程中的信号量

[1]无休眠

from multiprocessing import Process, Semaphore

def train(sem, num):
    sem.acquire()  # 获取信号量
    print(f'列车{num}开始发车')
    print(f'列车{num}到站')
    sem.release()  # 释放信号量

def create_process(sem):
    sp_list = [Process(target=train, args=(sem, i)) for i in range(1, 10)]
    [sp.start() for sp in sp_list]
    [sp.join() for sp in sp_list]

if __name__ == '__main__':
    signal = Semaphore(3)  # 生成信号量对象,默认参数是1
    create_process(sem=signal)

 [2]有休眠

import time
from multiprocessing import Process, Semaphore

def train(sem, num):
    sem.acquire()  # 获取信号量
    print(f'列车{num}开始发车')
    time.sleep(1)
    print(f'列车{num}到站')
    sem.release()  # 释放信号量

def create_process(sem):
    sp_list = [Process(target=train, args=(sem, i)) for i in range(1, 10)]
    [sp.start() for sp in sp_list]
    [sp.join() for sp in sp_list]

if __name__ == '__main__':
    signal = Semaphore(3)  # 生成信号量对象,默认参数是1
    create_process(sem=signal)

 4.3 线程中的信号量

import time
from threading import Thread, Semaphore

def train(sem, num):
    sem.acquire()  # 获取信号量
    print(f'列车{num}开始发车')
    time.sleep(1)
    print(f'列车{num}到站')
    sem.release()  # 释放信号量

def create_thread(sem):
    st_list = [Thread(target=train, args=(sem, i)) for i in range(1, 10)]
    [st.start() for st in st_list]
    [st.join() for st in st_list]

if __name__ == '__main__':
    signal = Semaphore(3)  # 生成信号量对象,默认参数是1
    create_thread(sem=signal)

 5. Event事件(了解知识点)

5.1 概念

python线程的事件的作用:一个线程可以控制另一个线程的运行

事件提供了三个方法:set、wait、clear

事件的处理机制:

  全局定义了一个"Flag",初始值值为Flase

  如果"Flag"值为False,程序运行到event.wait会阻塞

  如果"Flag"值为True,程序运行到event.wait不会阻塞

  clear:将"Flag"值设置为False

  set:将"Flag"值设置为True

5.2 代码示例

import time
from threading import Thread, Event

# 定义列车信号灯线程功能函数
def railway_light(event):
    print('红灯亮,所有列车处于等待中')
    time.sleep(10)
    print('绿灯亮,所有列车发车')
    event.set()

# 定义列车发车线程功能函数
def train(event, num):
    print(f'列车{num}等待发车信号')
    event.wait()
    print(f'列车{num}发车')

# 定义产生子线程函数
def create_thread():
    signal = Event()  # 生成事件对象
    railway_light_thread = Thread(target=railway_light, args=(signal,))  # 产生信号灯子线程
    railway_light_thread.start()

    train_thread_list = [Thread(target=train, args=(signal, i)) for i in range(1, 10)]
    [train_st.start() for train_st in train_thread_list]

if __name__ == '__main__':
    create_thread()

 分析:

列车信号灯线程先启动,打印"红灯亮,所有列车处于等待中"

进入10秒休眠---切换到列车发车线程1---打印"列车1等待发车信号"---wait为默认值False---程序阻塞

线程启动耗时极短,在休眠结束前列车发车9个线程依次启动,运行到wait都阻塞

休眠结束---打印"绿灯亮,所有列车发车"---set将Event对象的"Flag"设置为True---wait在收到"Flag"由False改为True时取消阻塞---依次打印"列车发车"

6. 进程池与线程池

 6.1 线程池

[1]概念

提前创建好固定数量的线程,后续反复使用这些线程

如果任务超出了线程池最大线程数,则等待

[2]线程名称不会超过指定线程的数值

import time
from concurrent.futures import ThreadPoolExecutor
from threading import current_thread

pool = ThreadPoolExecutor(5)  # 代码运行之后立刻产生5个等待工作的线程;默认:min(32, (os.cpucount() or 1) + 4)

def work():
    time.sleep(0.001)  # 休眠防止线程启动速度极快而导致都用一个线程名
    print(current_thread().name)  # ThreadPoolExecutor-0_?的名称不会超过5

for i in range(1, 10):
    pool.submit(work)  # 往线程池中添加任务

[3]任务的提交方式

同步提交:

在提交任务后,主进程会等待任务完成,才继续运行后续代码。

提交任务之后原地等待任务的结果。

异步提交:

在提交任务后,主进程不会等待任务完成,而是继续运行后续代码。

任务的结果可以通过回调函数或者在需要结果的时候再获取。这允许主进程同时处理多个任务。

提交任务之后不原地等待任务的结果,可通过反馈机制自动获取

 [4]线程池提交任务的方式是异步

from concurrent.futures import ThreadPoolExecutor

pool = ThreadPoolExecutor(5)  # 代码运行之后立刻产生5个等待工作的线程


def work(num):
    print(f'{num}开始')
    print(f'{num}结束')


for i in range(1, 10):
    pool.submit(work, i)  # 往线程池中提交任务

分析:

开始与结束之间无sleep,如果是同步提交,则下一个任务要等待上一个任务运行完成才能启动,类比串行来理解;

而以上代码在"开始"与"结束"之间没有休眠的情况为"乱序",不是"串行",因此线程池提交任务的方式是异步。

[5]获取任务返回结果的方式是同步提交

import time
from concurrent.futures import ThreadPoolExecutor

pool = ThreadPoolExecutor(5)  # 代码运行之后立刻产生5个等待工作的线程

def work(num):
    print(f'{num}开始')
    time.sleep(0.1)
    print(f'{num}结束')
    return '---'

for i in range(1, 10):
    res = pool.submit(work, i)  # 往线程池中提交任务(异步提交)
    print(res.result())  # 获取任务的返回结果的方式是同步提交,默认返回值是None
   # 不应该主动等待结果,应该让异步提交自动提醒,因此引出了异步回调机制

分析:

主进程往线程池异步提交任务,打印的顺序为步骤 [4] 中的"乱序";

在调用result( )函数之后打印顺序变为"串行",因此,获取任务返回结果的方式是同步提交。

[5]异步回调获取对象

为了解决步骤4中获取结果为同步提交而不是异步提交,导致主进程无法同时处理多个任务的问题,

采用异步回调机制add_done_callback( ),只要任务有结果,就会自动调用括号内的函数处理

from concurrent.futures import ThreadPoolExecutor

pool = ThreadPoolExecutor(5)  # 代码运行之后立刻产生5个等待工作的线程

def work(num):
    print(f'{num}开始')
    print(f'{num}结束')
    return '---'

def func(*args, **kwargs):
    print(args, kwargs)  # 这里的值与res=pool.submit(work, i)得到的结果是同一个对象,都可以调用result()拿到线程功能函数的返回值
    # print(args[0].result())  # 打印的内容为work函数的返回值
for i in range(1, 10):
    pool.submit(work, i).add_done_callback(func)
    # add_done_callback的作用:只要任务有结果,就会自动调用括号内的函数处理

分析:

异步回调得到的值与res=pool.submit(work, i)的值是同一个对象,该对象都可以调用result( )获取线程功能函数的返回值;

提交任务和获取结果都为异步提交,允许主进程同时处理多个任务。

[6]异步回调获取返回值

在步骤5的基础上通过索引取值+调用函数方法获取线程功能函数的返回值

from concurrent.futures import ThreadPoolExecutor

pool = ThreadPoolExecutor(5)  # 代码运行之后立刻产生5个等待工作的线程

def work(num):
    print(f'{num}开始')
    print(f'{num}结束')
    return '---'

def func(*args, **kwargs):
    print(args[0].result())

for i in range(1, 10):
    pool.submit(work, i).add_done_callback(func)

向线程池提交任务方式是异步提交,add_done_callback获取任务返回结果方式为异步提交

[7]shutdown的用法

shutdown函数用于控制线程池的关闭

shutdown(wait=True)

wait的默认值为True,等待所有线程完成正在运行的任务再关闭线程池

wait如果设置为False,立即关闭线程池,不再接收新任务,不等待正在运行的任务完成

用法类似于join:主进程等子线程结束再结束

没有shutdown

from concurrent.futures import ThreadPoolExecutor

pool = ThreadPoolExecutor(5)  # 代码运行之后立刻产生5个等待工作的线程

def work(num):
    print(f'{num}开始')
    print(f'{num}结束')
  
if __name__ == '__main__':
    print('主进程开始')
    for i in range(1, 10):
        pool.submit(work, i)
    print('主进程结束')

 有shutdown

from concurrent.futures import ThreadPoolExecutor

pool = ThreadPoolExecutor(5)  # 代码运行之后立刻产生5个等待工作的线程

def work(num):
    print(f'{num}开始')
    print(f'{num}结束')
    return '---'

if __name__ == '__main__':
    print('主进程开始')
    for i in range(1, 10):
        pool.submit(work, i)
    pool.shutdown()
    print('主进程结束')

 6.2 进程池

[1]概念

创建好固定数量的进程,后续反复使用这些进程

无需频繁创建进程,频繁销毁进程

[2]进程号取值范围不会超过指定进程号个数

import os
import time
from concurrent.futures import ProcessPoolExecutor

pool = ProcessPoolExecutor(5)  # 默认os.cpu_count() or 1

def work(num):
    time.sleep(0.001)  # sleep的作用是切换进程,防止进程启动速度极快而使用同一个进程号
    print(f'{num}开始')
    print(f'进程号为:{os.getpid()}')
    print(f'{num}结束')

if __name__ == '__main__':  # 与产生线程不同,产生进程要放在主程序入口
    for i in range(1, 10):
        pool.submit(work, i)

 分析:

打印结果仍然为"乱序",不是"串行",证明进程池提交任务也是异步提交

[3]异步回调获取对象

from concurrent.futures import ProcessPoolExecutor

pool = ProcessPoolExecutor(5)  # 默认os.cpu_count() or 1

def work(num):
    print(f'{num}开始')
    print(f'{num}结束')
    return '---'

def func(*args, **kwargs):
    print(args, kwargs)

if __name__ == '__main__':  # 与产生线程不同,产生进程要放在主程序入口
    for i in range(1, 10):
        pool.submit(work, i).add_done_callback(func)

 分析:

打印结果为"乱序",证明提交任务和获取结果都为异步提交;允许主进程同时处理多个任务。

[4]异步回调获取返回值

from concurrent.futures import ProcessPoolExecutor

pool = ProcessPoolExecutor(5)  # 默认os.cpu_count() or 1

def work(num):
    print(f'{num}开始')
    print(f'{num}结束')
    return '---'

def func(*args, **kwargs):
    print(args[0].result())

if __name__ == '__main__':  # 与产生线程不同,产生进程要放在主程序入口
    for i in range(1, 10):
        pool.submit(work, i).add_done_callback(func)

[5] time.sleep详解

对比维度有 time.sleep(0.001)无 time.sleep(0.001)
进程号复用情况 进程池内 5 个进程都会被充分利用,前 5 个任务大概率分配到不同进程(5 个不同进程号),第 6 + 任务复用这 5 个进程号,进程号多样性高。 任务执行速度远快于进程切换开销,同一个进程处理完 1 个任务后立刻复用处理下一个,多个任务共用 1~2 个进程号,进程号多样性极低。
任务执行 / 输出顺序 sleep 触发进程切换,CPU 调度其他进程执行,输出顺序混乱(如 3 开始→1 开始→5 开始),体现并行特性。 任务执行极快,输出顺序接近提交顺序(1 开始→1 结束→2 开始→2 结束),几乎无并行切换。
进程切换频率 高:sleep 使当前进程进入阻塞态,操作系统频繁调度不同进程占用 CPU。 极低:任务执行时间远小于进程切换开销,同一个进程连续占用 CPU 处理多个任务,调度器极少介入。
代码设计意图达成情况 达成:注释意图是 “防止进程启动快导致用同一个进程号”,sleep 后能清晰看到多进程号,体现进程池并行。 未达成:多个任务共用同一进程号,无法体现进程池多进程并行的核心特点。
底层进程调度行为 进程频繁在 “运行态↔阻塞态” 切换,调度器持续工作分配 CPU 资源。 进程长时间处于 “运行态”,调度器几乎不工作,同一进程连续占用 CPU。
任务执行耗时 整体耗时略长(每个任务额外增加 0.001 秒 sleep 时间),但能体现并行。 整体耗时极短(仅任务打印耗时),但并行特性被掩盖。

进程池工作机制:代码中 ProcessPoolExecutor(5) 初始化 5 个常驻进程,任务提交后放入队列,空闲进程从队列取任务执行。
sleep 的核心作用:time.sleep(0.001) 并非 “启动进程”,而是让当前执行任务的进程主动让出 CPU(进入阻塞态),强制操作系统调度池内其他空闲进程执行任务,从而避免 “一个进程快速处理多个任务” 的情况。
无 sleep 的本质问题:任务(仅 3 行打印)执行耗时远小于 “进程切换 + 调度” 的系统开销,操作系统为了效率,会让同一个进程连续处理多个任务,导致无法体现多进程并行的特性。

无 time.sleep(0.001)相同进程号连续出现的核心原因是进程池的常驻进程机制 + 任务执行耗时远小于进程切换开销,操作系统为了效率优先复用同一个进程。

posted @ 2024-10-02 22:10  pythondjango  阅读(58)  评论(0)    收藏  举报