Python 多线程实战指南:从入门到避坑,让代码效率直接起飞

目录

引言:别再让代码 “单线程裸奔” 了!3 分钟搞懂多线程能帮你解决什么问题

一、先搞懂:Python 多线程到底是什么?用通俗比喻讲清核心概念

1.1 线程 vs 进程:“工人” 和 “工厂” 的关系

1.2 Python 多线程的 “特殊情况”:GIL 锁到底是什么?

1.3 多线程的核心价值:解决 “等待浪费时间” 的问题

二、环境搭建:Python 多线程不用装任何库!自带模块就能用

2.1 验证 Python 版本(避免兼容性问题)

2.2 第一个多线程程序:让两个 “工人” 同时干活

单线程版本(对比用)

多线程版本

2.3 关键代码解释:3 步创建多线程

三、核心知识点:Python 多线程的 3 种创建方式 + 4 种同步机制

3.1 线程的 3 种创建方式:选哪种最合适?

方式 1:直接创建 Thread 类(最常用,适合简单任务)

方式 2:继承 Thread 类(适合复杂任务,需复用逻辑)

方式 3:用线程池(ThreadPoolExecutor)—— 管理大量线程更高效

3.2 线程同步:避免 “多个工人抢同一个工具” 导致数据错乱

问题示例:线程不安全导致数据错乱

解决方案 1:用 Lock(锁)—— 让线程 “排队” 操作

解决方案 2:用 RLock(递归锁)—— 解决 “嵌套锁” 问题

解决方案 3:用 Semaphore(信号量)—— 控制同时干活的线程数

解决方案 4:用 Event(事件)—— 线程间 “发信号”

四、实战项目:6 个场景覆盖 90% 多线程使用需求

项目 1:多线程爬取网页(基础场景)

项目 2:多线程处理文件(办公自动化)

项目 3:多线程下载图片(爬虫常用)

项目 4:多线程监控日志文件(运维场景)

项目 5:多线程 + 队列处理任务(生产消费模型)

项目 6:多线程 vs 多进程对比(CPU 密集型任务)

五、避坑指南:Python 多线程新手常踩的 6 个坑及解决方案

坑 1:误以为 “多线程能加速所有任务”——GIL 锁的坑

坑 2:线程安全问题 —— 多个线程操作共享变量导致数据错乱

坑 3:忘记加 join ()—— 主线程提前结束导致子线程未完成

坑 4:死锁 —— 线程互相等待对方释放锁

坑 5:线程数量过多 —— 导致系统卡顿或被目标网站封禁

坑 6:守护线程设置不当 —— 主线程退出时子线程未清理资源

六、总结与进阶学习建议

6.1 本文核心知识点总结


 

class 卑微码农:
    def __init__(self):
        self.技能 = ['能读懂十年前祖传代码', '擅长用Ctrl+C/V搭建世界', '信奉"能跑就别动"的玄学']
        self.发量 = 100  # 初始发量
        self.咖啡因耐受度 = '极限'
        
    def 修Bug(self, bug):
        try:
            # 试图用玄学解决问题
            if bug.严重程度 == '离谱':
                print("这一定是环境问题!")
            else:
                print("让我看看是谁又没写注释...哦,是我自己。")
        except Exception as e:
            # 如果try块都救不了,那就...
            print("重启一下试试?")
            self.发量 -= 1  # 每解决一个bug,头发-1
 
 
# 实例化一个我
我 = 卑微码农()

引言:别再让代码 “单线程裸奔” 了!3 分钟搞懂多线程能帮你解决什么问题

如果你用 Python 写过这些代码,大概率会遇到 “等得着急” 的情况:“用循环爬取 100 个网页,每个网页等 2 秒,硬生生等了 3 分钟才爬完”;“处理 1000 个 Excel 文件,单线程跑了半小时,电脑还卡得动不了”;“写了个监控脚本,一边要实时读取日志,一边要发送报警信息,结果日志读一半就卡住了”。

这些问题的根源,其实是代码在 “单线程裸奔”—— 同一时间只能干一件事,遇到等待(比如等网页响应、等文件读取)就只能闲着。而Python 多线程,就是给代码 “加帮手” 的工具:让多个 “小工人” 同时干活,等待的时候不浪费时间,效率直接翻几倍。

但很多新手一提多线程就怕:“听说有 GIL 锁?多线程是不是假的?”“线程安全怎么保证?会不会把数据搞乱?”“写多线程会不会很复杂?”

其实没那么难。这篇博客从 “新手能上手” 的角度,用 “工厂工人” 的比喻讲清基础概念,用 6几个实战案例带你落地(爬网页、处理文件、监控日志全涵盖),再把 “GIL 锁”“线程安全” 这些坑点掰开揉碎讲明白。所有代码都能直接复制运行。

看完这篇,你再也不用看着代码 “慢悠悠” 而着急,也能轻松写出 “多任务同时跑” 的高效代码。

一、先搞懂:Python 多线程到底是什么?用通俗比喻讲清核心概念

很多人学多线程卡壳,是因为一上来就被 “进程”“线程”“GIL 锁” 这些术语吓住了。咱们用 “工厂干活” 的比喻,5 分钟就能搞懂这些概念。

1.1 线程 vs 进程:“工人” 和 “工厂” 的关系

先明确两个最基础的概念,用工厂举例:

  • 进程:相当于一个 “工厂”。每个工厂有自己的厂房(内存空间)、设备(系统资源),工厂之间互相独立,比如 “浏览器进程” 和 “Python 脚本进程” 就像两个分开的工厂。
  • 线程:相当于工厂里的 “工人”。一个工厂里可以有多个工人(一个进程里可以有多个线程),工人共享工厂的设备(线程共享进程的内存资源),能一起干活。

举个具体例子:你用 Python 写了个脚本爬网页,这个脚本运行起来就是一个 “Python 进程”。如果用单线程,就像工厂里只有 1 个工人,他要负责 “发起请求→等响应→解析数据→保存文件” 一整套流程,等响应的时候只能坐着发呆;如果用 4 个线程,就像 4 个工人同时干活,一个工人在等响应时,其他工人还能继续爬其他网页,效率自然高。

1.2 Python 多线程的 “特殊情况”:GIL 锁到底是什么?

很多人说 “Python 多线程是假的”,根源就是这个GIL 锁(全局解释器锁) 。咱们还是用工厂比喻:Python 解释器(相当于工厂的 “总调度”)有个规定:不管工厂里有多少工人(线程),同一时间只能有 1 个工人拿到 “工具”(CPU)干活,其他工人只能排队等。

那这是不是意味着多线程没用?当然不是!要分两种情况看:

任务类型GIL 锁影响多线程是否有用?例子
IO 密集型任务影响小(工人大部分时间在等材料)非常有用(等待时换其他工人干活)爬网页(等响应)、读文件(等磁盘)、发请求(等网络)
CPU 密集型任务影响大(工人一直在用工具干活)基本没用(换工人还要浪费时间)数学计算、数据建模、图像识别(一直占 CPU)

