新闻详情

新闻详情

首页 / 资讯中心 / 详情

Thread基础一次讲透:Python线程、锁与线程池实战

发布时间:2026/10/2 15:10:08来源:尧图网络
Thread基础一次讲透:Python线程、锁与线程池实战
聊到 Thread线程很多刚写代码的同学第一反应是这是不是特别难其实线程本身没那么玄真正难的是并发场景下的状态管理。我这些年写过不少并发任务最常被问到的往往不是锁怎么写而是为什么我这段代码跑着跑着结果就不对了。所以这篇我打算把 Thread 基础一次讲透。主线用 Python 标准库里的 threading 模块因为它的 API 足够平易近人方便看清线程的本质后面再带一下 C std::thread 和 Java Thread 的对应写法方便你把知识迁移到别的语言。文章不会太长篇大论讲操作系统源码重点放在能落地干活。1. Thread 到底是什么先建立基本认知1.1 进程和线程一个能立刻记住的比喻把进程理解为一个公司。每个程序启动后操作系统给它分配独立的地址空间、文件句柄等资源这就是一个进程。公司有自己独立的财务和办公场地其他公司不能随便进出同理进程与进程之间的内存默认是隔离的一个进程挂了通常不会直接拖垮另一个进程。线程就是这个公司里的员工。员工之间在同一个办公区工作共享打印机、会议室、资料库——对应同一进程内的堆、全局变量和文件描述符但每个员工有自己的工位和记事本——对应各自的线程栈、寄存器状态。线程切换的开销比进程切换小得多因为大部分资源不用重建。这个比喻能帮新手快速理解为什么多线程编程容易出问题大家共享同一个资料库全局变量如果两个人同时去改一份文档就会出现互相覆盖的混乱。所以多线程的环境下怎么管理共享资源、怎么保证协作不出错就是整个技术的核心。1.2 多线程的收益和代价别为了并发而并发先说收益。单核时代为什么要多线程因为程序经常在等待IO、网络、磁盘等待期间 CPU 是空闲的多线程可以把这段时间利用起来。多核时代更直接多个线程可以真正并行跑在不同的核心上。典型场景爬虫在等待响应时另一个线程可以继续发请求GUI 程序在后台做耗时任务时主线程还能继续刷新界面。这些都是多线程的用武之地。再说代价。天下没有免费午餐。线程数量一多操作系统要分配栈空间默认可能几 MB线程切换要保存/恢复上下文这些都会产生开销。更麻烦的是并发引入了竞态条件和死锁。同一份数据被两个线程同时修改可能得到谁都不对的结果线程互相等待对方释放锁程序就卡死。经验法则I/O 密集型任务比如网络请求、文件读写、数据库查询多线程能明显提升吞吐CPU 密集型任务纯计算、加密、压缩如果用的是 Python受 GIL 限制多线程无法并行执行 Python 字节码不如用多进程C、Java 等语言则可以充分利用多核。先把任务分类再决定要不要上并发能省下很多调试时间。2. 标准库 Thread 初体验Python threading 最小闭环2.1 用 Thread 类创建第一个线程先给一段最基础的代码创建两个线程各自打印开始和结束。import threading import time def worker(name, delay): print(f线程{name}开始时间{time.time()}) time.sleep(delay) print(f线程{name}结束时间{time.time()}) t1 threading.Thread(targetworker, args(A, 2)) t2 threading.Thread(targetworker, args(B, 1)) t1.start() t2.start() t1.join() t2.join() print(主线程结束)target 是线程入口函数args 是传给该函数的参数。start() 才是真正让操作系统创建线程并让线程开始运行join() 让主线程在这里等待子线程结束。你可以把 join 理解成开会时等大家都到齐了再散会。注意观察执行顺序线程 A 和线程 B 是并发执行的打印时间会交叠。sleep(2) 不等于让主线程等 2 秒因为两个线程并行跑主线程只负责启动和等待。2.2 start() 和 run()千万别搞混Thread 对象的 run 方法是线程的入口start 方法会创建新线程并调用 run。直接调用 t.run() 只是在当前线程调用一个普通方法完全不会产生并发。很多新手把 run 当 start 用还来问为什么我的多线程程序跟单线程一样慢——原因就在这里。标准做法永远是 start()。另外还可以通过继承 Thread 并重写 run 来定义线程体灵活度和直接传 target 类似。但在实际业务里我建议优先用 target 方式因为组合优于继承函数天然可复用只有需要强绑定大量线程内状态时才用子类。2.3 join() 的 timeout 参数和 daemon 线程join 不只有傻等一个选择可以设置 timeout。例如 t.join(timeout2)表示最多等 2 秒超时后主线程继续往下走。这在主线程需要快速响应用户操作时很有用但要注意超时返回后线程可能还在跑你不能立刻释放它依赖的资源。daemon 是个绕不开的概念。默认情况下创建的线程都是非守护线程。主线程结束后解释器会等所有非守护线程全部结束才退出而 daemonTrue 的线程主线程一旦结束它会被直接丢弃。看这个例子import threading import time def heartbeat(): while True: time.sleep(1) print(beat) t threading.Thread(targetheartbeat, daemonTrue) t.start() time.sleep(3) print(main exit)这个程序会在 3 秒后退出因为 heartbeat 被标记为守护线程主线程结束它就没了。如果不加 daemonTrue程序会一直打印 beat永远不会退出——这也是新手最常见的为什么 CtrlC 结束不了程序的原因之一。3. 线程之间怎么协作锁、Event 和队列3.1 锁一个加了和没加效果天差地别的例子直接看计数器例子。import threading count 0 lock threading.Lock() def add(): global count for _ in range(100000): with lock: count 1 threads [threading.Thread(targetadd) for _ in range(8)] for t in threads: t.start() for t in threads: t.join() print(count)如果去掉 lock结果大概率不是 800000而且每次跑的数字可能不一样。原因在于 count 1 在解释器里并不是不可分割的。它大致分三步读取当前值、加 1、写回。线程 A 读到的值是 10此时线程 B 也读到 10A 写回 11B 也写回 11最终只加了 1。这就是竞态条件。用锁把它包起来保证同一时刻只有一个线程能执行这段代码数字就正确了。这里必须说清楚 Python 的 GIL很多人以为有 GIL 就不用锁这是错的。GIL 保证同一时刻只有一个线程执行 Python 字节码但字节码之间的切换仍然可能发生在 count 1 这几步之间所以结果依然会错。锁解决的是逻辑上的互斥不是解释器层面的保护。推荐用 with lock: 来获取和释放锁比 lock.acquire() / lock.release() 更安全万一代码中途抛异常with 也会正确释放。3.2 Lock 和 RLock重复进入的小坑Lock 是不可重入的。同一个线程用 acquire 获取锁后在释放前再次 acquire 同一个锁会把自己卡死。比如lock threading.Lock() def outer(): lock.acquire() inner() lock.release() def inner(): lock.acquire() # 这里会永久阻塞 lock.release()因为 lock 已经被当前线程持有再次 acquire 时Lock 不认主人直接阻塞。这时应该用 RLock可重入锁它内部维护了持有者线程和重入计数同一个线程可以多次 acquire只需对应 release。如果都在 with 环境里这会很方便。所以在设计锁的时候要先想清楚有没有可能被同一个线程嵌套调用。3.3 Event让线程按你的指令停下来Event 就是一个线程安全的信号旗。一个线程可以等旗子升起另一个线程可以把旗子升起。常用方法event.wait()阻塞直到 event 被 set也可带 timeout 参数event.set()设置标志唤醒所有等待的线程event.clear()清除标志event.is_set()查询当前是否已设置典型场景是优雅退出。工作线程在主循环里检查 stop_event.is_set()一旦被设置立即结束处理并清理资源。这种协作式停止要比强制杀线程安全得多因为线程知道自己在什么时候退出。3.4 queue.Queue生产者和消费者的标准姿势多线程之间传数据最省心的不是共享 list而是 queue.Queue。这个类内部实现了线程安全自带锁和条件变量你不需要额外写互斥逻辑。基本流程生产者 put(item)消费者 get(item)如果队列为空默认阻塞等待每做完一项调用 task_done()如果需要等队列清空可以调用 queue.join()实际代码import queue import threading import time q queue.Queue() def producer(): for i in range(5): q.put(i) time.sleep(0.2) def consumer(): while True: item q.get() if item is None: q.task_done() break print(fconsume {item}) q.task_done() threads [ threading.Thread(targetproducer), threading.Thread(targetconsumer) ] for t in threads: t.start() for t in threads: t.join()这里用 None 作为哨兵告诉消费者该退出了。如果不给消费者发停止信号它会一直阻塞在 q.get()程序退出时又会出现非守护线程卡住的问题。哨兵模式比设置 Event 再等待线程退出更简单直接尤其是多个消费者时各自收到哨兵再去退出。4. 工程化实践从裸线程到线程池4.1 为什么实际项目里很少手写一长串 Thread前面演示的裸线程适合学习。但真实项目里任务数量往往是动态的每次来一个任务就 new 一个线程线程总数很快就失控了。每个线程都有创建、调度、销毁的成本系统资源也是有限的。所以我更推荐线程池预先开好一批工作线程让它们从任务队列里取任务执行执行完继续取下一个。这既能限制并发数量又能复用线程避免反复创建销毁线程带来的性能损耗。4.2 ThreadPoolExecutor标准库里的线程池Python 3.2 提供了 concurrent.futures.ThreadPoolExecutor用法非常简单from concurrent.futures import ThreadPoolExecutor, as_completed import time def fetch(url): time.sleep(1) return fresult:{url} urls [fhttps://example.com/page/{i} for i in range(10)] with ThreadPoolExecutor(max_workers4) as pool: future_to_url {} for url in urls: future pool.submit(fetch, url) future_to_url[future] url for future in as_completed(future_to_url): url future_to_url[future] try: data future.result() print(url, data) except Exception as e: print(url, failed, e)max_workers 表示同时最多运行多少个线程不过任务如果超过这个数量会在队列里排队等待。with 语句会在退出时调用 shutdown(waitTrue)等所有排队任务完成后再释放线程池。as_completed 按任务完成顺序返回 Future适合先完成先处理的场景。4.3 异常处理和结果收集别让异常藏在里面pool.submit 提交任务后函数的异常会保存在 Future 对象里不会直接抛出来。调用 future.result() 时才会重新抛出。如果忘记收集 Future异常就静默丢失了。最简单的做法是在任务函数内部 try/except把异常转成可读结果或者统一在 result() 处捕获并记日志。executor.map 用法更简洁但它按输入顺序返回结果。如果前面一个任务很慢后面就算先完成也要等前面的迭代也就是阻塞式顺序遍历。需要按完成顺序处理时用 submit as_completed 更灵活。5. 横向补充C std::thread 和 Java Thread 的基础对应5.1 C11 std::thread 最小示例很多同学会问C 标准库里的 Thread 是另一套规则。C11 起的 std::thread 是一个相对底层的封装构造时传入函数和参数线程立即开始执行。你必须在析构前决定是 join等待还是 detach分离否则析构会调用 std::terminate 直接让程序终止。这比 Python 的默认非守护线程严格得多。#include iostream #include thread #include chrono #include mutex std::mutex mtx; int counter 0; void worker(int id) { std::lock_guardstd::mutex lock(mtx); counter; std::cout worker id done, counter counter std::endl; } int main() { std::thread t1(worker, 1); std::thread t2(worker, 2); t1.join(); t2.join(); return 0; }编译时还需要链接线程库像 g -stdc11 -pthread xxx.cpp。这个细节很容易漏掉漏了会编译报错。5.2 Java Thread 和 ExecutorService 的对应Java 的 Thread 是 JDK 标准库创建线程可以 new Thread(Runnable).start()也可以继承 Thread 重写 run。它和 Python threading 很像但 Java 还有一个非常常用的 ExecutorService类比 Python 的 ThreadPoolExecutor。Thread t new Thread(() - { System.out.println(hello from thread); }); t.start(); t.join();语言虽然不同但线程概念、锁、阻塞队列这些模型是共通的。建议你以一种语言为主线把概念学扎实迁移时只看 API 差异就行。5.3 不同标准库线程的共性一张大脑地图无论 Python threading、C std::thread 还是 Java Thread核心都是同一套东西创建线程并给定入口函数/任务启动线程让它真正进入可运行状态用 join/await 等待线程结束用锁或原子操作保护共享变量用队列/信号量/条件变量做线程间协作控制线程数量避免手动创建过多线程只要你脑子里有这张地图换语言只是查文档的时间。6. 高频问题与排查心得6.1 常见问题速查表一眼定位并发 bug现象最可能的原因解决办法程序执行完却不退出还有非 daemon 子线程在跑给线程加 join 或设 daemonTrue注意 daemon 线程清理问题打印的计数总和不对共享变量没有保护用 Lock 或使用 Queue程序卡死CPU 却不高死锁比如锁的顺序不一致或 Lock 重入使用 RLock统一锁顺序加超时线程不执行只调用了 run() 没有调 start()检查是否调用 start()print 输出乱序/乱码多个线程同时往 stdout 输出用 logging 模块替代 print或者加锁动不动创建上百个线程非常卡线程数量过多上下文切换和栈内存开销大使用线程池限制 max_workersfuture.result() 时才发现异常在线程池里异常被延迟抛出在每个任务内捕获异常或在 result 处捕获6.2 我在实际项目中踩过的几个坑第一个坑曾经为了图方便把所有用户请求的业务逻辑扔到多个守护线程里主线程一退出某些请求只跑到一半根本没有执行后续清理。后来我定了一条规矩凡是需要做完才退出的任务一律用非守护线程 join只有日志收集、心跳这类可丢失任务才用 daemon。第二个坑给一段网络请求代码加了锁以为保护共享会话对象就行结果锁的粒度太大整个请求都包在锁里并发全部退化成串行。正确的做法是只锁共享对象的读改写部分不要在锁里做 sleep、网络 IO 这些耗时操作。第三个坑Debug 时不知道还有哪些线程活着只能一遍遍加 print。后来发现 threading.enumerate() 能列出所有存活的线程对象配合 threading.current_thread().name 打印线程名可以快速定位是谁卡住了谁。给线程起名字真的花不了几秒钟但排查问题能省几小时。最后一点Python 的 GIL 让很多老开发误以为多线程没有用其实 GIL 只影响 CPU 密集型的纯计算任务对于 I/O 密集任务多线程的并行等待仍然可以大幅提升吞吐。用对场景才是关键。按我的经验能把 Thread 基础吃透最大的收获不是会用几个 API而是建立起一种并发视角。再去看生产环境里的排队、超时、并发限制、幂等设计都会觉得顺理成章。以后每次写线程之前先问自己三个问题这个线程要不要等它结束它什么时候结束多个线程会不会同时改同一个数据把这三个问题想清楚你的 Thread 就不会跑偏。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

