Linux之线程概念

线程的概念

教材概念: 线程是比进程更轻量化 的一种执行流/线程是在进程内部执行的一种执行流.

我们的概念: 线程 是CPU调度的基本单位 , 进程是承担系统资源的基本实体.

首先我们需要明确:

除了文件、通信等操作, 一个进程它要访问的大部分的代码和资源都是要通过进程地址空间+页表的方式去找到资源, 所以地址空间是资源的窗口.

解释概念:

  1. 线程是比进程更轻量化的一种执行流

线程是比进程更轻量化 的一种执行流 ,轻量体现在何处呢?

从资源占用的角度分析, 一个进程要被创建, 要完成一系列的初始化工作, 创建PCB , 分配和初始化内存地址空间, 把程序代码和相关数据从磁盘加载到物理内存中, 初始化文件描述符表, 设置进程调度信息插入调度队列等等一系列工作, 这样看来创建一个进程的成本比较高.

既然创建一个线程也是为了执行代码, 而且资源都是通过地址空间看到的, 那么如果再创建一个"进程", 这个"进程"只需要创建一个PCB, 且与父进程指向同一个地址空间, 共享一块资源呢?

如果这样, 现在我们创建五个这样的"进程", 每个PCB都指向同一个地址空间, 也就是看到同一份资源, 然后通过技术手段(划分页表)把正文代码的五个函数分别分配给五个进程, 其他大部分区域资源共享, 部分资源分别私有. 所以CPU执行时只会执行进程的一部分代码, 访问一部分数据, 这种比进程更轻 的概念就可以叫做一个线程.

所以一个进程先被启动时资源已经申请好了, 后面创建的线程默认只是参与资源的分配.

结论1: 线程创建和销毁更简单, 但线程更轻量(效率高)的主要原因要涉及**局部性原理,**下面再说.

  1. 线程 是在进程内部执行的一种执行流.

举一个例子:

理想情况下, 一个家庭是承担分配社会资源的基本实体. 进程对应于一个家庭, 家庭的任务是把生活过好. 家庭里的每一个人对应一个线程, 每个人都有自己的任务, 每个人各自干着不同的事, 但它们依然在家庭之下, 共同协作完成家庭的任务. 所以我们以前认为的进程是家庭里只有一个人.

此外, 每个家庭通常来说是独立, 每个家庭过自己的日子, 偶尔可能需要一些交流, 对应着进程间的通信.

进程和线程的关系如下图:

结论2 : 线程在进程的地址空间内运行.


线程的理解

系统中线程的实现:

在一个系统中, 如果支持多线程, 系统中一定会存在多个线程. 那么多的线程就要被OS管理, 这就又涉及到了: 先描述, 再组织. 所以就会出现TCB 的概念(先描述), 那么组织就更复杂 , 线程的各种队列(调度, 阻塞, 运行, 挂起队列...), 线程的ID, 优先级, 状态, 上下文等各种数据, 而线程是在进程内部执行的, 所以进程PCB内又要有一种数据结构(比如链表)去管理内部的线程... 而且一个线程也要有自己的一整套算法, 调度算法, 切换算法等. 所以这个先描述再组织的过程也比较复杂.

Linux中线程的实现方案:

设计Linux内核的人看来, 一个线程要被调度的大部分属性在PCB里本来就有, 没有必要再新增一个TCB, 所以把PCB 充当TCB, 所以OS中把进程调度切换 的代码在线程级别复用起来.

操作系统只给了我们关于线程的概念, 那么反过来, 一款具体的OS设计出的线程符合线程的特征, 那就可以称为线程. 所以教材上关于线程的概念和具体某款OS的解决方案可能是不一样的. 实际上, Windows是创建了TCB的, 所以Windows进程和线程的接口是分立的.

那么在CPU的角度, 它怎么知道当前的PCB是进程还是线程?

CPU不用区分当前的PCB是进程还是线程, 因为它们都是执行流, CPU的核心工作是执行代码, CPU只需要找到对应的代码执行即可. 目前来看, CPU中的 执行流<=进程 的.

我们该如何看待 现在 认为的进程 和之前认为的进程 呢?

现在整个红色区域的资源, 都认为是进程, 进程=内核数据结构+代码数据; 而以前认为的进程只有一个执行流, 现在的进程有多个执行流, 多个执行流只是参与进程资源的分配 , 解释了我们的概念之一, 进程是承担系统资源的基本实体.

