Linux多线程
- Linux 多线程
- 一、Linux 线程概念
- 什么是线程
- Linux 没有真正的线程
- 二、二级页表
- 二级页表的工作原理
- 修改常量字符串为何触发段错误?
- 三、线程的优缺点
- 线程的优点
- 线程的缺点
- 四、线程异常与用途
- 线程异常
- 线程用途
- 五、Linux 进程与线程
- 进程和线程的区别
- 线程私有数据
- 线程共享数据
- 进程和线程的关系
- 六、Linux 线程控制(POSIX 线程库)
- 线程创建
- 线程等待
- 线程终止
- 分离线程
- 线程 ID 及地址空间布局
- 七、总结
Linux 多线程
在 Linux 系统中,线程是一个非常重要的概念,它与进程密切相关,但又有其独特之处。本文将从线程的基本概念出发,深入探讨 Linux 中的线程实现、控制机制、优缺点、用途,以及相关的技术细节,如二级页表和 POSIX 线程库(pthread)的使用。
一、Linux 线程概念
什么是线程
线程(thread)是一个程序中的执行路线,更准确地说,它是“进程内部的一个控制序列”。每个进程至少包含一个执行线程,即主线程。线程运行在进程的地址空间内,依赖进程的资源运行。在 Linux 中,线程的本质是轻量级进程(Light Weight Process,LWP),由内核中的 task_struct
结构表示。CPU 将 task_struct
视为调度单位,而不区分它是传统意义上的进程还是线程。
当创建一个进程时,会伴随着进程控制块(task_struct
)、进程地址空间(mm_struct
)和页表的创建,虚拟地址通过页表映射到物理地址。而线程的创建则更为轻量化:只需新建一个 task_struct
,并让其与父进程共享地址空间和页表。这样,多个线程共享进程的资源,形成多个执行流。
因此,Linux 中的进程可以分为:
- 单执行流进程:只有一个
task_struct
,即单线程进程。 - 多执行流进程:包含多个
task_struct
,即多线程进程。
从内核角度看,进程是系统资源分配的基本单位,而线程是调度的基本单位。CPU 只关心独立的 task_struct
,并不区分它是进程还是线程,这使得 Linux 的线程实现比传统进程更轻量。
Linux 没有真正的线程
与 Windows 等支持原生线程的操作系统不同,Linux 并不存在真正意义上的线程,而是通过轻量级进程模拟线程。内核使用 task_struct
统一描述进程和线程,避免了为线程设计独立的控制结构。这种设计降低了操作系统的复杂性,但也意味着 Linux 中没有真正的线程相关系统调用。用户层通过 POSIX 线程库(pthread)模拟线程功能,而内核仅提供轻量级进程的创建接口,例如 vfork
。
例如,vfork
函数创建一个子进程并与父进程共享地址空间,其行为类似于线程:
#include <stdio.h>
#include <unistd.h>
int g_val = 100;
int main() {pid_t id = vfork();if (id == 0) {g_val = 200;printf("child: g_val=%d\n", g_val);exit(0);}sleep(3);printf("father: g_val=%d\n", g_val);return 0;
}
运行结果显示,子进程修改的全局变量 g_val
影响了父进程,证明它们共享地址空间。
二、二级页表
线程运行在进程的地址空间内,而地址空间的映射依赖页表。以 32 位平台为例,虚拟地址空间有 2³² 个地址(4GB)。如果使用单级页表,每个虚拟地址需映射到物理地址,假设每个表项占 10 字节(包括权限信息),则页表大小为 40GB,远超 32 位系统的内存容量(4GB)。因此,Linux 采用多级页表,其中 32 位平台使用二级页表。
二级页表的工作原理
- 页目录(一级页表):虚拟地址的前 10 位在页目录中查找,定位到对应的二级页表。
- 页表(二级页表):接下来的 10 位在二级页表中查找,确定物理页框的起始地址。
- 偏移量:最后 12 位作为页内偏移量,定位到具体的物理地址。
每个物理页框大小为 4KB(2¹² 字节),页目录和页表各有 2¹⁰ 个表项(1024 项),每项 10 字节,总大小约为 10MB,大幅降低了内存需求。映射过程由 CPU 内的内存管理单元(MMU)硬件完成,结合软件页表实现虚拟地址到物理地址的转换。
在 64 位平台下,Linux 使用更复杂的多级页表,以适应更大的地址空间。
修改常量字符串为何触发段错误?
常量字符串位于只读数据段,页表将其标记为只读。当尝试修改时,MMU 检测到权限冲突,触发硬件异常,操作系统随即向进程发送信号(如 SIGSEGV),终止进程。
三、线程的优缺点
线程的优点
- 创建开销小:创建线程比创建进程更快,因为无需复制地址空间。
- 切换效率高:线程切换仅涉及上下文切换,不需要更改页表。
- 资源占用少:线程共享进程资源,节省内存。
- 并行性强:多线程可充分利用多核处理器。
- 异步执行:在等待慢速 I/O 时,其他线程可继续计算。
- 任务分解:计算密集型任务可拆分为多线程并行执行;I/O 密集型任务可重叠 I/O 操作。
线程的缺点
- 性能损失:计算密集型线程过多时,调度和同步开销增加。
- 健壮性低:线程间缺乏隔离,一个线程崩溃可能导致整个进程终止。
- 缺乏访问控制:线程共享资源,调用某些 OS 函数可能影响整个进程。
- 编程复杂:多线程程序调试困难,容易出现竞争条件或死锁。
四、线程异常与用途
线程异常
线程可能因除零、野指针等异常崩溃。由于线程是进程的执行分支,其崩溃会触发信号机制,终止整个进程。例如:
void* routine(void* arg) {int a = 1 / 0; // 除零异常return NULL;
}
线程崩溃后,进程随之退出,所有线程停止运行。这体现了多线程健壮性较低的缺点。
线程用途
- 提升效率:多线程并行处理 CPU 密集型任务。
- 改善体验:I/O 密集型任务(如下载文件时同时编辑代码)通过多线程实现异步操作。
五、Linux 进程与线程
进程和线程的区别
- 进程:资源分配的基本单位,拥有独立的地址空间。
- 线程:调度的基本单位,共享进程的地址空间。
线程私有数据
尽管线程共享进程资源,但每个线程拥有:
- 线程 ID(pthread_t)
- 寄存器组(上下文)
- 栈空间
- errno 变量
- 信号屏蔽字
- 调度优先级
线程共享数据
线程共享进程的:
- 代码段和数据段(如全局变量、函数)
- 文件描述符表
- 信号处理方式
- 当前工作目录
- 用户 ID 和组 ID
进程和线程的关系
进程是一个容器,线程是其中的执行流。单线程进程只有一个执行流,多线程进程包含多个执行流。
六、Linux 线程控制(POSIX 线程库)
Linux 通过 POSIX 线程库(pthread)在用户层实现线程功能。pthread 封装了轻量级进程的系统调用,提供线程创建、等待、终止等接口。使用时需包含 <pthread.h>
并链接 -lpthread
。
线程创建
pthread_create
创建新线程:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);
thread
:输出参数,存储新线程 ID。attr
:线程属性,NULL 表示默认。start_routine
:线程执行函数。arg
:传递给线程的参数。
示例:
#include <stdio.h>
#include <pthread.h>
void* routine(void* arg) {while (1) {printf("New thread: %s\n", (char*)arg);sleep(1);}return NULL;
}
int main() {pthread_t tid;pthread_create(&tid, NULL, routine, "thread 1");while (1) {printf("Main thread\n");sleep(2);}return 0;
}
运行后,主线程和新线程交替打印,共享同一进程的 PID。
线程等待
pthread_join
等待线程结束:
int pthread_join(pthread_t thread, void **retval);
thread
:待等待的线程 ID。retval
:线程退出码。
如果不等待,线程退出后资源不会释放,可能导致内存泄漏。示例:
void* routine(void* arg) {sleep(5);return (void*)2022;
}
int main() {pthread_t tid;pthread_create(&tid, NULL, routine, NULL);void* ret;pthread_join(tid, &ret);printf("Exit code: %d\n", (int)ret); // 输出 2022return 0;
}
线程异常时(如除零),进程崩溃,pthread_join
无法执行,因此只能获取正常退出时的退出码。
线程终止
- return:从线程函数返回,终止当前线程。
- pthread_exit:主动退出线程:
void pthread_exit(void *retval);
- pthread_cancel:取消其他线程:
int pthread_cancel(pthread_t thread);
取消的线程退出码为 PTHREAD_CANCELED
(-1)。
示例(主线程取消新线程):
void* routine(void* arg) {while (1) {printf("Running\n");sleep(1);}
}
int main() {pthread_t tid;pthread_create(&tid, NULL, routine, NULL);sleep(3);pthread_cancel(tid);pthread_join(tid, NULL);printf("Thread canceled\n");return 0;
}
分离线程
pthread_detach
将线程设置为分离状态,退出时自动释放资源:
int pthread_detach(pthread_t thread);
示例:
void* routine(void* arg) {pthread_detach(pthread_self());sleep(5);return NULL;
}
int main() {pthread_t tid;pthread_create(&tid, NULL, routine, NULL);sleep(10); // 无需 joinreturn 0;
}
线程 ID 及地址空间布局
pthread_t
是用户级线程 ID,表示进程地址空间共享区中描述线程的内存块地址(NPTL 实现)。通过 pthread_self
获取当前线程 ID,与 pthread_create
的输出参数一致,但不同于内核的 LWP ID。
线程栈:
- 主线程使用进程原生栈。
- 新线程在共享区分配栈空间。
七、总结
Linux 通过轻量级进程模拟线程,利用 task_struct
和共享地址空间实现高效的多线程机制。POSIX 线程库提供了用户友好的接口,支持线程创建、管理和终止。线程的优点在于轻量和并行性,但缺点包括健壮性低和编程复杂性高。理解线程与进程的关系、二级页表的工作原理以及 pthread 的使用,能帮助开发者更好地利用多线程优化程序性能。