Linux 多线程(三)线程控制,线程终止,线程中的异常问题

目录

一、线程的资源

线程的共享资源与独立资源

进程和线程的关系

进程与主线程的关系

Cache高速缓存

​编辑

二、线程的优缺点

线程优点

线程缺点

线程异常

线程用途

三、线程控制

pthread_create

pthread_self

四、创建多线程

证明资源共享:

全局变量

五、多线程中的异常问题

除0异常

线程并发乱序问题

关于传参问题

线程终止

[第一种 : return](#第一种 : return)

[第二种 : pthread_exit](#第二种 : pthread_exit)

[第三种方法 : pthread_cancel](#第三种方法 : pthread_cancel)


在上一篇文章中,我们学习了线程和轻量级进程,在后面的学习中,我们就认为线程 == 轻量级进程,其实真正意义上Linux不存在线程,只有轻量级进程,我们在上一篇文章中已经学习过了,又因为后面我们会分别站在用户视角和内核视角,所以线程和轻量级进程我们都要使用,所以后面这两个概念我们高度统一。

一、线程的资源

线程的共享资源与独立资源

线程作为 Linux 系统中轻量级进程的本质存在,其资源特性核心围绕 "共享进程绝大部分资源,仅保留少量独立私有资源" 展开,这也是线程相比进程更轻量、创建与上下文切换开销更低的根本原因,而进程的独立性则对应着较高的进程间通信成本,线程的资源设计恰好解决了这一核心痛点。

从资源归属来看,进程是 Linux 系统中资源分配的基本单位,线程作为资源调度的基本单位 ,无法脱离进程独立存在,必须依托进程的虚拟地址空间、页表、文件描述符表、信号处理表、全局数据段、堆内存等核心资源,所有线程共享所属进程的这部分资源,因此线程之间无需通过复杂的进程间通信(IPC)机制就能直接访问同一块内存、打开的文件等,极大降低了数据交互的成本。

而线程独有的不可共享的私有资源, 是支撑其作为独立执行流的基础,具体包括:一是线程 ID ,用于区分同一进程内的不同轻量级进程,是线程在进程内的唯一标识;二是一组寄存器与线程上下文数据 ,这是线程调度的核心依赖,当 CPU 切换线程执行时,需保存和恢复这些寄存器的值,保证每个线程的执行状态不被破坏;三是栈结构 ,线程拥有独立的栈空间,用于存储函数调用、局部变量、返回地址等,压栈、出栈等栈操作均在各自的栈空间中完成,避免线程间栈数据互相干扰;四是errno ,每个线程都有独立的 errno 变量,确保线程调用系统函数出错时,能准确获取自身的错误码,不会出现多线程下错误码覆盖的问题;五是信号屏蔽字 ,线程可独立屏蔽指定信号,不影响进程内其他线程和整个进程的信号处理逻辑;六是调度优先级,线程可拥有独立的调度优先级,操作系统根据优先级对线程进行调度,保证不同线程的执行策略差异化。

除上述私有资源外,线程的其余资源均与进程共享,包括进程的虚拟地址空间、页表、文件描述符、信号处理方式、全局变量、堆内存等,这种 "大部分共享、少量私有" 的资源设计,既实现了线程之间的高效数据交互,又通过独立的私有资源保障了线程的独立调度与执行,完美契合了 Linux "一切皆轻量级进程" 的线程实现逻辑。

进程和线程的关系

  • ①单线程进程:一个进程仅含一个线程,进程与执行流完全绑定,是传统进程模型;
  • ②单进程多线程:一个进程内包含多个线程,线程共享进程资源、仅保留私有上下文,是现代多线程编程的主流并发模型;
  • ③多个单线程进程:多个独立进程、每个进程仅一个线程,进程间完全隔离,是传统多进程并发模型;
  • ④多个多线程进程:多个独立进程、每个进程内又含多个线程,结合了多进程隔离性与多线程高效性,是现代复杂系统的通用架构。

进程与主线程的关系

进程不等于主线程,二者是包含关系:进程是资源分配的基本单位,拥有完整的虚拟地址空间、页表、文件描述符等所有系统资源;主线程是进程创建时自动生成的第一个执行流,是 CPU 调度的基本单位,和进程内其他子线程共享进程的绝大部分资源,仅保留线程 ID、寄存器上下文、独立栈等私有资源。主线程是进程的核心执行入口,主线程退出则整个进程随之终止,进程必须依托主线程等执行流才能运行,二者不可等同。

Cache高速缓存

线程的轻量化与CPU高速缓存(Cache)存在也有直接的关系 : 其本质源于进程与线程的资源模型差异:Cache作为CPU寄存器与物理内存之间的分级高速缓存,依托程序的局部性原理,会在进程访问内存数据时,将该数据及其后续连续的大块数据预加载至Cache中形成热数据,以此大幅提升CPU的数据访问速度,避免频繁访问低速内存; 当发生进程切换时,由于不同进程拥有完全独立的虚拟地址空间与物理内存资源,原进程在Cache中缓存的热数据会完全失效,新切换到CPU的进程不仅需要加载自身的执行上下文到寄存器,更要将自身对应的物理内存数据重新加载至Cache,完成冷数据到热数据的预热,这一Cache数据的重新加载过程是进程切换高开销的核心来源,**而同一进程内的线程共享进程的虚拟地址空间、物理内存与全局数据,线程切换时仅需切换自身的寄存器上下文、独立栈等私有资源,无需重新加载Cache数据,CPU中已缓存的热数据可被所有线程直接复用,无需额外的预热开销,这使得线程切换的成本远低于进程切换,**再结合线程无需复制页表、共享进程绝大多数资源的特性,共同造就了线程相较于进程的轻量化优势,而Cache复用则是线程轻量化的关键底层原因之一。

二、线程的优缺点

线程优点

  • **创建开销极低:**创建新线程的代价远小于创建全新进程,无需为其分配独立的虚拟地址空间、页表等整套进程资源,仅需创建线程控制块、分配独立栈等少量私有资源即可。
  • 切换成本大幅降低:线程切换仅需保存/恢复寄存器上下文,无需切换虚拟地址空间;既不会刷新 TLB(页表缓存)导致内存访问低效,也不会破坏 CPU Cache 的热数据,避免了进程切换带来的巨大性能损耗。
  • 资源占用更少:线程共享所属进程的绝大部分资源(虚拟地址空间、文件描述符、全局数据等),仅保留线程 ID、栈、寄存器上下文等少量私有数据,系统资源消耗远低于独立进程。
  • 充分利用多核并行:可将任务拆解为多个线程,在多处理器系统上实现真正的并行执行,最大化利用 CPU 算力,提升计算密集型任务的运行效率。
  • **提升 I/O 场景性能:**在等待慢速 I/O 操作(如磁盘读写、网络请求)的同时,可让其他线程执行计算任务,实现 I/O 与计算的重叠;也可让多个线程同时等待不同的 I/O 操作,大幅提升 I/O 密集型应用的吞吐量。

线程缺点

  • **性能损失 :**计算密集型线程若数量超过可用处理器核心,会因额外的线程同步、调度开销导致性能下降,在总资源不变的情况下,额外开销会抵消多线程的并行收益。

  • **健壮性降低 :**线程共享进程的地址空间与资源,缺乏独立隔离保护,若出现时间片分配偏差、共享变量访问不当等问题,极易引发数据竞争、程序崩溃,且一个线程崩溃会导致整个进程崩溃。

  • **缺乏访问控制 :**进程是系统访问控制的基本粒度,线程仅为进程内的执行流,单个线程调用的系统函数、执行的操作会直接影响整个进程,无法对线程做独立的权限与访问控制。

  • **编程难度大幅提升 :**多线程程序的编写、调试难度远高于单线程程序,需要全面考虑线程同步、死锁避免、竞态条件等复杂问题,对开发人员的并发编程能力要求极高。

线程异常

线程异常的核心本质,源于线程与进程的强绑定、共享资源关系:

线程作为进程内的执行分支,与进程共享同一虚拟地址空间和资源,没有独立的隔离保护。当单个线程因除零错误、野指针访问等问题崩溃时,本质是触发了进程级的异常,会直接激活系统的信号机制,向进程发送终止信号,导致整个进程随之崩溃;而进程一旦终止,其内部的所有线程也会全部退出,不存在单个线程崩溃而进程存活的情况,这也是多线程程序健壮性远低于多进程程序的核心原因之一。

线程用途

一是针对CPU 密集型程序,通过合理使用多线程,可将计算任务拆分到多个线程,在多核处理器上实现并行执行,充分利用 CPU 算力,大幅提升程序的整体执行效率;

二是针对I/O 密集型程序,多线程能实现 I/O 操作与计算任务的重叠,在等待慢速 I/O(如网络请求、磁盘读写、文件下载)的同时,让其他线程继续执行计算或交互逻辑,避免程序因等待 I/O 而卡顿,显著提升用户体验,比如一边写代码一边下载开发工具,就是多线程在 I/O 密集型场景的典型应用。

三、线程控制

pthread_create

pthread_create是用于创建新线程的核心函数,其四个参数分工明确且逻辑紧密:第一个参数pthread_t *thread为输出型参数,用于存储新创建线程的 ID,供主线程后续操作使用;第二个参数const pthread_attr_t *attr用于配置线程属性,传NULL 则使用系统默认属性;第三个参数void *(*start_routine)(void *)是函数指针,即新线程的入口执行函数,新线程创建成功后将从该函数开始执行第四个参数void *arg正是传递给第三个参数入口函数的实参,新线程执行时会以start_routine(arg)的形式调用入口函数,二者为严格的 "函数 - 入参" 绑定关系

该函数成功时返回 0,失败返回错误码,编译链接时必须添加-pthread参数以链接 POSIX 线程库。

代码demo:

这段代码创建了一个新线程,再加上原本的主线程,一共打印显示出了两个线程。

我们继续 在主线程中把创建的新线程的 tid 打印出来 :

左图是第一行打印的的是新线程的 tid,但是和我们用 ps -aL 显示的 LWP 明显不一样,tid 和 LWP分别代表什么? 为啥它俩不一样?

我们在上一篇文章中讲过,从用户视角与内核视角的分别来看,pthread_t 类型的线程 ID(tid)与内核的 LWP 轻量级进程 ID 属于不同层级、不同用途,但高度统一的标识体系:用户态只关心线程的逻辑执行流,因此 tid 是用户视角下用于识别、管理线程的唯一标识,是pthread库为用户封装的, **服务于线程库与应用程序;而 Linux 内核并不直接识别 "线程",只调度作为内核执行实体的轻量级进程(LWP),因此轻量级进程 LWP 是内核视角下用于调度、管理、分配资源的唯一标识。**二者虽然分属用户态与内核态两个不同层面,却一一对应、高度统一,每一个用户态线程都唯一绑定一个内核 LWP,共同构成 Linux 上线程 "用户看线程、内核看轻量级进程 LWP" 的完整运行模型。
我们再继续以16进制的形式打印出这个 tid :

是一个非常大的数字,这个tid其实是一个地址,具体是什么地址我们后面再介绍

那我们怎么保证这个 tid 就是我们每次创建出的新线程的 id 呢?

下面我们再来介绍一个函数 pthread_self

pthread_self

pthread_self() 用于获取调用线程自身用户态 ID 的核心函数,返回值为调用该函数的线程对应的 pthread_t 类型用户态线程 ID。该函数的核心作用是解决 pthread_create 传参可能存在的时序与标识一致性问题:主线程通过 pthread_create 输出参数获取的新线程 ID ,是主线程视角的标识,而新线程内部可通过 pthread_self() 直接获取自身唯一的用户态线程 ID,确保线程在任何执行阶段都能精准识别自身。

下面我们在线程调用函数中改一下并用 pthread_self 打印出线程的 tid:

所以就可以证明我们在主线程中打印出新线程自己的 tid 和新线程自己用 ptherad_self 打印出来的tid是一样的,也就是说主线程创建新线程,主线程也能拿到新线程的tid

那我们也可以在主线程的while循环中用 pthread_self 打印出主线程自己的 tid :

此时我们就可以看出,主线程和新线程各自都有自己的 tid。

那我们又如何证明这两个线程属于同一个进程的呢?

所以我们可以分别在主线程和新线程各自的循环中用 getpid() 打印进程的pid 观察:

pid一样,证明了这两个线程属于同一个进程,所以在用户角度就可以看到一个进程内部存在了两个线程。


四、创建多线程

我们可以用for循环创建多线程:

主线程在 for 循环中循环创建 10 个线程,每个线程传入不同的线程名称参数(如 thread-1);入口函数 routine 作为可重入函数,会被多个线程同时执行,通过 static 修饰的局部变量 name 接收传入的线程名,并结合 pthread_self() 获取用户态线程 ID、getpid() 获取轻量级进程 ID(LWP),实现线程自身的标识与日志输出。

运行:

运行结果显示每个线程的 pthread_t 用户态 ID(tid)数值不同,但对应的内核态 LWP(pid)数值完全一致(均为 1707534),这印证了 Linux 中 "一个用户态线程对应一个内核轻量级进程" 的模型,用户态 tid 用于线程库管理,内核态 LWP 用于系统调度,二者高度统一但数值体系独立。

证明资源共享:

那如果我们再写一个函数,让这个函数被routinue函数调用呢?

同一进程内的所有线程共享完整的虚拟地址空间,因此进程中定义的代码、函数、全局数据等资源对所有线程完全可见、可共同调用执行 。示例中定义的PrintName函数,可被所有新创建的线程在routine入口函数中正常调用,程序运行正常且各线程可独立输出自身标识信息。这**种共享机制是线程轻量化的核心原因之一,**既大幅降低了线程创建与切换的开销,也让线程间数据交互更高效。

全局变量

接下来我们继续实验 : 我们在代码中写一个全局变量,因为线程也共享虚拟地址空间,如果我们在新线程中改了这个全局变量,那在主线程中应该就能看到修改的情况:

因为同一进程内的所有线程共享完整的虚拟地址空间,因此全局变量存储在进程的公共数据段中,所有线程均可对其进行读写访问。 示例中定义了全局变量 g_val 并初始化为 100,新线程在 routine 入口函数中对该变量执行自增操作,主线程则持续打印该变量的值与地址;运行结果显示,新线程修改后的 g_val 数值会立刻同步给主线程,且新线程与主线程打印出的 g_val 地址完全一致(均为 0x55a968a20010),这直接证明了新线程对全局变量的修改在主线程中是可见的,二者访问的是同一块物理内存区域,印证了线程共享进程虚拟地址空间的设计。

如果不是全局变量呢?如果是在堆/栈上呢?

现在我们在新线程中修改,在主线程中打印:

主线程在自己的栈上定义了局部变量hello,并通过全局指针addr保存其地址;新线程通过该指针,对主线程栈上的hello执行*addr += 10的修改操作。运行结果显示,主线程打印的hello值会随新线程的修改同步递增(从 1→11→21→31...),这直接证明:同一进程内的所有线程共享完整的虚拟地址空间,不仅全局变量、堆空间是共享的,主线程的栈空间也在共享的地址空间范围内,新线程可通过地址直接访问并修改主线程栈上的数据。
线程是进程内的执行流,共享进程的虚拟地址空间,仅拥有独立的栈 空间、寄存器上下文等私有资源;但这里的 "独立栈" 是线程私有的运行栈不代表线程无法访问其他线程的栈------ 只要拿到地址,就能跨线程访问任意地址空间的内存,这是共享地址空间的直接体现。

上述代码仅为原理验证,实际开发中绝对不建议跨线程操作其他线程的栈空间,极易引发栈溢出、野指针、数据混乱等严重问题,属于高风险的未定义行为。

堆空间同理,堆空间同样属于进程共享的虚拟地址空间,多线程可通过指针自由访问、修改堆上的数据,这也是多线程数据竞争的核心场景,必须通过互斥锁等同步机制保护。


五、多线程中的异常问题

除0异常

线程中也有异常问题,下面我们通过最典型的除0错误代码展现一下 :

上述代码当第 8 个线程(thread-8)执行到 a /= 0 就会触发除零异常从而引起线程异常,我们看现象 :

当当第 8 个线程(thread-8)执行到 a /= 0 触发除零异常时,系统立刻输出 Floating point exception (core dumped)。

整个进程随即直接终止,ps -aL 命令中看不到任何该进程的线程残留。这证明了线程异常不是独立退出,而是导致整个进程生命周期结束根源在于线程共享进程的虚拟地址空间,没有独立的内存隔离保护。当一个线程触发异常(如除零、段错误、野指针)时,内核会将其视为进程级的严重错误,发送终止信号(如 SIGFPE、SIGSEGV)给整个进程,而非仅处理出错线程。
健壮性缺陷 这是线程最大的缺点之一:任意线程出现异常都会拖垮整个进程,使整个进程崩溃。所有线程共享同一套资源,一个线程的非法操作(如除零、越界)会破坏进程的运行环境,导致进程整体崩溃,这也是多进程模型(进程间独立隔离)在高健壮性要求场景下的优势所在。

除了除零、段错误这类硬件异常,还可以通过向进程发送终止信号(如 kill -9)、调用 abort() 函数、或触发未定义行为(如栈溢出)来触发这种进程级崩溃。


线程并发乱序问题

当一次性创建 10 个线程、去掉主线程 sleep(1)后,线程打印顺序完全乱序,这是多线程并发调度的本质特性。导致的线程是操作系统的调度实体,主线程创建线程后,内核会根据调度算法(时间片轮转、优先级等)为每个线程分配 CPU 时间片,哪个线程先抢到 CPU、先执行PrintName打印,完全由内核决定,和创建顺序没有必然绑定关系,因此输出顺序必然乱序。


关于传参问题

pthread_create 的第三个参数是线程入口函数指针,类型为 void *(*start_routine)(void *),第四个参数是传递给该入口函数的实参,类型为 void *,这两个参数的类型一样。void* 是通用指针类型,因此我们可以在主线程中把任意类型(基本类型、数组、结构体、类对象等)的地址强转为 void* 传入再在线程入口函数中把 void* 强转回原类型,从而实现向线程传递任意数据、任务的需求。

需要注意的是传参的本质是传地址,我们传递的是对象的地址。这意味着我们必须保证对象的生命周期长于线程的生命周期,绝对不能出现主线程栈上的局部变量已经销毁,新线程还在访问其地址的情况,否则会触发野指针、内存越界等未定义行为。

下面我们就来试验一下通过第四个参数来传递任务:

先创建一个任务hpp文件 任务的声明和实现都包含在一起 :

定义Task类,封装加法任务的参数_x/_y、执行方法Execute()、结果获取方法Result(),将任务逻辑与数据打包成独立对象。

主线程在循环中为每个线程动态分配Task对象(new Task(x, y)),将对象指针作为pthread_create的第四个参数传入;线程入口函数routine中,将 void* 类型的入参强转回 Task* ,调用Execute()执行任务、Result()输出计算结果。

运行结果显示,每个线程独立执行对应Task的加法运算,输出正确的计算结果,证明了通过void*传递类对象指针、向线程分发复杂任务的可行性。

我们也可以用C语言,但是不推荐,有更好的C++写法,无论是 C 语言的结构体,还是 C++ 的类对象,都可以通过传递地址的方式传给线程,结构体可直接传结构体变量的地址,在线程中强转回结构体指针即可访问成员。类对象可传对象地址(需保证对象存活),或传动态分配的对象指针,线程中强转回类指针调用成员方法,这是多线程中传递复杂任务参数的常用方式。


线程终止

线程终止有三种做法:

第一种 : return

主线程不建议return,否则整个进程就会退出。

第二种 : pthread_exit

pthread_exit() 是主动终止调用线程的标准接口,**作用是终止当前调用该函数的线程,而非整个进程,其他线程不受影响。**参数 retval 是线程的退出状态指针,会作为返回值传递给等待该线程的 pthread_join(),示例中传 nullptr 表示无返回值。

线程终止时,会自动执行线程清理函数销毁线程局部存储,但堆上分配的内存(如示例中的 new Task)需要手动释放,否则会造成内存泄漏。若主线程调用 pthread_exit(NULL),主线程会终止,但进程不会退出,其他子线程可继续执行;若主线程 return 或调用 exit(),则整个进程会终止,所有线程随之退出。

那线程可不可以用exit()退出?

不可以,因为exit()是整个进程退出,pthread_exit() 只会终止调用它的线程。

第三种方法 : pthread_cancel

pthread_cancel 是向指定线程发送取消请求 的接口,**同一进程内的任意线程(通常是主线程),可以通过目标线程的 pthread_t ID,向其发送取消请求,请求终止该线程。**成功返回 0,失败返回错误码(如目标线程不存在)。

主线程创建 10 个子线程时,将每个线程的 tid 存入 std::vector<pthread_t>,用于后续管理和取消操作。主线程在 while(true) 循环中,随机选择一个子线程,调用 pthread_cancel(tids[who]) 发送取消请求,并将该线程 ID 标记为 -1 避免重复取消。

运行结果 ps -aL 输出显示,被取消的子线程对应的 LWP 条目消失,证明线程被成功终止,而主线程和进程仍正常运行。

pthread_cancel 只会终止指定的子线程,不会影响同进程的其他线程和主线程,更不会终止整个进程;同时,子线程无法通过 pthread_cancel 终止主线程 / 进程,这是线程权限的设计约束。

三种终止方式的对比 :

谢谢大家的观看!

相关推荐
KhalilRuan2 小时前
HybridCLR的底层原理
java·开发语言
We་ct2 小时前
LeetCode 137. 只出现一次的数字 II:从基础到最优的两种解法详解
前端·数据结构·算法·leetcode·typescript·位运算
zzzsde2 小时前
【Linux】进程间通信(1)管道&&进程池实现
linux·运维·服务器
Miki Makimura2 小时前
C++ 聊天室项目:Linux 环境搭建与问题总结
linux·开发语言·c++
CappuccinoRose2 小时前
排序算法和查找算法 - 软考备战(十五)
数据结构·python·算法·排序算法·查找算法
tq6J5Yg142 小时前
.NET 10 & C# 14 New Features 新增功能介绍-带修饰符的简单 lambda 参数
开发语言·c#·.net
Benszen2 小时前
一些存储类型
网络·网络协议·rpc
vortex52 小时前
一文厘清DDoS与CC攻击
网络·网络安全·渗透测试·ddos
Yiyi_Coding2 小时前
bat 脚本(真实项目可用):ftp取远程文件
运维·脚本·ftp