前言
在 Linux 上用 C 语言写多线程程序,绕不开一个事实:ISO C 标准直到 C11 才引入<threads.h>里的thrd_create等接口,而且这套接口在 glibc 上的实现长期不完整,实际项目几乎不用。Linux 上事实上的标准是 POSIX 线程(POSIX Threads,简称 pthread),头文件是<pthread.h>,由 glibc 的 NPTL 实现提供(现代 glibc 已把 libpthread 合并进 libc,但编译选项仍建议保留)。本文讲的都是 pthread。
初学者最常见的三个误解:第一,以为「开线程」就是调用一个函数然后各跑各的,忽略了线程函数的固定签名和参数传递的生命周期;第二,以为加了printf就能看出竞态,实际上竞态的窗口极窄,看不到不等于没有;第三,以为volatile能代替锁,这在 C 语言里同样是错的——C11 起标准明确了数据竞争(data race)是未定义行为(undefined behavior, UB)。
本文从线程的创建与回收讲起,依次说明互斥量、条件变量,最后给出一个完整的有界缓冲区生产者—消费者示例。所有代码以 POSIX 接口为准,编译时使用 GCC 的-pthread选项。
一、线程的创建、回收与参数传递
pthread 的核心接口只有几个:
| 接口 | 原型要点 | 作用 |
|---|
pthread_create | (pthread_t*, const pthread_attr_t*, void*(*)(void*), void*) | 创建线程 |
pthread_join | (pthread_t, void**) | 等待线程结束并取回返回值 |
pthread_detach | (pthread_t) | 标记为分离态,结束后自动回收资源 |
pthread_exit | (void*) | thread 主动退出并返回值 |
pthread_self | (void) | 取得当前线程 ID |
线程函数必须是void *func(void *arg)这一种签名,返回值通过pthread_join的第二个参数取出。一个线程要么被join,要么被detach,两者只能选其一;既没有 join 也没有 detach 的线程结束后,其栈和线程描述符不会释放,形成资源泄漏——这不会立刻报错,长时间运行的服务进程会慢慢耗尽资源。
向线程传参有个经典陷阱:把循环变量的地址传进去。例如在一个for循环里反复pthread_create(&tid, NULL, worker, &i),所有线程拿到的都是同一个i的地址,等线程真正调度起来时i早已改变,甚至循环已经结束、变量生命周期已终止,这时读取就是 UB。正确做法是每个线程传一份独立的数据,例如用malloc分配一个小结构体,或者直接传整数值并用intptr_t转换:
// 适用于 C11(POSIX 线程为平台扩展)
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
static void *worker(void *arg)
{
intptr_t id = (intptr_t)arg; /* 值传递,不依赖外部变量生命周期 */
printf("worker %ld\n", (long)id);
return NULL;
}
int main(void)
{
pthread_t tid[4];
for (intptr_t i = 0; i < 4; ++i) {
int rc = pthread_create(&tid[i], NULL, worker, (void *)i);
if (rc != 0) {
fprintf(stderr, "pthread_create: %s\n", strerror(rc));
return 1;
}
}
for (int i = 0; i < 4; ++i) {
pthread_join(tid[i], NULL);
}
return 0;
}intptr_t保证能装下一个指针宽度,(void *)i与(intptr_t)arg的往返转换在实现上可行;如果嫌这种写法别扭,就老老实实malloc一个结构体,在线程里free。编译命令是gcc -std=c11 -Wall -Wextra -pthread demo.c -o demo,-pthread同时影响预处理宏和链接,比单独写-lpthread更稳妥。
需要提醒:pthread_create成功返回 0,失败返回错误码,它不设置errno,所以不能用perror报告原因。示例把返回值交给strerror(rc)转成文字,并因此包含了<string.h>。这一点在 pthread 系列函数里是统一的:pthread_mutex_lock、pthread_join等都直接返回错误码。
二、互斥量与共享数据
多个线程读写同一块内存,且至少有一个是写,又没有同步,就是数据竞争。C11 标准把它定为 UB,不是「结果可能不对」这么轻——优化器可以假设不存在竞争,从而做出让代码「看起来完全不合逻辑」的变换。
保护共享数据用互斥量pthread_mutex_t:
- 静态初始化:
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; - 动态初始化:
pthread_mutex_init(&m, NULL);,用完pthread_mutex_destroy(&m); - 加锁
pthread_mutex_lock(&m);,解锁pthread_mutex_unlock(&m);
关键纪律是「每条加锁路径都必须有对应的解锁」。C 语言没有析构函数,函数中途return或跳goto时极易漏掉解锁,之后所有线程都会永久阻塞。工程上的通用做法是单一出口 +goto cleanup:
// 适用于 C11(POSIX 线程为平台扩展)
#include <pthread.h>
#include <stdio.h>
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
static long g_counter = 0;
static void *add_many(void *arg)
{
long n = *(long *)arg;
for (long i = 0; i < n; ++i) {
pthread_mutex_lock(&g_lock);
++g_counter; /* 临界区尽量短 */
pthread_mutex_unlock(&g_lock);
}
return NULL;
}这里++g_counter是「读—改—写」三步,不加锁时两个线程可能都读到旧值,最后一次写入覆盖前一次,总数偏小。注意临界区只包住真正共享的操作,把printf、文件 IO 放进临界区会显著拉长持锁时间,但这属于设计取舍,不能靠「少加锁」来换正确性。
pthread 互斥量默认不是递归的:同一线程对同一把普通互斥量重复lock会死锁。需要递归语义必须用pthread_mutexattr_settype设置PTHREAD_MUTEX_RECURSIVE,但递归锁往往意味着锁的粒度设计有问题,优先重构而不是打开这个开关。
三、条件变量与生产者—消费者
条件变量解决的是「等某个条件成立」的问题。它必须和互斥量配合,标准用法是:
pthread_mutex_lock(&m);
while (!condition) { /* 必须是 while,不是 if */
pthread_cond_wait(&cv, &m);
}
/* 此处持有锁,条件成立 */
pthread_mutex_unlock(&m);pthread_cond_wait做三件事:释放互斥量、阻塞等待、被唤醒后重新获取互斥量。必须用while而不是if,因为存在虚假唤醒(spurious wakeup),也可能被唤醒后条件又被别的线程改掉。判断条件时一定是持有锁的。
唤醒有pthread_cond_signal(唤醒至少一个)和pthread_cond_broadcast(唤醒全部)。当等待者等待的条件不同,或者一次状态变化能满足多个等待者时,用 broadcast。
下面是一个完整的有界缓冲区示例,一个生产者、一个消费者,缓冲区满时生产者等待,空时消费者等待:
// 适用于 C11(POSIX 线程为平台扩展)
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define CAP 8
#define TOTAL 32
static int g_buf[CAP];
static int g_head = 0; /* 下一个写入位置 */
static int g_tail = 0; /* 下一个读取位置 */
static int g_count = 0;
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t g_not_full = PTHREAD_COND_INITIALIZER;
static pthread_cond_t g_not_empty = PTHREAD_COND_INITIALIZER;
static void *producer(void *arg)
{
(void)arg;
for (int i = 0; i < TOTAL; ++i) {
pthread_mutex_lock(&g_mtx);
while (g_count == CAP) {
pthread_cond_wait(&g_not_full, &g_mtx);
}
g_buf[g_head] = i;
g_head = (g_head + 1) % CAP;
++g_count;
pthread_cond_signal(&g_not_empty);
pthread_mutex_unlock(&g_mtx);
}
return NULL;
}
static void *consumer(void *arg)
{
(void)arg;
for (int i = 0; i < TOTAL; ++i) {
pthread_mutex_lock(&g_mtx);
while (g_count == 0) {
pthread_cond_wait(&g_not_empty, &g_mtx);
}
int v = g_buf[g_tail];
g_tail = (g_tail + 1) % CAP;
--g_count;
pthread_cond_signal(&g_not_full);
pthread_mutex_unlock(&g_mtx);
printf("consume %d\n", v);
}
return NULL;
}
int main(void)
{
pthread_t tp, tc;
int rc;
rc = pthread_create(&tp, NULL, producer, NULL);
if (rc != 0) { fprintf(stderr, "create producer: %d\n", rc); return 1; }
rc = pthread_create(&tc, NULL, consumer, NULL);
if (rc != 0) { fprintf(stderr, "create consumer: %d\n", rc); return 1; }
pthread_join(tp, NULL);
pthread_join(tc, NULL);
pthread_mutex_destroy(&g_mtx);
pthread_cond_destroy(&g_not_empty);
pthread_cond_destroy(&g_not_full);
return 0;
}这个例子里两个条件变量分别对应「不满」和「不空」,是为了避免生产者把消费者唤醒后消费者发现还是空、又睡回去的无效唤醒。如果只用一个条件变量,就必须用 broadcast,正确性仍成立但效率差一些。
常见坑点
- ❌ 线程函数写成
void worker(void *)或int worker(void *)。✅ 必须是void *worker(void *),返回值和参数都是void *,否则pthread_create的类型检查通不过(C 里会给出不兼容指针类型的警告,强行转换则是 UB)。
- ❌ 把循环变量地址传给所有线程。✅ 用
intptr_t值传递,或为每个线程malloc独立的结构体;后者要在线程内负责释放。
- ❌ 创建线程后既不
join也不detach。✅ 明确选择一种:需要结果就join,不需要就detach,或者创建时把属性设为分离态。
- ❌ 用
if判断条件变量:if (count == 0) pthread_cond_wait(...)。✅ 必须用while循环重判条件,以应对虚假唤醒和被抢先修改。
- ❌ 把
volatile int flag当作线程间同步。✅volatile只阻止编译器对该变量的部分优化,不提供原子性和内存序;C11 用stdatomic.h的_Atomic类型,或用互斥量保护。
- ❌ 在临界区里调用可能阻塞或无期限等待的函数(如读网络、等另一个锁)。✅ 临界区只做最短的必要操作,把耗时工作移到锁外,并避免锁的嵌套顺序不一致导致死锁。
- ❌ 忘记
-pthread,只写gcc demo.c。✅ 编译和链接都加-pthread;在某些平台上漏掉会在链接阶段报undefined reference to pthread_create。
- ❌ 认为
printf输出顺序就是线程执行顺序。✅ 线程调度由内核决定,输出交错是正常现象;需要顺序就显式同步。
总结
| 主题 | 关键接口 | 要点 |
|---|
| 创建与回收 | pthread_create/pthread_join/pthread_detach | 签名固定,必须 join 或 detach |
| 互斥 | pthread_mutex_lock/unlock | 每条路径都要解锁,临界区尽量短 |
| 条件等待 | pthread_cond_wait/signal/broadcast | 配互斥量,用 while 重判条件 |
| 原子操作 | <stdatomic.h>(C11) | 比手写锁轻,但内存序要理解 |
| 编译 | -pthread | 预处理与链接都要 |
Linux 下的 C 多线程编程,语法层面并不复杂,难的是资源生命周期和同步纪律:线程要有人收尸,锁要有始有终,条件要循环重判。把这三条守住,绝大多数偶发的挂死和计数错误都能避免。真要排查竞态,helgrind、ThreadSanitizer(-fsanitize=thread)这类工具比盯着printf有效得多。