python进程和线程(一、主要讲解线程)

一、核心基础概念

 

1. 进程 vs 线程

 
  • 进程:操作系统分配资源的最小单位,独立内存空间,进程间通信成本高;
  • 线程:进程内的执行单元,共享进程内存空间,创建 / 销毁成本极低,切换速度快。
 
一个程序至少有 1 个进程,一个进程至少有 1 个主线程。
 
 

2. 多线程的适用场景

 
推荐使用:I/O 密集型任务(网络请求、文件读写、数据库操作、等待用户输入)
 
不推荐:CPU 密集型任务(大量计算、数据加密、图像处理)
 

3. Python 的 GIL 限制(重点)

 
全局解释器锁(GIL):CPython 解释器的机制,同一时刻,一个进程内只有 1 个线程能被 CPU 执行
 
  • 多线程无法利用多核 CPU 加速计算;
  • 但 I/O 等待时,线程会释放 GIL,其他线程可以执行,因此 I/O 场景效率极高。

二、Python 多线程实现(3 种方式)

 
Python 标准库 threading 是多线程核心模块,推荐优先使用。
 

方式 1:函数式(最简单)

 
直接将函数作为线程执行目标
import threading
import time

# 定义线程要执行的任务
def task(name, delay):
    print(f"线程 {name} 启动,等待 {delay} 秒")
    time.sleep(delay)  # 模拟 I/O 等待(释放GIL)
    print(f"线程 {name} 执行完成")

if __name__ == "__main__":
    # 创建线程:target=执行函数,args=参数元组
    t1 = threading.Thread(target=task, args=("线程1", 2))
    t2 = threading.Thread(target=task, args=("线程2", 1))

    # 启动线程
    t1.start()
    t2.start()

    # 等待线程执行完毕(主线程阻塞)
    t1.join()
    t2.join()

    print("所有线程执行完毕!")

方式 2:类继承(面向对象,推荐)

 
继承 threading.Thread 类,重写 run() 方法,封装性更好
import threading
import time

class MyThread(threading.Thread):
    # 初始化:接收自定义参数
    def __init__(self, name, delay):
        super().__init__()  # 调用父类构造
        self.name = name
        self.delay = delay

    # 重写run方法:线程执行的核心逻辑
    def run(self):
        print(f"线程 {self.name} 启动")
        time.sleep(self.delay)
        print(f"线程 {self.name} 结束")

if __name__ == "__main__":
    t1 = MyThread("A", 2)
    t2 = MyThread("B", 1)
    
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print("全部完成")

方式 3:线程池(ThreadPoolExecutor,企业级首选)

 
使用 concurrent.futures 模块,自动管理线程创建 / 销毁,无需手动控制,代码更简洁
from concurrent.futures import ThreadPoolExecutor
import time

def task(name, delay):
    print(f"线程 {name} 启动")
    time.sleep(delay)
    return f"线程 {name} 完成"

if __name__ == "__main__":
    # 创建线程池:最多同时运行2个线程
    with ThreadPoolExecutor(max_workers=2) as executor:
        # 提交任务
        future1 = executor.submit(task, "1", 2)
        future2 = executor.submit(task, "2", 1)

        # 获取返回结果
        print(future1.result())
        print(future2.result())
下面使用方法一创建爬虫的多线程的案例,如下所示:
import requests
import threading
import time
# 初识多线程
url_list = [
    ("一枝花.mp4", "https://s1.iesdouyin.com/mp4/v0300f570000bvmace0gvch71o53oog.mp4"),
    ("二个鸟.mp4",     "https://s1.iesdouyin.com/mp4/v0200f3e0000bv52fpn5t6p007e34q1g.mp4"),
    ("三只雀.mp4",       "https://s1.iesdouyin.com/mp4/v0200f240000buuer5aa4tjj4gv6ajqg.mp4")
]

def task(file_name, video_url):
    res = requests.get(video_url)
    with open(file_name, mode='wb') as f:
        f.write(res.content)
    print(time.time())

print(time.time())
for name, url in url_list:
    # 创建线程,让每个线程都去执行task函数(参数不同)
    t = threading.Thread(target=task, args=(name, url))
    t.start()

三、Python 多进程实现

与多线程的方法一的创建非常相似
import multiprocessing
import time

# 定义任务函数
def task(name):
    print(f"子进程 {name} 开始运行,进程ID:{multiprocessing.current_process().pid}")
    time.sleep(2)  # 模拟任务执行
    print(f"子进程 {name} 执行完毕")

