你是否曾困惑:线程和进程到底有什么区别?为什么 Linux 中线程的 PID 和 LWP 不一样?pthread 是怎么管理线程的?
本文将带你从 操作系统底层原理 出发,结合 实际代码示例,完整解析:
-
Linux 线程与轻量级进程 (LWP) 的关系
-
进程 VS 线程的调度与 Cache 差异
-
pthread_create/pthread_join 的使用原理
-
线程控制块 (TCB) 与线程 ID 的本质
-
线程参数传递与返回值
看完你将真正理解:线程在进程中的运行机制,以及为什么它比进程更"轻量级"。
Linux中的线程

现在大家看到这幅图应该已经不再陌生了,当我们的将写好的程序加载到内存之后,我们的操作系统会为我们的进程创建PCB(task_struct)和虚拟地址空间(mm_struct)和页表(通过页表进行虚拟地址到物理地址的映射),而无论我们想要进行程序的执行,变量的访问,还是系统调用等等,其实都是通过虚拟地址空间+页表的方式映射到物理内存,然后访问对应的数据和代码,所以地址空间就相当于进程的资源窗口。
当我们的进程想要被执行的时候,首先要做的就是先到CPU的运行队列中进行排队,然后由优先级和调度算法等等规则调度进程,假如我们现在需要在程序运行的同时,可以让其他的副程序帮助我们处理一些其他的事情,因为主程序现在正在忙于处理,无法腾出手来处理其他事情,这个时候就需要另外一个副程序的帮助我们处理其他事情即可,结合我们之前了解的内容,我们可能会想到通过fork系统调用接口创建一个新的进程帮助我们处理,这样就可以了。此时我们通过创建子进程的方式帮助我们处理的其他事情的时候,我们的操作系统同样会为子进程创建PCB,虚拟地址空间,以及页表,并且会根据父进程PCB进行资源的复制,一旦发生写入的时候,这个时候操作系统就会在物理内存为子进程拷贝一份新的数据给子进程,这也就是写时拷贝。但是假如我们现在就想要在我们的主程序执行的同时,让副程序帮助我们复现现在程序的执行情况这样简单的代码,如果创建子进程的话,我们的操作系统还得为其创建PCB,虚拟地址空间,页表等等,多少有点大炮打蚊子的感觉,那么有没有那么一种不创建新的进程,与主程序共用一个PCB,虚拟地址空间以及页表等等资源的方式,然后让其帮助我们完成副程序呢?

