一次多线程同步问题的排查:从 thread_count 到 thread.join() 的踩坑之旅

一次多线程同步问题的排查:从 thread_count 到 thread.join() 的踩坑之旅

在开发 IP 监控系统时,我遇到了一个让人头疼的 bug:项目启动后一直没有执行下一步,数据文件里始终是空的。经过一番排查,发现是一个多线程同步判断的问题。本文记录了我的排查过程和解决方案。

项目背景

最近我在开发一个基于 Flask 的 IP 监控系统。这个系统需要定期扫描网络,检测哪些 IP 地址在线,并将结果写入文件。系统的主要流程是:

  1. 主线程启动 Flask Web 服务
  2. 启动一个后台线程,定期执行网络扫描
  3. 扫描时,需要并发 ping 多个 IP 地址(使用多个子线程)
  4. 等待所有 ping 线程完成后,读取 ARP 表并写入文件

💡 项目地址 :如果你想了解更多关于这个项目的信息,可以访问 Gitee 仓库:gitee.com/zheng-enci0...

Bug 现象

项目启动后,Web 服务正常运行,可以访问界面,但是 activate_ip.txt 文件始终是空的,没有任何数据写入。

这很奇怪,因为:

  • Web 服务正常启动,说明主线程没问题
  • 定时任务应该也在运行(没有报错)
  • 但是文件里就是没有数据

问题排查过程

第一次怀疑:定时任务库的问题

最初,我使用了 APScheduler 库来实现定时任务。代码大概是这样的:

python 复制代码
from apscheduler.schedulers.background import BackgroundScheduler

scheduler = BackgroundScheduler()
scheduler.add_job(scan_network, 'interval', seconds=60)
scheduler.start()

当发现文件没有数据时,我第一反应是:是不是 APScheduler 库有问题?是不是定时任务没有正常执行?

于是我添加了一些调试日志,发现定时任务确实在执行,但是扫描函数执行后,文件里还是没有数据。

第一次尝试:自己实现定时任务

既然怀疑是库的问题,那我就自己实现定时任务吧。我改成了使用 threading.Thread 来实现:

python 复制代码
def _periodic_scan(self, seconds: int) -> None:
    """定期执行网络扫描"""
    while True:
        self._scan_network()
        time.sleep(seconds)

# 启动定时任务
threading.Thread(target=_periodic_scan, args=(60,)).start()

改完之后,我满怀期待地运行程序,结果...文件里还是没有数据!

发现问题依然存在

这下我意识到,问题可能不在定时任务库上。我开始仔细检查 _scan_network() 函数的实现。

深入分析:发现是线程同步判断的问题

当我仔细查看代码时,我发现了一个问题。在 _scan_network() 函数中,我需要等待所有 ping 线程完成后再读取 ARP 表。但是我使用了错误的判断方式:

python 复制代码
# 错误的代码
def _scan_network(self) -> None:
    # 清除 ARP 缓存
    subprocess.run(["arp", "-d"], ...)
    
    # 启动多个 ping 线程
    for ip in self.ai_workshop_own_ip:
        threading.Thread(target=self._ping_ip, args=(ip,)).start()
    
    # 等待所有 ping 线程完成
    while threading.active_count() > 1:  # ❌ 错误的判断!
        time.sleep(0.1)
    
    # 读取 ARP 表并写入文件
    all_activate_ip = self._get_all_active_ips()
    # ... 写入文件

问题根源分析

线程结构说明

让我解释一下当时的线程结构:

css 复制代码
主线程(Main Thread)
  └── 定时任务线程(Periodic Scan Thread)
        └── Ping 线程 1
        └── Ping 线程 2
        └── Ping 线程 3
        └── ...

错误的判断方式

我使用 threading.active_count() > 1 来判断是否还有子线程在运行。这个判断的逻辑是:

  • 如果活跃线程数大于 1,说明还有线程在运行
  • 如果等于 1,说明只剩下主线程了

但是,这个判断有一个致命的问题!