if __name__ == "__main__":
    # 查看CPU核心数
    print(f"CPU核心数:{multiprocessing.cpu_count()}")
    
    # 1. 创建进程对象
    p1 = multiprocessing.Process(target=task, args=("进程1",))
    p2 = multiprocessing.Process(target=task, args=("进程2",))
    
    # 2. 启动进程
    p1.start()
    p2.start()
    
    # 3. 等待进程执行完成(主进程阻塞)
    p1.join()
    p2.join()
    
    print("所有子进程执行完毕,主进程退出")
关键说明
  • if __name__ == "__main__"Windows 系统必须写,避免进程递归创建;Linux/macOS 建议也写。
  • Process(target=函数, args=参数元组):创建进程
  • start():启动进程(操作系统调度执行)
  • join():主进程等待子进程结束
  • pid:获取进程 ID

同样的,多进程的爬虫案例与多线程也是大体类似的,如下:

import time
import requests
import multiprocessing

url_list = [
    ("一枝花.mp4", "https://s1.iesdouyin.com/mp4/v0300f570000bvmace0gvch71o53oog.mp4"),
    ("二个鸟.mp4",     "https://s1.iesdouyin.com/mp4/v0200f3e0000bv52fpn5t6p007e34q1g.mp4"),
    ("三只雀.mp4",       "https://s1.iesdouyin.com/mp4/v0200f240000buuer5aa4tjj4gv6ajqg.mp4")
]
def task(file_name, video_url):
    res = requests.get(video_url)
    with open(file_name, mode='wb') as f:
        f.write(res.content)
    print(time.time())

if __name__ == '__main__':
    print(time.time())
    for name, url in url_list:
        # 使用多进程代替多线程
        t = multiprocessing.Process(target=task, args=(name, url))
        t.start()

四、GIL锁

1、GIL 是什么

 
GIL(Global Interpreter Lock,全局解释器锁)是CPython(官方解释器)里的一个全局互斥锁
 
同一时刻,一个 Python 进程内只能有一个线程执行 Python 字节码
 
 
关键点:
 
  • 只存在于 CPython;PyPy、Jython、IronPython 没有 GIL
  • 进程级别全局锁:一个进程一把 GIL,和线程数量无关。
  • 保护的是字节码执行,不是你写的某一行代码或某个对象。
 

2、为什么要有 GIL(历史与设计原因)

 
  1. 1引用计数内存管理非线程安全
    • Python 用引用计数做垃圾回收:每个对象有个 ob_refcnt,减到 0 就释放。
    • 无锁时,多线程并发改同一对象的引用计数,会导致计数错乱、内存泄漏、野指针崩溃
     
  2. 1实现简单、开发成本低
    • 早年多核未普及,简单全局锁比给每个对象加细粒度锁更省事、开销更低。
    • 写 C 扩展的人也不用自己处理多线程同步,降低扩展开发门槛。
     
 

3、GIL 怎么工作(核心机制)

 
  1. 抢锁→执行→释放→再抢
    • 线程要跑 Python 代码,必须先拿到 GIL
    • 拿到后执行,直到主动释放被动被切走
     
  2. 释放 GIL 的三种时机
    • I/O 阻塞时:网络、文件、数据库等 I/O 会主动释放 GIL,让别的线程跑。
    • 时间片到(字节码配额用完):默认执行 100 条字节码 后强制释放(可 sys.setcheckinterval(N) 改)。
    • 线程结束:自然释放 GIL。
     
    • 多线程无法并行跑 Python 字节码,只能并发交替执行
    • 多核 CPU 上,Python 多线程跑不满多核,CPU 密集型任务反而可能因切换变慢。GIL 与多核

4、GIL 对两类任务的影响

1)CPU 密集型(计算、循环、解析)

  • 多线程 ≈ 单线程,甚至更慢:GIL 串行执行,线程切换有开销。
  • 示例:
import threading

def count():
    for _ in range(10**7):
        pass

# 单线程:快
count()

# 多线程:更慢(GIL 串行+切换开销)
t1=threading.Thread(target=count)
t2=threading.Thread(target=count)
t1.start(); t2.start()

2)I/O 密集型(爬虫、接口调用、文件读写)

  • 多线程很有效:I/O 等待时释放 GIL,其他线程可执行,并发提升明显
  • 典型场景:爬虫、Web 服务、数据库批量查询。
  • 网络请求走网卡多,CPU很少用到。

五、多线程的核心方法

本节重点介绍join()函数的具体用法

Python 多线程 join () 方法详解

 
join() 是 Python threading.Thread 类的核心方法,作用是:让主线程(或其他线程)等待当前子线程执行完成后,再继续往下运行
 
