一次多线程同步问题的排查:从 thread_count 到 thread.join() 的踩坑之旅
在开发 IP 监控系统时,我遇到了一个让人头疼的 bug:项目启动后一直没有执行下一步,数据文件里始终是空的。经过一番排查,发现是一个多线程同步判断的问题。本文记录了我的排查过程和解决方案。
项目背景
最近我在开发一个基于 Flask 的 IP 监控系统。这个系统需要定期扫描网络,检测哪些 IP 地址在线,并将结果写入文件。系统的主要流程是:
- 主线程启动 Flask Web 服务
- 启动一个后台线程,定期执行网络扫描
- 扫描时,需要并发 ping 多个 IP 地址(使用多个子线程)
- 等待所有 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() 方法进行线程同步,这是多线程编程的正确姿势。
经验总结
多线程编程的注意事项
-
不要依赖线程计数来判断线程状态
threading.active_count()返回的是所有线程的总数,包括主线程和其他不相关的线程- 无法准确判断特定线程是否完成
-
使用线程对象的方法进行同步
thread.join()是等待线程完成的标准方法- 保存线程引用,然后调用
join()是最可靠的方式
-
理解线程的生命周期
- 线程启动后,需要明确知道如何等待它完成
- 不要假设线程会立即完成,特别是在并发场景下
线程同步的正确方式
推荐做法:
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)
调试技巧
- 添加日志:在关键位置添加日志,了解线程的执行情况
- 使用调试器:在调试器中查看线程状态,了解哪些线程在运行
- 简化问题:如果问题复杂,先简化场景,确认问题所在
- 查阅文档:遇到不确定的 API,查阅官方文档,理解其行为
结语
这次 bug 排查让我深刻认识到,在多线程编程中,线程同步是一个需要仔细处理的问题 。使用 threading.active_count() 来判断线程状态是一个常见的误区,正确的做法是保存线程引用并使用 join() 方法。
希望我的这次踩坑经历能帮助到遇到类似问题的朋友。如果你也在开发多线程应用,记住:保存线程引用,使用 join() 等待完成,这是最可靠的方式!
项目地址 :gitee.com/zheng-enci0...
如果你想了解更多关于这个项目的信息(功能特性、使用说明、技术实现等),欢迎访问上面的链接查看项目详情。如果这篇文章对你有帮助,欢迎 Star 支持一下!