总结一下, 我们以前认为的进程是内部只有一个执行流的进程, 现在认为的进程是内部有多个执行流的进程. 而一个进程内那些执行流的PCB都是对等的, 无关创建的先后. 所以现在我们认为的PCB严格来说并不是进程控制块 , 更像是一种执行流的描述符.

所以CPU拿到的PCB都是对等的, Linux中不存在真正意义上的线程, 只是用进程的数据结构模拟了线程**, 所以现在我们把每一个PCB对应的执行流** 叫做轻量级进程, (概念是对应也就是线程). 所以现在即使一个进程里只有一个执行流, CPU也认为自己拿到的是轻量级进程, 而不是进程, 进程的概念已经更改.

这也就解释了我们开头的另一个概念,线程是CPU调度的基本单位.


线程的简单控制

可以先简单地借用系统调用接口, 创建线程来验证一下之前的结论:

线程的创建

int pthread_create(pthread_t* thread, const pthread_attr_t *attr, void*(*start_routine)(void*), void* arg);

头文件: pthread.h

功能:创建一个新的线程
参数:

thread:返回线程ID

attr:设置线程的属性,attr为NULL表示使用默认属性

start_routine:是个函数地址,线程启动后要执行的函数

arg:传给线程启动函数的参数
**返回值:**成功返回0, 失败返回错误码

因为在Linux内核中不存在线程概念, 更没有TCB数据结构及其管理算法, 而我们所说的线程, 都是在宏观层面面对所有操作系统, Linux操作系统中当然也没有提供创建线程的系统调用, 它只能提供创建轻量级进程的系统调用.

所以我们需要在创建线程时调用一个线程库, 库中会通过一些系统调用创建轻量级进程. 在用户层程序员创建线程, Linux内核中创建轻量级进程, 双方的要求都能满足.

因为这个线程库是所有Linux操作系统必须自带的, 所以也叫做原生线程库

所以在编译时加上选项 -lpthread


PID和LWP

程序运行:

可以看到主线程和新线程的进程pid都相同, 全局变量的地址和数值都相同, 因为它们都**共享同一块地址空间,**所以线程间通信理论比进程间通信更简单.

输入ps ajx | grep ...的指令进行进程查看, 虽然现在有两个线程在运行 但我们只能找到一个进程, pid为174460:

ps -aL(L必须大写)查看线程, 此时可以看到testThread有两个线程, 它们的PID值都为174460, 但LWP值为174460和174461

其中PID是进程标识符, LWP(Light Weight Process)是轻量级进程标识符. 由于第一个线程的LWP和PID是一样的, 所以这个线程就被叫做主线程.

我们说过, CPU在调度PCB时将它们一律看作轻量级进程, 而不同进程的PID一定不同, 而同进程的线程PID是相同的, 所以只有LWP才能作为标识符来标识执行流的唯一性.

线程 是CPU调度的基本单位, 所以操作系统在CPU的调度之下去调度进程的时候, 是根据LWP去调度执行流的, 只不过之前我们的进程只有一个执行流, PID和LWP数字是相同的.


补充问题:

线程切换的效率为什么高? (重新解释线程的轻量化)

  1. 结论一就提到过, 一个线程在创建 的时候, 线程的PCB的地址空间和页表是不用变化的, 保存它们的CPU的相关的上下文寄存器就不用变化, 如果是线程间切换( 同一个进程内), 相关寄存器也不用变化, 只是一些临时变量寄存器需要更改; 但是进程间切换 是需要把CPU内的所有寄存器都切换的. 但是这并不是主要矛盾. 也就是之前提到的更"轻量".

  2. 最影响效率的是调度时的轻量化.

CPU内有一种存储器叫cache, 它的容量也不算小:

关于局部性原理:

空间局部性(Spatial Locality): 这是指如果一个数据项被访问, 那么与它相邻的数据项在未来也很可能会被访问. 这种特性是预加载机制的理论基础. 预取技术是一种主动将数据从慢速存储设 备(如主存)加载到快速缓存 (如CPU缓存)中的技术,以期望在未来这些数据会被访问. 基于空间局部性的预取策略会尝试加载当前被访问数据项附近的数据项到缓存中,以提高未来访问的命中率