简单说:调用 join () 的线程,会 “阻塞” 等待目标线程结束
 

 

1、核心概念

 
  1. 默认情况:主线程和子线程同时运行(并发),主线程不会等子线程。
  2. 使用 join():主线程会暂停,直到子线程运行完毕,才继续执行。
  3. 可选参数join(timeout),最多等待 timeout 秒,超时后不再等待。
 

 

2、基础代码示例

1. 不使用 join ()(主线程不等子线程)

import threading
import time

def task():
    print("子线程开始运行")
    time.sleep(2)  # 模拟耗时任务
    print("子线程运行结束")

# 创建并启动子线程
t = threading.Thread(target=task)
t.start()

# 主线程直接执行,不会等待子线程
print("主线程执行完毕")
image
注意:两个print输出在同一行了。
2. 使用 join ()(主线程等待子线程)
import threading
import time

def task():
    print("子线程开始运行")
    time.sleep(2)
    print("子线程运行结束")

t = threading.Thread(target=task)
t.start()

t.join()  # 主线程在这里等待,直到 t 线程结束

print("主线程执行完毕")
image

3、join (timeout) 超时等待

可以给 join() 传一个秒数,表示最多等多久
 
import threading
import time

def task():
    print("子线程开始")
    time.sleep(3)
    print("子线程结束")

t = threading.Thread(target=task)
t.start()

t.join(2)  # 只等待 2 秒,超时就继续执行

print("主线程继续执行")
结果:主线程 2 秒后就继续运行,不会等满 3 秒。
image

4、多个线程的 join () 使用

如果有多个子线程,可以给每个线程都调用 join(),主线程会等待所有子线程都完成
import threading
import time

def task(name, sleep_time):
    print(f"线程 {name} 开始")
    time.sleep(sleep_time)
    print(f"线程 {name} 结束")

# 创建 3 个子线程
t1 = threading.Thread(target=task, args=("A", 1))
t2 = threading.Thread(target=task, args=("B", 2))
t3 = threading.Thread(target=task, args=("C", 3))

# 启动所有线程
t1.start()
t2.start()
t3.start()

# 等待所有线程结束

t1.join()
t2.join()
t3.join()

print("所有子线程执行完毕,主线程继续")
下面,将启动的线程打乱,如
t2.join()
t1.join()
t3.join()
结果会如何呢?
image
结果还是按照A/B/C进行输出。
import threading
import time

def task(name, sleep_time):
    print(f"线程 {name} 开始")
    time.sleep(sleep_time)
    print(f"线程 {name} 结束")

# 创建 3 个子线程
t1 = threading.Thread(target=task, args=("A", 1))
t2 = threading.Thread(target=task, args=("B", 2))
t3 = threading.Thread(target=task, args=("C", 3))

# 启动所有线程
t1.start()
t1.join()

t2.start()
t2.join()

t3.start()
t3.join()

print("所有子线程执行完毕,主线程继续")
上面代码按照t1启动-阻塞,t2启动-阻塞,t3启动-阻塞进行编写,输出结果如下:
image
 

六、线程安全

线程安全:指多线程同时访问 / 修改同一个共享数据时,程序依然能正确运行,不会出现数据错乱、结果异常、死锁等问题。
 
Python 因为 GIL(全局解释器锁) 的存在,同一时刻只有一个线程执行 Python 字节码,但线程安全问题依然会发生
 

 

1、为什么会有线程安全问题?

 
核心原因:
 
多个线程同时读写共享变量 → 操作不是原子性的 → 数据被覆盖 / 错乱
import threading
import time  # 加个微小延时

count = 0

def add():
    global count
    for _ in range(100000):
        temp = count       # 第一步:读
        time.sleep(0)     # 强制让出CPU,触发线程切换
        count = temp + 1  # 第三步:写

t1 = threading.Thread(target=add)
t2 = threading.Thread(target=add)
t1.start()
t2.start()
t1.join()
t2.join()

print(count)  # 现在绝对 <200000,每次都不一样
image  image  image
以上是连续三次的输出值,每次都不一样。

⚠️ 为什么实际结果每次都不同且小于 200000?

正如你在问题中提到的,关键在于 count += 1 不是一个原子操作。它实际上包含了三个独立的步骤:
  1. 读取 (Read):从内存中获取 count 的当前值。
  2. 修改 (Modify):将获取的值加 1。
  3. 写入 (Write):将新的值存回内存中的 count 变量。
