Python(11) 进程与线程

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档

文章目录


并发与并行

并发

单个 CPU 处理多个任务。各个任务交替执行一段时间。

并行

多个 CPU 同时执行多个任务。

多进程

什么是进程

进程是操作系统进行资源分配的基本单位。

操作系统中一个正在运行的程序或软件就是一个进程。

每个进程都有自己独立的一块内存空间。

一个进程崩溃后,在保护模式下不会对其他进程产生影响。

多进程是指在操作系统中同时运行多个程序。

使用multiprocessing.Process创建进程

案例:同时读写文件

注意:在Windows上执行要加上if name == "main"。

python 复制代码
import time
import multiprocessing

# 向文件中写入数据
def write_file():
    with open("test.txt", "a") as f:
        while True:
            f.write("hello world\n")
            f.flush()
            time.sleep(0.5)

# 从文件中读取数据
def read_file():
    with open("test.txt", "r") as f:
        while True:
            time.sleep(0.1)
            print(f.read(1))

if __name__ == "__main__":
    # 创建一个子进程用于写文件
    p1 = multiprocessing.Process(target=write_file)
    # 创建一个子进程用于读文件
    p2 = multiprocessing.Process(target=read_file)
    # 启动子进程
    p1.start()
    # 启动子进程
    p2.start()

自定义Process子类创建进程

python 复制代码
import os
import multiprocessing

class Worker(multiprocessing.Process):
    def run(self):
        print("进程id:", os.getpid(), "\t父进程id:", os.getppid())

if __name__ == "__main__":
    for i in range(5):
        p = Worker()
        p.start()

与上面对比不同之处在于,上面哪个是实例化之后,启动实例,下面这个则是子类,子类进行实例化,然后启动实例化

进程池

当需要启动大量子进程时,可以使用进程池。。

案例

python 复制代码
import os
import time
import multiprocessing

# 打印10个数字,每次间隔0.5秒
def func():
    for i in range(10):
        print(os.getpid(), i)
        time.sleep(0.5)

if __name__ == "__main__":
    # 指定进程池大小
    process_num = 5
    pool = multiprocessing.Pool(process_num)
    for p in range(process_num):
        # 阻塞式
        # pool.apply(func)
        # 非阻塞式
        pool.apply_async(func)
    pool.close()
    pool.join()
    print("end")

一、先用一句话理解"进程"

你可以把进程理解成:

操作系统里一个独立运行的程序任务

比如你同时打开:

  • 微信
  • 浏览器
  • PyCharm

它们就是不同的进程。

在 Python 里,多进程就是:

让程序同时开出多个"独立干活的人"


二、multiprocessing.Process 到底怎么理解?

最基本写法:

python 复制代码
import multiprocessing

def func():
    print("子进程执行")

p = multiprocessing.Process(target=func)
p.start()

这几行是什么意思?


1. Process(...) 是"创建一个进程对象"

python 复制代码
p = multiprocessing.Process(target=func)

这句不是马上运行,而是:

先造出一个"子进程说明书"

这里的 p 就是一个进程对象。


2. target=func 是什么意思?

意思是:

这个子进程启动后,要执行 func() 这个函数

等价理解:

python 复制代码
子进程启动后执行 func()

3. p.start() 是什么意思?

python 复制代码
p.start()

意思是:

真正启动子进程

注意:

  • Process(...) 只是创建对象
  • start() 才是真的开始运行

三、Process() 里的几个参数是什么意思?

构造函数:

python 复制代码
multiprocessing.Process(group=None, target=None, name=None, args=(), kwargs={}, daemon=None)

你不用一口气全记住,常用的就这几个:


1. target

要执行的函数

python 复制代码
def func():
    print("hello")

p = multiprocessing.Process(target=func)

意思是子进程运行 func()


2. args

target 函数传位置参数

比如:

python 复制代码
def func(name, age):
    print(name, age)

p = multiprocessing.Process(target=func, args=("Tom", 18))

子进程执行的其实就是:

python 复制代码
func("Tom", 18)

注意:

python 复制代码
args=("Tom", 18)

必须是元组。

如果只有一个参数,要写成:

python 复制代码
args=("Tom",)

不是:

python 复制代码
args=("Tom")

这个是常见易错点。


3. kwargs

给函数传关键字参数

python 复制代码
def func(name, age):
    print(name, age)

p = multiprocessing.Process(target=func, kwargs={"name": "Tom", "age": 18})