即使所有的 ping 线程都完成了,threading.active_count() 也不会等于 1,因为:

  • 主线程(1个)
  • 定时任务线程(1个)
  • 至少还有 2 个线程!

所以 threading.active_count() > 1 这个条件永远为 True,导致 while 循环永远不会退出,程序就一直卡在那里,无法执行后续的读取 ARP 表和写入文件的操作。

为什么这个判断是错误的

threading.active_count() 返回的是所有活跃线程的总数,包括:

  • 主线程
  • 所有子线程
  • 所有子线程的子线程

在我的场景中:

  • 主线程:1个
  • 定时任务线程:1个
  • Ping 线程:N个(取决于需要 ping 的 IP 数量)

即使所有 ping 线程都完成了,threading.active_count() 至少也是 2(主线程 + 定时任务线程),所以 > 1 的条件永远成立。

解决方案

发现问题后,我意识到有两种解决方案:

方案一:修改线程计数判断条件

最简单的方法是修改判断条件,从 > 1 改为 > 2

python 复制代码
# 方案一:修改判断条件
while threading.active_count() > 2:  # 改为 > 2
    time.sleep(0.1)

这样确实可以解决问题,因为:

  • 主线程:1个
  • 定时任务线程:1个
  • 总共至少 2 个线程
  • 当所有 ping 线程完成后,线程数应该等于 2,所以 > 2 的条件会为 False,循环退出

方案二:保存线程引用,使用 thread.join()(推荐)

更好的做法是保存线程引用,然后使用 thread.join() 方法

python 复制代码
def _scan_network(self) -> None:
    """扫描网络,检测激活的IP地址并写入activate_ip.txt"""
    # 清除 arp 缓存
    subprocess.run(
        ["arp", "-d"],
        stdout = subprocess.DEVNULL,
        stderr = subprocess.DEVNULL,
        check = False
    )
    
    # 并发 ping 所有坊内IP,并保存线程引用
    ping_threads = []
    for ip in self.ai_workshop_own_ip:
        thread = threading.Thread(
            target = self._ping_ip,
            args = (ip,),
        )
        thread.start()
        ping_threads.append(thread)
    
    # 等待所有ping线程完成
    for thread in ping_threads:
        thread.join()
    
    # 现在可以安全地读取 ARP 表并写入文件了
    all_activate_ip = self._get_all_active_ips()
    
    # 构建要写入的内容
    content = str(time.time()) + " "
    for ip in all_activate_ip:
        if ip in self.ai_workshop_own_ip:
            content += ip + " "
    content += "\n"
    
    # 写入文件
    self.file_handler.write_file(self.file_path, content)

为什么推荐使用 thread.join()?