当两个线程同时运行时,它们的执行顺序是不可预测的,可能会发生交错。一个典型的数据竞争场景如下:
  • 初始状态count = 0
  • 线程1 读取 count,得到 0。
  • 线程2 也读取 count,同样得到 0。(此时两个线程都持有旧值)
  • 线程1 将 0 加 1,得到 1,然后将 1 写回 count。现在 count = 1
  • 线程2 也将它持有的 0 加 1,得到 1,然后将 1 写回 count。现在 count 仍然是 1。
在这个例子中,两个线程都执行了一次 += 1 操作,但 count 的值只增加了 1,而不是预期的 2。线程1的更新被线程2覆盖了。由于这种“丢失更新”的现象在整个循环中会发生很多次,所以最终的结果总是会小于理论上的 200000,并且每次运行因为线程调度的随机性,结果都会不一样。
要解决这个问题,就需要使用锁(Lock)来保护 count += 1 这段临界区代码,确保同一时间只有一个线程能执行它。
 

🛡️ 如何实现线程安全?

Python 提供了多种机制来确保线程安全,你可以根据不同场景选择使用。

1. 使用锁 (Locks)

锁是最基础的同步工具,用于保护临界区代码,确保同一时间只有一个线程可以执行。
  • threading.Lock: 互斥锁,最常用。建议使用 with 语句作为上下文管理器,它可以自动获取和释放锁,避免死锁。
  • threading.RLock: 可重入锁,允许同一个线程多次获取该锁而不会造成死锁,适用于递归调用等场景。
修正后的线程安全示例:
import threading
import time  # 加个微小延时

count = 0

# def add():
#     global count
#     for _ in range(100000):
#         temp = count       # 第一步:读
#         time.sleep(0)     # 强制让出CPU,触发线程切换
#         count = temp + 1  # 第三步:写
#
# t1 = threading.Thread(target=add)
# t2 = threading.Thread(target=add)
# t1.start()
# t2.start()
# t1.join()
# t2.join()
#
# print(count)  # 现在绝对 <200000,每次都不一样

import threading

count = 0
lock = threading.Lock()  # 创建锁

def add():
    global count
    for _ in range(100000):
        with lock:       # 加锁,保证原子性
            temp = count
            time.sleep(0)
            count = temp + 1

t1 = threading.Thread(target=add)
t2 = threading.Thread(target=add)
t1.start()
t2.start()
t1.join()
t2.join()

print(count)  # 永远 200000
image
多次运行的结果一直都是200000
 

七、死锁

1、什么是死锁?

 
死锁是多线程 / 多进程编程中最严重的问题之一
 
两个或多个线程互相持有对方需要的锁,并且都不释放自己的锁,导致所有线程永久阻塞,程序卡死,无法继续执行。
 

死锁的 4 个必要条件(缺一不可)

 
  1. 互斥条件:同一时间只有一个线程能持有锁
  2. 请求与保持:线程持有一个锁,又去请求另一个锁
  3. 不可剥夺:锁只能由持有者主动释放,不能被强行抢占
  4. 循环等待:线程 1 等线程 2 的锁,线程 2 等线程 1 的锁,形成环路
 

 

2、Python 死锁代码示例(最经典场景)

 
threading.Lock() 模拟两个线程互相等待锁:
import threading
import time

# 创建两把锁
lock1 = threading.Lock()
lock2 = threading.Lock()

def task1():
    lock1.acquire()  # 拿到 lock1
    print("线程1 持有 lock1,等待 lock2...")
    time.sleep(1)    # 给线程2足够时间拿到 lock2
    
    lock2.acquire()  # 等待 lock2(永远拿不到)
    print("线程1 执行完毕")
    
    lock2.release()
    lock1.release()

def task2():
    lock2.acquire()  # 拿到 lock2
    print("线程2 持有 lock2,等待 lock1...")
    time.sleep(1)
    
    lock1.acquire()  # 等待 lock1(永远拿不到)
    print("线程2 执行完毕")
    
    lock1.release()
    lock2.release()

# 启动线程
t1 = threading.Thread(target=task1)
t2 = threading.Thread(target=task2)
t1.start()
t2.start()
image
pycharm卡死在这个输出界面 ,无法动弹,必须要强制退出才行。

🔍 以上代码为什么会死锁?

让我们一步步分析:
  1. 线程1 (task1) 首先获取了 lock1
  2. 线程2 (task2) 几乎同时获取了 lock2
  3. 线程1 休眠1秒后醒来,尝试去获取 lock2。但此时 lock2 正被线程2持有,所以线程1进入等待状态。
  4. 线程2 也休眠1秒后醒来,尝试去获取 lock1。但此时 lock1 正被线程1持有,所以线程2也进入等待状态。