相当于:

python 复制代码
func(name="Tom", age=18)

4. name

给进程起名字

python 复制代码
p = multiprocessing.Process(target=func, name="进程1")

方便调试,不是必须的。


5. daemon

是否设置为守护进程

先简单理解:

主进程结束后,守护进程一般也会跟着结束


四、最基础的 Process 代码模板

你以后写多进程,最常见模板就是这个:

python 复制代码
import multiprocessing
import os

def worker(name):
    print("子进程开始执行")
    print("参数:", name)
    print("当前进程id:", os.getpid())

if __name__ == "__main__":
    p = multiprocessing.Process(target=worker, args=("Tom",))
    p.start()
    p.join()
    print("主进程结束")

这段代码逐行解释

if __name__ == "__main__":

这个在 Windows 上必须写

因为 Windows 创建子进程不是复制当前进程,而是"重新导入当前脚本"。

如果你不加这个保护,可能会无限创建子进程。

你可以先死记:

写 multiprocessing 时,Windows 下一定加这个。


p = multiprocessing.Process(target=worker, args=("Tom",))

创建一个子进程对象,它将来执行:

python 复制代码
worker("Tom")

p.start()

启动子进程


p.join()

让主进程等待子进程执行完

如果不写 join(),主进程可能先结束。


五、第一个例子怎么理解?

原来的代码:

python 复制代码
import time
import multiprocessing

# 向文件中写入数据
def write_file():
    with open("test.txt", "a") as f:
        while True:
            f.write("hello world\n")
            f.flush()
            time.sleep(0.5)

# 从文件中读取数据
def read_file():
    with open("test.txt", "r") as f:
        while True:
            time.sleep(0.1)
            print(f.read(1))

if __name__ == "__main__":
    p1 = multiprocessing.Process(target=write_file)
    p2 = multiprocessing.Process(target=read_file)
    p1.start()
    p2.start()

这段代码在干什么?

创建了两个子进程:

  • p1:不停往文件里写 "hello world\n"
  • p2:不停从文件里读字符

重点看这两句

python 复制代码
p1 = multiprocessing.Process(target=write_file)
p2 = multiprocessing.Process(target=read_file)

意思就是:

  • p1 这个子进程启动后执行 write_file()
  • p2 这个子进程启动后执行 read_file()

再看这两句

python 复制代码
p1.start()
p2.start()

意思是:

  • 启动第一个子进程
  • 启动第二个子进程

于是两个进程就同时工作了。


你可以把它想成:

  • 工人1:不停写文件
  • 工人2:不停读文件

这就是"多进程同时执行"。


六、自定义 Process 子类是什么意思?

这个例子:

python 复制代码
import os
import multiprocessing

class Worker(multiprocessing.Process):
    def run(self):
        print("进程id:", os.getpid(), "\t父进程id:", os.getppid())

if __name__ == "__main__":
    for i in range(5):
        p = Worker()
        p.start()

这个写法和前面的 target=... 写法,本质一样。


它的意思是:

你自己定义了一个进程类 Worker,继承自 multiprocessing.Process

然后重写了它的 run() 方法:

python 复制代码
def run(self):
    print(...)

当你执行:

python 复制代码
p.start()

实际上子进程会自动执行:

python 复制代码
p.run()

所以这两种写法本质类似:

写法1:传函数

python 复制代码
p = multiprocessing.Process(target=func)

写法2:继承类

python 复制代码
class Worker(Process):
    def run(self):
        ...
p = Worker()

七、进程池到底是什么?

这是你最容易迷糊的地方。


1. 为什么需要进程池?

如果你有很多任务,比如 100 个任务:

  • 不可能每个任务都手动创建一个进程
  • 这样太麻烦,也浪费资源

所以可以准备一个"进程池":

先创建几个固定的进程,让它们反复接任务干活

就像公司里先招 5 个员工,不是每来一个任务就重新招人。


2. 例子

python 复制代码
pool = multiprocessing.Pool(5)

意思是:

创建一个有 5 个工作进程的进程池

这个池子里最多同时有 5 个子进程工作。


八、你给的进程池例子逐行解释

代码:

python 复制代码
import os
import time
import multiprocessing

def func():
    for i in range(10):
        print(os.getpid(), i)
        time.sleep(0.5)

if __name__ == "__main__":
    process_num = 5
    pool = multiprocessing.Pool(process_num)
    for p in range(process_num):
        pool.apply_async(func)
    pool.close()
    pool.join()
    print("end")

