ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

python基础之并发编程(一)

python基础之并发编程(一) 前言并发编程concurrency是 Python 基础里最容易会写但用错的一块。原因不是 API 难而是它的正确用法完全取决于任务类型同样是开十个线程跑一个循环I/O 密集的任务可能真的变快CPU 密集的任务则几乎不会变快甚至更慢。不先判断任务类型就上并发是绝大多数性能问题的根源。这一篇先把两个地基铺好一是并发concurrency与并行parallelism的区别二是 GIL全局解释器锁到底限制了什么。后面几篇再展开具体工具。本文示例按 Python 3.8 编写用到的time.perf_counter()、threading、queue都是标准库可以直接运行。一、并发不等于并行并发指的是多个任务在同一时间段内交替推进并行指的是多个任务在同一时刻同时推进。一个人边煮饭边回消息是并发不是并行——他只是把等待的时间利用起来了。这个区分直接决定了工具的选择任务类型特征目标合适工具CPU 密集大量计算几乎不等待并行真同时multiprocessing、ProcessPoolExecutorI/O 密集大量等待网络 / 磁盘并发重叠等待threading、asyncio混合两者都有拆分后分别处理组合使用判断方法很朴素用time.perf_counter()量一下看时间花在算上还是花在等上。二、GIL 限制了什么GIL 是全局解释器锁Global Interpreter Lock准确表述有三层缺一层就会理解偏它是 CPython 的实现细节不是 Python 语言的规定。语言规范并没有要求必须有 GIL是 CPython 这个具体实现为了简化对象模型的内存管理而引入的。其他实现例如 Jython、IronPython本来就不存在 GIL。它的效果是同一进程内同一时刻只有一个线程在执行 Python 字节码。也就是说纯 Python 代码的多线程无法真正并行因为同一刻只有一把锁的持有者在跑。它在等待 I/O 时会释放。线程发起网络请求、读写文件、time.sleep()这类操作时会主动放开 GIL让别的线程去跑。第 2 条和第 3 条合起来给出了那句关键结论多线程对 CPU 密集任务基本没有加速效果对 I/O 密集任务有效。那为什么还会有共享数据的竞争问题因为 GIL 只保证单条字节码不被打断而像x 1这样的操作会被编译成读、加、写多条字节码线程随时可能在中间被切换。所以GIL 不能替代你写自己的锁。关于GIL 是不是已经从 Python 里消失了必须说准并没有。PEP 703 提出的自由线程free-threading构建在3.13 起是实验性的不是默认构建需要用--disable-gil编译或安装单独标记的可执行文件通常叫python3.13t并可用PYTHON_GIL环境变量或-X gil1在运行时重新启用 GIL。官方文档同时提醒去掉 GIL 不等于自动变快自由线程构建目前仍有可观的单线程性能损失且存在待完善的 bug。所以现阶段CPU 密集用多进程仍然是主流解法。三、threading解决等待不解决计算threading.Thread的用法非常简单把要跑的函数交给它start()启动join()等待结束。join()很重要——不加它主线程可能在子线程干完活之前就退出了。# 适用于 Python 3.8import threadingimport timedef worker(name, seconds):print(f{name} 开始)time.sleep(seconds) # 模拟 I/O 等待sleep 会释放 GILprint(f{name} 结束)start time.perf_counter()threads [threading.Thread(targetworker, args(f任务{i}, 1)) for i in range(3)]for t in threads:t.start()for t in threads:t.join()print(f耗时约 {time.perf_counter() - start:.2f} 秒)推演三个线程几乎同时启动各自进入time.sleep(1)并释放 GIL于是三份等待重叠在一起总耗时大约 1 秒左右而不是 3 秒。变快的部分全部来自等被重叠了而不是算被分担了。如果把time.sleep换成一段纯计算循环总耗时反而可能比串行更长因为线程切换本身有开销。这就是threading的定位它是等待的放大器不是计算的加速器。四、共享状态必须加锁多个线程读写同一个变量时会出现竞争race condition。典型症状是结果每次都不一样或总是差一点。# 适用于 Python 3.8import threadingcounter 0lock threading.Lock()def increment(n):global counterfor _ in range(n):with lock: # 加锁保护读-改-写整个序列counter 1threads [threading.Thread(targetincrement, args(1000,)) for _ in range(4)]for t in threads:t.start()for t in threads:t.join()print(counter) # 期望 4000如果不加with lock:counter 1的读、加、写三步可能交错执行两个线程都读到同一个旧值结果小于 4000。用with lock:而不是手动acquire()/release()是因为即使循环体里抛异常上下文管理器也保证锁会被释放避免死锁。锁的粒度要权衡锁太大并行退化成串行锁太小可能保护不全。原则是把必须整体完成才有意义的操作圈进同一个临界区像这里的读-改-写就必须当成一个整体。五、queue.Queue让线程之间传递数据共享变量加锁容易写错更好的模式是不共享内存靠队列通信。queue.Queue内部实现了锁它本身就是线程安全的多个线程同时put()和get()不会破坏内部状态。这是生产者-消费者模式的标准做法。# 适用于 Python 3.8import queueimport threadingimport timedef producer(q, n):for i in range(n):q.put(i)time.sleep(0.01)q.put(None) # 哨兵值通知消费者结束def consumer(q, results):while True:item q.get()if item is None:q.task_done()breakresults.append(item * item)q.task_done()q queue.Queue()results []p threading.Thread(targetproducer, args(q, 10))c threading.Thread(targetconsumer, args(q, results))p.start(); c.start()p.join(); c.join()print(results)推演生产者不断往队列里塞数字消费者取出后平方并追加到results。用None当哨兵值表示没有更多数据了消费者遇到它就退出循环。这里results.append()之所以不用加锁是由于list.append在 CPython 上是原子操作但这是实现细节换一种操作比如先判断再追加就不再安全所以更稳妥的做法仍然是让消费者把结果也放进队列由主线程统一收集。Queue还有两个常用的配套方法task_done()通知队列这个任务处理完了join()阻塞到所有已放入的任务都被处理完。它们必须成对使用否则join()永远不会返回。常见坑点用多线程加速 CPU 密集任务。受 GIL 限制同一进程内同一时刻只有一个线程执行字节码纯计算的多线程不会真正并行。❌ 把纯计算的循环拆成 10 个线程期待加速 ✅ CPU 密集改用multiprocessing或ProcessPoolExecutor各自独立解释器、各自有 GIL。误以为现在跑 Python 已经不带 GIL 了。PEP 703 的自由线程构建在 3.13 起是实验性的、非默认需要单独的可执行文件。❌ 新版 Python 去掉了 GIL多线程能加速计算了 ✅ 自由线程构建是实验性的且去掉 GIL 不等于自动变快单线程可能更慢。共享计数器不加锁。counter 1不是原子操作多个线程交错执行会丢失更新。❌counter 1裸写在新线程里 ✅ 用with lock:把整个读-改-写圈成临界区。忘了join()。主线程不等子线程结束就继续往下走甚至退出任务被中途丢弃输出残缺。❌t.start()之后直接打印结果 ✅ 所有start()之后统一join()再汇总。手动acquire()后异常导致锁不释放。函数中途抛异常release()永远执行不到其他线程全部卡死。❌lock.acquire()...lock.release()✅with lock:上下文管理器异常时也能保证释放。用threading.Thread的默认返回值判断任务成功。线程不会把异常传回主线程的调用点异常只打印到 stderr主流程看起来一切正常。❌ 认为t.join()没报错就代表子线程没出错 ✅ 在线程内部捕获异常并记录或改用concurrent.futures让Future把异常带回来。在queue.Queue上忘记哨兵值或task_done()。消费者get()会永久阻塞或join()永远不返回。❌ 消费者直接while True: q.get()而不设退出条件 ✅ 用哨兵值通知结束并让put()与task_done()配对。以为time.sleep()之外的操作也会释放 GIL。sleep因为有明确的等待语义会释放 GIL但并不是任意 Python 语句都这样纯 Python 循环持有 GIL 时不释放。❌ 认为任何调用都会让出 GIL从而让多线程自动公平 ✅ 只在明确的 I/O 等待处期待线程切换CPU 密集不要指望它。总结概念准确表述并发交替推进重叠等待并行同时推进需要多核真正分配GILCPython 实现细节同进程内同一刻只有一个线程执行字节码等待 I/O 时释放线程适用I/O 密集CPU 密集无加速共享状态用Lock保护临界区GIL 不能替代锁线程通信用线程安全的queue.Queue生产者-消费者模式自由线程3.13 起实验性、非默认不等于自动变快把这活主要是在等还是在算这个问题问清楚并发编程的路就走对了一半在等用线程在算用多进程。剩下的一半是把共享状态管住——要么加锁要么干脆不共享用队列传数据。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表