这就是我们今天要了解的内容------线程。由图可知,在Linux中的线程就是上面这样,当我们的主程序在执行的同时,我们创建多个线程为我们工作的时候,操作系统只需要创建对应线程的的task_struct即可,然后让这些线程都共享同一个虚拟地址空间和页表等资源,然后将进程地址空间中的各个部分进行划分,然后对应的线程只需要通过对应的代码经页表映射物理内存之后进行执行即可,这就是线程。
- 在Linux中,线程是在进程内部的,线程在进程的地址空间内运行。
- 在Linux中,线程的执行粒度要比进程更细,线程执行进程代码的一部分。
那么现在有了进程也有了线程,所以我们的CPU在调度的时候是调度进程还是调度线程呢?并且CPU的运行队列中的task_struct,CPU如何区分这个ask_struct是线程的还是进程的?其实这个答案很简单,就是CPU根本不关心线程还是进程,CPU只有调度执行流的概念,也就是无论进程还是线程,只要通过task_struct,可以找到对应的虚拟地址空间以及页表,然后映射到对应的物理空间,最后找到对应的代码和数据就可以了。
线程和进程
线程是操作系统调度的基本单位,进程则是承担分配系统资源的基本实体,这是什么意思呢?
一般情况下,我们的程序中只会有一个执行流,当程序执行的时候,操作系统会为其创建task_struct(PCB),虚拟地址空间,以及页表等等系统资源,所以进程是承担系统资源分配的基本实体,而只有一个执行流的缘故,这个执行流独占这些资源,这就让我们觉得进程是操作系统调度的基本单位,对于单执行流中,可以这样理解。但是到了多线程的时候,这种理解就有所偏差了,因为此时我们的进程中不止一个执行流了,而是由很多的执行流,每一个执行流都是一个线程,而由于这些线程都同属于一个进程,所以这些线程会共享由操作系统为这个进程分配的系统资源,而每一个线程都有一个独立的task_struct,这样我们的CPU在调度的时候只需要根据task_struct进行调度即可,所以线程是操作系统调度的基本单位。(大家对于进程和线程疑惑的点,可能是因为生活的因素影响,大家觉得父进程创建子进程之后,父进程的优先级会比子进程的优先级高,还会觉得,在主执行流创建其他执行流之后,主执行流就比其他执行流的优先级高,但其实这是错误的理解,这些只是我们的习惯使然,但真实的情况其实是当父进程创建子进程之后,两者就都是一个单独的进程,至于CPU会调度哪一个进程,与调度算法有关,与谁创建谁没有关系,要说有关系的话,就是父进程需要等待子进程,对子进程的退出状态进行回收。而主执行流和其他执行流亦是如此,没有谁创建谁,谁就优先这样的说法,一切都听调度算法的规则,它们之间要是一定有关系的话,就是主执行流必须等待其他执行流全部结束之后,主执行流才可以结束,这就是线程和进程)
在 Linux 调度器眼里:
调度的是线程(task_struct),不是进程
-
一个进程 1 个线程 → 看起来像调度进程
-
一个进程 N 个线程 → 实际调度的是这 N 个线程
Linux 采用轻量级进程(Light Weight Process, LWP)的方式实现线程。无论是进程还是线程,在内核中都统一使用 task_struct 来描述。
其实我们可以将进程类比为一家正在营业的餐馆。当这个餐馆开张,就会占用一整套独立的资源,比如店面空间、厨房、锅碗瓢盆以及原材料,这些资源都属于这家餐馆本身,其他餐馆无法直接使用,这就正好对应了进程在操作系统中承担系统资源分配的角色。线程则可以理解为餐馆里的厨师,每个厨师都在独立地干活,有自己的操作位置和当前的工作状态,但所有厨师都共享同一个厨房和同一批食材。当餐馆里只有一名厨师时,人们很容易认为是"餐馆在工作",但实际上真正干活的是这名厨师;而当餐馆里有多名厨师时,老板并不会让整个餐馆一起工作,而是轮流安排某一位厨师先上灶操作。CPU 的调度过程正是如此,操作系统并不是调度整个进程,而是调度进程中的具体线程。正因如此,进程更像是资源的容器,而线程才是被操作系统实际调度和执行的基本单位。
详谈地址空间

我们在了解动态库的那一篇博客中我们对这部分内容有所了解,我们知道我们的物理空间为了管理,被我们的操作系统分配为了一个个的4KB的页,而我们的磁盘是按照一个个的扇区进行划分的,我们的编译器在编译好之后,将我们的程序保存到这些扇区之中,当我们执行这个程序的时候,会通过扇区一个个的将其分配到我们物理空间中的每一个页中,当我们执行该程序时,操作系统并不会一次性将整个程序加载到内存,而是由加载器按照需要,将程序内容从磁盘的扇区中读取出来,并分配到物理内存中的一个个页中。同时,编译器在编译程序时,已经按照代码段、数据段等不同功能区域对程序进行了划分,当程序被加载到内存后,操作系统会为其建立页表,将程序使用的逻辑地址与实际的物理页一一对应起来。CPU 在执行指令时,并不直接使用物理地址,而是先生成逻辑地址,再通过页表完成地址转换;页表的基地址由 CR3 寄存器保存,CPU 正是通过 CR3 找到页表,从而实现逻辑地址到物理地址的映射。但是现在有一个问题就是CPU是如何通过页表进行逻辑地址和物理地址的映射呢?
关于这个知识点,学过计算机组成原理这门课程的同学一点不会陌生,因为逻辑地址到物理地址的转化,我们现在以32位地址空间为例。其实我们的地址空间是简单划分为32 = 10 +10 + 12,其中低12位为表示页内偏移量中间的10位则表示二级页表的地址,高10位表示一阶页表的地址(也叫页目录表),我们其实可以简单这么想一想,我们不可能在我们的页表中针对每一个具体的虚拟地址都映射到一个具体的物理地址,要是这样做了,我们可以预算一下,一个地址空间是32位,也就是占4个字节的空间,但是虚拟地址加物理地址就8个字节了,要是我们程序的每一个地址空间都通过这样进行映射的话,起码我们的页表的大小就要比我们的程序的大小还要大,这样明显可以感觉是不对的,所以操作系统并不是这样做的,那么页表是如何进行工作的呢?