第 1 步:定义任务函数

python 复制代码
def func():
    for i in range(10):
        print(os.getpid(), i)
        time.sleep(0.5)

这是"每个子进程要干的活":

  • 打印当前进程 id
  • 打印 0~9
  • 每次停 0.5 秒

第 2 步:创建进程池

python 复制代码
pool = multiprocessing.Pool(process_num)

如果 process_num = 5,就是:

创建 5 个工作进程


第 3 步:提交任务

python 复制代码
for p in range(process_num):
    pool.apply_async(func)

这一段意思是:

往进程池里提交 5 个任务,每个任务都是执行一次 func()

也就是相当于提交了 5 次:

python 复制代码
func()
func()
func()
func()
func()

但是这 5 次不是在主进程里执行,而是交给进程池里的工作进程执行。


第 4 步:关闭提交入口

python 复制代码
pool.close()

意思是:

不再接收新任务了

不是立刻关闭进程,而是告诉池子:

"任务提交完了,后面没新任务了。"


第 5 步:等待所有子进程结束

python 复制代码
pool.join()

意思是:

主进程在这里等待,直到进程池里的任务全部执行完


第 6 步:打印结束

python 复制代码
print("end")

等所有任务干完,才会打印 end


九、apply()apply_async() 到底区别是什么?

这是重点。


1. apply()

同步提交任务

python 复制代码
pool.apply(func)

意思是:

提交一个任务,然后主进程在这里等它执行完,再继续往下走

所以如果你写:

python 复制代码
for i in range(5):
    pool.apply(func)

效果更像:

  • 第1个任务做完
  • 再做第2个
  • 再做第3个

虽然用了进程池,但这写法很像串行阻塞。


2. apply_async()

异步提交任务

python 复制代码
for i in range(5):
	pool.apply_async(func)

意思是:

把任务扔进池子里,马上返回,不等它做完

所以:

python 复制代码
for i in range(5):
    pool.apply_async(func)

是把 5 个任务快速都提交出去,然后让多个子进程并发处理。


一句话记忆

  • apply():提交后要等结果,阻塞
  • apply_async():提交后立刻继续,不阻塞

十、把进程池例子改成更容易看懂的版本

你可以先看这个版本:

python 复制代码
import multiprocessing
import os
import time

def worker(num):
    print(f"任务 {num} 开始,进程id: {os.getpid()}")
    time.sleep(2)
    print(f"任务 {num} 结束,进程id: {os.getpid()}")

if __name__ == "__main__":
    pool = multiprocessing.Pool(3)   # 池里有3个进程

    for i in range(5):               # 提交5个任务
        pool.apply_async(worker, args=(i,))

    pool.close()   # 不再提交新任务
    pool.join()    # 等所有任务做完

    print("主进程结束")

这段代码是什么意思?

  • 进程池里只有 3 个工作进程
  • 但一共提交了 5 个任务
  • 所以会先同时执行 3 个任务
  • 哪个进程空出来了,再去接剩下的任务

这就是"池"的感觉。


十一、你可能没看懂的几个符号,我单独解释


1. target=write_file

表示子进程要执行哪个函数

不是写:

python 复制代码
target=write_file()

而是写:

python 复制代码
target=write_file

因为这里传的是函数本身,不是函数调用结果。


2. args=(i,)

表示传给函数的参数

比如:

python 复制代码
def worker(x):
    print(x)

pool.apply_async(worker, args=(10,))

等价于:

python 复制代码
worker(10)

3. for p in range(process_num):

这只是普通循环

python 复制代码
for p in range(5):

就是循环 5 次。

这里的变量 p 只是个计数变量,实际上没怎么用到,写成 i 更直观:

python 复制代码
for i in range(process_num):
    pool.apply_async(func)

4. os.getpid()

获取当前进程 id

用于区分:

现在到底是哪个进程在执行代码


5. os.getppid()

获取父进程 id

就是当前进程是谁创建的。


十二、你可以先这样理解三种常见写法


写法1:创建一个子进程执行函数

python 复制代码
import multiprocessing

def func():
    print("子进程执行")

if __name__ == "__main__":
    p = multiprocessing.Process(target=func)
    p.start()
    p.join()

适合:只开少量进程


写法2:创建多个子进程

python 复制代码
import multiprocessing
import os

def func(num):
    print("任务", num, "进程id", os.getpid())

