C 进阶(10) - 线程

在 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 标准,跨平台且开销极小。
  • 系统调试与高级应用 :如果你需要排查系统级的性能问题(比如用 perftop 抓某个特定线程的 CPU 占用),或者需要调用 Linux 特有的系统接口(如设置线程的 CPU 亲和性 sched_setaffinity),这时就必须使用内核级的 LWP ID
  • 不要混淆 :千万不要把 pthread_self() 打印出来的数字拿到 topps 命令里去查,那是查不到的。

线程创建

在 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;
}

⚠️ 线程创建的常见避坑指南

  1. 主线程提前退出导致子线程"夭折"

    线程共享同一个进程的地址空间。如果主线程(main 函数)执行完毕直接 return 或调用了 exit(),整个进程会立刻终止,此时即使子线程还没执行完,也会被强制杀死。因此,主线程通常需要使用 pthread_join() 来等待子线程执行完毕。

  2. 传递局部变量的地址(悬垂指针)

    如果你给线程传递的参数是一个局部变量的地址(例如 int num = 10; pthread_create(..., &num);),当主线程的该函数作用域结束,局部变量会被销毁,子线程拿到的就会是一个无效的悬垂指针,极易导致程序崩溃。如果需要传递复杂数据,建议使用堆内存(malloc)或全局变量。

  3. 忘记链接 pthread 库 在 Linux 下编译时,如果只写 gcc main.c 会报链接错误。必须加上 -pthread 编译选项,它不仅会链接线程库,还会启用一些线程安全的宏定义。

线程终止

在 Linux C 多线程编程中,线程的终止(退出)机制非常有讲究。根据终止的主体(是线程自己走、被别人杀、还是主线程退出)不同,处理方式和对整个进程的影响也完全不同。

我们可以把线程终止分为以下三种核心情况:

🚶 线程主动退出(优雅离场)

线程完成任务后,可以通过以下三种方式主动结束自己。这三种方式都能让线程正常触发清理函数,并安全地释放资源:

  1. 从线程入口函数中 return 返回:这是最自然的方式,函数的返回值就是线程的退出码。
  2. 调用 pthread_exit() 函数:线程可以在入口函数或它调用的任何子函数中调用此函数主动退出。
  3. 被其他线程取消(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 并不是魔法,它不会让线程立刻原地消失。目标线程只有运行到取消点 时,才会真正响应取消请求并退出。常见的取消点包括 sleepreadwriteprintf 等阻塞型系统调用或标准 I/O 操作。
  • 资源泄漏风险 :由于是强制终止,如果目标线程正拿着互斥锁或者占用了堆内存,突然被取消会导致死锁或内存泄漏。因此,必须配合线程清理处理程序pthread_cleanup_pushpthread_cleanup_pop)来确保资源被安全释放。

♻️ 终止后的资源回收(防止"僵尸线程")

线程终止后,它的内核资源(如线程描述符、栈空间等)并不会自动消失。如果不处理,会产生类似"僵尸进程"的资源泄漏。回收方式有两种:

  1. 可结合状态(Joinable,默认状态) :必须由其他线程(通常是主线程)调用 pthread_join() 来等待它结束并"收尸",回收资源并获取退出状态。
  2. 分离状态(Detached) :调用 pthread_detach() 将线程设为分离状态。线程结束后,内核会自动回收其资源,不需要其他线程来等待。

📊 线程终止方式核心对比

为了让你更直观地掌握,这里有一份核心对比表:

终止方式 适用场景 核心特点与注意事项
return 线程入口函数自然结束 最常用,返回值即为退出码
pthread_exit() 线程在任意深层子函数中退出 仅终止当前线程,不杀进程
pthread_cancel() 外部强制终止耗时/卡死的线程 需在取消点响应,注意防范资源泄漏
exit() (禁忌) 会直接杀死整个进程,导致所有线程陪葬

在日常开发中,最推荐的优雅做法是:让线程通过 returnpthread_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,并唤醒等待的线程。
  • 适用场景:控制对固定数量资源池的访问(如数据库连接池、固定大小的环形缓冲区)。

⚡ 自旋锁(Spinlock)------ 极短临界区的"忙等待"

自旋锁与互斥锁最大的区别在于:拿不到锁时,线程不会休眠,而是原地循环(自旋)不断尝试获取锁

  • 优缺点:避免了线程上下文切换(休眠和唤醒)的开销,但如果锁被占用的时间较长,会白白浪费大量的 CPU 资源。
  • 适用场景:锁持有的时间极短(如只修改几个寄存器或极小的内存区域),且不希望发生线程切换的场景。

📊 核心同步机制对比总结

表格

同步机制 核心特点 适用场景
互斥锁 (Mutex) 独占访问,拿不到锁会休眠 保护临界区,最通用的同步方式
读写锁 (RWLock) 读锁共享,写锁独占 读多写少的数据访问场景
条件变量 (Cond) 配合互斥锁,实现等待/通知 生产者-消费者、任务队列等
信号量 (Semaphore) 基于计数器的资源控制 限制同时访问资源的线程数量
自旋锁 (Spinlock) 拿不到锁时原地忙等待 临界区极短,追求极致低延迟

在实际的 Linux C 开发中,互斥锁条件变量的组合使用频率最高。掌握这些同步机制,是写出高性能、无 Bug 的多线程程序的必修课。

相关推荐
长谷深风1111 天前
Java并发编程:线程安全与多线程实战指南【个人八股】
java·安全·线程·进程·juc·并发与并行·上下文切换(性能影响因素)
2401_858286112 天前
OS74.【Linux】线程互斥(3) 线程安全、重入
linux·运维·服务器·开发语言·线程
sdm0704273 天前
socket-udp
网络·网络协议·udp·线程
code monkey.4 天前
【Linux之旅】Linux 线程同步与互斥实战:从锁机制到生产消费模型全指南
linux·c++·线程·同步·互斥
拾光Ծ5 天前
【Linux系统】线程(上)
java·linux·运维·jvm·线程·c/c++
冷小鱼7 天前
锁:从操作系统到分布式系统的完整面试指南
码农-阿杰8 天前
深入理解 synchronized 底层实现:从 HotSpot C++ 源码看对象锁与 Monitor 机制
开发语言·c++·
Chloeis Syntax8 天前
JavaEE初阶学习日记(1)---线程和进程
java·开发语言·学习·线程·javaee
大袁同学9 天前
【线程】:在并发的荒原上构筑秩序
linux·c++·线程