比如爬网页是 IO 密集型任务:工人发起请求后,要等 2 秒才能拿到网页数据(相当于等材料),这时候总调度可以让其他工人先干活,等 2 秒后再回来处理。这样 4 个工人同时爬,总时间差不多是 1 个工人的 1/4。

而如果是算 100 万次乘法(CPU 密集型):工人一直在用 CPU 干活,总调度频繁换工人,反而要浪费时间(切换线程的开销),最后总时间可能比 1 个工人还长。这种情况就该用 “多进程”,但咱们这篇先聚焦多线程(后续会对比两者)。

1.3 多线程的核心价值:解决 “等待浪费时间” 的问题

总结一下:Python 多线程的核心作用,是在 IO 密集型任务中,利用 “等待时间” 并行处理,从而提升效率。

用一个直观的对比:假设要爬 3 个网页,每个网页需要 2 秒响应时间,1 秒解析时间。

  • 单线程:(2+1)+(2+1)+(2+1)= 9 秒(每个任务做完才能做下一个,等待时闲着)
  • 3 线程:总耗时≈3 秒(3 个线程同时发起请求,2 秒后同时拿到响应,再用 1 秒解析,总时间≈2+1=3 秒)

这就是多线程的魅力 —— 把 “等待时间” 利用起来,让代码 “不闲着”。

二、环境搭建:Python 多线程不用装任何库!自带模块就能用

Python 的多线程功能在threading模块里,是标准库自带的,不用装任何第三方库(Python 3.5 + 版本都支持)。咱们先验证环境,写第一个多线程程序。

2.1 验证 Python 版本(避免兼容性问题)

打开命令行(Windows 用 CMD,Mac 用终端),输入python --version,确保版本是 3.5 以上(推荐 3.8+,功能更全)。

如果版本太低,建议升级:

# Windows/Mac通用(用pip升级)
pip install --upgrade python

2.2 第一个多线程程序:让两个 “工人” 同时干活

写一个简单的示例:让两个线程同时打印 “任务 1” 和 “任务 2”,看看它们的执行顺序(和单线程对比)。

单线程版本(对比用)

import time

# 定义一个任务函数:打印任务名,睡1秒(模拟任务耗时)
def do_task(task_name):
    print(f"开始执行:{task_name}")
    time.sleep(1)  # 模拟等待(比如等网页响应、等文件读取)
    print(f"完成执行:{task_name}")

# 单线程执行两个任务
start_time = time.time()
do_task("任务1")
do_task("任务2")
end_time = time.time()

print(f"单线程总耗时:{end_time - start_time:.2f}秒")

运行结果

开始执行:任务1
完成执行:任务1
开始执行:任务2
完成执行:任务2
单线程总耗时:2.01秒

—— 两个任务按顺序执行,总耗时≈2 秒(1+1)。

多线程版本

import time
import threading  # 导入多线程模块

# 任务函数和单线程一样(不用改)
def do_task(task_name):
    print(f"开始执行:{task_name}")
    time.sleep(1)  # 模拟等待
    print(f"完成执行:{task_name}")

# 多线程执行两个任务
start_time = time.time()

# 1. 创建线程对象:target=任务函数,args=任务参数(元组格式,注意逗号)
thread1 = threading.Thread(target=do_task, args=("任务1",))
thread2 = threading.Thread(target=do_task, args=("任务2",))

# 2. 启动线程(让工人开始干活)
thread1.start()
thread2.start()

# 3. 等待线程完成(让主线程等两个子线程干完再继续)
thread1.join()
thread2.join()

end_time = time.time()
print(f"多线程总耗时:{end_time - start_time:.2f}秒")

运行结果

开始执行:任务1
开始执行:任务2
完成执行:任务1
完成执行:任务2
多线程总耗时:1.01秒

—— 两个任务同时执行,总耗时≈1 秒(和单个任务时间差不多),效率直接翻倍!

2.3 关键代码解释:3 步创建多线程

上面的示例里,创建多线程只需要 3 步,记牢这 3 步,就能应对大部分场景:

  1. 定义任务函数:把要并行执行的逻辑写成函数(比如do_task),参数根据需求定;
  2. 创建线程对象:用threading.Thread(target=函数名, args=参数元组)创建线程,args必须是元组(即使只有一个参数,也要加逗号,比如("任务1",));
  3. 启动并等待线程thread.start()启动线程,thread.join()让主线程等待子线程完成(如果不加join(),主线程会直接结束,子线程可能还没干完)。

三、核心知识点:Python 多线程的 3 种创建方式 + 4 种同步机制

学会了基础示例,接下来要掌握多线程的核心用法:不同的线程创建方式(适合不同场景),以及线程同步(避免数据错乱)。

3.1 线程的 3 种创建方式:选哪种最合适?

Python 多线程有 3 种常见的创建方式,各有优缺点,根据场景选择:

方式 1:直接创建 Thread 类(最常用,适合简单任务)

就是上面示例用的方式,直接调用threading.Thread,适合任务逻辑简单、不需要复用的场景。

示例:多线程爬取 3 个网页(用requests发请求,IO 密集型任务)

import time
import threading
import requests  # 需安装:pip install requests

# 任务函数:爬取指定URL的页面标题
def crawl_url(url, name):
    print(f"线程{name}:开始爬取 {url}")
    try:
        # 发请求(IO等待,多线程能利用这段时间)
        response = requests.get(url, timeout=5)
        response.raise_for_status()  # 若状态码不是200,抛异常
        # 提取页面标题(简单处理,实际可结合BeautifulSoup)
        if "<title>" in response.text:
            title = response.text.split("<title>")[1].split("</title>")[0].strip()
        else:
            title = "无标题"
        print(f"线程{name}:爬取成功!标题:{title}")
    except Exception as e:
        print(f"线程{name}:爬取失败!错误:{str(e)}")

# 要爬取的URL列表(用公开测试网站,无版权问题)
urls = [
    "https://httpbin.org/html",  # 测试页面1
    "https://httpbin.org/status/200",  # 测试页面2
    "https://httpbin.org/robots.txt"  # 测试页面3
]

start_time = time.time()

# 创建并启动3个线程
threads = []
for i, url in enumerate(urls, 1):
    thread = threading.Thread(target=crawl_url, args=(url, i))
    threads.append(thread)
    thread.start()

# 等待所有线程完成
for thread in threads:
    thread.join()

end_time = time.time()
print(f"\n多线程爬取总耗时:{end_time - start_time:.2f}秒")

运行结果(耗时≈1 秒,单线程要≈3 秒):

线程1:开始爬取 https://httpbin.org/html
线程2:开始爬取 https://httpbin.org/status/200
线程3:开始爬取 https://httpbin.org/robots.txt
线程2:爬取成功!标题:200 OK
线程3:爬取成功!标题:无标题
线程1:爬取成功!标题:Herman Melville - Moby-Dick
多线程爬取总耗时:1.23秒

方式 2:继承 Thread 类(适合复杂任务,需复用逻辑)