if __name__ == "__main__":
    plist = []
    for i in range(5):
        p = multiprocessing.Process(target=func, args=(i,))
        p.start()
        plist.append(p)

    for p in plist:
        p.join()

适合:自己管理多个进程


写法3:用进程池批量处理任务

python 复制代码
import multiprocessing
import os

def func(num):
    print("任务", num, "进程id", os.getpid())

if __name__ == "__main__":
    pool = multiprocessing.Pool(3)

    for i in range(10):
        pool.apply_async(func, args=(i,))

    pool.close()
    pool.join()

适合:任务很多,想统一调度


十三、这部分最常见易错点


易错点1:Windows 忘记写 if __name__ == "__main__":

会出很奇怪的问题,甚至无限创建进程。


易错点2:target=func() 写错

错误:

python 复制代码
p = multiprocessing.Process(target=func())

这表示主进程先把 func() 执行了,再把返回值传进去。

正确:

python 复制代码
p = multiprocessing.Process(target=func)

易错点3:单个参数的 args 忘记写逗号

错误:

python 复制代码
args=(10)

正确:

python 复制代码
args=(10,)

易错点4:start()run() 混淆

  • start():启动一个新进程
  • run():只是普通方法调用

如果你直接写:

python 复制代码
p.run()

那通常不是新开进程,而是在当前进程里执行。

所以一般都写:

python 复制代码
p.start()

易错点5:close() 之后不能再提交任务

python 复制代码
pool.close()
pool.apply_async(...)

这样是不行的。


易错点6:join() 前通常要先 close()terminate()

标准顺序一般是:

python 复制代码
pool.close()
pool.join()

十四、给你一个最简单的"记忆版"


1. 普通进程

python 复制代码
p = multiprocessing.Process(target=函数名, args=(参数,))
p.start()
p.join()

含义:

  • 创建子进程
  • 启动
  • 等它结束

2. 进程池

python 复制代码
pool = multiprocessing.Pool(进程数)
pool.apply_async(函数���, args=(参数,))
pool.close()
pool.join()

含义:

  • 创建一批固定数量的工作进程
  • 往池子里丢任务
  • 不再丢新任务
  • 等全部执行完

进程间通信

1)进程间不共享全局变量

子进程向传入的列表中添加元素,最终发现主进程与子进程之间的列表结果不同:

python 复制代码
import os
import multiprocessing

# 向list1中添加10个元素
def func(list1):
    for i in range(10):
        list1.append(i)
        print(os.getpid(), list1)

if __name__ == "__main__":
    list1 = []
    p1 = multiprocessing.Process(target=func, args=(list1,))
    p2 = multiprocessing.Process(target=func, args=(list1,))
    p1.start()
    p2.start()
    p1.join()
    p2.join()
    print(os.getpid(), list1)

2)使用 Queue 通信

案例:两个进程分别读写Queue

python 复制代码
import time
import random
import multiprocessing

# 间隔随机时间向queue中放入随机数
def func1(queue):
    while True:
        queue.put(random.randint(1, 50))
        time.sleep(random.random())

# 从queue中取出数据
def func2(queue):
    while True:
        print("=" * queue.get())

if __name__ == "__main__":
    queue = multiprocessing.Queue()
    p1 = multiprocessing.Process(target=func1, args=(queue,))
    p2 = multiprocessing.Process(target=func2, args=(queue,))
    p1.start()
    p2.start()
    p1.join()
p2.join()

注意:multiprocessing.Queue存在兼容性问题,如果要使用进程池,可以使用Mananger().Queue

3)进程池之间使用 Manager().Queue 通信

python 复制代码
import time
import random
import multiprocessing

# 间隔随机时间向queue中放入随机数
def func1(queue):
    while True:
        queue.put(random.randint(1, 50))
        time.sleep(random.random())

# 从queue中取出数据
def func2(queue):
    while True:
        print("=" * queue.get())

if __name__ == "__main__":
    queue = multiprocessing.Manager().Queue()
    pool = multiprocessing.Pool(2)
    pool.apply_async(func1, (queue,))
    pool.apply_async(func2, (queue,))
    pool.close()
    pool.join()

多线程

线程是处理器任务调度和执行的基本单位。

一个进程至少有一个线程,也可以运行多个线程。

多个线程之间可共享数据。

线程运行出错异常后,如果没有捕获,会导致整个进程崩溃。

多线程是指在同一进程中同时执行多个任务。