虽然方案一可以解决问题,但我强烈推荐使用方案二(thread.join(),原因如下:

1. 精确控制,不受其他线程影响

使用 threading.active_count() 的问题是,它统计的是所有活跃线程的总数,包括:

  • 主线程
  • 定时任务线程
  • 其他可能存在的线程(如 Flask 的工作线程、调试线程等)

如果将来代码中增加了其他线程,或者 Flask 启动了额外的线程,> 2 的判断就会失效。

thread.join() 只等待我们明确指定的线程,不受其他线程影响,更加可靠。

2. 语义清晰,代码可读性强

thread.join() 的语义非常明确:等待这个线程完成。任何阅读代码的人都能立即理解代码的意图。

相比之下,threading.active_count() > 2 的语义不够清晰,需要读者理解线程结构才能明白为什么是 > 2

3. 不依赖全局状态

threading.active_count() 是一个全局状态,它依赖于整个程序的线程情况。如果程序的其他部分创建或销毁了线程,这个值就会变化,可能导致判断错误。

thread.join() 是线程对象的方法,只依赖于特定的线程对象,不依赖全局状态,更加稳定可靠。

4. 标准做法,符合最佳实践

thread.join() 是 Python 官方推荐的线程同步方式,是多线程编程的标准做法。使用标准方法可以让代码更容易维护,也更容易被其他开发者理解。

5. 性能更好

thread.join() 是操作系统级别的同步原语,效率很高。而 while threading.active_count() > 2: time.sleep(0.1) 需要轮询检查,会浪费 CPU 资源。

总结

虽然方案一(修改为 > 2)可以快速解决问题,但方案二(使用 thread.join())是更好的选择,因为:

  • 更可靠:不受其他线程影响
  • 更清晰:代码语义明确
  • 更稳定:不依赖全局状态
  • 更标准:符合最佳实践
  • 更高效:性能更好

在实际开发中,应该优先使用 thread.join() 方法进行线程同步,这是多线程编程的正确姿势。

经验总结

多线程编程的注意事项

  1. 不要依赖线程计数来判断线程状态

    • threading.active_count() 返回的是所有线程的总数,包括主线程和其他不相关的线程
    • 无法准确判断特定线程是否完成
  2. 使用线程对象的方法进行同步

    • thread.join() 是等待线程完成的标准方法
    • 保存线程引用,然后调用 join() 是最可靠的方式
  3. 理解线程的生命周期

    • 线程启动后,需要明确知道如何等待它完成
    • 不要假设线程会立即完成,特别是在并发场景下

线程同步的正确方式

推荐做法:

python 复制代码
# ✅ 正确:保存线程引用,使用 join()
threads = []
for task in tasks:
    thread = threading.Thread(target=task)
    thread.start()
    threads.append(thread)

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

# 现在可以安全地使用结果了

不推荐做法:

python 复制代码
# ❌ 错误:依赖线程计数
for task in tasks:
    threading.Thread(target=task).start()

while threading.active_count() > 1:
    time.sleep(0.1)

调试技巧

  1. 添加日志:在关键位置添加日志,了解线程的执行情况
  2. 使用调试器:在调试器中查看线程状态,了解哪些线程在运行
  3. 简化问题:如果问题复杂,先简化场景,确认问题所在
  4. 查阅文档:遇到不确定的 API,查阅官方文档,理解其行为

结语

这次 bug 排查让我深刻认识到,在多线程编程中,线程同步是一个需要仔细处理的问题 。使用 threading.active_count() 来判断线程状态是一个常见的误区,正确的做法是保存线程引用并使用 join() 方法。

希望我的这次踩坑经历能帮助到遇到类似问题的朋友。如果你也在开发多线程应用,记住:保存线程引用,使用 join() 等待完成,这是最可靠的方式!


项目地址gitee.com/zheng-enci0...

如果你想了解更多关于这个项目的信息(功能特性、使用说明、技术实现等),欢迎访问上面的链接查看项目详情。如果这篇文章对你有帮助,欢迎 Star 支持一下!

相关推荐
乾元33 分钟前
AI + Jinja2/Ansible:从自然语义到可执行 Playbook 的完整流水线(工程级深度)
运维·网络·人工智能·网络协议·华为·自动化·ansible
ULTRA??39 分钟前
ROS Action 完整示例(AI辅助):客户端发目标 + 服务器接参数(lambda 替代 boost::bind)
c++·python
free-elcmacom41 分钟前
用Python玩转GAN:让AI学会“造假”的艺术
人工智能·python·机器学习
计算机毕设匠心工作室1 小时前
【python大数据毕设实战】全国健康老龄化数据分析系统、Hadoop、计算机毕业设计、包括数据爬取、数据分析、数据可视化、机器学习
后端·python
oxygen-12041 小时前
https nginx步骤
网络协议·http·https
Dxy12393102161 小时前
Python的PIL对象crop函数详解
开发语言·python
翔云 OCR API1 小时前
护照NFC识读鉴伪接口集成-让身份核验更加智能与高效
开发语言·人工智能·python·计算机视觉·ocr
路由侠内网穿透.1 小时前
本地部署问答社区 Apache Anwser 并实现外部访问
服务器·windows·网络协议·apache·远程工作
三好kiii1 小时前
海康威视热成像摄像头温度矩阵提取实战:ISAPI + Python 实现无 SDK 读取
图像处理·python