在 Linux C 编程中,多线程是实现高并发、提升程序性能的核心技术。Linux 下的多线程编程遵循 POSIX 线程(pthread) 标准。
你可以把线程理解为"轻量级的进程"。同一个进程内的多个线程共享进程的内存、文件描述符等资源,但每个线程又有自己独立的执行流和栈空间。
线程概念
如果把一个进程 比作一套房子 ,那么线程 就是住在房子里的人:
- 共享资源:住在同一套房子里的人,共享客厅、厨房、卫生间(对应进程中的代码段、全局变量、文件描述符等共享资源)。
- 独立活动 :每个人都有自己的房间和私人物品(对应线程独立的栈空间 、寄存器 和程序计数器),可以各自做不同的事情(独立执行代码)。
在 Linux 内核中,线程和进程并没有本质的区别,它们都被统称为"任务(task)",用 task_struct 结构体来描述。线程之所以被称为"轻量级进程(LWP)",是因为它共享了进程的大部分资源,创建和切换的开销远小于传统进程。
📊 进程与线程的核心区别
结合之前的进程,这里做一个直观的对比:
| 对比项 | 进程 (Process) | 线程 (Thread) |
|---|---|---|
| 资源拥有 | 拥有独立的地址空间和系统资源 | 共享进程的内存、文件等资源 |
| 开销 | 创建、切换、销毁开销大 | 轻量级,开销极小 |
| 通信方式 | 需要 IPC(管道、共享内存等) | 直接读写全局变量,通信极快 |
| 稳定性 | 进程间互不影响,隔离性好 | 一个线程崩溃会导致整个进程崩溃 |
| 基本单位 | 资源分配的基本单位 | CPU 调度的基本单位 |
线程标识
提到"线程标识",其实存在两套完全不同的 ID 体系。很多初学者容易把它们搞混,理解它们的区别对于调试和高级编程非常重要。
这两套体系分别是:用户级线程 ID (pthread_t) 和 内核级线程 ID (LWP)。
🆔 用户级线程 ID (pthread_t)
这是我们平时使用 POSIX 线程库(pthread)时最常打交道的 ID。
- 获取方式 :通过
pthread_self()函数获取。 - 本质与作用域 :它是由线程库(如 Linux 下的 NPTL)在用户空间维护的标识符。它的作用域仅限于当前进程内部,用来在进程内唯一区分不同的线程。
- 数据类型 :
pthread_t是一个不透明的数据类型。在 Linux (glibc) 中,它通常是一个无符号长整型(unsigned long),但在其他系统(如 FreeBSD)上可能是一个指针。因此,绝对不能直接用==来比较两个pthread_t,而应该使用pthread_equal()函数。 - 打印方式 :在 Linux 下通常可以强转为
unsigned long后用%lu打印。
代码示例:
#include <pthread.h>
#include <stdio.h>
void* thread_func(void* arg) {
// 获取当前线程的用户级 ID
pthread_t tid = pthread_self();
printf("子线程:用户级线程ID = %lu\n", (unsigned long)tid);
return NULL;
}
int main() {
pthread_t tid;
pthread_create(&tid, NULL, thread_func, NULL);
printf("主线程:子线程用户级ID = %lu\n", (unsigned long)tid);
pthread_join(tid, NULL);
return 0;
}
⚙️ 内核级线程 ID (LWP)
这是 Linux 内核真正用来调度和管理线程时使用的 ID。
- 获取方式 :通过
gettid()系统调用获取(在 C 语言中通常写作syscall(SYS_gettid))。 - 本质与作用域 :在 Linux 内核中,线程本质上就是"轻量级进程(LWP, Light Weight Process)"。这个 ID 是系统全局唯一的,内核调度器就是靠它来识别和调度线程的。
- 查看方式 :你在终端使用
top命令(按H键切换线程视图)或ps -aL命令时,看到的 PID/TID 列就是这个内核级线程 ID。 - 特点:主线程的 LWP 等于整个进程的 PID,而子线程的 LWP 则是独立分配的全局唯一值。
代码示例:
#include <stdio.h>
#include <sys/syscall.h> // 需要包含此头文件
#include <unistd.h>
int main() {
// 通过系统调用获取内核级线程ID (LWP)
pid_t lwp = syscall(SYS_gettid);
printf("当前线程的内核级ID (LWP):%d\n", lwp);
printf("当前进程的PID:%d\n", getpid());
return 0;
}
📊 核心区别对比
为了让你更直观地理解,这里有一份核心区别对比表:
| 特性 | 用户级线程 ID (pthread_t) |
内核级线程 ID (LWP) |
|---|---|---|
| 获取函数 | pthread_self() |
syscall(SYS_gettid) / gettid() |
| 定义者 | POSIX 线程库 (用户态) | Linux 内核 (内核态) |
| 作用域 | 仅在当前进程内唯一 | 系统全局唯一 |
| 本质 | 线程库维护的句柄/地址 | 内核调度的轻量级进程 ID |
| 典型用途 | 配合 pthread_join 等库函数管理线程 |
系统级调试、设置 CPU 亲和性、top 查看 |
| 调用开销 | 极低(无系统调用) | 较高(需要陷入内核) |
💡 总结与避坑
- 日常开发 :如果你只是在代码里进行线程的创建、等待、取消等常规操作,使用
pthread_self()就足够了,它符合 POSIX 标准,跨平台且开销极小。 - 系统调试与高级应用 :如果你需要排查系统级的性能问题(比如用
perf或top抓某个特定线程的 CPU 占用),或者需要调用 Linux 特有的系统接口(如设置线程的 CPU 亲和性sched_setaffinity),这时就必须使用内核级的 LWP ID。 - 不要混淆 :千万不要把
pthread_self()打印出来的数字拿到top或ps命令里去查,那是查不到的。
线程创建
在 Linux C 语言中,创建线程主要依赖于 POSIX 线程标准(Pthreads),核心函数是 pthread_create。
🚀 核心函数 pthread_create 详解
使用线程需要包含 <pthread.h> 头文件,并且在编译时必须加上 -pthread 选项(例如 gcc main.c -o main -pthread)。
函数原型:
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
参数详解:
thread(输出参数):传入一个pthread_t类型的指针,用于接收新创建线程的唯一标识符(线程ID)。attr(输入参数):用于设置线程的属性(如栈大小、分离状态等)。一般传NULL表示使用默认属性。start_routine(输入参数):线程的入口函数(回调函数)。该函数必须满足void* func(void*)的形式,即接受一个void*参数并返回一个void*。arg(输入参数):传递给线程入口函数的参数。如果不需要传参,填NULL即可。
返回值:
- 成功返回
0。 - 失败返回非 0 的错误码(如
EAGAIN表示资源不足)。注意 :pthread 函数出错时不会设置全局变量errno,需要直接检查返回值。
📝 基础创建示例
下面是一个标准的线程创建与等待的完整示例
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
// 线程入口函数
void* thread_func(void* arg) {
char* msg = (char*)arg;
printf("子线程正在执行,收到的消息是: %s\n", msg);
return NULL; // 线程正常退出
}
int main() {
pthread_t tid; // 定义线程ID
char* input = "你好,我是主线程传进来的参数";
int ret;
// 创建线程
ret = pthread_create(&tid, NULL, thread_func, input);
if (ret != 0) {
// pthread 函数返回的是错误码,需要用 strerror 转换
fprintf(stderr, "创建线程失败: %s\n", strerror(ret));
exit(EXIT_FAILURE);
}
printf("主线程:子线程创建成功,等待其结束...\n");
// 阻塞等待子线程结束,回收资源
pthread_join(tid, NULL);
printf("主线程:子线程已退出,程序结束。\n");
return 0;
}
⚠️ 线程创建的常见避坑指南
-
主线程提前退出导致子线程"夭折"
线程共享同一个进程的地址空间。如果主线程(
main函数)执行完毕直接return或调用了exit(),整个进程会立刻终止,此时即使子线程还没执行完,也会被强制杀死。因此,主线程通常需要使用pthread_join()来等待子线程执行完毕。 -
传递局部变量的地址(悬垂指针)
如果你给线程传递的参数是一个局部变量的地址(例如
int num = 10; pthread_create(..., &num);),当主线程的该函数作用域结束,局部变量会被销毁,子线程拿到的就会是一个无效的悬垂指针,极易导致程序崩溃。如果需要传递复杂数据,建议使用堆内存(malloc)或全局变量。 -
忘记链接 pthread 库 在 Linux 下编译时,如果只写
gcc main.c会报链接错误。必须加上-pthread编译选项,它不仅会链接线程库,还会启用一些线程安全的宏定义。
线程终止
在 Linux C 多线程编程中,线程的终止(退出)机制非常有讲究。根据终止的主体(是线程自己走、被别人杀、还是主线程退出)不同,处理方式和对整个进程的影响也完全不同。
我们可以把线程终止分为以下三种核心情况:
🚶 线程主动退出(优雅离场)
线程完成任务后,可以通过以下三种方式主动结束自己。这三种方式都能让线程正常触发清理函数,并安全地释放资源:
- 从线程入口函数中
return返回:这是最自然的方式,函数的返回值就是线程的退出码。 - 调用
pthread_exit()函数:线程可以在入口函数或它调用的任何子函数中调用此函数主动退出。 - 被其他线程取消(
pthread_cancel):虽然是被动触发,但线程会在"取消点"响应请求并主动退出。
⚠️ 绝对禁忌:千万不要在线程里调用 exit()!
exit() 是用来终止整个进程 的。如果某个子线程调用了 exit(),整个进程会立刻崩溃,导致同进程内的所有其他线程(包括主线程)被强制杀死。
🧟 主线程退出对子线程的影响(极易踩坑)
这是新手最容易搞混的地方:主线程(main 函数)退出了,子线程会怎样? 这完全取决于主线程是怎么退出的:
- 情况一:主线程直接
return 0或调用exit()
整个进程会立即终止,所有子线程会被强制杀死,无论它们是否还在运行。 - 情况二:主线程调用
pthread_exit()
主线程会"单独死亡",但进程不会终止。其他子线程会继续在后台运行,直到所有线程都结束后,进程才会真正退出。
对比代码示例:
// 情况一:主线程 return,子线程还没打印就会被强制杀掉
int main() {
pthread_t tid;
pthread_create(&tid, NULL, worker, NULL);
return 0; // 进程立即终止,子线程夭折
}
// 情况二:主线程 pthread_exit,子线程能完整运行
int main() {
pthread_t tid;
pthread_create(&tid, NULL, worker, NULL);
pthread_exit(NULL); // 主线程退出,但进程存活,子线程继续跑
}
🔪 强制终止其他线程(pthread_cancel)
一个线程可以请求强制取消另一个线程,这就像给目标线程发送了一个"死亡通知"。
- 函数原型 :
int pthread_cancel(pthread_t thread); - 取消点(Cancellation Points) :
pthread_cancel并不是魔法,它不会让线程立刻原地消失。目标线程只有运行到取消点 时,才会真正响应取消请求并退出。常见的取消点包括sleep、read、write、printf等阻塞型系统调用或标准 I/O 操作。 - 资源泄漏风险 :由于是强制终止,如果目标线程正拿着互斥锁或者占用了堆内存,突然被取消会导致死锁或内存泄漏。因此,必须配合线程清理处理程序 (
pthread_cleanup_push和pthread_cleanup_pop)来确保资源被安全释放。
♻️ 终止后的资源回收(防止"僵尸线程")
线程终止后,它的内核资源(如线程描述符、栈空间等)并不会自动消失。如果不处理,会产生类似"僵尸进程"的资源泄漏。回收方式有两种:
- 可结合状态(Joinable,默认状态) :必须由其他线程(通常是主线程)调用
pthread_join()来等待它结束并"收尸",回收资源并获取退出状态。 - 分离状态(Detached) :调用
pthread_detach()将线程设为分离状态。线程结束后,内核会自动回收其资源,不需要其他线程来等待。
📊 线程终止方式核心对比
为了让你更直观地掌握,这里有一份核心对比表:
| 终止方式 | 适用场景 | 核心特点与注意事项 |
|---|---|---|
return |
线程入口函数自然结束 | 最常用,返回值即为退出码 |
pthread_exit() |
线程在任意深层子函数中退出 | 仅终止当前线程,不杀进程 |
pthread_cancel() |
外部强制终止耗时/卡死的线程 | 需在取消点响应,注意防范资源泄漏 |
exit() |
(禁忌) | 会直接杀死整个进程,导致所有线程陪葬 |
在日常开发中,最推荐的优雅做法是:让线程通过 return 或 pthread_exit 自然退出,并在主线程中通过 pthread_join 进行等待和资源回收。
线程同步
在 Linux C 多线程编程中,线程同步是保证程序正确性和稳定性的基石。
由于同一进程内的多个线程共享全局变量、堆内存等资源,当它们同时访问(尤其是修改)同一块共享数据时,就会发生竞态条件(Race Condition),导致数据错乱、逻辑异常甚至程序崩溃。
为了解决这些问题,Linux 的 pthread 线程库提供了多种工业级的同步机制。下面为你详细拆解最核心的几种同步方式:
🔒 互斥锁(Mutex)------ 最通用的"独占锁"
互斥锁是线程同步中最基础、最常用的机制。它的核心思想非常直白:同一时刻,只允许一个线程持有锁并进入临界区(操作共享资源的代码段)。
- 工作机制 :线程在访问共享资源前先尝试加锁。如果锁空闲,则加锁成功并继续执行;如果锁已被其他线程占用,当前线程会阻塞休眠(不占用 CPU),直到锁被释放。
- 核心 API :
pthread_mutex_init(&mutex, NULL):初始化互斥锁。pthread_mutex_lock(&mutex):加锁(拿不到锁会一直阻塞等待)。pthread_mutex_unlock(&mutex):解锁(释放锁,唤醒等待的线程)。pthread_mutex_destroy(&mutex):销毁锁。
- 适用场景:任何需要保护共享数据不被并发修改的场景(如全局计数器、共享队列等)。
📖 读写锁(Read-Write Lock)------ "读多写少"的性能优化
互斥锁虽然安全,但有时候过于严格(即使是多个线程同时读取数据,互斥锁也会强制它们排队)。读写锁正是为了解决这种读多写少的场景而生的。
- 核心规则 :
- 读锁共享:允许多个线程同时持有读锁,并发地读取共享资源。
- 写锁独占:当有线程持有写锁时,其他任何线程(无论是读还是写)都不能加锁;反之,当有线程持有读锁时,写锁请求也会被阻塞。
- 核心 API :
pthread_rwlock_rdlock(&rwlock):加读锁。pthread_rwlock_wrlock(&rwlock):加写锁。pthread_rwlock_unlock(&rwlock):解锁。
- 适用场景:频繁读取、偶尔修改的共享数据(如配置信息表、路由表等)。
⏳ 条件变量(Condition Variable)------ 线程间的"等待与通知"
互斥锁解决了"抢资源"的问题,但无法解决"等条件"的问题。条件变量通常必须配合互斥锁一起使用,用于让线程在某个条件不满足时主动挂起等待,直到其他线程改变了条件并通知它。
- 核心机制 :
- 等待 :线程调用
pthread_cond_wait()时,会先自动释放互斥锁,然后进入休眠状态。 - 通知 :其他线程在修改了条件后,调用
pthread_cond_signal()唤醒一个等待的线程(或pthread_cond_broadcast()唤醒所有)。被唤醒的线程会重新尝试获取互斥锁,然后继续执行。
- 等待 :线程调用
- 适用场景 :经典的生产者-消费者模型(消费者等待缓冲区有数据,生产者等待缓冲区有空位)。
🔢 信号量(Semaphore)------ 资源计数的"通行证"
信号量本质上是一个非负整数计数器,用于控制同时访问特定资源的线程数量。
- 核心操作 :
- P操作 (
sem_wait):申请资源,将信号量值减 1。如果值为 0,线程阻塞等待。 - V操作 (
sem_post):释放资源,将信号量值加 1,并唤醒等待的线程。
- P操作 (
- 适用场景:控制对固定数量资源池的访问(如数据库连接池、固定大小的环形缓冲区)。
⚡ 自旋锁(Spinlock)------ 极短临界区的"忙等待"
自旋锁与互斥锁最大的区别在于:拿不到锁时,线程不会休眠,而是原地循环(自旋)不断尝试获取锁。
- 优缺点:避免了线程上下文切换(休眠和唤醒)的开销,但如果锁被占用的时间较长,会白白浪费大量的 CPU 资源。
- 适用场景:锁持有的时间极短(如只修改几个寄存器或极小的内存区域),且不希望发生线程切换的场景。
📊 核心同步机制对比总结
表格
| 同步机制 | 核心特点 | 适用场景 |
|---|---|---|
| 互斥锁 (Mutex) | 独占访问,拿不到锁会休眠 | 保护临界区,最通用的同步方式 |
| 读写锁 (RWLock) | 读锁共享,写锁独占 | 读多写少的数据访问场景 |
| 条件变量 (Cond) | 配合互斥锁,实现等待/通知 | 生产者-消费者、任务队列等 |
| 信号量 (Semaphore) | 基于计数器的资源控制 | 限制同时访问资源的线程数量 |
| 自旋锁 (Spinlock) | 拿不到锁时原地忙等待 | 临界区极短,追求极致低延迟 |
在实际的 Linux C 开发中,互斥锁 和条件变量的组合使用频率最高。掌握这些同步机制,是写出高性能、无 Bug 的多线程程序的必修课。