使用threading.Thread创建线程

Python的标准库提供了两个模块:_thread 和 threading,_thread 是低级模块,threading是高级模块,对 _thread 进行了封装。绝大多数情况下,我们只需要使用 threading 这个高级模块。

1)Thread 的创建

threading.Thread(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)

group:应为 None,保留给将来实现 ThreadGroup 类的扩展使用。

target:用于 run() 方法调用的可调用对象。默认是 None,表示不需要调用任何方法。

name:线程名称。 在默认情况下,会以 "Thread-N" 的形式构造唯一名称,其中 N 为一个较小的十进制数值,或是 "Thread-N (target)" 的形式,其中 "target" 为 target.name ,如果指定了 target 参数的话。

args:用于发起调用目标函数的参数列表或元组。 默认为 ()。

kwargs:用于调用目标函数的关键字参数字典。默认是 {}。

daemon:True 或 False 来设置该线程是否为守护模式。如果是 None (默认值),线程将继承当前线程的守护模式属性。

案例:两线程分别交替打印

python 复制代码
import time
import threading

# 交替打印 00000 和 11111
def func():
    flag = 0
    while True:
        print(threading.current_thread().name, f"{flag}" * 5)
        flag = flag ^ 1  # 替换0和1
        time.sleep(0.5)

if __name__ == "__main__":
    t1 = threading.Thread(target=func, name="线程1")
    t2 = threading.Thread(target=func, name="线程2")
    t1.start()
    t2.start()

自定义Thread子类创建线程

python 复制代码
import time
import threading

class Worker(threading.Thread):
    def __init__(self, name):
        super().__init__()
        self.name = name

    def run(self):
        flag = 0
        while True:
            print(f"\r{self.name}:{str(flag)*5}", end="")
            flag = flag ^ 1  # 替换0和1
            time.sleep(0.2)

if __name__ == "__main__":
    t1 = Worker("线程1")
    t2 = Worker("线程2")
    t1.start()
    t2.start()

线程池

ThreadPoolExecutor 是 concurrent.futures 模块中的线程池实现,它允许我们轻松地提交任务到线程池,并管理任务的执行和结果。

1)线程池的创建

concurrent.futures.ThreadPoolExecutor(max_workers=None, thread_name_prefix="", initializer=None, initargs=())

 max_workers:线程池的最大线程数(默认取决于系统资源)。

 thread_name_prefix:线程名称前缀。

 initializer:可选的初始化函数。

 initargs:传递给初始化函数的参数。

2)案例

python 复制代码
import concurrent.futures

def func(tname):
    global word #if不隔离作用域,并且进行了重新赋值,所以这里需要global,
    for i, char in enumerate(word):
        word[i] = chr(ord(char) ^ 1)
        print(f"{tname}: {word}\n", end="")
    return word

if __name__ == "__main__":
    word = list("idmmn!vnsme")
    # 使用 with 语句来确保线程被迅速清理
    with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
        future1 = executor.submit(func, "线程1")
        future2 = executor.submit(func, "线程2")
        future3 = executor.submit(func, "线程3")
        word = future1.result()
        word = future2.result()
        word = future3.result()
print("".join(word))  # hello world

互斥锁

线程之间共享数据会存在线程安全的问题。

比如下面这段代码,3个线程,每个线程都将g_num +1 十次:

python 复制代码
import time
import threading

def func():
    global g_num
    for _ in range(10):
        tmp = g_num + 1
        time.sleep(0.01)
        g_num = tmp
        print(f"{threading.current_thread().name}: {g_num}\n", end="")

if __name__ == "__main__":
    g_num = 0
    threads = [threading.Thread(target=func, name=f"线程{i}") for i in range(3)]
    [t.start() for t in threads]
    [t.join() for t in threads]
    print(g_num)  # 10

可以看到最终结果并不是30。这是因为在修改 g_num 前,有0.01秒的休眠时间,某个线程延时后,CPU立即分配计算资源给其他线程。此时0.01秒的休眠还未结束,这个线程还未将修改后的数据赋值给 g_num,因此其他线程获取到的并不是最新值,所以才出现上面的结果。简单来说原因是因为数据赋值操作不同步导致的

2)互斥锁的概念

某个线程要更改共享数据时,先将其锁定,此时其他线程不能更改。直到该线程释放资源,将资源的状态变成"非锁定",其他的线程才能再次锁定该资源。互斥锁保证了每次只有一个线程进行写入操作,从而保证了多线程情况下数据的正确性。