最终结果是:
  • 线程1 持有 lock1,在等 lock2
  • 线程2 持有 lock2,在等 lock1
它们形成了一个循环等待的僵局,永远无法继续执行。

✅ 如何解决和避免死锁?

解决死锁的核心思想是打破循环等待的条件。这里有几种常见且有效的方法:

方法一:按固定顺序获取锁(推荐)

这是最根本、最有效的解决方案。规定所有线程都必须以相同的顺序来获取锁。例如,都规定先拿 lock1,再拿 lock2
def task1():
    lock1.acquire()
    print("线程1 持有 lock1")
    time.sleep(1)
    
    lock2.acquire()  # 顺序:lock1 → lock2
    print("线程1 执行完毕")
    
    lock2.release()
    lock1.release()

def task2():
    lock1.acquire()  # 统一顺序!先 lock1,再 lock2
    print("线程2 持有 lock1")
    time.sleep(1)
    
    lock2.acquire()
    print("线程2 执行完毕")
    
    lock2.release()
    lock1.release()
image不会死锁

2. 使用超时获取锁(acquire (timeout=))

 
不永久等待,超时后放弃并释放已持有的锁:
import threading
import time

# 定义两把互斥锁
lock1 = threading.Lock()
lock2 = threading.Lock()

def task1():
    # 先获取 lock1
    lock1.acquire()
    print("线程1 持有 lock1")

    # 尝试获取 lock2,超时2秒
    success = lock2.acquire(timeout=2)
    if success:
        print("线程1 拿到 lock2,执行业务逻辑")
        # 业务代码写这里
        time.sleep(0.5)
        lock2.release()
        print("线程1 释放 lock2")
    else:
        print("线程1 获取 lock2 超时,避免死锁")

    # 最终一定释放自己持有的 lock1
    lock1.release()
    print("线程1 释放 lock1\n")

def task2():
    lock2.acquire()
    print("线程2 持有 lock2")

    # 反向等待 lock1,同样加超时
    success = lock1.acquire(timeout=2)
    if success:
        print("线程2 拿到 lock1,执行业务逻辑")
        time.sleep(0.5)
        lock1.release()
        print("线程2 释放 lock1")
    else:
        print("线程2 获取 lock1 超时,避免死锁")

    lock2.release()
    print("线程2 释放 lock2")

if __name__ == "__main__":
    t1 = threading.Thread(target=task1)
    t2 = threading.Thread(target=task2)
    t1.start()
    t2.start()

    t1.join()
    t2.join()
    print("所有线程执行完毕,程序正常退出")
image  image
大部分情况下,可以通过“超时”的手段拿到对方的锁,但是在少数情况下,会出现超时而拿不到锁的情况。
进阶优化(推荐写法:with 上下文)
import threading
import time
# 推荐写法:with 上下文

lock1 = threading.Lock()
lock2 = threading.Lock()

def task1():
    # with 自动加锁、自动释放锁
    with lock1:
        print("线程1:持有 lock1")
        # 尝试非永久等待 lock2
        get_lock2 = lock2.acquire(timeout=2)
        try:
            if get_lock2:
                print("线程1:成功获取 lock2,执行业务逻辑")
                time.sleep(1)
            else:
                print("线程1:获取 lock2 超时,放弃竞争,防止死锁")
        finally:
            # 抢到才释放,避免重复释放报错
            if get_lock2:
                lock2.release()

def task2():
    with lock2:
        print("线程2:持有 lock2")
        get_lock1 = lock1.acquire(timeout=2)
        try:
            if get_lock1:
                print("线程2:成功获取 lock1,执行业务逻辑")
                time.sleep(1)
            else:
                print("线程2:获取 lock1 超时,放弃竞争,防止死锁")
        finally:
            if get_lock1:
                lock1.release()

if __name__ == "__main__":
    t1 = threading.Thread(target=task1)
    t2 = threading.Thread(target=task2)

    t1.start()
    t2.start()

    t1.join()
    t2.join()
    print("\n全部线程执行完成,程序正常退出")

image

连续运行了多次,都是以上这个输出结果,所以说使用with不仅优雅,还非常稳定。

3. 使用可重入锁(RLock)

 
同一个线程可以多次获取同一把锁,避免自己锁自己:
 
反面案例,如下:
import threading
# 重复锁的反而案例
num = 0
lock_object = threading.Lock()


