Linux 服务器高阶编程(13):线程基础、资源共享与 pthread 创建
一、线程为什么出现
1.1 多进程模型的成本
进程通常被看作资源分配的基本单位。不同进程拥有各自的地址空间和相关管理信息。
如果任务需要多个执行单元,传统方案是创建多个进程。执行单元增多时,地址空间管理、资源维护和切换等成本也随之增加。
比喻:
- 多进程像建多个工厂,每个工厂安排一个工人。
- 多线程像在一个工厂里安排多个工人,共用工厂中的设施。
这个比喻强调共享资源和执行单元数量,不表示操作系统真的为每个进程立刻复制一整份全部物理内存。
1.2 线程是什么
线程是进程内部的执行单元。同一进程可包含多个线程,它们共享地址空间中的许多资源,但分别执行自己的任务。
- 进程:资源分配的基本单位。
- 线程:调度和执行的基本单位。
不要把进程、线程理解为互相排斥的两种东西:使用线程后,进程仍然存在,线程运行在进程之中。
1.3 “线程更轻量”不等于“创建线程没有成本”
“多一个执行单元,无须再建一个完整进程”说明线程的优势。
二、主控线程与普通线程
2.1 进程原本就有执行单元
进程启动后已有一个默认执行单元。它执行启动流程,进入 main()。
随后通过 pthread_create() 创建出来的执行单元
一个进程:共享的地址空间及进程资源├─ 主控线程:执行 main() 中的任务├─ 普通线程 A:执行指定工作函数└─ 普通线程 B:执行指定工作函数2.2 “进程退化为主线程”
- 只有一个执行单元时,常直接称为“进程在运行”。
- 出现多个执行单元后,需要明确谁在运行,就改用主线程、普通线程区分。
不是说创建线程后,进程这个资源容器消失了。
2.3 多线程不能完全替代多进程
| 比较方向 | 多进程 | 同一进程中的多线程 |
|---|---|---|
| 地址空间 | 通常相互隔离 | 共享 |
| 资源组织 | 各自维护进程资源 | 共用许多进程级资源 |
| 数据交换 | 常需 IPC | 可直接访问共享数据,但需同步 |
| 故障影响 | 通常有更好的隔离边界 | 致命错误可能终止整个进程 |
| 选择重点 | 隔离性、独立性 | 共享方便、较轻量的执行单元 |
某个线程发生导致进程终止的致命错误,其他线程也无法继续;不能只因线程开销较小,就忽略整体稳定性。
三、线程调度:并发与并行
3.1 并发
多个执行单元交替推进,可以共享同一个 CPU 执行资源。
例如单核上:
时间推进:T1 → T2 → T3 → T1 → T2 → ……3.2 并行
多个执行单元在不同 CPU 核上同时执行。
是否能真正并行,不只看程序中创建了多少线程,还要看线程实现模型、可用 CPU、调度与负载。
3.3 “内核对象”
“系统识别的执行单元对应管理对象”解释调度:操作系统需要知道某个执行单元存在,才能直接为它进行管理和调度。
四、内核级、用户级与混合线程
4.1 内核级线程
- 操作系统能识别并管理线程。
- 各线程可独立参与调度。
- 单核上可以交替执行。
- 有多个可用核时,可分布到不同核上并行执行。
- 线程管理和调度需要系统参与,有相应开销。
重点不是“线程永远比进程快”,而是共享资源的执行单元可能降低整体资源成本。
4.2 用户级线程
“不直接被内核看见、由用户层线程库管理”
- 内核调度一个执行实体。
- 用户层库在这个实体获得的执行时间内,切换多个用户线程。
- 可以有并发式推进。
- 不能仅靠增加这些用户线程就在多个核上同时执行。
- 用户层切换可减少进入内核进行调度的需要。
“用户级线程永远不能并行”必须限定到这种多对一模型,不能拿来否定建立在多个内核线程之上的其他运行时实现。
4.3 混合线程
既利用内核支持获得并行能力,又在用户层处理部分线程管理工作,减少频繁切换。
4.4 三类模型
| 方向 | 内核级/一对一示意 | 纯用户级/多对一示意 | 混合模型 |
|---|---|---|---|
| 内核看到谁 | 各内核执行实体 | 承载它们的一个实体 | 一组内核执行实体 |
| 用户线程切换 | 系统调度参与 | 用户库负责 | 分层参与 |
| 多核并行 | 可实现 | 本模型下不可 | 可实现 |
| 主要取舍 | 系统管理成本 | 并行与阻塞限制 | 映射和实现复杂度 |
五、共享资源与独立资源
5.1 同一进程中的共享资源
| 资源 | 理解方式 | 开发中的含义 |
|---|---|---|
| 全局变量、静态数据 | 各线程可访问同一份数据 | 并发读写时要考虑同步 |
| 堆空间 | 得到有效地址即可访问 | 一线程申请的内存可交给其他线程使用 |
| 进程级身份信息 | PID 等对各线程一致 | 各线程调用 getpid() 得到同一进程 PID |
| 工作目录等进程属性 | 进程级状态 | 修改后可能影响其他线程的后续操作 |
| 文件描述符表 | 线程可使用相同描述符 | 一个线程关闭描述符可能影响其他线程 |
| 信号处理方式 | 默认、忽略、捕捉设定为进程级 | 某线程修改处理方式,会影响进程内其他线程 |
“PCB 进程信息共享”概括进程级属性
5.2 各线程独立维护的资源或状态
| 资源/状态 | 要点 |
|---|---|
| 线程栈 | 每个线程有自己的函数调用栈和自动变量 |
| 调度状态及相应参数 | 线程可处在不同状态,具备各自的调度相关信息 |
| 信号屏蔽字 | 可为不同线程设置不同阻塞集合 |
| 线程标识与管理信息 | 不同线程有各自标识与运行状态 |
errno | 每个线程分别保存,避免互相覆盖 |
5.3 每线程有独立栈
独立栈表示分别分配和管理函数调用所用区域,不是拥有硬件隔离的私有地址空间。
同一地址空间内,其他线程若得到有效指针,可以访问该内存。关键在于对象是否仍存活以及访问是否同步。
六、信号行为共享,信号屏蔽字独立
6.1 问题示意
假设进程中有 T1、T2、T3。T1 想处理 SIGINT,调用 sigaction() 安装捕捉函数。
这个捕捉设定并不是只属于 T1,而是整个进程对 SIGINT 的处理方式。
对于发向进程、可由多个线程接收的信号,不应仅因设定是在 T1 中做的,就认为处理函数必定由 T1 执行。
6.2 解决思路
- T1 不屏蔽该信号。
- T2、T3 屏蔽该信号。
- 利用各线程独立的屏蔽字限制接收范围。
这与“先屏蔽、准备好再解除屏蔽”的思路相通。
七、errno 与线程库错误处理
7.1 errno 为什么不能被各线程共同覆盖
T1 调用 open() 失败,尚未处理错误;若 T2 的 read() 失败把同一个错误号覆盖,T1 就可能解释错错误。
因此 errno 虽然使用形式像一个全局变量,在线程环境下应理解为线程局部的错误状态,而不是任意线程写同一个全局整数。
7.2 常见 pthread 接口直接返回错误号
以 pthread_create() 为例:
int err;err = pthread_create(...);if (err > 0) { printf("thread create call failed:%s\n", strerror(err)); exit(0);}这里 ... 只是参数占位,不是可直接编译的代码。
成功返回 0,失败返回错误号,用 strerror(err) 取得对应文字。
strerror()声明在<string.h>。printf()需要<stdio.h>。exit()需要<stdlib.h>。- pthread 接口需要
<pthread.h>。
不要直接用 perror() 解释 pthread_create() 的失败,因为它查看的是 errno,而不是变量 err。
八、NPTL、头文件和编译链接
8.1 NPTL 是什么
全称:Native POSIX Threads Library,原生 POSIX 线程库。
它是现代 Linux 上 GNU C 库的 POSIX 线程实现
这里的主要对象是 pthread 接口;不能因函数是库函数,就断定线程必然是多对一用户级线程。
8.2 头文件
#include <pthread.h>头文件提供声明,不等于链接器已经找到了所有实现。
8.3 编译方式
gcc pthread_create.c -lpthread -o app./app区分:
- 未声明函数、类型未知:先检查头文件和拼写。
- undefined reference / 未定义引用:检查链接环节。
九、pthread_create():四个参数逐一理解
9.1 接口形式
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);| 参数 | 用途 | 常用传法 |
|---|---|---|
thread | 输出新线程的 pthread ID | &tid |
attr | 指定线程创建属性 | NULL 使用默认属性 |
start_routine | 指定工作函数 | business,传函数指针而不是调用结果 |
arg | 传入工作函数的单个参数 | 地址;不需要参数时传 NULL |
成功返回 0;失败返回错误号。成功之后,输出参数中才有本次创建出的有效 ID。
9.2 工作函数签名
void *business(void *arg){ /* 线程任务 */}9.3 NULL 属性不是“线程没有属性”
它表示采用默认属性
9.4 不要把函数调用写进去
pthread_create(&tid, NULL, business, &var);第三个参数是工作函数入口。不是在主线程中先执行 business(&var),再把它的返回值交给创建函数。
9.5 指针参数不是自动拷贝变量内容
主线程中:
int var = 4096;pthread_create(&tid, NULL, business, (void *)&var);工作线程中:
int var;var = *((int *)arg);这里传的是地址。取值过程是:将 void * 转回 int *,然后解引用取得整数。
必须保证工作线程访问时,原变量仍存在、地址有效;若传的是循环变量地址或短生命周期局部变量,要另外考虑时序与生命周期。
十、查看线程:ps 命令
10.1 查看系统所有线程
ps -eLf注意中间是大写 L。
10.2 查看指定进程中的线程
ps -Lf <PID>例如
ps -Lf 5069这里 5069 是那次运行的示例 PID,自己的进程需要使用自己的实际编号
10.3 表头含义
| 字段 | 含义 |
|---|---|
| PID | 所属进程编号 |
| PPID | 父进程编号 |
| LWP | 内核层面显示的轻量级执行实体编号 |
| NLWP | 该进程中的线程数量 |
同一进程的线程行通常具有相同 PID;LWP 不同。
识别主线程的方法:其 LWP 与 PID 相等,其他行为普通线程。
10.4 线程 ID 有两套语境
| 获取位置 | 标识 |
|---|---|
pthread_create() / pthread_self() | pthread 库层面的 pthread_t |
ps 的 LWP / Linux gettid() | 内核线程 ID |
不要把两者混为同一数值,也不要因为 pthread ID 打印得像地址,就拿它去填 ps -Lf 的进程 PID。
10.5 运行状态观察
两个线程都在 sleep(),因此表现为睡眠状态。还解释了相关状态显示中的:
S:可中断睡眠状态。l:多线程相关标志。+:位于前台进程组。
区分大写 L 命令选项、小写 l 状态标记,不要把它们都当作同一含义。
十一、创建线程并传入 4096
11.1 首先理解两段任务
main()中的逻辑由主线程执行。business()中的逻辑由新创建的普通线程执行。
11.2 代码
#include <unistd.h>#include <stdio.h>#include <stdlib.h>#include <string.h>#include <pthread.h>
void *business(void *arg){ //线程任务代码 int var; var = *((int *)arg); printf("普通线程被创建,可用参数为 %d\n", var); while (1) sleep(1);}
int main(void){ //主线程代码 pthread_t tid; int var = 4096; int err; if ((err = pthread_create(&tid, NULL, business, (void *)&var)) > 0) { printf("thread create call failed:%s\n", strerror(err)); exit(0); } printf("主线程创建普通线程成功.\n"); while (1) sleep(1);}11.3 测试步骤
gcc pthread_create.c -lpthread -o app./app预期看到主线程创建成功,以及普通线程取得参数 4096。
另开终端用 ps -eLf 或 ps -Lf <PID> 检查:
- 这是一个进程。
- 创建一个普通线程后,包含主线程在内共两个线程。
- 两个线程共享 PID,LWP 不同。
- 主线程的 LWP 等于 PID。
两条输出的先后顺序不应当被当作固定协议。创建成功后,哪个执行流先继续运行与调度有关。
十二、删掉主线程等待,为什么普通线程有时不输出
12.1 修改位置
删除主函数末尾:
while (1) sleep(1);然后重新编译、多次运行。
12.2 观察到的主要问题
主线程创建成功后继续走到 main() 末尾,导致进程终止。普通线程可能还没有来得及输出,整个进程已经结束。
因此,不能只看 pthread_create() 成功,就断定工作线程一定有足够时间执行完任务。
12.3 要精确区分“main 返回”与“一个线程退出”
本例中让整个进程结束的关键是:主执行流从 main() 返回,或调用进程退出函数。
十三、pthread_self():获得当前线程 ID
13.1 接口
pthread_t pthread_self(void);- 无参数。
- 返回调用者自己的 pthread ID。
- 类比
getpid(),但标识的对象和类型不同。
13.2 打印方式
普通线程输出:
printf("普通线程0x%x 被创建,可用参数为 %d\n", (unsigned int)pthread_self(), var);主线程输出:
printf("主线程 0x%x 创建普通线程 0x%x成功.\n", (unsigned int)pthread_self(), (unsigned int)tid);工作线程自行取得的 ID,与主线程保存的那个新线程 ID,打印一致;主线程自己的 ID 与它们不同。
13.3 同一个 ID,不代表对象一直存活
- 在线程内部调用
pthread_self(),说明此时调用者正在执行。 - 另一线程持有一个 ID 变量,并不能仅凭该变量还有数值,就证明目标线程此刻仍在运行。
保存标识符与管理生命周期不是同一件事。线程 ID 也可能在适当条件下被复用。
十四、实验二:统计一个进程能创建多少线程
14.1 实验目的
不断创建线程,成功一次就增加计数;当资源不足导致创建失败时,观察最后成功创建到多少个。
工作线程没有业务任务,只执行:
while (1) sleep(1);这样既让线程继续存在,又不让每个线程持续忙等抢 CPU。
14.2 代码
主循环关键部分:
int number = 0;int err;while (true) { if ((err = pthread_create(&tid, NULL, business, NULL)) > 0) { printf("pthread_create call failed"); exit(0); } printf("threadNum = %d\n", ++number);}这里 number 统计的是创建成功的普通线程数量,不包含本来就有的主线程。
如果改成 number++,第一次可能打印 0,最后显示值也会差 1;不要把计数写法造成的差异误认为系统资源发生变化。
14.3 编译与实验环境
gcc thread_max.c -lpthread -o app十五、线程数量为什么会受限制
15.1 估算思路
从线程栈入手:
可供线程栈等使用的虚拟地址空间余量 ÷ 每线程栈占用 ↓ 可创建数量的估计在地址空间紧张的 32 位环境中,如果默认每线程栈预留较大,这个因素很容易先成为瓶颈。
15.2 不只有线程栈一种限制
创建失败还可能由用户资源限制、系统线程数量限制、PID 限制及其他资源不足引起。不能只凭最终计数就断定是栈耗尽。
因此诊断时至少要记录:
- 系统和程序是 32 位还是 64 位。
- 线程栈配置。
- 创建失败返回的错误号。
- 当前用户及系统的任务限制。
- 同时运行的其他进程/线程情况。
15.3 缩小栈一定能增加线程数量吗
在 32 位地址空间受限的情形,可以考虑减小线程栈。
十六、能创建多少,不等于适合用多少
16.1 容量与处理能力不同
饭店类比:
- 面积足够放很多桌,类似“能容纳很多线程”。
- 厨师和服务员有多少,类似“CPU 等处理能力有多少”。
线程能创建成功,只能说明当时资源分配成功,不代表它们都能立刻获得 CPU,也不代表吞吐量会一直随线程数增长。
16.2 休眠与忙等的差异
while (1) sleep(1);与:
while (1);两者负载差异很大。后者持续运行、竞争 CPU;前者大部分时间休眠,但并不是绝对没有唤醒与调度成本。
不要为了测试“更快”而把数百个线程都改成无休眠死循环.
16.3 线程数不是越多越好
设计时要考虑可用 CPU、任务是否经常等待、共享资源竞争及切换成本
如果这篇文章对你有帮助,欢迎分享给更多人!