这并不是说预加载的数据一定是有效的, 但是由于局部性原理, 这种概率是比较低的, 所以程序仍可以在较高效率的情况下运行. 我们一般把缓存到cache里的数据称为热数据.

基于以上描述, 如果是线程切换, 理论上是不需要切换cache里的数据的, 即使可能热数据失效, 但是概率较低; 如果是进程间切换, A进程的cache数据对于B进程是没有意义的, 所以进程间切换cache里的数据是被重新缓存的. 这就是线程切换比进程切换效率高主要矛盾.

CPU在调度的时候, 线程间时间片改怎么分配呢?

假设当前进程是10ms, 现在又创建了五个线程, 那么这五个线程必须要瓜分这10ms(总时间不能增多), 也就是说当创建新的线程的PCB时, 不会为它重新分配10ms, 而是要去瓜分这10ms, 因为时间片本身也是资源.


重谈地址空间---虚拟->物理地址

页框

首先我们知道32位下文件系统IO的基本单位是4kb(page size) , 对应的磁盘上的可执行文件 中的内容也被划分为了4kb大小的页帧 ,物理内存 实际是被划分成4KB大小的页框. (特殊情况页框大小可以被更改). 也就是说即使磁盘文件加载到物理内存中只需要加载并修改1bit位的数据, 也要以4kb为单位.

这些页框当然需要操作系统的管理, 同样是先描述, 再组织:

cpp 复制代码
struct page
{
    //page属性

    //描述一个page的使用情况
    int flag;
    //其它属性
    //...
}

如果我们再将多个结构体对象放在一个数组中: struct page pages[1048576](4GB/4KB); 此时所有的页框就被管理起来了, 操作系统会用LRU, 伙伴系统之类的调度算法管理这些数组, 可自行查阅资料了解.

补充: 我们之前提到过, 文件的struct file , 其中一定要有inode, 方法集, 缓冲区. 文件的缓冲区的本质是缓冲区与物理内存中属于这个文件的页框 的 struct page 相对应.

理解多级页表

我们已经知道了页表 可以储存虚拟地址物理地址的映射, 而且转换物理地址的过程是将CPU内eip指令寄存器内的虚拟地址 通过页表MMU共同完成的, 但是我们对页表的理解仍不完善.

4G内存的机器中, 如果页表就是单纯的一张表存储虚拟和物理内存之间的映射关系, 那么这张表就需要建立2 ^ 32 个虚拟地址和物理地址之间的关系, 就有2 ^ 32个映射项 , 假设页表项只有虚拟地址和物理地址两个内容 (实际还有其它信息), 这就至少需要使用 2 ^ 32 * 2 * 4个字节(32G)来存储这张表. 这样的话4G内存的机器连页表都装不下. 所以在32位平台下, 页表的映射过程并非直接映射:

页目录与页表

实际上, 页表是由页目录页表项组成的. 那么通过一个32比特位的虚拟地址又是怎么找到一个物理地址的呢?

由于一个地址总共32个比特位, 我们把它分为三个部分, 高位10个比特位, 中间10个比特位, 低位12个比特位:

1、选择虚拟地址的高位十个比特位作为页目录的下标, 页目录对应的内容指向下一级页表.

2、再选取虚拟地址的次十个比特位在二级页表中进行查找, 找到物理内存中对应页框的起始位置

所以前两步工作主要是为了帮助我们找到地址所在的页框的起始地址.

3、最后将虚拟地址的剩下12个比特位 作为偏移量 从对应页框的起始地址向后进行偏移, 找到物理内存中某一个对应的字节数据**, 注意 2^12 = 4kb,**正好就是一个页框的大小, 所以这些设计都是相关联的.

所以最终我们使用了1张一级页表和2 ^ 10 张二级页表, 设每一条表项10字节, 那么只需要使用 (2 ^ 10 + 1) * 2 ^ 10 * 10 差不多10MB就可以将所有页表加载到内存中了.

注意: 对于每一个进程而言, 一级页表(页目录)是必须被加载和创建的(在内存中常驻), 二级页表是可以选择性创建的 。根据局部性原理可能目前访问的代码数据都集中在固定的几个(二级)页表里面 , 其它的(二级)页表其实就不着急创建, 如果当进程首次访问某个虚拟地址时, 该地址对应的二级页表项尚未创建, 系统会触发缺页中断, 并在处理该异常时创建所需的二级页表项. (所以其实页表占的总大小又大大降低了).