3)互斥锁的使用

可以通过 threading.Lock() 创建互斥锁。

使用 lock.acquire([blocking=True][, timeout=-1]) 来获取锁(blocking 如果为 True,线程会阻塞直到获取到锁。如果为 False,线程立即返回。获取锁成功返回 True,否则返回 False。timeout 为等待的超时时间,单位为秒。如果超时仍未获取到锁,则返回 False。)。

使用 lock.release() 释放锁。

python 复制代码
import time
import threading

def func():
    global g_num
    for _ in range(10):
        lock.acquire()  # 获取锁
        tmp = g_num + 1
        time.sleep(0.01)
        g_num = tmp
        lock.release()  # 释放锁
        print(f"{threading.current_thread().name}: {g_num}\n", end="")

if __name__ == "__main__":
    g_num = 0
    lock = threading.Lock()  # 创建锁
    threads = [threading.Thread(target=func, name=f"线程{i}") for i in range(3)]
    [t.start() for t in threads]
    [t.join() for t in threads]
    print(g_num)  # 30

GIL

Python 全局解释器锁(Global Interpreter Lock, 简称 GIL)是一个锁,同一时间只允许一个线程保持 Python 解释器的控制权,这意味着在任何时间点都只能有一个线程处于执行状态。执行单线程程序时看不到 GIL 的影响,但它可能是 CPU 密集型和多线程代码中的性能瓶颈。GIL并不是Python的特性,它是在实现Python解析器(CPython)时所引入的一个概念。

Python于1991年诞生,从操作系统没有线程概念的时代就已经存在了。由于物理上的限制,各CPU厂商在核心频率上的比赛已经被多核所取代。为了利用多核,Python开始支持多线程。而为了解决多线程之间数据完整性和状态同步,于是有了GIL,GIL 提供了线程安全的内存管理。

GIL 的存在会对多线程的效率有不小影响。甚至就几乎等于Python是个单线程的程序。我们可能会想 GIL只要释放的勤快效率也不会差,至少也不会比单线程的效率差。理论上是这样。

但实际上,Python为了让各个线程能够平均利用CPU时间,会计算当前已执行的微代码数量,达到一定阈值后就强制释放GIL。而这时也会触发一次操作系统的线程调度(当然是否真正进行上下文切换由操作系统自主决定)。从释放 GIL 到获取 GIL 之间几乎是没有间隙的。所以当其他在其他核心上的线程被唤醒时,大部分情况下主线程已经又再一次获取到 GIL 了。这个时候被唤醒执行的线程只能白白的浪费CPU时间,看着另一个线程拿着 GIL 执行。然后达到切换时间后进入待调度状态,再被唤醒,再等待,以此往复恶性循环。

上述实现方式是较为原始的,Python的每个版本中也在逐渐改进GIL和线程调度之间的互动关系。例如先尝试持有GIL在做线程上下文切换,在IO等待时释放GIL等尝试。但是无法改变的是GIL的存在使得操作系统线程调度的这个本来就昂贵的操作变得更奢侈了。

总之,当你的程序需要进行大量的CPU计算时,GIL会成为性能的瓶颈。即使你有多个线程,GIL也会阻止它们在多个CPU核心上并行执行。实际上,多个线程会轮流获取GIL,这样就不能真正并行地使用多个处理器核心。而对于涉及I/O操作(如文件读写、网络请求等)的程序,GIL的影响较小。因为在I/O操作时,线程会释放GIL,其他线程可以在此时执行,这使得多线程在I/O密集型任务中能更有效地并发。

进程和线程对比

区别

 资源分配

进程拥有独立的内存空间和系统资源,每个进程都有自己的代码段、数据段和堆栈等。而线程共享所属进程的内存空间和资源,同一进程内的线程之间可以直接访问共享内存。

 开销

创建进程需要分配独立的内存、打开文件等系统资源,开销较大。

创建线程只需在所属进程的内存空间内进行少量资源分配,开销较小。

 并发性

在多核心 CPU 环境下,进程和线程都可以异步执行但进程之间的异步是真正的异步(每个进程在不同核心上同时执行),而线程之间的异步在单核心 CPU 上是通过时间片轮转实现的 "伪异步"(在同一时刻只有一个线程执行),在多核心 CPU 上可以实现异步。但是在Cpython中,因为GIL的存在,也不是真正的异步

 独立性