如果任务逻辑复杂,需要封装多个方法(比如爬取 + 解析 + 保存),可以继承threading.Thread类,重写run()方法(线程启动后会自动执行run())。

示例:封装一个 “网页爬虫类”,支持爬取 + 保存结果

import time
import threading
import requests

# 继承Thread类,封装爬虫逻辑
class UrlCrawler(threading.Thread):
    def __init__(self, url, name):
        # 调用父类构造函数
        super().__init__()
        self.url = url  # 要爬取的URL
        self.name = name  # 线程名
        self.result = None  # 保存爬取结果

    # 重写run()方法:线程启动后执行的逻辑
    def run(self):
        print(f"线程{self.name}:开始爬取 {self.url}")
        try:
            response = requests.get(self.url, timeout=5)
            response.raise_for_status()
            # 提取标题
            if "<title>" in response.text:
                self.result = {
                    "url": self.url,
                    "title": response.text.split("<title>")[1].split("</title>")[0].strip(),
                    "status": "success"
                }
            else:
                self.result = {"url": self.url, "title": "无标题", "status": "success"}
            print(f"线程{self.name}:爬取成功!")
        except Exception as e:
            self.result = {"url": self.url, "error": str(e), "status": "failed"}
            print(f"线程{self.name}:爬取失败!")

# 测试
start_time = time.time()

# 创建3个爬虫线程
crawlers = [
    UrlCrawler("https://httpbin.org/html", "爬虫1"),
    UrlCrawler("https://httpbin.org/status/200", "爬虫2"),
    UrlCrawler("https://httpbin.org/robots.txt", "爬虫3")
]

# 启动线程
for crawler in crawlers:
    crawler.start()

# 等待线程完成,并收集结果
results = []
for crawler in crawlers:
    crawler.join()
    results.append(crawler.result)

# 打印结果
print("\n爬取结果汇总:")
for res in results:
    print(res)

end_time = time.time()
print(f"\n总耗时:{end_time - start_time:.2f}秒")

优势:逻辑封装更清晰,适合需要多次复用的场景(比如多个地方都要爬网页),还能通过实例属性(如self.result)收集结果。

方式 3:用线程池(ThreadPoolExecutor)—— 管理大量线程更高效

如果要创建几十、上百个线程(比如爬 100 个网页),手动创建Thread对象会很麻烦,而且频繁创建 / 销毁线程会有开销。这时候用concurrent.futures.ThreadPoolExecutor(线程池)更合适:提前创建好固定数量的线程,重复利用,减少开销。

示例:用线程池爬取 10 个网页(公开测试 URL)

import time
import requests
from concurrent.futures import ThreadPoolExecutor  # 导入线程池

# 任务函数:和方式1一样,不需要改
def crawl_url(url):
    print(f"开始爬取:{url}")
    try:
        response = requests.get(url, timeout=5)
        response.raise_for_status()
        title = response.text.split("<title>")[1].split("</title>")[0].strip() if "<title>" in response.text else "无标题"
        return {"url": url, "title": title, "status": "success"}
    except Exception as e:
        return {"url": url, "error": str(e), "status": "failed"}

# 要爬取的10个测试URL(用httpbin的不同路径,无版权问题)
urls = [f"https://httpbin.org/html?page={i}" for i in range(1, 11)]

start_time = time.time()

# 1. 创建线程池:max_workers=3(最多3个线程同时干活)
with ThreadPoolExecutor(max_workers=3) as executor:
    # 2. 提交任务:map()自动分配URL给线程,返回结果迭代器
    results = executor.map(crawl_url, urls)

# 3. 处理结果
print("\n爬取结果汇总:")
for res in results:
    print(res)

end_time = time.time()
print(f"\n线程池爬取总耗时:{end_time - start_time:.2f}秒")

优势

  • 不用手动创建 / 管理线程,with语句自动关闭线程池;
  • max_workers控制并发数(避免线程太多导致系统卡顿,一般设为 CPU 核心数的 2-4 倍,或根据 IO 等待时间调整);
  • executor.map()自动分配任务,收集结果,代码更简洁。

3.2 线程同步:避免 “多个工人抢同一个工具” 导致数据错乱

多线程虽然高效,但如果多个线程同时操作同一个变量(比如多个线程往同一个列表加数据、改同一个计数器),会出现 “数据错乱” 的问题。这就是 “线程不安全”,需要用 “同步机制” 让线程按顺序操作。

举个例子:两个线程同时给计数器加 1,预期结果是 2,实际可能是 1(因为两个线程同时读了初始值 0,加 1 后都写回 1)。

问题示例:线程不安全导致数据错乱

import time
import threading

# 全局计数器(多个线程共享)
count = 0

# 任务函数:给计数器加1,执行10000次
def add_count():
    global count  # 声明使用全局变量
    for _ in range(10000):
        count += 1  # 这行代码看似简单,实际是“读→加→写”三步,可能被打断

# 两个线程同时执行add_count
start_time = time.time()
thread1 = threading.Thread(target=add_count)
thread2 = threading.Thread(target=add_count)
thread1.start()
thread2.start()
thread1.join()
thread2.join()

print(f"预期结果:20000")
print(f"实际结果:{count}")  # 实际结果可能是12345、15678等,不是20000
print(f"耗时:{time.time() - start_time:.2f}秒")

运行结果(每次可能不同):

预期结果:20000
实际结果:14567
耗时:0.01秒

—— 这就是线程不安全的问题,多个线程同时操作共享变量,导致数据错乱。

解决方案 1:用 Lock(锁)—— 让线程 “排队” 操作

threading.Lock是最常用的同步机制,相当于给 “共享工具” 加一把锁:一个线程拿到锁后,其他线程只能等它用完释放锁,才能继续用。

修改后的代码

import time
import threading

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

def add_count():
    global count
    for _ in range(10000):
        # 1.  acquire():获取锁(没拿到就等)
        lock.acquire()
        try:
            count += 1  # 临界区:只有拿到锁的线程能执行
        finally:
            # 2. release():释放锁(不管有没有异常,都要释放,避免死锁)
            lock.release()

# 测试
start_time = time.time()
thread1 = threading.Thread(target=add_count)
thread2 = threading.Thread(target=add_count)
thread1.start()
thread2.start()
thread1.join()
thread2.join()

print(f"预期结果:20000")
print(f"实际结果:{count}")  # 这次一定是20000
print(f"耗时:{time.time() - start_time:.2f}秒")

运行结果

预期结果:20000
实际结果:20000
耗时:0.02秒

—— 加锁后,线程按顺序操作临界区(count +=1),数据不再错乱。

注意

  • 锁要 “成对使用”:acquire()后必须release(),最好用try...finally包裹,避免异常导致锁没释放(死锁);
  • 锁的粒度要小:只给 “必须排队” 的代码加锁(比如count +=1),不要把整个循环加锁(否则和单线程没区别)。

解决方案 2:用 RLock(递归锁)—— 解决 “嵌套锁” 问题

如果代码中有嵌套的锁(比如函数 A 加锁后调用函数 B,函数 B 也要加同一个锁),用Lock会导致死锁(线程拿到锁后,再要拿同一个锁会卡住)。这时候要用threading.RLock(递归锁):同一个线程可以多次获取同一把锁,只要释放次数和获取次数相同。