我们现在假设要访问一个逻辑地址
0001001000 0001000101 011001111000。
在 32 位系统的两级分页机制中,一个页表项通常为 4B,当页大小为 4KB 时,一个页中可以存放 4KB / 4B = 1024 个页表项,因此页目录项和页表项的索引范围均为 0~1023。页目录索引和页表索引本质上就类似于数组下标,只是图中将其以二进制的形式表示出来。
当 CPU 拿到这个逻辑地址后,首先取出其高 10 位 0001001000,这部分称为页目录索引 。CPU 通过 CR3 寄存器可以获取页目录表的物理起始地址,在此基础上,通过
CR3 + 页目录索引 × 4
即可定位到对应的页目录项。也就是
CR3 + 0001001000 × 4 = CR3 + 0100100000,
从而找到该逻辑地址在页目录表中的位置。
接着,CPU 读取该页目录项的内容。页目录项中高 20 位保存的是二级页表所在的物理页框号 ,假设该页框号为
0123456789 9876543210。
将该页框号左移 12 位,即可得到二级页表的物理基地址。
随后,CPU 取出逻辑地址中间的 10 位 0001000101,作为页表索引 ,并通过
二级页表基地址 + 页表索引 × 4
来定位对应的页表项。读取该页表项后,可以得到最终的物理页框号,假设其值为
4455661122 3311227788。
同样地,将该物理页框号左移 12 位得到物理页框的基地址,最后再加上逻辑地址低 12 位的页内偏移 011001111000,即可得到最终的物理地址。至此,CPU 便完成了从逻辑地址到物理地址的转换,并成功访问对应的物理内存空间。
这样大家就可以感受到一个创建一个进程是一个很重的工作,不仅要为其建立task_struct,还要为其创建虚拟地址空间以及页表等等资源,这就导致进程的创建的成本相对过高,所以相较比进程,线程是轻量级进程,因为它只需要和其他的线程共享同一份进程的系统资源即可。
从本质上看,线程分配资源,实际上就是对虚拟地址空间的使用进行划分。操作系统为进程分配了一整块虚拟地址空间,而多个线程只是运行在这同一地址空间中,分别执行不同的代码路径。由于编译器在编译阶段已经将程序划分为代码段、数据段等不同区域,并且每个函数在虚拟地址空间中都有明确的位置,因此创建线程时,只需要让线程从不同的函数入口开始执行即可。也正因为线程之间共享地址空间,它们在访问共享数据时不可避免地会产生竞争问题,这就引出了线程同步与互斥机制,我们之后再详谈。
进程切换VS线程切换
在计算机组成原理中,有详细介绍过cache,cache是CPU和内存之间的一个高速中转站,因为我们的CPU在执行的时候,速度是特别快的,如果CPU处理一条指令的时间是1个时钟周期的话,访问内存就需要几十到上百个时钟周期,如果没有cache的话,CPU就会在这段时间内处于等待状态,严重影响了CPU的执行效率,所以为了加快CPU的访问,我们的硬件设计者就在CPU和内存之间增加了一个cache,如果我们要访问一个地址的数据,它会将这个地址附近的数据一并保存起来,这样CPU就不需要去内存中查找,只需要在cache中进行查询即可,这样就大大提升了CPU的访问效率,同时cache还会将一些经常访问的数据一并保存起来,比如频繁执行同一段代码或者某一个函数频繁的被调用,这些都会被保存到cache,这就是Cache热度。