进程之间相互独立,一个进程的崩溃通常不会影响其他进程。而同一进程内的线程之间相互影响,一个线程出现问题可能导致整个进程崩溃。

 通信

进程间通信相对复杂,需要使用特殊的机制,如管道、消息队列、共享内存等。

线程间通信相对简单,因为它们共享内存,可以直接访问共享变量。

使用场景

 适合使用多线程的情况:

I/O 密集型任务:如网络请求、文件读写等。线程共享内存,切换开销小,在等待 I/O 操作完成的时间内可以切换到其他线程执行,提高整体效率。例如一个程序需要同时从多个网站下载数据,使用多线程可以在等待网络响应时执行其他下载任务。

对资源共享要求高:线程间共享内存,方便数据共享和通信。例如在一个图形界面程序中,多个线程需要共享界面数据并进行实时更新。

 适合使用多进程的情况:

CPU 密集型任务:多进程可以利用多核心 CPU 实现真正的并行计算,充分发挥硬件性能。例如进行复杂的科学计算、数据处理等任务,每个进程在不同核心上独立计算,提高计算速度。

需要隔离的任务:进程相互独立,一个进程崩溃不会影响其他进程。对于一些可能出现异常或不稳定的任务,使用多进程可以保证系统的稳定性。例如运行多个独立的服务,每个服务作为一个进程,避免一个服务出错影响其他服务。

这是一份针对你提供的**"Python 并发编程(多进程、多线程、GIL、锁)"的长篇知识的 核心总结高频易错点避坑指南**。

并发编程是 Python 进阶中最难、也最容易写出 Bug 的部分,我们需要把这些概念在大脑里建立起清晰的映射。


一、 核心知识点极简总结

  1. 并发 vs 并行
    • 并发:一个人同时"交替"对付多个任务(单核 CPU 切换)。
    • 并行:多个人同时"一起"对付多个任务(多核 CPU 同时发力)。
  2. 多进程 (Multiprocessing)
    • 特点:造价昂贵(开销大),互相隔离(内存不共享),极其安全(一个崩溃不影响其他)。
    • 优势 :能真正利用多核 CPU,无视 GIL 锁
    • 通信 :必须借助外部工具(QueueManager)。
  3. 多线程 (Multithreading)
    • 特点:造价低廉(开销小),内存共享(能直接读写同一个全局变量),互相影响(一个崩了全家完蛋)。
    • 劣势 :受制于 GIL 锁,在 Python 中无法实现真正的 CPU 多核并行。
  4. GIL (全局解释器锁)
    • 本质:CPython 解释器的"单行道"关卡,保证同一时刻只有一个线程在执行 Python 字节码。
    • 后果:多线程在计算密集型任务中是"伪并发",甚至比单线程还慢(因为切换要消耗资源)。
  5. 互斥锁 (Lock)
    • 作用:多线程共享内存容易引发"抢夺踩踏",加锁保证同一时刻只有一个线程能修改数据。
  6. 终极心法(什么时候用什么)
    • CPU 密集型(死算力,如死循环、图像处理、AI 训练) 👉 多进程
    • I/O 密集型(死等,如爬虫下载、读写文件、数据库请求) 👉 多线程

二、 高频易错点解析

🔴 易错点 1:Windows 下多进程忘记写 if __name__ == '__main__':
  • 现象 :程序疯狂报错(RuntimeError),或者无限弹窗、电脑死机。
  • 原因 :Linux 开启子进程用的是 fork(直接克隆内存),而 Windows 没有 fork,它会重新导入并执行一遍当前脚本 。如果不加 if __name__ == '__main__': 拦截,子进程在导入脚本时又会创建新的子进程,形成无限递归。
  • 对策 :只要写 multiprocessing,在 Windows 下必须把创建和启动进程的代码放在这句代码下面!
🔴 易错点 2:单参数元组忘记加逗号
  • 现象:传参报错,或者传进去的参数被拆解了。
  • 代码Process(target=func, args=("Tom")) ❌ 错误!
  • 原因 :在 Python 中,("Tom") 只是一个普通的字符串加了括号,("Tom",) 才是元组。
  • 对策 :如果只有一个参数,绝对不能省略逗号args=("Tom",) ✅。
