


目录
[1-2 🌭线程的理解](#1-2 🌭线程的理解)
[2-1 🍔虚拟地址和⻚表的由来](#2-1 🍔虚拟地址和⻚表的由来)
[2-2 🍕物理内存管理](#2-2 🍕物理内存管理)
[2-3🍟 ⻚表](#2-3🍟 ⻚表)
[2-4 🍿⻚⽬录结构](#2-4 🍿⻚⽬录结构)
[2-5 🥖两级⻚表的地址转换](#2-5 🥖两级⻚表的地址转换)
[2-6 🥞缺⻚异常](#2-6 🥞缺⻚异常)
[3-1 🥗POSIX线程库](#3-1 🥗POSIX线程库)
[3-2 🍞创建线程](#3-2 🍞创建线程)
[3-3 🥨线程等待](#3-3 🥨线程等待)
[3-4 🧀线程资源共享](#3-4 🧀线程资源共享)
[3-5 🍗线程异常](#3-5 🍗线程异常)
[3-6 🥪线程退出](#3-6 🥪线程退出)
[3-7 🥙线程分离](#3-7 🥙线程分离)
[3-8 🥟线程ID(tid)及进程地址空间布局](#3-8 🥟线程ID(tid)及进程地址空间布局)
[3-9 🥩线程切换](#3-9 🥩线程切换)
[3-10 🍖线程优缺点](#3-10 🍖线程优缺点)
[四、😎Linux线程 vs 进程](#四、😎Linux线程 vs 进程)
[4-1 🍚资源共享](#4-1 🍚资源共享)
[内核视角的线程实现(Linux 为例)](#内核视角的线程实现(Linux 为例))
[4-3 🍥线程安全](#4-3 🍥线程安全)
[4-3 🍙线程传参及返回](#4-3 🍙线程传参及返回)
[五 、😗C++11多线程](#五 、😗C++11多线程)
前言
在计算机科学的广阔领域中,线程与虚拟地址空间是两个极为重要的概念,它们如同计算机系统运行的基石,支撑着现代软件的高效执行和复杂功能的实现。线程,作为操作系统能够进行运算调度的最小单位,是进程中的实际运作单元,它的出现使得程序可以并发执行多个任务,极大地提高了程序的执行效率和响应速度 。无论是在多核处理器上实现并行计算,还是在服务器端处理大量并发请求,线程都发挥着不可或缺的作用。
而虚拟地址空间,则是操作系统为每个进程提供的一种抽象,它让每个进程都仿佛独占整个内存空间,有效地隔离了进程之间的内存访问,保障了系统的稳定性和安全性。通过虚拟地址空间,操作系统能够灵活地管理内存资源,实现内存的分配、回收和保护,同时也为程序的加载和运行提供了便利。
理解线程与虚拟地址空间,不仅是掌握计算机底层原理的关键,也是成为一名优秀程序员的必备技能。在接下来的内容中,我们将深入探讨线程和虚拟地址空间的相关知识,从基础概念到高级应用,从原理剖析到实践技巧,全方位地揭开它们的神秘面纱,为你在计算机编程的道路上打下坚实的基础。
本节重点
- 深刻理解线程
- 深刻理解虚拟地址空间
- 了解线程概念,理解线程与进程区别与联系。
- 学会线程控制,线程创建,线程终⽌,线程等待。
- 了解线程分离与线程安全概念。
- 掌握线程与进程地址空间布局
- 理解LWP和原⽣线程库封装关系
一、😀线程概念
- 在一个程序里的 一个执行路线就叫做线程(thread) 。更准确的定义是:线程是 "一个进程内部的控制序列"。
- 一切进程至少都有一个执行线程。
- 线程在进程内部运行,本质是在进程地址空间内运行。
- 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
简单来说:线程是进程内部(地址空间内)的一个执行分支,且线程是CPU调度的基本单位。
1-2 🌭线程的理解
加载到内存中的程序,我们称为进程。我们创建一个进程,简单来说是,分配进程地址空间,加载各种数据,与物理内存之间建立映射关系等。做完这些动作,我们才能说创建了进程,也就是说,创建一个进程的开销(时间空间成本开销)是很大的。
并且我们所写的程序,每一个函数都会被解析为一个个代码块,每个代码块都有自己的入口地址(C语言中的函数名即为地址),进程对程序中的函数进行调用时,只能一个个的调用,并不能同时调用多个函数。也就是说,我们代码在进程中全部是串行调用的 。
而我们要说的进程,可以使函数并行调用 。
在Linux当中,并不存在真正的 "线程", 这是因为Linux的设计者认为,线程和进程都是执行流,具有高度相似性,没必要为线程单独设计数据结构与算法,所以 Linux线程是使用进程来模拟线程的 ! 有些操作系统有真实的线程,比如windows。
而在CPU看来,不论是进程还是线程,当CPU拿到时并不会明确的区分,因为CPU拿到的都是执行流,所以对于CPU来说进程还是线程根本就不重要。所以 Linux中的所有调度执行流 ,都可称为 轻量级进程(Light Weight Process, 简称LWP) 。
线程在进程中可能存在多个,所以OS需要对线程进行管理,先描述再组织。所以线程的task_struct 称为 TCB(Thread Control Block: 线程控制块) 。
那么我们来重新从 内核角度 认识一下进程的概念:承担分配系统资源的基本实体 。分配资源包括线程资源的分配等等。
二、😁分⻚式存储管理
2-1 🍔虚拟地址和⻚表的由来
思考⼀下,如果在没有虚拟内存和分⻚机制的情况下,每⼀个⽤⼾程序在物理内存上所对应的空间必须是连续的,如下图:
因为每⼀个程序的代码、数据⻓度都是不⼀样的,按照这样的映射⽅式,物理内存将会被分割成各种 离散的、⼤⼩不同的块。经过⼀段运⾏时间之后,有些程序会退出,那么它们占据的物理内存空间可 以被回收,导致这些物理内存都是以很多碎⽚的形式存在。
怎么办呢?我们希望操作系统提供给⽤⼾的空间必须是连续的,但是物理内存最好不要连续。此时虚拟内存和分⻚便出现了,如下图所⽰:
把物理内存按照⼀个固定的⻓度的⻚框进⾏分割,有时叫做物理⻚。每个⻚框包含⼀个物理⻚ (page)。⼀个⻚的⼤⼩等于⻚框的⼤⼩。⼤多数 32位 体系结构⽀持 4KB 的⻚,⽽ 64位 体系结构⼀般会⽀持 8KB 的⻚。区分⼀⻚和⼀个⻚框是很重要的
- ⻚框****是⼀个存储区域;
- **⽽****⻚**是⼀个数据块,可以存放在任何⻚框或磁盘中。
有了这种机制,CPU 便并⾮是直接访问物理内存地址,⽽是通过虚拟地址空间来间接的访问物理内存 地址。所谓的虚拟地址空间,是操作系统为每⼀个正在执⾏的进程分配的⼀个逻辑地址,在32位机 上,其范围从0 ~ 4G-1。
操作系统通过将虚拟地址空间和物理内存地址之间建⽴映射关系,也就是⻚表,这张表上记录了每⼀ 对⻚和⻚框的映射关系,能让CPU间接的访问物理内存地址。总结⼀下,其思想是将虚拟内存下的逻辑地址空间分为若⼲⻚,将物理内存空间分为若⼲⻚框,通过 ⻚表便能把连续的虚拟内存,映射到若⼲个不连续的物理内存⻚。这样就解决了使⽤连续的物理内存 造成的碎⽚问题。
2-2 🍕物理内存管理
假设⼀个可⽤的物理内存有 4GB 的空间。按照⼀个⻚框的⼤⼩ 4KB 进⾏划分, 4GB 的空间就是 4GB/4KB = 1048576 个⻚框。有这么多的物理⻚,操作系统肯定是要将其管理起来的,操作系统 需要知道哪些⻚正在被使⽤,哪些⻚空闲等等。
内核⽤ struct page 结构 表⽰系统中的每个物理⻚,出于节省内存的考虑, struct page 中使
⽤了⼤量的 联合体union。
其中⽐较重要的⼏个参数
1️⃣ flags : ⽤来存放⻚的状态。这些状态包括⻚是不是脏的,是不是被锁定在内存中等。flag的 每⼀位单独表⽰⼀种状态,所以它⾄少可以同时表⽰出32种不同的状态。这些标志定义在 <linux/page-flags.h>中。其中⼀些⽐特位⾮常重要,如PG_locked⽤于指定⻚是否锁定/ PG_uptodate⽤于表⽰⻚的数据已经从块设备读取并且没有出现错误。
2️⃣ _mapcount : 表⽰在⻚表中有多少项指向该⻚,也就是这⼀⻚被引⽤了多少次。当计数值变 为-1时,就说明当前内核并没有引⽤这⼀⻚,于是在新的分配中就可以使⽤它。
3️⃣ virtual : 是⻚的虚拟地址。通常情况下,它就是⻚在虚拟内存中的地址。有些内存(即所谓 的⾼端内存)并不永久地映射到内核地址空间上。在这种情况下,这个域的值为NULL,需要的 时候,必须动态地映射这些⻚。要注意的 ,⽽并⾮与虚拟⻚相关。⽽系统中的每个物理⻚都要分配⼀ 个这样的结构体,让我们来算算对所有这些⻚都这么做,到底要消耗掉多少内存。
算 struct page 占40个字节的内存吧,假定系统的物理⻚为 4KB ⼤⼩,系统有 4GB 物理内存。
那么系统中共有⻚⾯ 1048576 个(1兆个),所以描述这么多⻚⾯的page结构体消耗的内存只不过 40MB ,相对系统 4GB 内存⽽⾔,仅是很⼩的⼀部分罢了。因此,要管理系统中这么多物理⻚⾯,这 个代价并不算太⼤。
要知道的是,⻚的⼤⼩对于内存利⽤和系统开销来说⾮常重要,⻚太⼤,⻚内必然会剩余较⼤不能利 ⽤的空间(⻚内碎⽚)。⻚太⼩,虽然可以减⼩⻚内碎⽚的⼤⼩,但是⻚太多,会使得⻚表太⻓⽽占⽤内存,同时系统频繁地进⾏⻚转化,加重系统开销。因此,⻚的⼤⼩应该适中,通常为 512B - 8KB ,windows/Linux系统的⻚框⼤⼩为4KB。注意:
操作系统也要管理每⼀个⻚
2-3🍟 ⻚表
**⻚表中的每⼀个表项,指向⼀个物理⻚的开始地址。**在 32 位系统中,虚拟内存的最⼤空间是 4GB , 这是每⼀个⽤⼾程序都拥有的虚拟内存空间。既然需要让 4GB 的虚拟内存全部可⽤,那么⻚表中就需 要能够表⽰这所有的 4GB 空间,那么就⼀共需要 4GB/4KB = 1048576 个表项。如下图所⽰:
虚拟内存看上去被虚线"分割"成⼀个个单元,其实并不是真的分割,虚拟内存仍然是连续的。这个 虚线的单元仅仅表⽰它与⻚表中每⼀个表项的映射关系,并最终映射到相同⼤⼩的⼀个物理内存⻚ 上。
⻚表中的物理地址,与物理内存之间,是随机的映射关系,哪⾥可⽤就指向哪⾥(物理⻚)。虽然最终使 ⽤的物理内存是离散的,但是与虚拟内存对应的线性地址是连续的。处理器在访问数据、获取指令时,使⽤的都是线性地址,只要它是连续的就可以了,最终都能够通过⻚表找到实际的物理地址。假设,在 32 位系统中,地址的⻓度是 4 个字节,那么⻚表中的每⼀个表项就是占⽤ 4 个字节。所以 ⻚表占据的总空间⼤⼩就是: 1048576*4 = 4MB 的⼤⼩。也就是说映射表⾃⼰本⾝,就要占⽤ 4MB / 4KB = 1024 个物理⻚。这会存在哪些问题呢?
- 回想⼀下,当初为什么使⽤⻚表,就是要将进程划分为⼀个个⻚可以不⽤连续的存放在物理内存 中,但是此时⻚表就需要1024个连续的⻚框,似乎和当时的⽬标有点背道⽽驰了......
- 此外,根据局部性原理可知,很多时候进程在⼀段时间内只需要访问某⼏个⻚就可以正常运⾏ 了。因此也没有必要⼀次让所有的物理⻚都常驻内存。
解决需要⼤容量⻚表的最好⽅法是:把⻚表看成普通的⽂件,对它进⾏离散分配,即对⻚表再分⻚, 由此形成多级⻚表的思想。
为了解决这个问题,可以把这个单⼀⻚表拆分成 1024 个体积更⼩的映射表。如下图所⽰。这样⼀ 来,1024(每个表中的表项个数) * 1024(表的个数),仍然可以覆盖 4GB 的物理内存空间。
这⾥的每⼀个表,就是真正的⻚表,所以⼀共有 1024 个⻚表。⼀个⻚表⾃⾝占⽤ 4KB ,那么 1024 个⻚表⼀共就占⽤了 4MB 的物理内存空间,和之前没差别啊?
从总数上看是这样,但是⼀个应⽤程序是不可能完全使⽤全部的 4GB 空间的,也许只要⼏⼗个⻚表就 可以了。例如:⼀个⽤⼾程序的代码段、数据段、栈段,⼀共就需要 10 MB 的空间,那么使⽤ 3 个⻚表就⾜够了。计算过程:
- 每⼀个⻚表项指向⼀个 4KB 的物理⻚,那么⼀个⻚表中 1024 个⻚表项,⼀共能覆盖 4MB 的物理内 存;
- 那么 10MB 的程序,向上对⻬取整之后(4MB 的倍数,就是 12 MB),就需要 3 个⻚表就可以了。
2-4 🍿⻚⽬录结构
到⽬前为⽌,每⼀个⻚框都被⼀个⻚表中的⼀个表项来指向了,那么这 1024 个⻚表也需要被管理起 来。管理⻚表的表称之为⻚⽬录表,形成⼆级⻚表。如下图所⽰:
- 所有⻚表的物理地址被⻚⽬录表项指向
- ⻚⽬录的物理地址被 CR3 寄存器 指向,这个寄存器中,保存了当前正在执⾏任务的⻚⽬录地址。
所以操作系统在加载⽤⼾程序的时候,不仅仅需要为程序内容来分配物理内存,还需要为⽤来保存程 序的⻚⽬录和⻚表分配物理内存。
实际上从虚拟地址到物理地址之间的转换,我们通过虚拟地址来完成,常规OS虚拟地址为32位,而这32位虚拟地址被划分为3部分:页目录、页表、页内偏移。
我们把页目录与页表内的条目,称为 页表项。而他们之间的关系完全可以用下面这一张图来清晰的解释:
虚拟地址的前十位为页目录,页目录的每一个页表项包含每张页表的地址,页目录最大有1024个页表项。前十位比特位查找到对应的页表,页表的页表项内保存的是内存的每一个内存块地址,通过中间10位比特位可以查找到对应的内存块,最大有1024张页表。
前面我们说了,物理内存的内存块大小为4kb,转换为2进制其实也就是12位比特位,正好对应了最后12位比特位,从特定的内存块进行页内偏移找到对应的需要获取的内容。所以说,页表内保存的是内存的物理地址没错,只不过是页框的物理地址!
所以我们从内存中查询数据时,需要经过的步骤为:**页目录 => 页表 => 内存块 => 数据(页内偏移)。**也就完成了虚拟到物理的转化。
2-5 🥖两级⻚表的地址转换
下⾯以⼀个逻辑地址为例。将逻辑地址( 0000000000,0000000001,11111111111 )转换为物
理地址的过程:
1. 在32位处理器中,采⽤4KB的⻚⼤⼩,则虚拟地址中低12位为⻚偏移,剩下⾼20位给⻚表,分成 两级,每个级别占10个bit(10+10)。
2. CR3 寄存器 读取⻚⽬录起始地址,再根据⼀级⻚号查⻚⽬录表,找到下⼀级⻚表在物理内存中 存放位置。
3. 根据⼆级⻚号查表,找到最终想要访问的内存块号。
4. 结合⻚内偏移量得到物理地址。
5.注:⼀个物理⻚的地址⼀定是 4KB 对⻬的(最后的 12 位全部为 0 ),所以其实只需要记录物理 ⻚地址的⾼ 20 位即可。
6. 以上其实就是 MMU 的⼯作流程。MMU(Memory Manage Unit)是⼀种硬件电路,其速度很快, 主要⼯作是进⾏内存管理,地址转换只是它承接的业务之⼀ 。
到这⾥其实还有个问题,MMU要先进⾏两次⻚表查询确定物理地址,在确认了权限等问题后,MMU再 将这个物理地址发送到总线,内存收到之后开始读取对应地址的数据并返回。那么当⻚表变为N级时, 就变成了N次检索+1次读写。可⻅,⻚表级数越多查询的步骤越多,对于CPU来说等待时间越⻓,效率 越低。让我们现在总结⼀下:单级⻚表对连续内存要求⾼,于是引⼊了多级⻚表,但是多级⻚表也是⼀把双 刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。
有没有提升效率的办法呢?计算机科学中的所有问题,都可以通过添加⼀个中间层来解决。 MMU 引⼊ 了新武器,江湖⼈称快表的 TLB (其实,就是缓存,Translation Lookaside Buffer,学名转译后备 缓冲器)
当 CPU 给 MMU 传新虚拟地址之后, MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存,⻬活。但 TLB 容量⽐较⼩,难免发⽣ Cache Miss ,这时候 MMU 还有保底的⽼武器 ⻚表,在⻚表中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录 ⼀下刷新缓存。
2-6 🥞缺⻚异常
设想,CPU 给 MMU 的虚拟地址,在 TLB 和⻚表都没有找到对应的物理⻚,该怎么办呢?其实这就是 缺⻚异常 Page Fault ,它是⼀个由硬件中断触发的可以由软件逻辑纠正的错误。
假如⽬标内存⻚在物理内存中没有对应的物理⻚或者存在但⽆对应权限,CPU 就⽆法获取数据,这种 情况下CPU就会报告⼀个缺⻚错误。
由于 CPU 没有数据就⽆法进⾏计算,CPU罢⼯了⽤⼾进程也就出现了缺⻚中断,进程会从⽤⼾态切换 到内核态,并将缺⻚中断交给内核的 Page Fault Handler 处理。
缺⻚中断会交给 PageFaultHandler 处理,其根据缺⻚中断的不同类型会进⾏不同的处理
- Hard Page Fault 也被称为 Major Page Fault ,翻译为硬缺⻚错误/主要缺⻚错误,这时物理内存中没有对应的物理⻚,需要CPU打开磁盘设备读取到物理内存中,再让MMU建⽴虚拟 地址和物理地址的映射。
- Soft Page Fault 也被称为 Minor Page Fault ,翻译为软缺⻚错误/次要缺⻚错误,这时物理内存中是存在对应物理⻚的,只不过可能是其他进程调⼊的,发出缺⻚异常的进程不知道 ⽽已,此时MMU只需要建⽴映射即可,⽆需从磁盘读取写⼊内存,⼀般出现在多进程共享内存区 域。
- Invalid Page Fault 翻译为⽆效缺⻚错误,⽐如进程访问的内存地址越界访问,⼜⽐如对 空指针解引⽤内核就会报 segment fault 错误中断进程直接挂掉。
三、🤔Linux线程控制
3-1 🥗POSIX线程库
既然我们已经初步了解了线程的概念,那么我们是不是该思考,一个进程既然存在多个执行流,那么是如何对这些代码进行划分的呢?这里就不得不重新认识一下地址空间了。
原生线程库的作用 :将轻量级进程进行封装,转换成线程相关的接口语义提供给用户 。且原生线程库是必须存在的,但是不属于内核(用户级线程)。
- 与线程有关的函数构成了⼀个完整的系列,绝⼤多数函数的名字都是以"pthread_"打头的
- 要使⽤这些函数库,要通过引⼊头⽂ <pthread.h>
- 链接这些线程函数库时要使⽤编译器命令的"-lpthread"选项
而原生线程库的位置:
3-2 🍞创建线程
创建一个线程,我们使用**pthread_create()**接口:
各个参数含义:
- thread : 返回线程ID
- attr : 设置线程的属性,attr为NULL表示使用默认属性
- start_routine : 是个函数地址,线程启动后要执行的函数
- arg : 传给线程启动函数的参数
- 返回值 :成功返回0;失败返回错误码
我们立刻编写一个简单的线程demo:
cpp#include <iostream> #include <pthread.h> #include <unistd.h> // 新创建线程执行回调 void* newthreadrun(void* args) { while(true) { std::cout << "This is a new thread" << std::endl; sleep(1); } } int main() { pthread_t tid; pthread_create(&tid, nullptr, newthreadrun, nullptr); while(true) { std::cout << "I am main thread" << std::endl; sleep(1); } }makefile:
bashtestThread:test.cc g++ -o $@ $^ -lpthread -std=c++11 #-lpthread 链接原生线程库 .PHONY:clean clean: rm -f testThread当我们想查看执行流时,使用上图的命令,只能查看到一个执行流,也就是进程,如果我们想要查看线程的情况,使用如下命令:
bashps -aL | head -1 && ps -aL | grep testThread #L表示轻量级进程LWP这样就可查出每个执行流了,我们看到这两个线程的pid相同,这表示他们同属一个进程。而Linux下的线程id是LWP的id。所以从此处看,线程确实只是进程中的一个执行流。
而要查看线程的tid,我们可以通过直接打印得到,也可以通过 **thread_self()**函数调用得到:
这两种线程id获取方式是有所不同,但是打印的结果是相同的,因为其过于长,所以下面的测试用例我使用了16进制转换:
cpp#include <iostream> #include <pthread.h> #include <unistd.h> #include <string> // 进制转换 std::string ToHex(pthread_t tid) { char id[64]; snprintf(id, sizeof(id), "0x%x", tid); return id; } void* newthreadrun(void* args) { int cnt = 10; while(cnt) { std::cout << "This is a new thread is running: " << cnt << " pid: " << getpid() << "new thread id: " << ToHex(pthread_self()) << std::endl; sleep(1); cnt--; } return nullptr; } int main() { pthread_t tid; pthread_create(&tid, nullptr, newthreadrun, nullptr); int cnt = 10; while(cnt--) { std::cout << "I am main thread is running: "<< cnt << ", pid:" << getpid() << "new thread id: " << ToHex(tid) << " " << "main thread id: "<< ToHex(pthread_self()) << std::endl; sleep(1); } return 0; }这样就拿到了线程的id,并将其转化为16进制。
是新线程先运行还是主线程先运行?
新线程和主线程部分运行顺序,是不确定的,这个由调度器决定。
pthread_create()函数最后一个参数的含义?
实际上最后一个参数的类型是(void*),这表示可以传入任意类型,这个参数表示线程的线程名称。比如:
主线程退出意味着什么?会影响新线程吗?
实际上,主线程其实就是进程本身,主线程退出也就意味着进程退出,进程退出会回收进程的资源和空间,所以进程退出时,所有资源将不复存在,所以,进程退出时,其他线程资源会被回收。
这意味着,我们需要保证主线程往往是最后一个退出的。我们知道进程退出,如果子进程还没回收,那么就会产生僵尸,解决方法是wait。同样,线程退出也需要wait方法。而线程的等待方法为 pthread_join()。
3-3 🥨线程等待
多线程与多进程一样,需要对其他线程资源进行回收,进程的回收方法为wait,而线程的回收方法为
pthread_join():选项参数 :
- thread : 线程ID
- retval : 输出型参数,它指向一个指针,后者指向线程的返回值
- 返回值 :成功返回0;失败返回错误码
需要进行线程等待的原因 :
- 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
- 创建新的线程不会复用刚才退出线程的地址空间。
我们让mian thread除了等待new thread以外不做任何事,new thread创建打印一段话,每隔一秒打印一次,总计五秒,我们观察线程状态:
这时候线程就正常退出了。
thread_join()第二个参数实际上是一个输出型参数,它指向的是创建线程时新线程调用的函数返回值。我们可以将其打印出来:
cppvoid* newthreadrun(void* args) { std::string threadname = (char*)args; int cnt = 5; while(cnt) { std::cout << "New thread name: " << threadname <<" This is a new thread is running: " << cnt << " pid: " << getpid() << "new thread id: " << ToHex(pthread_self()) << std::endl; sleep(1); cnt--; } return (void*)123; } int main() { // main thread do northing, wait 5 second recyle new thread pthread_t tid; pthread_create(&tid, nullptr, newthreadrun, (void*)"thread-1"); void* ret = nullptr; int n = pthread_join(tid, &ret); std::cout << "main thread quit, ret_value:n= " << n << "main thread get threadrun ret: " << (long long)ret << std::endl; return 0; }
3-4 🧀线程资源共享
同一个进程下的所有线程共享进程的资源 ,我们定义一个全局变量,让新线程和主线程分别同时打印变量的值,观察是否相同:
cpp#include <iostream> #include <pthread.h> #include <unistd.h> #include <string> int g_val = 100; std::string ToHex(pthread_t tid) { char id[64]; snprintf(id, sizeof(id), "0x%x", tid); return id; } void* newthreadrun(void* args) { std::string threadname = (char*)args; int cnt = 5; while(cnt) { printf("new thread, g_val: %d, &g_val: %p\n", g_val, &g_val); g_val++; sleep(1); cnt--; } return (void*)123; } int main() { // main thread do northing, wait 5 second recyle new thread pthread_t tid; pthread_create(&tid, nullptr, newthreadrun, (void*)"thread-1"); void* ret = nullptr; int cnt = 10; while(cnt) { printf("main thread, g_val: %d, &g_val: %p\n", g_val, &g_val); sleep(1); cnt--; } int n = pthread_join(tid, &ret); std::cout << "main thread quit, ret_value:n= " << n << "main thread get threadrun ret: " << (long long)ret << std::endl; return 0; }
3-5 🍗线程异常
如果我们在运行线程的代码,但是线程却出现了异常,会发生什么事情?
cpp#include <iostream> #include <pthread.h> #include <unistd.h> #include <string> int g_val = 100; std::string ToHex(pthread_t tid) { char id[64]; snprintf(id, sizeof(id), "0x%x", tid); return id; } void* newthreadrun(void* args) { std::string threadname = (char*)args; int cnt = 5; while(cnt) { printf("new thread, g_val: %d, &g_val: %p\n", g_val, &g_val); g_val++; sleep(1); // 野指针错误 int *p = nullptr; *p = 100; cnt--; } return (void*)123; } int main() { pthread_t tid; pthread_create(&tid, nullptr, newthreadrun, (void*)"thread-1"); void* ret = nullptr; while(true) { printf("main thread, g_val: %d, &g_val: %p\n", g_val, &g_val); sleep(1); } int n = pthread_join(tid, &ret); std::cout << "main thread quit, ret_value:n= " << n << "main thread get threadrun ret: " << (long long)ret << std::endl; return 0; }从结果我们可以发现,当新线程发生异常并且主线程没有任何问题时,整个进程就会退出。所以我们可以得出结论:多线程中,当一个线程出现异常,那么整个进程都要退出 。所以 多线程代码,往往健壮性不好 。
我们之前在学习进程的时候,进程退出时我们经常会打印退出码,来表示进程是否是正常退出的,通过自定义信号或者是Linux信号我们可以判断异常的类型。那为什么线程这里我们并没有观察到什么"线程退出码" 呢?很简单,因为只要其中一个线程出问题了,整个进程都会被终止,所以pthread_join()不关心线程异常。
3-6 🥪线程退出
进程退出时可以使用exit(), 或者_exit()来终止进程,线程难不成也是用这两个接口来退出线程吗?显然不行,exit()是直接退出进程,所有的线程都会被终止。在Linux的原生线程库中,给我们提供了线程退出的接口
pthread_exit():函数参数:
- retval : retval不要指向一个局部变量。
- 返回值 :无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
注意 :
pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了
cpp#include <iostream> #include <pthread.h> #include <cstdlib> #include <unistd.h> #include <string> int g_val = 100; std::string ToHex(pthread_t tid) { char id[64]; snprintf(id, sizeof(id), "0x%x", tid); return id; } void* newthreadrun(void* args) { std::string threadname = (char*)args; int cnt = 5; while(cnt) { printf("new thread, g_val: %d, &g_val: %p\n", g_val, &g_val); g_val++; sleep(1); cnt--; } pthread_exit((void*)123); } int main() { // main thread do northing, wait 5 second recyle new thread pthread_t tid; pthread_create(&tid, nullptr, newthreadrun, (void*)"thread-1"); void* ret = nullptr; int n = pthread_join(tid, &ret); std::cout << "main thread quit,ret_value:n=" << n << " main thread get threadrun ret: " << (long long)ret << std::endl; return 0; }线程还有另外一种接口方式退出,使用pthread_cancle():
cppint pthread_cancel(pthread_t thread);函数参数 :
- 功能 :取消一个执行中的线程
- thread : 线程ID
- 返回值 :成功返回0;失败返回错误码
此时我们让新线程仅仅跑两秒则被主线程退出,观察此时的返回值:
cpp#include <iostream> #include <pthread.h> #include <cstdlib> #include <unistd.h> #include <string> int g_val = 100; std::string ToHex(pthread_t tid) { char id[64]; snprintf(id, sizeof(id), "0x%x", tid); return id; } void* newthreadrun(void* args) { std::string threadname = (char*)args; int cnt = 5; while(cnt) { printf("new thread, g_val: %d, &g_val: %p\n", g_val, &g_val); g_val++; sleep(1); cnt--; } pthread_exit((void*)123); } int main() { // main thread do northing, wait 5 second recyle new thread pthread_t tid; pthread_create(&tid, nullptr, newthreadrun, (void*)"thread-1"); sleep(2); pthread_cancel(tid);// 关闭线程 void* ret = nullptr; int n = pthread_join(tid, &ret); std::cout << "main thread quit,ret_value:n=" << n << " main thread get threadrun ret: " << (long long)ret << std::endl; return 0; }从上述代码可以观察到,pthread_cancel()有点类似于Linux当中的kill命令。当一个线程被取消了,那么其退出结果就是-1,在Linux内核中定义为 PTHREAD_CANCELED 的宏:
3-7 🥙线程分离
我们在使用pthread_join()时,main线程会阻塞等待其他线程。为了让主线程在其他线程运行时也可同时运行。
- 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
- 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
如果我们main线程不关心新线程执行信息,我们可将新线程设置为分离状态,我们可以使用pthread_detach()接口使线程分离:
如果设置了pthread_detach(), 那么在main线程中使用pthread_join()则会报错:
线程分离表示从今往后,main thread再也不关心调用pthread_detach()的线程了,从而多个进程可以实现真正的并发了。但是如果创建的新线程生命周期要比main thread生命周期长的话,当main thread结束,其他线程也会被终止,因为main thread结束,表示进程结束,需要回收进程资源,而其他线程与main thread共享进程资源也会被回收。不论线程是否分离,都要保证main thread最后一个退出 。
所以到底什么是线程分离呢?
线程分离只是一种工作状态,底层依旧属于同一进程, 只是不需要等待了。
3-8 🥟线程ID(tid)及进程地址空间布局
- pthread_ create函数会产⽣⼀个线程ID,存放在第⼀个参数指向的地址中。该线程ID和前⾯说的 线程ID不是⼀回事。
- 前⾯讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最⼩单位, 所以需要⼀个数值来唯⼀表⽰该线程。
- pthread_ create函数第⼀个参数指向⼀个虚拟内存单元,该内存单元的地址即为新创建线程的线 程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
- 线程库NPTL提供了pthread_ self函数,可以获得线程⾃⾝的ID:
前面我们把线程的tid打印了出来,并且因为其太长将其转化为了16进制。我们知道,Linux中没有真正线程,只有轻量级进程。
当程序刚刚启动时,代码还没有执行到pthread_create()时,此时还是进程,而系统中没有线程概念,但是却有轻量级进程概念。那么用户就可以通过接口来管理"线程",比如pthread_create(), pthread_join()... 这些接口我们是通过库函数来调用的:
实际上这个动态库对底层做了封装,所以我们使用的线程库也被称为 用户级线程。而这个线程库属于文件,所以这个库一定是磁盘文件。而这个库文件又是动态库文件,所以它一定会映射到进程的共享空间当中。
进程的内核空间只会提供一些轻量级进程的接口,至于线程id,线程优先级,线程状态,线程栈等等这些进程是不知道的。那么这些信息既然不是进程来管理,是谁在管理呢?实际上 线程的管理工作由库来完成 。如何管理?先描述再组织 !
所以在库中,必须要有描述该线程的结构体,其中包含线程id,线程优先级等等属性字段,具体的描述组织方式如下图所示:
上图库当中的每个线程块,也有自己的结构体,叫做 TCB(Thread Control Block: 线程控制块 ) 。而 每个线程控制块的起始地址就被称为线程的tid !
包括线程的栈结构,也是在动态库内维护的。我们在学习动静态库时学习过,动态库又叫做共享库,也就是说,同一进程下的所有线程都会使用该动态库,那也就意味着,该 动态库可以管理当前进程的所有线程。
在动态库中,我们能够看到 线程的局部存储,我们来举例说明(设置两个线程函数,创建两个线程,两个线程分别调用两个线程函数,函数全部都对同一个全局变量做自减操作):
cpp#include <iostream> #include <pthread.h> #include <string> #include <unistd.h> int g_val = 100; void* threadrun1(void* args) { std::string res = static_cast<char*>(args); while(1) { g_val -= 1; std::cout << res << " is running, " << "g_val : " << g_val << std::endl; sleep(1); } return nullptr; } void* threadrun2(void* args) { std::string res = static_cast<char*>(args); while(1) { g_val -= 1; std::cout << res << " is running, " << "g_val : " << g_val << std::endl; sleep(1); } return nullptr; } int main() { pthread_t tid1, tid2; pthread_create(&tid1, nullptr, threadrun1, (void*)"thread-1"); pthread_create(&tid2, nullptr, threadrun2, (void*)"thread-2"); pthread_join(tid1, nullptr); pthread_join(tid2, nullptr); return 0; }但是如果我们想要每个线程都私有一份全局变量,我们可以将全局变量前加上
__thread, 这表示该全局变量在所有线程中都私有一份,现在我让其中一个线程停止修改g_val的操作,再次打印观察结果:如果你将前后两次局部变量的地址打印出来,你会发现当使用了__thread修饰之后,两个线程的g_val的地址就变得不同了。线程局部存储这种技术相对来说应用还是比较少一点的。但是 局部存储只能存储内置类型 !
3-9 🥩线程切换
* **与进程切换相比,线程切换OS要做的工作少很多?***🤔
- 对,不过主因不是因为寄存器,而是因为一个名为Cache的缓存,在进程间切换时,寄存器自然需要缓存一定的信息,而真正导致进程切换开销大的主要还是Cache。Cache名为缓存,更加靠近CPU,程序在执行代码时,很大概率会接着运行当前代码的下一行代码,所以会将代码数据放在缓存里,这样下次再拿数据时可以从更快的缓存里拿数据了。
- 而当进程切换的时候,Cache上的内容要全部清空,并且需要重新加载新的进程的数据,这个过程的开销是很大的。
- 而线程切换,因为所有线程共享进程资源,所以Cache中的数据不需要做清除并且重新加载,建立映射等等功能。寄存器开销与进程差别并不大。综上所述,线程切换主要因为共享进程资源不需要对cache重新加载数据,所以线程切换OS要做的工作比进程少得多。
可以看出,CPU的缓存都是kb级别的,所以cache加载过程是很消耗资源的。
3-10 🍖线程优缺点
优点 :
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
缺点 :
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
四、😎Linux线程 vs 进程
4-1 🍚资源共享
进程是资源分配的基本单位
线程是调度的基本单位
进程的多个线程共享同一地址空间, 因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个 全局变量,在各线程中都可以访问到,除此之外, 各线程还 共享 以下进程资源和环境:
|___ 文件描述符表
|___ 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
|___ 当前工作目录
|___ 用户id和组id
进程和线程的关系图:
3线程与进程的关系(类比)
- 进程:相当于一个 "工厂"(拥有独立的厂房、设备、资源)。
- 线程:相当于工厂内的 "工人"(共享厂房设备,各自独立干活,调度灵活)。
内核视角的线程实现(Linux 为例)
Linux 没有独立的线程结构体,而是通过 轻量级进程(LWP) 实现线程:
- 每个线程对应一个
task_struct(与进程共用同一结构体)。- 同一进程的线程共享
mm_struct(地址空间)、files_struct(文件描述符表)等核心数据结构。- 线程的唯一标识是
tid(内核态),进程的标识是pid。
4-2🍠线程私有
线程私有重要的两个点:
1、线程的硬件上下文(CPU寄存器的值)(强调调度) 。
2、线程的独立栈结构(强调常规运行)线程并不是所有资源都共享的,每个线程都会拥有自己的独立栈空间,这样一个线程函数内的临时变量就会保存在自己的栈空间内,不会与其他栈空间内的临时变量等资源起冲突。
- 线程共享进程数据,但也拥有自己的一部分私有数据:
|___ 线程ID
|___ 一组寄存器(硬件上下文)
|___ 栈
|___ errno
|___ 信号屏蔽字
|___ 调度优先级
4-3 🍥线程安全
一个线程出问题了,导致其他线程出了问题,导致进程退出。这关乎着线程安全问题。
在多线程应用场景中,如果存在一个公共函数被多个线程同时进入,那么该函数就被重入了。
4-3 🍙线程传参及返回
线程传参与返回值不仅仅可以传内置类型,我们 自定义类型也可以传参与做返回值。线程传参和返回值,我们可以传递级别消息,也可以传递其他对象(包括自定义类型对象)。
下面以类和对象的角度,实现进程传自定义类型,并且打印结果:
cpp#include <iostream> #include <pthread.h> #include <cstdlib> #include <unistd.h> #include <string> #include <vector> #include <cstdio> const int threadrun = 5; // create five number threads class Task { public: Task() {} void SetData(int x, int y) { _datax = x; _datay = y; } int Excute() { return _datax + _datay; } ~Task() {} private: int _datax; int _datay; }; class ThreadData : public Task { public: ThreadData(int x, int y, const std::string &threadname):_threadname(threadname) { _t.SetData(x, y); } std::string threadname() { return _threadname; } int run() { return _t.Excute(); } private: std::string _threadname; Task _t; }; class Result { public: Result(){} ~Result(){} void SetResult(int result, const std::string &threadname) { _result = result; _threadname = threadname; } void Print() { std::cout << _threadname << " : " << _result << std::endl; } private: int _result; std::string _threadname; }; void* handlerTask(void* args) { ThreadData *td = static_cast<ThreadData*>(args); std::string name = td->threadname(); Result* res = new Result(); int result = td->run(); res->SetResult(result, name); std::cout << name << "run result: " << result << std::endl; delete td; sleep(2); return (Result*)res; } // 1. create multi threads // 2. 线程传参和返回值,我们可以传递级别信息,也可以传递其他对象(包括你自己定义的!) int main() { std::vector<pthread_t> threads; for(int i = 0; i < threadrun; ++i) { char threadname[64]; snprintf(threadname, sizeof(threadname), "Thread-%d", i+1); // 向threadname写入当前进程名称 ThreadData *td = new ThreadData(10, 20, threadname); // 每个线程指向独立的线程信息 pthread_t tid; pthread_create(&tid, nullptr, handlerTask, td);// 传参传入 td 对象 threads.push_back(tid); } std::vector<Result*> result_set; void* ret = nullptr; for(auto & tid : threads) { pthread_join(tid, &ret); result_set.push_back(static_cast<Result*>(ret)); } for(auto & res : result_set) { res->Print(); delete res; } return 0; }
五 、😗C++11多线程
果你学过C++11那么,你一定知道C++11也是支持多线程的,那么在编译阶段,makefile就不要带-lpthread来链接原生线程库了,我们以C++11的代码举例:
cpp#include <iostream> #include <thread> #include <unistd.h> // C++11 多线程编程 void threadrun(int num) { while(num) { std::cout << "I am a new thread, num: " << num << std::endl; num--; sleep(1); } } int main() { std::thread t1(threadrun, 10); while(1) { std::cout << "I am main thread " << std::endl; sleep(1); } t1.join(); return 0; }我们编译时发现,不能编译通过,原因竟然是查询不到 pthread_create所链接的库??这里也就能说明 C++11的线程库是封装了Linux的原生线程库的!
这里是在Linux下的情况,为什么要封装呢?Windows环境下呢? 实际上C++具有跨平台性,在Linux下C++会封装Linux的原生线程库,如果把上面的代码搬到windows下来,同样可以运行,这时就会去链接windows下的库,从而实现跨平台性。也就是说,C++标准库在windows和Linux下编译是不同的结果 !
要知道,可不止C++支持线程,java,python,go等许多语言都支持线程库,那么其他语言是如何支持线程的呢?在Linux环境下,这些语言大多都是对Linux下原生线程库进行的封装,只不过java特殊一些,在虚拟机上运行的。
结束语
以上是我对于【Linux文件系统】线程概念与控制:线程基础
感谢您的三连支持!!!











