示例:嵌套锁场景用 RLock

import threading

rlock = threading.RLock()  # 递归锁

def func_b():
    rlock.acquire()
    try:
        print("func_b:拿到锁")
    finally:
        rlock.release()
        print("func_b:释放锁")

def func_a():
    rlock.acquire()
    try:
        print("func_a:拿到锁")
        func_b()  # 调用func_b,需要再拿同一个锁
    finally:
        rlock.release()
        print("func_a:释放锁")

# 启动线程执行func_a
thread = threading.Thread(target=func_a)
thread.start()
thread.join()

运行结果(无死锁):

func_a:拿到锁
func_b:拿到锁
func_b:释放锁
func_a:释放锁

—— 如果用Lockfunc_bacquire()会卡住,导致死锁;用RLock则正常。

解决方案 3:用 Semaphore(信号量)—— 控制同时干活的线程数

Semaphore相当于 “有限的锁”,可以控制同时获取锁的线程数量(比如允许 3 个线程同时操作,其他线程等待)。适合场景:限制并发访问资源的线程数(比如同时最多 3 个线程访问数据库,避免数据库压力过大)。

示例:用 Semaphore 限制 3 个线程同时爬网页

import time
import threading
import requests

# 创建信号量,允许3个线程同时获取
semaphore = threading.Semaphore(3)

def crawl_url(url, name):
    # 获取信号量(如果已达3个线程,等待)
    semaphore.acquire()
    try:
        print(f"线程{name}:开始爬取 {url}")
        response = requests.get(url, timeout=5)
        time.sleep(1)  # 模拟解析耗时
        print(f"线程{name}:爬取完成 {url}")
    except Exception as e:
        print(f"线程{name}:爬取失败 {str(e)}")
    finally:
        # 释放信号量
        semaphore.release()

# 要爬取的10个URL
urls = [f"https://httpbin.org/html?page={i}" for i in range(1, 11)]

start_time = time.time()

# 创建10个线程
threads = []
for i, url in enumerate(urls, 1):
    thread = threading.Thread(target=crawl_url, args=(url, i))
    threads.append(thread)
    thread.start()

# 等待所有线程
for thread in threads:
    thread.join()

print(f"\n总耗时:{time.time() - start_time:.2f}秒")  # 总耗时≈4秒(10个任务,3个并发,10/3≈3.33,加等待时间≈4秒)

运行结果

线程1:开始爬取 https://httpbin.org/html?page=1
线程2:开始爬取 https://httpbin.org/html?page=2
线程3:开始爬取 https://httpbin.org/html?page=3
线程1:爬取完成 https://httpbin.org/html?page=1
线程4:开始爬取 https://httpbin.org/html?page=4
线程2:爬取完成 https://httpbin.org/html?page=2
线程5:开始爬取 https://httpbin.org/html?page=5
...(后续线程按3个一批执行)
总耗时:4.12秒

—— 信号量控制了同时干活的线程数,避免并发过高。

解决方案 4:用 Event(事件)—— 线程间 “发信号”

Event相当于线程间的 “信号灯”:一个线程设置信号(set()),其他线程等待信号(wait()),收到信号后继续执行。适合场景:一个线程等待另一个线程完成某个操作(比如 “监控线程” 等待 “爬取线程” 完成后,再生成报告)。

示例:爬取线程完成后,通知报告线程生成报告

import time
import threading

# 创建Event对象(初始为False)
crawl_done = threading.Event()

# 爬取线程:爬完后设置Event
def crawl_task():
    print("爬取线程:开始爬取数据...")
    time.sleep(3)  # 模拟爬取耗时
    print("爬取线程:数据爬取完成!")
    crawl_done.set()  # 设置Event,发送信号

# 报告线程:等待Event后生成报告
def report_task():
    print("报告线程:等待数据爬取完成...")
    crawl_done.wait()  # 等待信号(没收到就阻塞)
    print("报告线程:开始生成数据报告...")
    time.sleep(1)
    print("报告线程:报告生成完成!")

# 启动两个线程
thread_crawl = threading.Thread(target=crawl_task)
thread_report = threading.Thread(target=report_task)
thread_crawl.start()
thread_report.start()
thread_crawl.join()
thread_report.join()

print("\n所有任务完成!")

运行结果

爬取线程:开始爬取数据...
报告线程:等待数据爬取完成...
爬取线程:数据爬取完成!
报告线程:开始生成数据报告...
报告线程:报告生成完成!
所有任务完成!

—— 报告线程会一直等爬取线程的信号,收到后才继续执行,实现了线程间的同步。

四、实战项目:6 个场景覆盖 90% 多线程使用需求

学会了核心知识点,接下来用 6 个实战项目巩固 —— 覆盖 IO 密集型的常见场景,每个项目都有完整代码和运行说明,直接复制就能用。

项目 1:多线程爬取网页(基础场景)

需求:爬取 10 个公开测试网页的标题和响应时间,用线程池管理线程,结果保存到字典。

代码

import time
import requests
from concurrent.futures import ThreadPoolExecutor

# 任务函数:爬取URL,返回标题和响应时间
def crawl_with_time(url):
    result = {"url": url}
    start = time.time()
    try:
        response = requests.get(url, timeout=5)
        response.raise_for_status()
        # 提取标题
        if "<title>" in response.text:
            result["title"] = response.text.split("<title>")[1].split("</title>")[0].strip()
        else:
            result["title"] = "无标题"
        # 计算响应时间
        result["response_time"] = round(time.time() - start, 2)
        result["status"] = "success"
    except Exception as e:
        result["error"] = str(e)
        result["response_time"] = round(time.time() - start, 2)
        result["status"] = "failed"
    return result

# 要爬取的测试URL(10个,均为公开测试资源)
urls = [
    "https://httpbin.org/html",
    "https://httpbin.org/status/200",
    "https://httpbin.org/robots.txt",
    "https://httpbin.org/json",
    "https://httpbin.org/xml",
    "https://httpbin.org/encoding/utf8",
    "https://httpbin.org/user-agent",
    "https://httpbin.org/ip",
    "https://httpbin.org/get",
    "https://httpbin.org/post"  # 这个会返回405(方法不允许),用于测试失败场景
]

if __name__ == "__main__":
    start_total = time.time()

    # 用线程池,max_workers=4
    with ThreadPoolExecutor(max_workers=4) as executor:
        results = executor.map(crawl_with_time, urls)

    # 打印结果
    print("多线程爬取结果汇总:")
    print("-" * 80)
    for res in results:
        if res["status"] == "success":
            print(f"URL:{res['url']:30} | 标题:{res['title']:20} | 响应时间:{res['response_time']}秒")
        else:
            print(f"URL:{res['url']:30} | 状态:失败 | 错误:{res['error'][:50]} | 耗时:{res['response_time']}秒")

    print("-" * 80)
    print(f"总耗时:{round(time.time() - start_total, 2)}秒")