所以我们可以看到cache的大小是28160KB,所以当发生进程切换的时候,cache也要被切换,所以代价是很大的,而我们的线程是进程中的一个执行分支,共享进程中的系统资源,所以线程切换的时候,是不需要切换cache,所以从这点来看,线程切换的代价是要比进程切换的代价要小。
进程切换:Cache 友好性最差
当发生进程切换时,操作系统通常需要:
-
切换
task_struct -
切换虚拟地址空间
-
切换页表(即更新 CR3 寄存器)
而 CR3 的切换是一个非常关键的操作。在传统分页机制下,一旦 CR3 被修改:
-
新进程需要重新建立地址映射关系
-
CPU 不得不频繁访问内存页表,带来额外的访存开销
更严重的是,Cache 的局部性也会被破坏。由于不同进程使用的是完全不同的虚拟地址空间,它们访问的代码和数据往往不在同一批 Cache 行中,导致:Cache 需要重新"预热"
线程切换:Cache 更友好
相比之下,线程切换发生在同一进程内部,具有明显优势:
-
虚拟地址空间不变
-
页表不变(CR3 不需要切换)
由于多个线程共享同一份地址空间:
-
它们很可能执行的是同一段代码
-
访问的是同一批数据结构
这使得 CPU Cache 在切换线程后仍然具有较高的命中率,Cache 热度可以延续,从而显著降低内存访问延迟。
线程的缺点
上面一直介绍线程的优点,现在就来一一列举一下线程有哪些缺点。
- 健壮性降低(编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的 )
- 缺乏访问控制(进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响)
- 编程难度提高(编写与调试一个多线程程序比单线程程序困难得多 )
线程的控制
线程在共享进程数据的同时,也会拥有自己的一部分数据:
- 线程ID
- 一组寄存器
- 栈
- 信号屏蔽字
- 调度优先级
在早期的操作系统中,由于各家的操作系统的调用接口设计的五花八门,这就导致多线程的程序无法很好的移植,因此,POSIX引入了pthread库,目的就是统一线程的模型,屏蔽底层的实现差异,这是什么意思呢?其实就和我们平时下载软件是一模一样的,就比如抖音,我们使用苹果系统的时候下载的是iOS 安装包,使用的是安卓系统的时候下载的就是Android 安装包,对应的安装包在底层实现、系统调用方式甚至运行环境上都存在差异,但是我们看到的都是抖音这个软件,所以pthread就是这样的,统一了函数调用接口,具体实现交给各个系统。
所以pthread 并不是 Linux 独有的产物,也不是某个操作系统私有的线程实现 ,它的真正来源是一个POSIX 线程 标准。
在 POSIX 标准中,线程相关的 API 被统一定义,例如:
-
pthread_create -
pthread_exit -
pthread_join -
pthread_mutex_* -
pthread_cond_*
这些函数的行为由标准规定,而不是由某个具体操作系统随意决定。
其实,pthread 线程库在性质上与 C 标准库类似,它们都不是由内核直接提供的机制,而是运行在用户态的标准库实现。pthread 的接口和语义由 POSIX 标准统一规定,而具体的实现则由各个操作系统或其配套的系统库完成,例如 Linux 中的 pthread 由 glibc 提供实现,底层依赖内核的线程与调度机制。通过这种"标准接口 + 用户态实现"的方式,pthread 在屏蔽系统差异的同时,也保证了多线程程序的可移植性。
总而言之,就是我们在Linux中编写多线程的代码,需要使用第三方库pthread库

-
thread :用于保存新创建线程的标识符,通常定义一个
pthread_t变量并将其地址传入即可,然后这个pthread库就会在这个地址处写入我们创建的这个线程的ID,用于标识我们的线程。而thread_t就是一个长整数类型,typedef了一下而已。
-
attr :线程属性参数,大多数情况下直接传
NULL,表示使用系统默认的线程属性。 -
start_routine:线程入口函数,指定新线程开始执行的函数,一般为用户自定义的函数。
-
arg :传递给线程入口函数的参数,大多数情况下传入一个结构体或变量的地址,若无需参数则可传
NULL。
现在我们就先来使用以下这个接口,看看两个执行流到底是什么样子的。
#include <iostream>
#include <thread>
#include <unistd.h>
void *threadRoutine(void *arg)
{
while (1)
{
std::cout << "new thread , pid : " << getpid() << std::endl;
sleep(2);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, nullptr);
while (1)
{
std::cout << "main thread , pid : " << getpid() << std::endl;
sleep(1);
}
return 0;
}

可以看到确实有两个执行流在执行代码,这在我们之前的代码中是见不到的,并且我们可以看到这两个执行流的pid都是一样的,可以很好的证明,这两个线程是属于同一个进程的。

虽然这两个线程的pid是一样的,但是这两个线程的LWP是不一样的,LWP就是轻量级进程,是内核中对线程的标识方式,这样就可以很好的区分不同的线程了。但是大家有没有观察到一点就是其中一个线程的PID和它的LWP是一模一样的,这个线程就是主线程,所以操作系统区分一个进程中的多个线程哪一个是主线程,只需要查看PID和LWP是否相等,相等就是主线程,不相等就是有主线程创建出来的其它线程。