def task():
    print("开始")
    lock_object.acquire()  # 第1个抵达的线程进入并上锁,其他线程就需要再此等待。
    lock_object.acquire()  # 第1个抵达的线程进入并上锁,其他线程就需要再此等待。
    global num
    for i in range(1000000):
        num += 1
    lock_object.release()  # 线程出去,并解开锁,其他线程就可以进入并执行了
    lock_object.release()  # 线程出去,并解开锁,其他线程就可以进入并执行了

    print(num)


for i in range(2):
    t = threading.Thread(target=task)
    t.start()

image

pycharm锁死在这个界面 ,整个程序动弹不了。最后强制退出的。

  • Lock 是不可重入锁(非 RLock):同一个线程对 Lock 重复调用 acquire() 时,第一次加锁成功后,第二次会阻塞等待锁释放。但锁的释放需要当前线程调用 release(),而此时线程因第二次 acquire() 被阻塞,永远无法执行到 release(),最终导致死锁(所有线程卡住,程序无法继续)。

正面案例,如下:

import threading
import time

# 可重入锁:同一个线程可以多次 acquire,不会自己锁死自己
lock = threading.RLock()

def inner_func():
    """嵌套函数:再次获取同一把锁"""
    # 第二次获取锁 —— RLock 允许!
    with lock:
        print("→ 嵌套函数:再次获取 RLock 成功(重入)")
        time.sleep(0.5)

def outer_func():
    """外层函数:第一次获取锁"""
    with lock:
        print("外层函数:第一次获取 RLock 成功")
        
        # 调用嵌套函数,里面会再次获取同一把锁
        inner_func()
        
        print("← 嵌套函数执行完毕,回到外层")

    # 离开 with 后,锁自动释放
    print("RLock 已完全释放\n")

def test_thread():
    """测试多线程 + 可重入锁"""
    print(f"\n线程 {threading.current_thread().name} 开始执行")
    outer_func()

if __name__ == "__main__":
    # 创建两个线程测试
    t1 = threading.Thread(target=test_thread, name="A")
    t2 = threading.Thread(target=test_thread, name="B")

    t1.start()
    t2.start()

    t1.join()
    t2.join()
    print("所有线程执行完成")

image

RLock = Reentrant Lock(可重入锁)
 
  • 同一个线程,可以多次获取同一把锁
  • 普通 Lock 这么做会直接死锁
  • 必须获取几次,就释放几次

最后 ,看一个极简的版本,如下:

import threading

lock = threading.RLock()

def func():
    with lock:          # 第 1 次获取
        with lock:      # 第 2 次获取(重入,不阻塞)
            print("执行成功(可重入锁安全)")

if __name__ == "__main__":
    # 创建两个线程测试
    t1 = threading.Thread(target=func)
    t2 = threading.Thread(target=func)

    t1.start()
    t2.start()

    t1.join()
    t2.join()
    print("所有线程执行完成")

image 

八、线程池

首先,我们来看一下,为什么需要线程池

手动多线程 vs 线程池:全面对比

核心概念区别