运行结果(总耗时≈2 秒,单线程要≈10 秒):

多线程爬取结果汇总:
--------------------------------------------------------------------------------
URL:https://httpbin.org/html       | 标题:Herman Melville - Moby-Dick | 响应时间:0.32秒
URL:https://httpbin.org/status/200 | 标题:200 OK                    | 响应时间:0.21秒
URL:https://httpbin.org/robots.txt | 标题:无标题                    | 响应时间:0.18秒
URL:https://httpbin.org/json       | 标题:httpbin.org               | 响应时间:0.25秒
URL:https://httpbin.org/xml       | 标题:httpbin.org               | 响应时间:0.23秒
URL:https://httpbin.org/encoding/utf8 | 标题:httpbin.org           | 响应时间:0.27秒
URL:https://httpbin.org/user-agent | 标题:httpbin.org               | 响应时间:0.22秒
URL:https://httpbin.org/ip         | 标题:httpbin.org               | 响应时间:0.24秒
URL:https://httpbin.org/get        | 标题:httpbin.org               | 响应时间:0.26秒
URL:https://httpbin.org/post       | 状态:失败 | 错误:405 Client Error: Method Not Allowed | 耗时:0.20秒
--------------------------------------------------------------------------------
总耗时:0.89秒

项目 2:多线程处理文件(办公自动化)

需求:生成 100 个文本文件(每个文件 100 行随机数字),然后用多线程读取所有文件,计算每个文件的数字总和,结果保存到 CSV。

代码

import os
import time
import random
import csv
import threading
from concurrent.futures import ThreadPoolExecutor

# 第一步:生成100个测试文件(单线程即可,避免文件创建冲突)
def generate_test_files(num_files=100, lines_per_file=100):
    print("开始生成测试文件...")
    # 创建文件目录
    if not os.path.exists("test_files"):
        os.makedirs("test_files")
    # 生成文件
    for i in range(num_files):
        file_path = f"test_files/file_{i+1}.txt"
        with open(file_path, "w", encoding="utf-8") as f:
            for _ in range(lines_per_file):
                # 生成1-100的随机数字,写入文件
                f.write(f"{random.randint(1, 100)}\n")
    print(f"生成完成!共{num_files}个文件,每个文件{lines_per_file}行")

# 第二步:多线程读取文件,计算数字总和
def calculate_file_sum(file_path):
    """读取单个文件,计算所有数字的总和"""
    try:
        sum_num = 0
        line_count = 0
        with open(file_path, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if line.isdigit():
                    sum_num += int(line)
                    line_count += 1
        # 返回文件名、总行数、总和
        file_name = os.path.basename(file_path)
        return {"file_name": file_name, "line_count": line_count, "total_sum": sum_num, "status": "success"}
    except Exception as e:
        return {"file_name": os.path.basename(file_path), "error": str(e), "status": "failed"}

# 第三步:批量处理文件,结果保存到CSV
def batch_process_files():
    # 获取所有测试文件路径
    file_paths = [os.path.join("test_files", f) for f in os.listdir("test_files") if f.endswith(".txt")]
    if not file_paths:
        print("没有找到测试文件!")
        return

    start_time = time.time()

    # 用线程池处理(max_workers=5,避免同时打开太多文件)
    with ThreadPoolExecutor(max_workers=5) as executor:
        results = executor.map(calculate_file_sum, file_paths)

    # 保存结果到CSV
    with open("file_sum_results.csv", "w", encoding="utf-8-sig", newline="") as f:
        fieldnames = ["file_name", "line_count", "total_sum", "status", "error"]
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        for res in results:
            # 确保每个结果都有所有字段(失败的结果没有line_count和total_sum)
            row = {
                "file_name": res.get("file_name", ""),
                "line_count": res.get("line_count", ""),
                "total_sum": res.get("total_sum", ""),
                "status": res.get("status", ""),
                "error": res.get("error", "")
            }
            writer.writerow(row)

    print(f"\n文件处理完成!")
    print(f"总耗时:{round(time.time() - start_time, 2)}秒")
    print(f"结果已保存到:file_sum_results.csv")

# 执行流程
if __name__ == "__main__":
    # 1. 生成测试文件(仅需执行一次)
    generate_test_files(num_files=100, lines_per_file=100)
    # 2. 多线程处理文件
    batch_process_files()

运行结果

开始生成测试文件...
生成完成!共100个文件,每个文件100行

文件处理完成!
总耗时:0.12秒
结果已保存到:file_sum_results.csv

—— 打开file_sum_results.csv,能看到每个文件的总行数和数字总和,多线程处理 100 个文件仅需 0.12 秒,单线程要 0.5 秒左右。

项目 3:多线程下载图片(爬虫常用)

需求:从 unsplash(免费图片网站)的公开 API 获取 10 张图片 URL,用多线程下载到本地,命名为 “image_1.jpg” 到 “image_10.jpg”。

代码

import os
import time
import requests
import threading
from concurrent.futures import ThreadPoolExecutor

# 从unsplash获取免费图片URL(公开API,无需APIkey,每次返回10张)
def get_unsplash_image_urls(count=10):
    print("开始获取图片URL...")
    url = f"https://source.unsplash.com/random/{count}"  # 公开测试API
    try:
        # 发送请求,获取重定向后的图片URL列表(unsplash会返回count个URL)
        response = requests.get(url, timeout=10)
        response.raise_for_status()
        # 解析响应,提取图片URL(简化处理,实际可根据API返回格式调整)
        # 注:unsplash的random API实际返回单张图片,这里用另一个公开API示例
        # 改用picsum.photos的API(更稳定,支持批量获取)
        image_urls = [f"https://picsum.photos/800/600?random={i}" for i in range(count)]
        print(f"获取成功!共{len(image_urls)}张图片URL")
        return image_urls
    except Exception as e:
        print(f"获取URL失败:{str(e)}")
        return []

# 多线程下载单张图片
def download_image(url, save_path):
    """下载图片到指定路径"""
    try:
        print(f"开始下载:{url}")
        # 发请求获取图片二进制数据
        response = requests.get(url, timeout=10, stream=True)  # stream=True避免一次性加载大文件
        response.raise_for_status()
        # 保存图片
        with open(save_path, "wb") as f:
            for chunk in response.iter_content(chunk_size=1024):  # 分块写入,适合大文件
                if chunk:
                    f.write(chunk)
        print(f"下载完成:{save_path}")
        return {"url": url, "save_path": save_path, "status": "success"}
    except Exception as e:
        print(f"下载失败:{url} | 错误:{str(e)}")
        # 下载失败则删除空文件
        if os.path.exists(save_path):
            os.remove(save_path)
        return {"url": url, "error": str(e), "status": "failed"}

# 批量下载图片
def batch_download_images(image_urls):
    if not image_urls:
        print("没有图片URL可下载!")
        return

    # 创建保存目录
    save_dir = "downloaded_images"
    if not os.path.exists(save_dir):
        os.makedirs(save_dir)

    start_time = time.time()

    # 准备下载任务:每个URL对应一个保存路径
    tasks = []
    for i, url in enumerate(image_urls, 1):
        save_path = os.path.join(save_dir, f"image_{i}.jpg")
        tasks.append((url, save_path))

    # 用线程池下载(max_workers=3,避免并发过高被网站限制)
    with ThreadPoolExecutor(max_workers=3) as executor:
        # 用executor.submit()提交任务,获取结果(比map更灵活,支持多参数)
        futures = [executor.submit(download_image, url, save_path) for url, save_path in tasks]
        # 收集结果
        results = [future.result() for future in futures]

    # 统计结果
    success_count = len([res for res in results if res["status"] == "success"])
    failed_count = len(results) - success_count

    print(f"\n批量下载完成!")
    print(f"总耗时:{round(time.time() - start_time, 2)}秒")
    print(f"成功:{success_count}张 | 失败:{failed_count}张")
    print(f"图片保存目录:{os.path.abspath(save_dir)}")

# 执行流程
if __name__ == "__main__":
    # 1. 获取图片URL(10张)
    image_urls = get_unsplash_image_urls(count=10)
    # 2. 多线程下载图片
    if image_urls:
        batch_download_images(image_urls)

运行结果

开始获取图片URL...
获取成功!共10张图片URL
开始下载:https://picsum.photos/800/600?random=1
开始下载:https://picsum.photos/800/600?random=2
开始下载:https://picsum.photos/800/600?random=3
下载完成:downloaded_images\image_1.jpg
开始下载:https://picsum.photos/800/600?random=4
下载完成:downloaded_images\image_2.jpg
开始下载:https://picsum.photos/800/600?random=5
...(后续图片继续下载)
批量下载完成!
总耗时:2.34秒
成功:10张 | 失败:0张
图片保存目录:C:\Users\XXX\downloaded_images

—— 打开downloaded_images目录,能看到 10 张下载好的图片,多线程下载总耗时≈2 秒,单线程要≈6 秒。

项目 4:多线程监控日志文件(运维场景)

需求:模拟两个日志文件(app.logerror.log)实时写入内容,用两个线程分别监控这两个文件,当出现 “ERROR” 关键字时,立即打印报警信息。

代码

import time
import random
import threading
import os

# 模拟日志写入:每隔1-3秒往日志文件写一行内容(含随机ERROR)
def simulate_log_writing(file_path, log_type):
    """
    模拟日志写入
    file_path:日志文件路径
    log_type:日志类型(app/error)
    """
    print(f"开始模拟{log_type}日志写入:{file_path}")
    log_messages = {
        "app": [
            "INFO: 应用启动成功",
            "INFO: 用户登录成功",
            "DEBUG: 数据库连接正常",
            "ERROR: 接口请求超时",  # 错误日志
            "INFO: 数据同步完成"
        ],
        "error": [
            "ERROR: 数据库连接失败",  # 错误日志
            "ERROR: 内存占用过高",    # 错误日志
            "WARN: 磁盘空间不足",
            "INFO: 错误已修复",
            "ERROR: 配置文件缺失"     # 错误日志
        ]
    }
    try:
        while True:
            # 随机选一条日志
            message = random.choice(log_messages[log_type])
            # 写入日志(追加模式)
            with open(file_path, "a", encoding="utf-8") as f:
                # 日志格式:时间 类型 内容
                log_line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}\n"
                f.write(log_line)
                print(f"写入日志:{log_line.strip()}")
            # 随机等待1-3秒
            time.sleep(random.uniform(1, 3))
    except KeyboardInterrupt:
        print(f"\n停止模拟{log_type}日志写入")
    except Exception as e:
        print(f"{log_type}日志写入出错:{str(e)}")