可以看到当我们给线程发送信号时,无论是进程中的哪一个线程,只要发送信号,这个进程就被直接中止了,这也是线程的缺点之一,就是健壮性太差,一个线程崩溃,很容易影响其他的线程。
#include <iostream>
#include <thread>
#include <unistd.h>
#include <string>
void show(const char *name) //可以看到这个函数被重入了
{
std::cout << name << std::endl;
}
void *threadRoutine(void *arg)
{
while (1)
{
show("new thread");
sleep(2);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, nullptr);
while (1)
{
show("main thread");
sleep(1);
}
return 0;
}

同时可以通过这段代码,我们看到这两个函数同时调用了show这个函数,说明了这个show函数被两个执行流同时进入了,这就是上一篇博客中一个函数被多个执行流同时进入的情况之一。
现在我们再来用一用函数调用中的参数arg:传递给线程入口函数的参数。
#include <iostream>
#include <thread>
#include <unistd.h>
#include <string>
int value = 100;
void *threadRoutine(void *arg)
{
char *name = (char *)arg;
while (1)
{
printf("%s , pid : %d , value : %d , &value : 0x%p\n", name, getpid(), value, &value);
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void *)"thread 1");
while (1)
{
printf("main thread , pid : %d , value : %d , &value : 0x%p\n", getpid(), value, &value);
value++;
sleep(1);
}
return 0;
}

通过这样的方式,我们就可以将我们想要给线程的入口函数传递对应的参数,并且从这段代码中,我们可以看到我们定义了一个全局变量,当我们的主线程对这个变量的值进行修改的同时,我们创建的线程也可以得知修改后的结果,并且这两个线程打印出来的该变量的地址也是相同的,(这就与我们的进程有区别了,我们之前的进程在执行这样一段代码的时候,一旦让其中一个进程发生写入,就会发生写时拷贝,操作系统就会为其重新开辟一段空间,虽然我们看到两个进程的虚拟地址打印出来都是一样的,但是在物理内存中,它们两个进程已经被映射到了不同的地方),但是现在线程没有那么麻烦了,我们的线程,只要一个线程一旦修改全局变量,那么我们的另外线程就都可以接收到这个全局变量的修改,所以其实通过这点我们就可以发现,如果让我们线程间进行通信,是比进程要简单的多的,进程在进行创建的时候,需要通过管道,共享内存,消息队列等等通信方式,才能完成两个进程间的通信,但是线程间通信,只需要定义一个全局数据区,这样另一个线程也就可以看到了,所以线程间的通信要比进程间的通信要简单的多。
线程的等待
一个线程被创建出来之后,其实和我们的进程无比的类似,我们的父进程创建子进程之后,哪一个进程先被调度,由调度器说了算,同样的我们的主线程创建线程之后,哪一个线程先被调度呢?同样也是调度器说了算,所以谁先调度我们不知道,但是我们知道一点就是无论哪一个线程先被调度,在最后退出的时候,一般情况下都是主线程最后才退出,这是因为我们的主线程创建一个新的线程,目的就是让这个新的线程帮我们完成一些事情,那这个新线程完成的怎么样,我主线程要不要知道呢?那肯定还是需要的,因为毕竟还是要得知一下创建的新线程的退出结果的。并且已经退出的线程,如果没有主线程回收,其空间没有被释放,仍然在进程的地址空间内,浪费系统资源,所以需要进行线程等待。那么如何等待呢?

-
thread :要等待的线程标识符,即 pthread_create 创建时返回的
pthread_t。 -
retval :用于接收线程函数的返回值,线程结束时
return或pthread_exit的值会写入这里。如果不关心返回值,可以传NULL。#include <iostream>
#include <thread>
#include <unistd.h>void *threadRoutine(void *arg)
{
char *name = (char *)arg;
int count = 5;
while (count--)
{
printf("%s , pid : %d\n", name, getpid());
sleep(1);
}
std::cout << "new thread quit!!!" << std::endl;
return nullptr;
}int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void *)"thread 1");pthread_join(tid, nullptr); std::cout << "main thread quit!!!" << std::endl; return 0;}

