多线程基础:从单车道到多车道并行
说实话,我第一次接触多线程编程时,心里是有点发怵的。那时候我刚从单片机开发转到Linux平台,习惯了顺序执行的思维模式。直到我在一个网络代理项目中,发现单线程处理上千个连接时CPU利用率只有30%,而响应延迟却高得离谱——我才真正意识到:多线程不是炫技,是刚需。
这一章,我会带你从最基础的pthread创建开始,一步步深入到互斥锁、条件变量,最后聊聊线程安全那些坑。嗯,都是我在实战中踩过的雷。
1. 线程的创建与回收:pthread_create / pthread_join
先看一个最简单的例子。创建一个线程,让它打印一句话,然后主线程等它结束。
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
void* thread_func(void* arg) {
char* msg = (char*)arg;
printf("子线程说:%s\n", msg);
return NULL;
}
int main() {
pthread_t tid;
char* hello = "Hello from thread!";
// 创建线程
if (pthread_create(&tid, NULL, thread_func, (void*)hello) != 0) {
perror("pthread_create");
exit(1);
}
// 等待线程结束
pthread_join(tid, NULL);
printf("主线程:子线程已回收\n");
return 0;
}
编译时记得加 -lpthread 链接线程库:
gcc -o thread_demo thread_demo.c -lpthread
几个关键点:
pthread_create的第一个参数是线程ID指针,第二个是属性(传NULL用默认),第三个是线程函数,第四个是传给函数的参数。- 线程函数的签名必须是
void* (*)(void*),返回值和参数都是void指针。 pthread_join会阻塞等待指定线程结束,并回收其资源。如果不join,子线程结束后会变成僵尸线程,浪费系统资源。
我的习惯: 创建线程后,只要不是detach状态,一定要在合适的时机调用pthread_join。我曾经在一个长期运行的服务里忘记join,结果线程泄漏导致内存暴涨,排查了好久才发现。
2. 互斥锁:保护共享资源的门卫
多线程最大的问题是什么?竞争条件。两个线程同时修改同一个变量,结果不可预测。
举个例子:两个线程各自对全局变量counter加100万次。如果不加锁,结果大概率不是200万。
#include <pthread.h>
#include <stdio.h>
#define LOOP 1000000
int counter = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void* add_loop(void* arg) {
for (int i = 0; i < LOOP; i++) {
pthread_mutex_lock(&mutex);
counter++;
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, add_loop, NULL);
pthread_create(&t2, NULL, add_loop, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("counter = %d (期望 %d)\n", counter, 2 * LOOP);
return 0;
}
运行结果:
counter = 2000000 (期望 2000000)
如果不加锁,你可能会得到类似 1823456 这样的数字。为什么会这样?因为 counter++ 不是原子操作,它包含读、改、写三步。两个线程同时读到了同一个值,各自加1再写回,结果只加了1次。
我曾经踩过的坑: 在信号处理函数里使用互斥锁。信号处理函数是异步执行的,如果它在主线程持有锁时被触发,就会造成死锁。记住:信号处理函数里尽量别碰锁。
3. 条件变量:让线程学会等待和通知
互斥锁解决了互斥访问的问题,但有时候我们需要线程之间进行同步——比如生产者-消费者模型:生产者生产数据,消费者等待数据到来再处理。
条件变量就是干这个的。它让一个线程在某个条件不满足时挂起等待,另一个线程在条件满足时唤醒它。
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int ready = 0;
void* producer(void* arg) {
sleep(2); // 模拟生产耗时
pthread_mutex_lock(&mutex);
ready = 1;
printf("生产者:数据准备好了\n");
pthread_cond_signal(&cond); // 唤醒等待的消费者
pthread_mutex_unlock(&mutex);
return NULL;
}
void* consumer(void* arg) {
pthread_mutex_lock(&mutex);
while (ready == 0) {
printf("消费者:等待数据...\n");
pthread_cond_wait(&cond, &mutex); // 自动释放锁并等待
}
printf("消费者:收到数据,开始处理\n");
pthread_mutex_unlock(&mutex);
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, consumer, NULL);
pthread_create(&t2, NULL, producer, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return 0;
}
条件变量的使用要点:
- 必须与互斥锁配合使用。调用
pthread_cond_wait前必须已经持有锁。 pthread_cond_wait会原子性地释放锁并进入等待,被唤醒后重新获取锁。- 判断条件要用
while而不是if,防止虚假唤醒(spurious wakeup)。 pthread_cond_signal唤醒一个等待线程,pthread_cond_broadcast唤醒所有等待线程。
为什么用while而不是if? 因为pthread_cond_wait可能在没有收到signal的情况下返回(虚假唤醒)。用while可以重新检查条件,确保真的满足才继续执行。这不是理论问题,我在生产环境确实遇到过。
4. 线程安全:写代码时心里要有数
线程安全,说白了就是:多个线程同时调用某个函数或访问某个数据,不会出问题。
常见的线程不安全场景:
- 使用全局变量或静态变量而不加锁
- 返回指向局部静态变量的指针(比如
strtok、gmtime等老函数) - 不加锁地读写同一个文件描述符
- 使用非线程安全的库函数(如
rand()在早期实现中)
如何写出线程安全的代码?
- 尽量使用局部变量:每个线程有自己的栈,局部变量天然是线程私有的。
- 共享数据加锁保护:用互斥锁、读写锁等保护临界区。
- 使用线程安全版本的函数:比如用
strtok_r代替strtok,用rand_r代替rand。 - 避免死锁:多个锁时,所有线程按相同顺序加锁,或者使用
pthread_mutex_trylock尝试加锁。 - 注意锁的粒度:锁太粗影响性能,锁太细容易死锁。我一般先粗后细,性能不够再优化。
我的经验: 写多线程代码时,先想清楚哪些数据是共享的,哪些是私有的。把共享数据封装成模块,对外提供线程安全的接口。这样即使内部实现变了,调用方也不用改。
5. 知识体系总览
下面这张图总结了本章的核心知识点和它们之间的关系:
6. 避坑指南:我踩过的那些雷
最后分享几个实战中容易忽略的问题:
- 线程栈大小:默认线程栈通常8MB,如果递归很深或局部变量很大,记得用
pthread_attr_setstacksize调整。 - 信号处理:多线程程序中信号的处理方式很复杂。我建议专门用一个线程处理所有信号,其他线程屏蔽信号。
- fork与线程:在多线程程序中调用fork,子进程只保留调用fork的那个线程,其他线程会消失。这很容易导致死锁——因为其他线程可能正持有锁。
- 性能监控:用
perf top或htop观察线程的CPU使用情况。如果发现锁竞争严重,考虑改用读写锁或无锁数据结构。
一句话总结: 多线程编程的核心不是学会API怎么用,而是建立起「共享数据必须保护」的思维习惯。API只是工具,思维才是根本。
公众号:蓝海资料掘金营,微信deep3321