# 多线程监控日志文件:实时读取新内容,检测ERROR关键字
def monitor_log(file_path, monitor_name):
    """
    监控日志文件
    file_path:日志文件路径
    monitor_name:监控线程名
    """
    print(f"开始监控日志:{file_path}(线程:{monitor_name})")
    # 如果文件不存在,先创建
    if not os.path.exists(file_path):
        with open(file_path, "w", encoding="utf-8") as f:
            f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} INFO: 日志文件创建\n")
    # 移动到文件末尾(避免读取历史内容)
    with open(file_path, "r", encoding="utf-8") as f:
        f.seek(0, os.SEEK_END)  # 定位到文件末尾
        try:
            while True:
                # 读取新行
                line = f.readline()
                if line:
                    line = line.strip()
                    # 检测ERROR关键字
                    if "ERROR" in line:
                        print(f"\033[91m【{monitor_name}报警】{line}\033[0m")  # 红色字体突出报警
                    else:
                        print(f"【{monitor_name}】{line}")
                else:
                    # 没有新内容,等待0.5秒再查
                    time.sleep(0.5)
        except KeyboardInterrupt:
            print(f"\n停止监控日志:{file_path}")
        except Exception as e:
            print(f"{monitor_name}监控出错:{str(e)}")

# 执行流程
if __name__ == "__main__":
    # 日志文件路径
    app_log_path = "app.log"
    error_log_path = "error.log"

    # 1. 创建日志写入线程(两个线程,分别写入app.log和error.log)
    thread_write_app = threading.Thread(
        target=simulate_log_writing,
        args=(app_log_path, "app"),
        name="日志写入-app"
    )
    thread_write_error = threading.Thread(
        target=simulate_log_writing,
        args=(error_log_path, "error"),
        name="日志写入-error"
    )

    # 2. 创建日志监控线程(两个线程,分别监控app.log和error.log)
    thread_monitor_app = threading.Thread(
        target=monitor_log,
        args=(app_log_path, "APP监控"),
        name="日志监控-app"
    )
    thread_monitor_error = threading.Thread(
        target=monitor_log,
        args=(error_log_path, "ERROR监控"),
        name="日志监控-error"
    )

    # 3. 启动所有线程(设置为守护线程,主线程退出时自动退出)
    thread_write_app.daemon = True
    thread_write_error.daemon = True
    thread_monitor_app.daemon = True
    thread_monitor_error.daemon = True

    thread_write_app.start()
    thread_write_error.start()
    thread_monitor_app.start()
    thread_monitor_error.start()

    # 主线程保持运行(等待用户按Ctrl+C退出)
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        print("\n程序退出!")

运行结果(部分):

开始模拟app日志写入:app.log
开始模拟error日志写入:error.log
开始监控日志:app.log(线程:APP监控)
开始监控日志:error.log(线程:ERROR监控)
写入日志:2024-06-20 15:30:00 INFO: 应用启动成功
【APP监控】2024-06-20 15:30:00 INFO: 应用启动成功
写入日志:2024-06-20 15:30:02 ERROR: 数据库连接失败
【ERROR监控】
【ERROR监控报警】2024-06-20 15:30:02 ERROR: 数据库连接失败
写入日志:2024-06-20 15:30:03 ERROR: 接口请求超时
【APP监控】
【APP监控报警】2024-06-20 15:30:03 ERROR: 接口请求超时
...(后续持续输出日志和报警)