此外:

  • 一级页表(页目录表) : 物理起始地址通常存储在CPU的CR3寄存器中(x86架构)
  • **二级页表:**地址通过一级页表中的页目录项内容来间接引用.

上面所说的所有映射过程, 都是由MMU (MemoryManagementUnit 内存管理单元)这个硬件来完成的, 该硬件被集成在了CPU中.

MMU从CR3中读取页目录的基地址, 然后使用虚拟地址中的页目录索引(10bit)和页表索引(10bit)来访问相应的页表项, 并从中获取物理页面的地址. 然后, MMU将虚拟地址中的页内偏移量(12bit)添加到页框地址上, 从而得到最终的物理地址. (所以CPU其实就直接计算好了物理地址, 然后通过地址总线映射物理内存).

而页表是一种软件映射- ---操作系统通过创建、更新和维护页表来提供软件映射, 而MMU是一种硬件映射 , 所以计算机进行虚拟地址到物理地址的转化采用的是软硬结合的方式.

现在再来理解我们之前提到的,线程分配代码数据的本质, 就是在划分页表, 划分地址空间.


线程的优缺点

线程的优点:

  1. 创建线程, 调度线程, 释放线程整体的量级要比进程轻

  2. 调度的时候充分利用多处理器可并行的数量

  3. 我们的进程可以按类别划分为两类:

计算密集型应用: 加密解密, 打包压缩等, 为了能在多处理器系统上运行,将计算分解到多个线程中实现, 比如压缩10GB文件可以分为5个线程每个线程压缩5GB.

**IO密集型应用:**线程可以在等待一个I/O操作完成时开始另一个I/O操作,或者执行其他计算任务。通过这种方式,处理器可以在等待I/O操作完成时保持忙碌状态,从而提高整体性能。

线程的缺点:

**缺乏访问控制:**线程能共享进程的地址空间和其他资源, 一个线程可能无意中修改或破坏另一个线程正在使用的数据, 从而引发问题.

**健壮性降低:**多进程由于进程间的独立性, 一个进程崩溃不会影响另一个进程. 多线程如果一个线程崩溃, 整个进程都崩溃.

性能损失: 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器. 如果计算密集型线程的数量比可用的处理器多, 那么可能会有较大的性能损失, 这里的性能损失指的是增加了额外的同步和调度开销, 而可用的资源不变.

编程难度提高: 编写与调试一个多线程程序比单线程程序困难得多

进程VS线程

进程资源分配 的基本单位
线程调度的基本单位

进程的多个线程共享同一地址空间,因此 Text Segment、Data Segment 都是共享的, 如果定义一个函数, 在各线程中都可以调用, 如果定义一个全局变量, 在各线程中都可以访问到.

除此之外, 各线程还共享以下进程资源和环境:

文件描述符表(多个线程都指向同一个struct files)

handler表, 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)

当前工作目录(cwd)

用户id和组id

线程共享进程数据, 但也拥有自己的一部分数据:

上下文寄存器(动态切换)

栈(动态执行)

线程ID

调度优先级(线程被独立调度)

信号屏蔽字(pending, block位图)

errno


相关推荐
JunLan~3 小时前
Rocky Linux 系统安装/部署 Docker
linux·docker·容器
方竞4 小时前
Linux空口抓包方法
linux·空口抓包
海岛日记5 小时前
centos一键卸载docker脚本
linux·docker·centos
AttackingLin5 小时前
2024强网杯--babyheap house of apple2解法
linux·开发语言·python
学Linux的语莫7 小时前
Ansible使用简介和基础使用
linux·运维·服务器·nginx·云计算·ansible
踏雪Vernon7 小时前
[OpenHarmony5.0][Docker][环境]OpenHarmony5.0 Docker编译环境镜像下载以及使用方式
linux·docker·容器·harmonyos
学Linux的语莫7 小时前
搭建服务器VPN,Linux客户端连接WireGuard,Windows客户端连接WireGuard
linux·运维·服务器
legend_jz7 小时前
【Linux】线程控制
linux·服务器·开发语言·c++·笔记·学习·学习方法
Komorebi.py7 小时前
【Linux】-学习笔记04
linux·笔记·学习