通过这个结果,我们可以看到我们的主线程正在阻塞式的等待新线程的结束,当其创建的新线程结束之后,主线程才退出。这点已经从结果很好的验证了,所以以后只要我们进行了pthread_join函数调用,这样就可以保证主线程再创建新线程之后结束,那么我们如何才能得到创建的新线程的结果呢?
因为我们创建的新线程干什么,是我们通过pthread_create函数调用的参数start_routine 保证的,其中**void *(*start_routine) (void *)**是一个函数指针,参数是void*,表示可以传递任意类型的参数,而同样返回值为void*,表示可以返回任意类型的数据指针。这些都可以理解,但是我们的主线程通过pthread_join进行线程等待的时候,为什么它的参数是void **retval呢?现在我们先看一份样例代码,再给大家进行解释。
#include <iostream>
#include <thread>
#include <unistd.h>
#include <string>
void *threadRoutine(void *arg)
{
char *name = (char *)arg;
int count = 5;
while (count--)
{
printf("%s , pid : %d\n", name, getpid());
sleep(1);
}
std::cout << "new thread quit!!!" << std::endl;
return (void *)100;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void *)"thread 1");
void *retval;
pthread_join(tid, &retval);
std::cout << "main thread quit!!! , retval:" << (long long int)retval << std::endl;
return 0;
}

现在大家应该明白了为什么pthread_join函数参数中retval的函数类型为void**,因为retval是一个输出型参数,我们想要获取创建的新线程的返回值直接是获取不到的,只有通过调用pthread库中的函数接口才可以获取到创建的新线程的执行结果,而我们的新线程执行的任务(start_routine)的返回值是void*,所以我们想要获取这个返回值必须先定义一个同类型变量,然后将这个变量的地址交给函数接口,最后由这个函数接口将新线程的退出结果保存到这个变量中,这样我们就可以得到新线程的退出结果了。大家要是还是不理解,可以看下面这段两段代码。
void func(int x)
{
x = 10;
}
int main()
{
int x = 0;
func(x);
std::cout << "x : " << x << std::endl;
}

void func(int* x)
{
*x = 10;
}
int main()
{
int x = 0;
func(&x);
std::cout << "x : " << x << std::endl;
}

当 func(x) 被调用时,操作系统会为 func 创建一个新的栈帧,并在该栈帧中生成一个新的局部变量 x,其初始值是 main 中 x 的拷贝。换句话说就是main 的栈帧中有一个变量 x,func 的栈帧中 又有一个独立的 x ,两者的值最初相同,但地址完全不同 ,因此当执行x=10之后,只会修改 func 栈帧中的局部变量,而不会影响 main 中的 x,这也是最终输出仍然为 0 的原因。
而调用 func(&x) 时:main 栈帧中依然只有一个 x,func 栈帧中新建的是一个指针变量 x,该指针保存的是 main 中变量 x 的地址,所以当执行*x=10之后,实际上是通过地址,直接修改了 main 栈帧中的变量 x ,所以最终输出为 10。
所以pthread_join 需要将线程的返回值写回调用者提供的变量中 ,而这个返回值本身是一个指针(void *)。
为了在函数内部修改调用者的这个指针变量,就必须:拿到该指针变量的地址,也就是使用二级指针**(void **)。**
这样,主线程就可以拿到其创建的新线程的退出结果,现在有一个问题就是,为什么不考虑创建的新线程发生异常的情况呢?在父子进程那里,如果子进程异常退出了,我们可以通过waitpid参数中的status参数的次低8位,得知子进程的异常退出状态,那子线程这里万一它崩溃了怎么办?
这个答案就是线程一旦发生致命异常,默认行为是整个进程直接崩溃,根本不存在"只回收异常线程"的机会,所以我们不需要考虑创建的线程异常退出了怎么办,当它异常退出的时候,这就代表整个程序也就崩溃了。
还有一种线程退出的方式,就是通过pthread库中的函数调用接口 pthread_exit。

void *threadRoutine(void *arg)
{
char *name = (char *)arg;
int count = 5;
while (count--)
{
printf("%s , pid : %d\n", name, getpid());
sleep(1);
}
std::cout << "new thread quit!!!" << std::endl;
pthread_exit((void *)100);
// return (void *)100;
}