—— 两个监控线程实时读取日志,一旦出现 “ERROR” 就用红色字体报警,实现了 “同时监控多个文件” 的需求,单线程无法同时监控两个文件的实时更新。

项目 5:多线程 + 队列处理任务(生产消费模型)

需求:模拟 “生产者” 线程生成任务(比如生成随机数字),放入队列;“消费者” 线程从队列中取任务,处理任务(比如计算数字的平方),实现 “生产 - 消费” 模型(适合任务数量不确定的场景)。

代码

import time
import random
import threading
from queue import Queue  # Python自带的线程安全队列

# 生产者线程:生成任务,放入队列
def producer(queue, producer_name, max_tasks=20):
    """
    生产者
    queue:任务队列(线程安全)
    producer_name:生产者名
    max_tasks:最大任务数
    """
    print(f"生产者{producer_name}:开始生成任务,共{max_tasks}个")
    for i in range(max_tasks):
        # 生成任务:随机数字(1-100)
        task = random.randint(1, 100)
        queue.put(task)  # 放入队列(线程安全,不会出现多个线程同时放的问题)
        print(f"生产者{producer_name}:生成任务 {task},队列当前大小:{queue.qsize()}")
        # 随机等待0.1-0.5秒(模拟生产任务耗时)
        time.sleep(random.uniform(0.1, 0.5))
    # 生产完成,放入"结束标志"(每个消费者对应一个结束标志)
    queue.put(None)
    print(f"生产者{producer_name}:任务生成完成!")

# 消费者线程:从队列取任务,处理任务
def consumer(queue, consumer_name):
    """
    消费者
    queue:任务队列
    consumer_name:消费者名
    """
    print(f"消费者{consumer_name}:开始处理任务")
    while True:
        # 从队列取任务(block=True:队列空时等待)
        task = queue.get(block=True)
        # 检测结束标志
        if task is None:
            queue.put(None)  # 把结束标志放回队列,供其他消费者检测
            print(f"消费者{consumer_name}:收到结束标志,停止处理")
            break
        # 处理任务:计算数字的平方
        result = task ** 2
        print(f"消费者{consumer_name}:处理任务 {task} → 结果 {result},队列当前大小:{queue.qsize()}")
        # 标记任务完成(让queue.join()知道任务已处理)
        queue.task_done()
        # 随机等待0.2-0.8秒(模拟处理任务耗时)
        time.sleep(random.uniform(0.2, 0.8))

# 执行流程
if __name__ == "__main__":
    # 创建线程安全队列(maxsize=5:队列最大容量为5,满时生产者会等待)
    task_queue = Queue(maxsize=5)

    # 1. 创建生产者线程(1个生产者,生成20个任务)
    producer_thread = threading.Thread(
        target=producer,
        args=(task_queue, "P1", 20),
        name="生产者-P1"
    )

    # 2. 创建消费者线程(3个消费者)
    consumer_threads = [
        threading.Thread(target=consumer, args=(task_queue, f"C{i}"), name=f"消费者-C{i}")
        for i in range(1, 4)
    ]

    # 3. 启动线程
    producer_thread.start()
    for thread in consumer_threads:
        thread.start()

    # 4. 等待生产者完成
    producer_thread.join()
    print(f"\n所有生产者已完成任务")

    # 5. 等待队列中所有任务处理完成
    task_queue.join()
    print(f"队列中所有任务已处理完成")

    # 6. 等待所有消费者完成
    for thread in consumer_threads:
        thread.join()

    print(f"\n所有任务处理完成!")

运行结果(部分):

生产者P1:开始生成任务,共20个
消费者C1:开始处理任务
消费者C2:开始处理任务
消费者C3:开始处理任务
生产者P1:生成任务 45,队列当前大小:1
生产者P1:生成任务 78,队列当前大小:2
消费者C1:处理任务 45 → 结果 2025,队列当前大小:1
生产者P1:生成任务 32,队列当前大小:2
消费者C2:处理任务 78 → 结果 6084,队列当前大小:1
生产者P1:生成任务 91,队列当前大小:2
消费者C3:处理任务 32 → 结果 1024,队列当前大小:1
...(后续持续生产和消费)
生产者P1:任务生成完成!
所有生产者已完成任务
消费者C1:收到结束标志,停止处理
消费者C2:收到结束标志,停止处理
消费者C3:收到结束标志,停止处理
队列中所有任务已处理完成
所有任务处理完成!

—— 用Queue实现线程安全的任务传递,生产者和消费者解耦,适合任务数量动态变化的场景(比如实时接收用户请求并处理)。

项目 6:多线程 vs 多进程对比(CPU 密集型任务)

需求:对比多线程和多进程在 CPU 密集型任务(比如计算 100 万次质数判断)中的效率,验证 “CPU 密集型任务多线程没用” 的结论。

代码

import time
import threading
import multiprocessing

# CPU密集型任务:判断一个数是否为质数
def is_prime(n):
    """判断n是否为质数"""
    if n <= 1:
        return False
    if n <= 3:
        return True
    if n % 2 == 0 or n % 3 == 0:
        return False
    i = 5
    while i * i <= n:
        if n % i == 0 or n % (i + 2) == 0:
            return False
        i += 6
    return True

# 批量判断质数(任务函数)
def batch_check_primes(start, end, name):
    """判断[start, end]区间内的所有数是否为质数,返回质数列表"""
    print(f"{name}:开始判断 {start} 到 {end} 的质数")
    primes = []
    for num in range(start, end + 1):
        if is_prime(num):
            primes.append(num)
    print(f"{name}:判断完成!共找到 {len(primes)} 个质数")
    return primes

# 多线程处理CPU密集型任务
def test_multithreading():
    print("\n=== 测试多线程处理CPU密集型任务 ===")
    start_time = time.time()
    # 任务:分4个线程,各判断25万个数(1-100万)
    ranges = [(1, 250000), (250001, 500000), (500001, 750000), (750001, 1000000)]
    threads = []
    results = []

    # 定义线程函数(收集结果)
    def thread_task(start, end, idx):
        primes = batch_check_primes(start, end, f"线程{idx+1}")
        results.append(primes)

    # 创建并启动线程
    for i, (start, end) in enumerate(ranges):
        thread = threading.Thread(target=thread_task, args=(start, end, i))
        threads.append(thread)
        thread.start()

    # 等待线程完成
    for thread in threads:
        thread.join()

    # 统计总质数数量
    total_primes = sum(len(res) for res in results)
    end_time = time.time()
    print(f"多线程总耗时:{round(end_time - start_time, 2)}秒")
    print(f"1-100万范围内总质数数量:{total_primes}")