大模型核心技术全解析:从AIGC到Agent,一文读懂AI技术演进路径与TaoToken统一接入实践 2026/10/2 16:01:57

大模型核心技术全解析:从AIGC到Agent,一文读懂AI技术演进路径与TaoToken统一接入实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
LlamaIndex实战:从零搭建RAG知识库的完整学习路线 2026/10/2 16:01:57

LlamaIndex实战:从零搭建RAG知识库的完整学习路线

刚开始接触 LlamaIndex 的时候,我其实挺困惑的:网上铺天盖地的教程都在讲 RAG(检索增强生成),讲向量数据库,讲 LangChain,但轮到 LlamaIndex 的时候,很多人只说了一句"它也是个…

阅读更多 →
本地LLM异步流控:背压识别、量化与取消实战 2026/10/2 16:01:51

本地LLM异步流控:背压识别、量化与取消实战

1. “模型已经开始吐字,界面为什么还会卡?”——这不是Bug,是背压在敲门你有没有遇到过这种场景:本地跑着一个7B参数的LLM,用的是transformers llama.cpp或ollama,前端界面已经收到第一个token&#xff0c…

阅读更多 →
龙虾 OpenClaw 本地电脑部署指南:无需Node,100%成功 2026/10/2 16:01:44

龙虾 OpenClaw 本地电脑部署指南:无需Node,100%成功

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
Visual Studio 2026 正式发布:AI 原生 IDE 与性能提升,TaoToken 统一 Key 接入实测 2026/10/2 16:01:44

Visual Studio 2026 正式发布:AI 原生 IDE 与性能提升,TaoToken 统一 Key 接入实测

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
PHP反序列化与文件包含漏洞组合利用详解 2026/10/2 16:01:44

PHP反序列化与文件包含漏洞组合利用详解

1. 项目概述:一道经典PHP反序列化文件包含的CTF实战题ZJCTF 2019 的这道题叫“NiZhuanSiWei”,直译是“逆转换四维”,名字带点玄学色彩,但实际考察的是非常扎实的Web安全基本功——PHP反序列化漏洞与本地文件包含(LFI&…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