可以看到通过这个函数调用,最后我们也同样拿到了对应的线程的退出结果,但是注意一点就是大家看到这个函数很容易和我们的C标准库给我们提供的exit函数联合在一起,但是线程退出的时候是不可以使用这个函数接口的,因为这个函数接口是用来终止进程的,不是用来终止线程的,一旦使用这个函数,我们的整个进程就直接退出了,我们可以看看整个结果。
void *threadRoutine(void *arg)
{
char *name = (char *)arg;
int count = 5;
while (count--)
{
printf("%s , pid : %d\n", name, getpid());
sleep(1);
}
exit(10);
// std::cout << "new thread quit!!!" << std::endl;
// pthread_exit((void *)100);
// return (void *)100;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void *)"thread 1");
void *retval;
pthread_join(tid, &retval);
std::cout << "main thread quit!!! , retval:" << (long long int)retval << std::endl;
return 0;
}

可以看到我们主线程最后的打印代码并没有执行,所以我们在之后线程进行退出的时候不要使用exit这个函数接口,可以使用pthread库中提供的pthread_exit这个函数接口。
但是我们的线程在退出的时候pthread给我们提供的是一个指针,而我们上面的那种用法感觉多少有点奇怪,现在我们就来见一见主线程创建新线程完成任务的正确打开方式,我们让新创建的线程完成1-100内的总和。
#include <iostream>
#include <thread>
#include <unistd.h>
#include <string>
class Request
{
public:
Request(int start, int end)
: start_(start), end_(end)
{
}
public:
int start_;
int end_;
};
class Respond
{
public:
Respond(int result_)
: result_(result_)
{
}
public:
int result_;
};
void *sumCount(void *arg)
{
Request* req = (Request*)arg;
Respond* retval = new Respond(0);
for(int i = req->start_;i<=req->end_;i++)
{
retval->result_ +=i;
}
return retval;
}
int main()
{
pthread_t tid;
Request *req = new Request(1, 100);
pthread_create(&tid, nullptr, sumCount, req);
void *ret;
pthread_join(tid, &ret);
Respond *retval = (Respond *)ret;
std::cout << "retval : " << retval->result_ << std::endl;
delete req;
delete retval;
return 0;
}
这才是主线程创建一个新的线程的正确打开方式。
线程ID
线程库中提供了pthread_ self函数,可以获得线程自身的ID。

#include <iostream>
#include <thread>
#include <unistd.h>
#include <string>
std::string toHex(pthread_t tid)
{
char hex[64];
snprintf(hex,sizeof hex,"%p",tid);
return hex;
}
void *threadRoutine(void *arg)
{
while (1)
{
sleep(1);
std::cout << "new thread , id : " << toHex(pthread_self()) << std::endl;
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, nullptr);
std::cout << "main thread , id : " << toHex(tid) << std::endl;
pthread_join(tid, nullptr);
return 0;
}

但是线程在我有一个问题就是,都是表示线程的ID,为什么tid的值和LWP的值不一样呢?

pthread_t 是 pthread 库定义的类型, 表示在同一进程内唯一,且能被 pthread 库用来管理线程,并不等于内核中的线程ID,LWP(Light Weight Process)是 Linux 内核视角的线程,内核通过 task_struct 描述它,是CPU 调度的对象,LWP是 真正的 内核线程 ID, 所以它们两不一样的原因就是pthread_t 是线程库在用户态维护的抽象标识,而 LWP 是 Linux 内核中真正参与调度的实体。二者位于不同层级,服务于不同目的,因此数值不相同是正常且必然的设计结果。
那这个通过pthread库中的tid代表什么意思呢?
在 Linux 内核 中,其实只有 **轻量级进程(LWP)**的概念,并没有真正意义上的线程概念。线程的管理、调度和状态维护,都需要靠 pthread 库来完成。
pthread 库在用户态为每个线程维护了一整套线程信息,包括:
-
线程的 标识符(tid)
-
线程的 栈空间
-
线程要执行的 回调函数(入口函数)
因为每个线程都需要这些信息,pthread 库就需要维护一个 线程控制块集合(TCB),记录每个线程的状态和属性。
在这个设计里:
-
每个线程的 TCB 都有自己的内存空间
-
库内部可以通过 TCB 找到线程的栈、函数指针和线程状态
-
pthread 库为了方便管理,把 TCB 的起始地址 当作线程的库 ID,也就是我们常说的
pthread_t tid
所以:tid 本质上是 pthread 库内部 TCB 的起始地址,是用户态线程库对线程的唯一标识符,而不是内核的 LWP/TID。
这篇博客的内容就到这里,更加详细的内容我们在下一篇博客中详谈,谢谢大家的观看!!!