# 多进程处理CPU密集型任务
def test_multiprocessing():
    print("\n=== 测试多进程处理CPU密集型任务 ===")
    start_time = time.time()
    # 任务:分4个进程,各判断25万个数(1-100万)
    ranges = [(1, 250000), (250001, 500000), (500001, 750000), (750001, 1000000)]
    
    # 用进程池
    with multiprocessing.Pool(processes=4) as pool:
        # 提交任务:每个进程处理一个区间
        futures = [pool.apply_async(batch_check_primes, args=(start, end, f"进程{i+1}")) for i, (start, end) in enumerate(ranges)]
        # 收集结果
        results = [future.get() for future in futures]

    # 统计总质数数量
    total_primes = sum(len(res) for res in results)
    end_time = time.time()
    print(f"多进程总耗时:{round(end_time - start_time, 2)}秒")
    print(f"1-100万范围内总质数数量:{total_primes}")

# 执行对比测试
if __name__ == "__main__":
    # 先测试多线程
    test_multithreading()
    # 再测试多进程
    test_multiprocessing()

运行结果(取决于 CPU 核心数,4 核 CPU 示例):

=== 测试多线程处理CPU密集型任务 ===
线程1:开始判断 1 到 250000 的质数
线程2:开始判断 250001 到 500000 的质数
线程3:开始判断 500001 到 750000 的质数
线程4:开始判断 750001 到 1000000 的质数
线程1:判断完成!共找到 22030 个质数
线程2:判断完成!共找到 21336 个质数
线程3:判断完成!共找到 20803 个质数
线程4:判断完成!共找到 20387 个质数
多线程总耗时:45.23秒
1-100万范围内总质数数量:84556

=== 测试多进程处理CPU密集型任务 ===
进程1:开始判断 1 到 250000 的质数
进程2:开始判断 250001 到 500000 的质数
进程3:开始判断 500001 到 750000 的质数
进程4:开始判断 750001 到 1000000 的质数
进程1:判断完成!共找到 22030 个质数
进程2:判断完成!共找到 21336 个质数
进程3:判断完成!共找到 20803 个质数
进程4:判断完成!共找到 20387 个质数
多进程总耗时:12.35秒
1-100万范围内总质数数量:84556

—— 明显看到:CPU 密集型任务中,多进程耗时(12 秒)远少于多线程(45 秒),因为多进程能真正利用多个 CPU 核心,而多线程受 GIL 锁限制,同一时间只能用一个核心。这也验证了之前的结论:IO 密集型用多线程,CPU 密集型用多进程

五、避坑指南:Python 多线程新手常踩的 6 个坑及解决方案

多线程虽然好用,但新手很容易踩坑,这里总结 6 个最常见的问题,每个都给具体的解决方案,帮你少走弯路。

坑 1:误以为 “多线程能加速所有任务”——GIL 锁的坑

现象:用多线程处理 CPU 密集型任务(比如计算),发现耗时比单线程还长。原因:GIL 锁限制,多线程在 CPU 密集型任务中无法真正并行,只能并发(切换线程),切换开销反而增加耗时。解决方案

  • IO 密集型任务(爬网页、读文件)用多线程;
  • CPU 密集型任务(计算、建模)用多进程(multiprocessing模块)或多线程 +concurrent.futures.ProcessPoolExecutor
  • 也可以用cythonnumba等工具绕过 GIL 锁,但复杂度较高,新手优先选多进程。

坑 2:线程安全问题 —— 多个线程操作共享变量导致数据错乱

现象:多个线程同时修改同一个列表、字典或计数器,结果出现数据丢失、重复或错误(比如计数器加 1 后结果不对)。原因:共享变量的操作(如count +=1)不是原子操作,会被线程切换打断。解决方案

  • threading.Lock加锁,确保临界区代码(操作共享变量的代码)只能被一个线程执行;
  • 用线程安全的数据结构,比如queue.Queue(自带锁,适合传递任务)、collections.deque(线程安全的双端队列);
  • 尽量避免共享变量,用 “线程局部变量”(threading.local())存储线程私有数据。

坑 3:忘记加 join ()—— 主线程提前结束导致子线程未完成

现象:启动子线程后,主线程直接结束,子线程还没干完就被强制终止(比如爬网页到一半就停了)。原因:主线程不会自动等待子线程完成,除非调用thread.join()解决方案

  • 单个线程:thread.start()后调用thread.join()
  • 多个线程:用列表保存所有线程对象,循环调用join()
    threads = [thread1, thread2, thread3]
    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join()
    
  • 用线程池时,with ThreadPoolExecutor()会自动等待所有任务完成,无需手动join()

坑 4:死锁 —— 线程互相等待对方释放锁

现象:程序卡住不动,控制台没有输出,CPU 占用率低(线程都在等锁)。原因:多个线程持有对方需要的锁,互相等待(比如线程 A 持有锁 1,等锁 2;线程 B 持有锁 2,等锁 1)。解决方案

  • 避免嵌套锁:尽量不要在一个锁的临界区里获取另一个锁;
  • 统一锁的获取顺序:如果必须用多个锁,所有线程都按相同顺序获取(比如先获取锁 1,再获取锁 2);
  • threading.RLock(递归锁)处理嵌套锁场景,但要确保释放次数和获取次数相同;
  • 给锁加超时时间:用lock.acquire(timeout=5),超时后抛异常,避免无限等待。

坑 5:线程数量过多 —— 导致系统卡顿或被目标网站封禁

现象:创建几十、上百个线程爬网页,结果电脑卡顿,或被网站识别为爬虫封 IP。原因:线程数量过多,会增加系统调度开销(切换线程),且高并发请求会触发网站反爬。解决方案

  • 用线程池控制并发数:ThreadPoolExecutor(max_workers=5),根据任务类型调整max_workers(IO 密集型可设为 CPU 核心数的 2-4 倍,CPU 密集型设为 CPU 核心数);
  • 爬取网页时加随机延迟:time.sleep(random.uniform(0.5, 2)),模拟人类操作,避免被封 IP;
  • 监控系统资源:用psutil库监控 CPU、内存占用,动态调整线程数量。

坑 6:守护线程设置不当 —— 主线程退出时子线程未清理资源

现象:设置thread.daemon=True后,主线程退出时子线程直接终止,导致文件未关闭、数据库连接未断开等资源泄漏。原因:守护线程(daemon thread)会随主线程退出而终止,不管是否完成任务,适合 “辅助任务”(如日志打印),不适合 “需要清理资源” 的任务。解决方案

  • 对于需要完成的任务(如文件写入、数据保存),不要设为守护线程,用join()等待完成;
  • 必须用守护线程时,在任务中加 “资源清理” 逻辑,比如用try...finally确保文件关闭:
    def daemon_task():
        try:
            f = open("data.txt", "w")
            # 处理任务
        finally:
            f.close()  # 即使线程被终止,也会执行finally块
    
  • atexit模块注册退出回调函数,在程序退出时清理资源。

六、总结与进阶学习建议

6.1 本文核心知识点总结

  1. 基础概念:线程是 “工人”,进程是 “工厂”,多线程适合 IO 密集型任务,受 GIL 锁限制;
  2. 创建方式:3 种方式(直接创建 Thread、继承 Thread、线程池),线程池最适合大量任务;
  3. 同步机制:4 种工具(Lock、RLock、Semaphore、Event),解决线程安全和同步问题;
  4. 实战能力:6 个项目覆盖爬网页、处理文件、下载图片

 

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值