1. 手动多线程(threading.Thread

  • 自己手动创建线程、启动线程、等待线程结束
  • 来一个任务,就创建一个线程
  • 线程执行完就销毁
  • 完全自己控制所有细节

2. 线程池(ThreadPoolExecutor

  • 预先创建一批固定线程,重复利用
  • 任务来了交给空闲线程,不用反复创建 / 销毁
  • 自动控制并发数量,自动排队、自动回收
  • 不用管线程生命周期,只关注任务

 直观代码对比(同一个任务)

 1. 手动多线程写法

import threading
import time

def task(i):
    time.sleep(1)
    print(f"任务{i}完成")

# 手动创建10个线程
threads = []
for i in range(10):
    t = threading.Thread(target=task, args=(i,))
    threads.append(t)
    t.start()

# 手动等待所有线程结束
for t in threads:
    t.join()

2. 线程池写法 

from concurrent.futures import ThreadPoolExecutor
import time

def task(i):
    time.sleep(1)
    print(f"任务{i}完成")

# 自动管理,代码极简
with ThreadPoolExecutor(max_workers=3) as executor:
    executor.map(task, range(10))

肉眼可见差距:线程池代码少、逻辑清晰。

所以,下面我们就来学习一下线程池及其步骤

线程池是复用线程、控制并发数量的高效工具,避免频繁创建 / 销毁线程的开销,特别适合 I/O 密集型任务(网络请求、文件读写、数据库操作)。
 
Python 官方推荐 concurrent.futures.ThreadPoolExecutor(Python3.2+ 内置,无需安装第三方库),是最简单、最常用的线程池实现。
 

🛠️ 核心用法:ThreadPoolExecutor

ThreadPoolExecutor 的使用主要包含三个步骤:创建线程池、提交任务、关闭线程池。

第1步. 创建线程池

使用 ThreadPoolExecutor 创建线程池,最常用的方式是结合 with 语句,它可以确保任务完成后自动、安全地关闭线程池。
from concurrent.futures import ThreadPoolExecutor

# 创建一个最多包含 5 个工作线程的线程池
with ThreadPoolExecutor(max_workers=5) as executor:
    # 在此处提交任务
    pass
# 离开 with 块后,线程池会自动调用 shutdown() 方法关闭
核心参数 max_workers
这个参数定义了线程池中最大的工作线程数量。如何设置是一个关键问题:
  • I/O 密集型任务 (如网络请求、文件读写):由于线程大部分时间在等待 I/O 操作完成,可以设置一个较大的值,例如 CPU核心数 * 5 或更高。
  • CPU 密集型任务 (如复杂计算):由于 Python 的全局解释器锁(GIL)限制,多线程并不能真正实现并行计算。对于此类任务,建议设置为 CPU核心数 + 1

第2步. 提交任务

线程池提供了两种主要的任务提交方式,适用于不同场景。
特性 map()函数 submit()+Future模式
语法复杂度 低,一行代码批量提交 稍高,需处理 Future 对象
结果顺序 与输入可迭代对象的顺序严格一致 默认无序,但可通过 as_completed 按完成顺序获取
灵活性 较低,仅支持简单的函数调用 非常高,支持回调、超时控制、异常捕获等
适用场景 任务简单,且需要按顺序处理结果 任务复杂,追求高效率,或对单个任务有精细控制需求
 
方式一:submit() 提交单个任务
submit() 用于提交单个任务,并立即返回一个 Future 对象。Future 对象代表了任务的异步执行,可以用来查询任务状态、获取结果或添加回调函数。
from concurrent.futures import ThreadPoolExecutor
import time

# 定义任务函数
def task(name):
    print(f"任务 {name} 开始执行")
    time.sleep(1)  # 模拟 I/O 耗时
    print(f"任务 {name} 执行完成")
    return f"任务{name}结果"

# 1. 创建线程池(max_workers=最大并发线程数)
with ThreadPoolExecutor(max_workers=3) as executor:
    # 2. 提交任务到线程池
    future1 = executor.submit(task, "任务1")
    future2 = executor.submit(task, "任务2")
    future3 = executor.submit(task, "任务3")

# with 语句会自动等待所有任务完成,关闭线程池

image

submit() 返回的 Future 对象可以获取结果、捕获异常:

from concurrent.futures import ThreadPoolExecutor

def task(x):
    if x == 3:
        raise ValueError("故意抛出异常")
    return x * 2


with ThreadPoolExecutor(max_workers=2) as executor:
    future = executor.submit(task, 3)

    try:
        # 获取任务结果(阻塞等待完成)
        result = future.result()
        print(result)
    except Exception as e:
        print(f"任务执行失败:{e}")

image

 

方式二:map() 批量提交 

 map() 的用法类似于内置的 map 函数,它将一个函数和一个可迭代对象作为参数,然后并发地对可迭代对象中的每个元素执行该函数。

import time
from concurrent.futures import ThreadPoolExecutor


def download_file(url):
    print(f"开始下载: {url}")
    time.sleep(2)  # 模拟耗时操作
    return f"{url} 下载完成"


urls = ["url1", "url2", "url3"]

with ThreadPoolExecutor(max_workers=3) as executor:
    # map 返回一个迭代器,按 urls 的顺序产出结果
    results = executor.map(download_file, urls)

    for result in results:
        print(result)

image

第3步. 关闭线程池

如前所述,使用 with 语句是推荐的做法,它会在代码块执行完毕后自动调用 executor.shutdown(wait=True),等待所有已提交的任务执行完毕后再关闭线程池。
如果不使用 with 语句,则需要手动调用 shutdown() 方法来关闭线程池。

 

 

🛠️高级用法:as_completed(先完成先获取)

as_completed() 是 Python concurrent.futures 模块中的一个核心函数,它用于处理并发任务的结果。
简单来说,它的作用是:当一批并发任务中有任何一个完成时,就立即返回该任务的结果,而不需要等待所有任务都结束。
这与按提交顺序返回结果的 map() 方法形成了鲜明对比。as_completed() 遵循“谁先完成就先处理谁”的原则,这在处理耗时不一的任务时能极大提升效率。

🚀 核心用法与优势

as_completed(fs, timeout=None) 接收两个参数:
  • fs: 一个包含多个 Future 对象的可迭代对象(例如列表)。这些 Future 对象通常由线程池或进程池的 submit() 方法产生。
  • timeout: (可选) 等待结果的最大秒数。如果超时,会抛出 TimeoutError 异常。
它的返回值是一个生成器,每次迭代都会产出一个已经完成的 Future 对象。

代码示例:感受“快进快出”

示例一:

from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def task(seconds):
    time.sleep(seconds)
    return f"耗时{seconds}秒的任务完成"

with ThreadPoolExecutor(max_workers=3) as executor:
    tasks = [executor.submit(task, s) for s in [3, 1, 2]]
    
    # 遍历已完成的任务
    for future in as_completed(tasks):
        print(future.result())

输出结果是:(任务是按照3->1->2的顺序进入线程的,输出的却是1->2->3) 

 image

 做为对比,我将上面的改写为map函数,对比就更加清晰明了,代码如下:

from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def task(seconds):
    time.sleep(seconds)
    return f"耗时{seconds}秒的任务完成"

# with ThreadPoolExecutor(max_workers=3) as executor:
#     tasks = [executor.submit(task, s) for s in [3, 1, 2]]
#
#     # 遍历已完成的任务
#     for future in as_completed(tasks):
#         print(future.result())

# 按照map方式进行更改
ch_str = [3,1,2]
with ThreadPoolExecutor(max_workers=3) as executor:
    tasks = executor.map(task, ch_str)
    for task in tasks:
        print(task)

输出结果:(明显是按照列表中3->1->2的顺序进行输出的)

image

示例二:

from concurrent.futures import as_completed, ThreadPoolExecutor
import time


def task(name, delay):
    time.sleep(delay)
    return f"任务 {name} 完成,耗时 {delay} 秒"


with ThreadPoolExecutor(max_workers=2) as executor:
    # 提交多个任务,得到 Future 对象列表
    futures = [
        executor.submit(task, "A", 3),
        executor.submit(task, "B", 1),
        executor.submit(task, "C", 2)
    ]

    # 方式 A: 按任务完成的先后顺序获取结果 (效率更高)
    print("--- 按完成顺序 ---")
    for future in as_completed(futures):
        print(future.result())

    # 方式 B: 按任务提交的顺序获取结果 (会阻塞等待)
    # print("--- 按提交顺序 ---")
    # for future in futures:
    #     print(future.result())

输出结果:(任务执行是A->B->C,输出结果却是B->A->C)

image

示例三:

from concurrent.futures import ThreadPoolExecutor, as_completed
import time


def task(name, delay):
    time.sleep(delay)
    return f"任务 {name} 完成,耗时 {delay} 秒"


# 定义任务列表,每个元素是 (任务名, 耗时)
tasks = [("A", 3), ("B", 1), ("C", 2)]

with ThreadPoolExecutor(max_workers=3) as executor:
    # 1. 提交所有任务,得到 Future 对象列表
    futures = [executor.submit(task, name, delay) for name, delay in tasks]

    # 2. 使用 as_completed 按完成顺序获取结果
    print("--- 开始处理任务 ---")
    for future in as_completed(futures):
        # future.result() 会获取已完成任务的返回值
        print(future.result())

输出结果:(尽管任务是按 A, B, C 的顺序提交的,但结果是按照它们实际完成的先后顺序(B -> C -> A)被处理的。)

image

⚖️ 与 map() 的关键区别

理解 as_completed() 的最佳方式就是将其与 executor.map() 进行对比。
 
特性 as_completed() executor.map()
结果顺序 按任务实际完成的顺序 严格按任务提交的顺序
灵活性 高,可配合 submit() 实现复杂逻辑 低,适用于简单的批量并行处理
响应速度 更快,最早完成的任务能被立即处理 较慢,必须等待序列中前面的任务完成
适用场景 任务耗时差异大,追求高效率 任务简单且需保持结果顺序一致

场景模拟:网络爬虫

想象你正在爬取 10 个网页,它们的加载时间各不相同。
  • 使用 map():你必须等到第 1 个网页爬完,才能处理它,即使第 5 个网页早就加载好了。这会造成不必要的等待。
  • 使用 as_completed():任何一个网页(比如第 5 个)一旦下载完成,你就可以立刻开始解析和保存它的数据,无需等待其他慢速页面。这使得整体处理流程更加高效。

 

 

posted @ 2026-04-29 22:27  chenlight  阅读(8)  评论(0)    收藏  举报