🔴 易错点 3:错把 run() 当成 start()
  • 现象:程序变成了串行运行,根本没有并发。
  • 代码t1.run() / p1.run() ❌ 错误!
  • 原因 :直接调用 run() 就只是普通的方法调用,依然在当前主线程/主进程里执行。调用 start() 才会向操作系统申请创建新的线程/进程,然后由底层自动去调 run()
  • 对策 :永远调用 start() 来启动。
🔴 易错点 4:进程池的 applyapply_async 混淆
  • 现象:用了进程池,结果任务还是一个接一个排队执行的。
  • 代码pool.apply(func) ❌ 错误(除非你真的想阻塞排队)。
  • 原因apply 是同步阻塞的,主进程会卡在这里等子进程干完活才进行下一次循环。apply_async 才是异步非阻塞的,瞬间把所有任务扔进池子并发执行。
  • 对策 :99% 的情况下,使用 pool.apply_async() ✅。
🔴 易错点 5:主进程不等待,子进程瞬间"暴毙" (忘记 join)
  • 现象:使用进程池时,程序瞬间运行结束,什么都没打印出来。
  • 原因:主进程代码执行到最后一行结束退出了,由于子进程/进程池通常依附于主进程,主进程一死,里面的工作进程直接被系统强行回收,活都没干完。
  • 对策 :进程池标准三步曲不可省:
    1. 提交任务:pool.apply_async(...)
    2. 关大门:pool.close()(不再接收新任务)
    3. 死等:pool.join()(主进程阻塞,直到池子里任务干完)
🔴 易错点 6:误以为"多进程"可以像"多线程"一样直接修改全局变量
  • 现象 :在子进程里往 list1append 数据,执行完后主进程打印 list1,发现还是个空列表。
  • 原因进程间内存是绝对物理隔离的! 子进程在启动时,相当于把主进程的 list1 复印了一份带走了。子进程修改的只是它自己手里的复印件,主进程的原本根本没变。
  • 对策 :进程间通信(IPC)必须使用专业的工具。普通队列用 multiprocessing.Queue()进程池之间 通信必须用 multiprocessing.Manager().Queue()
🔴 易错点 7:多线程不加锁导致数据算错
  • 现象 :多个线程对同一个变量执行 +1 操作,最终结果总是比预期的少。
  • 原因a = a + 1 这句代码在 CPU 底层是分三步走的(取值 -> 计算 -> 赋值)。如果线程 A 刚计算完还没赋值,CPU 瞬间切换给线程 B,线程 B 取到的还是老数据。两人都加了 1,但最终结果只覆盖了一次。
  • 对策 :涉及到多线程修改同一个全局变量(特别是数值计算、列表修改),必须加锁lock.acquire()lock.release())。

三、 总结一张表:多进程 vs 多线程

对比维度 多进程 (multiprocessing) 多线程 (threading)
内存/资源 独立分配,互相隔离,开销大 共享主进程内存,开销极小
GIL锁影响 无影响(每个进程有独立的解释器) 受限制(同一时刻只能执行一个线程)
数据共享 困难(需借助 Queue、Pipe、Manager) 极其简单(直接读写全局变量),但需加锁
崩溃风险 稳定(一个子进程崩溃不影响其他) 脆弱(一个线程抛出致命异常,整个进程崩溃)
最佳适用场景 CPU 密集型(图像处理、大量数学运算) I/O 密集型(网络爬虫、文件读写、数据库请求)
相关推荐
明月_清风26 分钟前
FastAPI 从入门到实战:3 分钟构建高性能异步 API
后端·python·fastapi
笨拙的老猴子31 分钟前
[特殊字符] Java GC机制详解:G1、ZGC、Shenandoah全面解析与版本演进对比
java·开发语言
bellus-32 分钟前
ubuntu26测试win10的ollama大模型性能
python
水木流年追梦34 分钟前
大模型入门-Reward 奖励模型训练
开发语言·python·算法·leetcode·正则表达式
JavaWeb学起来34 分钟前
Python学习教程(六)数据结构List(列表)
数据结构·python·python基础·python教程
liuyunshengsir1 小时前
PyTorch 动态量化(Dynamic Quantization)
人工智能·pytorch·python
电子云与长程纠缠1 小时前
UE5制作六边形包裹球体效果
开发语言·python·ue5
砍材农夫1 小时前
物联网 基于netty构建mqtt协议规范(遗嘱与保留消息)
java·开发语言·物联网·netty
DFT计算杂谈1 小时前
KPROJ编译教程
java·前端·python·算法·conda
froginwe111 小时前
Python3 迭代器与生成器
开发语言