CPU并发程序设计与性能优化 - 第2节
多线程基础:线程的创建与生命周期
聊并发,咱们得先从最基础的线程说起。相信我,后面那些花里胡哨的优化技巧,全是建立在对线程深刻理解之上的。你想想看,连线程怎么生、怎么死都不清楚,怎么可能指望它帮你把CPU性能榨干?
我最早接触多线程是在做网络爬虫的时候。那时候觉得线程嘛,不就是开几个并行任务嘛。结果倒好,线程泄漏、死锁、资源疯狂抢占,当时那个头大。嗯,后来我总结出一句话:线程不是银弹,但你绕不过它。
什么是线程?进程和线程的区别
核心理解:进程是资源分配的最小单位,线程是CPU调度的最小单位。
说白了,一个进程就像一个独立的"车间"。它有自己独立的内存空间、文件句柄、环境变量。而线程呢?是车间里的"流水线工人"。工人之间共享车间里的工具和原材料,但每个工人有自己的"工作台"。
- 进程:拥有独立的4GB虚拟地址空间(32位系统),切换开销大
- 线程:共享进程的内存空间,切换开销小,所以才能做高并发
- 关键区别:线程间通信靠共享内存,进程间通信需要IPC(管道、消息队列等)
我在项目中遇到过一种情况:某个业务服务疯狂创建线程来处理请求,觉得这样"并发更高"。结果呢?内存被吃光,CPU上下文切换占掉50%以上。为什么?因为线程多了不一定是好事,调度开销你得算清楚。
线程的生命周期
线程不是一直活着跑的。它有自己的状态变迁。搞不清这个,你写出来的代码可能就是"薛定谔的线程"——你以为它在跑,实际上它堵着呢。
| 状态 | 含义 | 触发条件 |
|---|---|---|
| 新建 | 刚被创建,还没开始跑 | 调用线程创建函数(如pthread_create) |
| 就绪 | 资源齐全,等CPU | 调度器把线程放入就绪队列 |
| 运行 | 真正在CPU上执行 | 调度器选中,分配时间片 |
| 阻塞 | 等待某件事完成 | IO等待、锁竞争、sleep等 |
| 终止 | 跑完了或者被别人干掉 | 线程函数返回、主动退出或被动取消 |
我曾经踩过的坑:在线程阻塞状态下调用了pthread_cancel,结果资源没释放,直接造成内存泄漏。记住,终止线程前一定要做好清理工作。
POSIX线程(pthread)入门
在Linux/C环境下做并发,pthread是绕不开的。我个人的习惯是:只要涉及高性能IO或计算密集型任务,首选pthread。它够底层,控制力够强。Python的threading虽然好用,但说白了就是它的一个"包装盒"。
线程的创建与参数传递
创建线程其实就一个函数的事儿,但坑全在参数传递上。
#include <pthread.h>
#include <stdio.h>
void* worker(void* arg) {
int* num = (int*)arg;
printf("线程收到参数: %d\n", *num);
return NULL;
}
int main() {
pthread_t tid;
int val = 42;
// 创建线程,传地址过去
pthread_create(&tid, NULL, worker, &val);
// 等着子线程跑完
pthread_join(tid, NULL);
return 0;
}
我曾经犯过的错:直接传一个局部变量的地址给线程,主线程执行完后变量就被回收了。子线程再去读,拿到的全是垃圾数据。记住,要么传堆上分配的内存,要么确保变量的生命周期够长。
线程的分离与资源回收
线程跑完了不回收?那是会泄漏的。pthread有两种资源回收方式:joinable和detached。
| 属性 | joinable(默认) | detached |
|---|---|---|
| 资源回收方式 | 调用pthread_join显式回收 | 线程退出时自动回收 |
| 适用场景 | 需要获取线程返回值 | 只管执行,不需要结果 |
| 安全风险 | 忘记join会导致资源泄漏 | 无法获取返回值,也无法强制取消 |
// 设置为分离线程
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
pthread_t tid;
pthread_create(&tid, &attr, worker, NULL);
pthread_attr_destroy(&attr);
// 此时不需要pthread_join,线程退出自动回收
我个人的建议:对于"fire-and-forget"模式的任务——比如日志写入、心跳检测——就用detached。但如果你需要收集结果或控制线程生命周期,老老实实用joinable。
Python threading模块
说完了底层,咱们看看上层。Python的threading模块把pthread包装得更友好,但也有它的"坑"。为什么这么说?
你想想看,Python有GIL啊。全局解释器锁,让同一时刻只有一个线程能执行Python字节码。所以CPU密集型任务用Python多线程?基本等于"白忙活"。但IO密集型就完全不同了——网络请求、文件读写,这些时候线程在等IO,GIL就释放了,别的线程就能上。
Thread类的基本使用
import threading
import time
def worker(name, delay):
print(f"[{name}] 开始执行")
time.sleep(delay) # 模拟IO操作
print(f"[{name}] 执行完毕")
# 创建线程
t1 = threading.Thread(target=worker, args=("线程A", 2))
t2 = threading.Thread(target=worker, args=("线程B", 1))
# 启动
t1.start()
t2.start()
# 等待完成
t1.join()
t2.join()
print("全部线程执行完毕")
线程同步:为什么需要锁?
共享资源不加锁?那数据就是"不可预测的"。我在项目中遇到过一个经典的计数器场景:两个线程同时给一个全局变量自增,结果跑出来总比预期的少。
import threading
counter = 0
lock = threading.Lock()
def safe_increment():
global counter
for _ in range(100000):
with lock: # 加锁保护临界区
counter += 1
t1 = threading.Thread(target=safe_increment)
t2 = threading.Thread(target=safe_increment)
t1.start()
t2.start()
t1.join()
t2.join()
print(f"最终结果: {counter}") # 正确结果:200000
核心原则:临界区越短越好。锁的粒度决定了并发能跑多快。把锁拆细、把同步点后移,是性能优化的基本功。
线程安全的数据结构
Python标准库提供了一些现成的线程安全容器。能用的就别自己写了,相信我,自己写的锁多半有bug。
| 容器类型 | 线程安全方式 | 适用场景 |
|---|---|---|
| queue.Queue | 生产者-消费者模式,内部锁 | 任务队列、流水线处理 |
| collections.deque | append/pop是原子的(CPython中) | 简单栈或队列 |
| threading.Event | 条件变量,用于线程间信号通知 | 等待某个事件发生后再继续 |
| threading.Semaphore | 控制并发访问数量 | 限流、连接池管理 |
我建议你从queue.Queue开始。它把锁、条件变量都封装好了,犯错几率最小。等你对并发有了手感,再自己动手设计细粒度锁方案。
本章总结
多线程基础知识,说白了就是要搞清楚三件事:
- 生命周期:线程是怎么从新建走到终止的,中间有哪些状态
- 资源管理:创建线程后要记得回收,别让资源泄漏成为你的"常态"
- 同步机制:共享数据必须加锁,但锁的范围要控制得尽可能小
我在带团队的时候经常讲一句话:写多线程代码不难,难的是让它在生产环境里稳定跑三年不出事。这些基础的东西,看起来简单,但每个坑都是用真实故障换来的教训。
嗯,把这些吃透了,咱们后面才能大刀阔斧地优化CPU利用率。你想想看,连线程都控制不好,怎么去谈"CPU优化"呢?