多线程基础:从单车道到多车道并行

说实话,我第一次接触多线程编程时,心里是有点发怵的。那时候我刚从单片机开发转到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. 线程安全:写代码时心里要有数

线程安全,说白了就是:多个线程同时调用某个函数或访问某个数据,不会出问题。

常见的线程不安全场景:

  • 使用全局变量或静态变量而不加锁
  • 返回指向局部静态变量的指针(比如 strtokgmtime 等老函数)
  • 不加锁地读写同一个文件描述符
  • 使用非线程安全的库函数(如 rand() 在早期实现中)

如何写出线程安全的代码?

  1. 尽量使用局部变量:每个线程有自己的栈,局部变量天然是线程私有的。
  2. 共享数据加锁保护:用互斥锁、读写锁等保护临界区。
  3. 使用线程安全版本的函数:比如用 strtok_r 代替 strtok,用 rand_r 代替 rand
  4. 避免死锁:多个锁时,所有线程按相同顺序加锁,或者使用 pthread_mutex_trylock 尝试加锁。
  5. 注意锁的粒度:锁太粗影响性能,锁太细容易死锁。我一般先粗后细,性能不够再优化。
我的经验: 写多线程代码时,先想清楚哪些数据是共享的,哪些是私有的。把共享数据封装成模块,对外提供线程安全的接口。这样即使内部实现变了,调用方也不用改。

5. 知识体系总览

下面这张图总结了本章的核心知识点和它们之间的关系:

多线程基础核心知识体系 多线程编程 线程创建与回收 互斥锁 (Mutex) 条件变量 线程安全 pthread_create pthread_join lock/unlock 死锁预防 wait/signal 虚假唤醒 局部变量 可重入函数 四个核心模块相互配合,构成多线程编程的基础

6. 避坑指南:我踩过的那些雷

最后分享几个实战中容易忽略的问题:

  • 线程栈大小:默认线程栈通常8MB,如果递归很深或局部变量很大,记得用 pthread_attr_setstacksize 调整。
  • 信号处理:多线程程序中信号的处理方式很复杂。我建议专门用一个线程处理所有信号,其他线程屏蔽信号。
  • fork与线程:在多线程程序中调用fork,子进程只保留调用fork的那个线程,其他线程会消失。这很容易导致死锁——因为其他线程可能正持有锁。
  • 性能监控:用 perf tophtop 观察线程的CPU使用情况。如果发现锁竞争严重,考虑改用读写锁或无锁数据结构。
一句话总结: 多线程编程的核心不是学会API怎么用,而是建立起「共享数据必须保护」的思维习惯。API只是工具,思维才是根本。

公众号:蓝海资料掘金营,微信deep3321