mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4292 字
21 分钟
Linux 服务器高阶编程(13):线程基础、资源共享与 pthread 创建

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 内核级线程#

  1. 操作系统能识别并管理线程。
  2. 各线程可独立参与调度。
  3. 单核上可以交替执行。
  4. 有多个可用核时,可分布到不同核上并行执行。
  5. 线程管理和调度需要系统参与,有相应开销。

重点不是“线程永远比进程快”,而是共享资源的执行单元可能降低整体资源成本。

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> 检查:

  1. 这是一个进程。
  2. 创建一个普通线程后,包含主线程在内共两个线程。
  3. 两个线程共享 PID,LWP 不同。
  4. 主线程的 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、任务是否经常等待、共享资源竞争及切换成本

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Linux 服务器高阶编程(13):线程基础、资源共享与 pthread 创建
https://joyanblog.com/posts/linux-server-advanced-programming-13-thread-basics-pthread-create/
作者
Joyan
发布于
2026-11-07
许可协议
CC BY-NC-SA 4.0

目录