『Linux』第九讲:Linux多线程详解(一)_ 线程概念 | 线程控制之线程创建 | 虚拟地址到物理地址的转换

「前言」文章是关于Linux多线程方面的知识,讲解会比较细,下面开始!

「归属专栏」Linux系统编程

「笔者」枫叶先生(fy)

「座右铭」前行路上修真我

「枫叶先生有点文青病」

「每篇一句」 

我与春风皆过客,

你携秋水揽星河。
——网络流行语,诗词改版

用现在的话来说:我不再喜欢你了

目录

一、  进程地址空间的第三次理解

二、线程概念

2.1 理解线程概念

2.2 线程控制---线程的创建

2.3 Linux进程 VS 线程

2.4 线程的优点

2.5 线程的缺点

2.6 线程异常

2.7 线程用途


一、  进程地址空间的第三次理解

在谈线程之前,需要再次理解进程地址空间,为后序讲解线程做准备。

  • 进程地址空间在进程概念篇章已经说过一部分,这是进程地址空间的第一次理解,点击穿越
  • 之后进程地址空间在进程信号篇章已经说过一部分,这是进程地址空间的第二次理解,点击穿越

在这里,是关于进程地址空间的第三次理解,重点: 虚拟地址到物理地址的转换

如何看待地址空间和页表?

  • 地址空间是进程能看到的资源窗口
  • 而页表决定进程正真拥有资源的情况

所以,合理的对地址空间 + 页表进行资源划分,我们就可以对一个进程所有的资源进行分类,合理对资源进行分类后(堆区,共享区、栈区等)就可以通过页表进行映射到不同的物理内存区域

下面对页表进行介绍:

页表中除了要有虚拟地址和与其映射的物理地址以外,实际上还有存储着其他的信息:

  • 是否命中
  • RWX权限(读写执行权限)
  • U/K权限,U:user(用户级权限),K:kernel(内核级权限)

比如我们之前在进程地址空间的第二次理解时,所说的用户级页表和内核级页表,实际就是通过 U/K 权限进行区分的,U就是用户级页表,K就是内核级页表,页表的每一行可以称为一个条目,如图:

以32位平台为例,在32位平台下一共有 2^32 个地址,如果页表单纯只是映射物理内存,那么这张表就需要建立 2^32 个虚拟地址和物理地址之间的映射关系,即这张表一共有 2^32 个映射条目

假设物理地址为4字节,是否命中、RWX权限、U/K权限各占一个字节,加起来最少都有 7个字节了,就按8字节算

这张页表一共有 2^32 个映射条目,就意味着存储这张页表我们需要用 2^32 * 8 个字节,2^32 是 4GB,最后结果是 32GB。而在32位平台下,我们的内存是 4GB,压根就存不下这张页表。也就是说,页表映射并不是这样简单的映射计算

下面讲解虚拟内存到物理内存的转换规则(以32位平台为例)

 每个虚拟地址都是 32个比特位,即 0000 0000 0000 0000 0000 0000 0000 0000,页表映射的过程为:

  1. 选择虚拟地址的前10个比特位在页目录当中进行查找,找到对应的页表(页目录用于索引相应的页表),2^10字节 = 1KB,存储页目录所需的内存是 1KB,且页目录只有一张
  2. 再选择虚拟地址中间的10个比特位在对应的页表当中进行查找,找到物理内存中对应页框的起始地址,每张页表的大小也是 2^10字节 = 1KB,页表是有多张的
  3. 最后将虚拟地址中剩下的12个比特位作为偏移量从对应页框的起始地址处向后进行偏移,找到物理内存中某一个对应的字节数据,2^12字节 = 4KB,与物理内存的页框对应

说明:

  • 之前的篇章也谈过,物理内存是被划分为以 4KB 为大小的块,这个块称为页框;磁盘的也是被划分为以 4KB 为大小的块,这个块称为页帧
  • 我们编写好的可执行程序,也是被划分为以 4KB 为单位进行存储,加载到内存也是以 4KB 为单位进行加载

  • 4KB 实际上就是 2^12 个字节,一个页框的大小也是 2^12 个字节
  • 访问内存的基本大小是1字节,因此一个页框中就有 2^12 个地址,
  • 我们将后 12个比特位作为偏移量,从页框的起始地址处开始向后进行偏移(页表中存着每个页框的起始地址),从而找到物理内存对应的物理地址
  • 页目录可以存储 2^10 个页表的地址,也就是说最多有 2^10 个页表
  • 页目录为一级页表,页目录存储的每张页表的地址,这些页表称为二级页表

 实际上,我们需要用到页表时,OS才会为我们创建相应的页表,不用的话就不创建,这就大大节省了内存空间,即便全部加载全部的页表,也不会占用太多的内存空间

虚拟内存到物理内存映射过程,都是由 MMU(Memory Management Unit)这个硬件完成的,该硬件是集成在CPU内的。页表是一种软件,MMU是一种硬件,所以计算机进行虚拟地址到物理地址的转化采用的是软硬件结合的方式

这就是虚拟内存到物理内存的映射规则

修改常量字符串为什么会触发段错误? 

当我们要修改一个字符串常量时,虚拟地址必须经过页表映射找到对应的物理内存,而在查表过程中发现其权限是只读的,此时你要对其进行修改就会在MMU内部触发硬件错误,操作系统在识别到是哪一个进程导致的之后,就会给该进程发送信号对其进行终止

二、线程概念

2.1 理解线程概念

线程概念如下: 

  • 在一个程序里的一个执行路线(执行流)就叫做线程(thread)或者是线程是进程内的一个执行流。更准确的定义是:线程是 “一个进程内部的控制序列”
  • 一切进程至少都有一个执行线程
  • 线程在进程内部运行,本质是在进程地址空间内运行
  • 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
  • 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流

再谈进程,以进程为切入点理解这些概念

  • 之前的篇章我们所讲的:进程 = 内核数据结构 + 进程对应的代码和数据
  • 一个进程的创建实际上伴随着PCB(进程控制块,Linux是 task_struct)、地址空间(mm_struct)、页表的创建
  • 每个进程都有自己独立的 task_struct 和 mm_struct、页表
  • 虚拟内存决定了进程所能看到的 “资源”

下面只创建 task_struct,并要求创建出来的 task_struct 和原来的 task_struct 共享进程地址空间和页表,创建的结果如下:

事实上,我们所创建的其实就是三个线程,只不过这三个线程共用一张 mm_struct 和 一张页表,这三个 task_struct 就是三个不同的执行流

  • 线程被创建之后,我们可以虚拟地址空间 + 页表的方式对原先的进程进行资源划分,划分相应的资源给线程,所以这里的其中的一个 “进程” 的执行力度,一定要比之前所谈的一个进程的执行力度要细
  • 之前的篇章所谈的一个进程是独占一个虚拟地址空间和页表和与之对应的物理内存,现在在这里,这个进程的对应的虚拟地址空间、页表和与之对应的物理内存资源被几个 task_struct 所划分(被线程划分),所以在这里的一个 “进程” 的执行力度要比之前独占全部资源的进程执行力度要细

思考:

抛开上面的,假设 OS 要真的设计 “线程” 这个概念,那 OS 就必须对 “线程” 进行管理,如何管理?先描述,再组织。先描述就是为线程设计专门的数据结构 TCB(Task Control Block,线程控制块),用来表示线程对象

专门设计线程这个概念,常见的系统有:Windows,专门设计线程概念的结果是:除了要维护进程与进程之间的关系,还要维护进程与线程之间的关系,还要维护线程与线程之间的关系,并且它们代码之间的耦合度极高,带来的结果就是它们之间的关系极其复杂,维护成本极高

  • 一个线程被创建的目的是:被执行被调度,以此衍生:线程一定要有(id,状态,优先级,上下文,栈等等)相关的概念
  • 可以看出,单单就 “线程” 被调度而言,线程与进程就存在许多的重叠(id,状态,优先级,上下文,栈等等)
  • 所以,Linux的工程师,不想给 “线程” 设计相应的数据结构,而是直接复用 PCB,用 PCB 来表示 Linux 内部的 “线程”

所以,在Linux中创建线程,只需创建相应的 PCB 即可,所以在Linux中,线程是在进程的内部 “运行”,线程在该进程的地址空间内 “运行”,拥有该进程的一部分资源

那现在应如何看待进程?

之前谈的进程是:进程 = 内核数据结构 + 进程对应的代码和数据,现在要以全新的视角看待进程:内核视角

以内核的视角看待进程:进程是承担分配系统资源的基本实体

  • Linux进程 = 大量的task_struct + 一个虚拟地址空间 + 页表 + 一部分的物理内存
  • 我们之前篇章所谈的进程 = 一个task_struct + 一个虚拟地址空间 + 页表 + 一部分的物理内存
  • 一个进程的创建:必定要花费相应的资源

那如何看待我们之前篇章所学习的进程概念,与今天的所讲的进程冲突吗? 

答案是不冲突,是互相补充的

  • 之前所谈的进程也是承担分配系统资源的基本实体,只不过,该进程的内部只有一个执行流(一个 task_struct)
  • 而现在所谈的进程也是承担分配系统资源的基本实体,只不过,该进程内部有多个执行流(多个 task_struct)
  • 进程的内部允许只有自己一个执行流,也可以允许有多个执行流,我们之前所学的进程,内部只有一个执行流
  • 以前所讲的 “进程” 只是一个子集,今天所讲的进程才是全貌

  在Linux中,什么是线程?线程是CPU调度的基本单位

站在CPU的角度,历史调度的进程 VS 今天调度的进程?

  • 历史调度的进程,也就是我们之前篇章所谈的被调度的进程,被CPU调度的是:一个进程
  • 今天调度的进程,被CPU调度的是:进程内部的一个分支

实际上,CPU并不关心你是进程(只有一个执行流)还是线程(进程内部有多个执行流中的一个执行流),只要你给了 task_struct,CPU都是无脑执行,所以站在CPU的角度:看待它们都是一个轻量级进程!!CPU看来没有所谓进程、线程,CPU眼里只有轻量级进程

概念总结 

(0)以例子比喻线程:一个家庭(进程),家庭成员(线程) ,线程是进程的一个子集

(1)在Linux内核中有没有真正意义上的线程?

答案是没有,Linux是用进程PCB 来模拟线程的,是一种完全属于自己的一套方案 

(2)站在CPU的视角,每一个 PCB,都可以称之为轻量级线程

(3)Linux线程是CPU调度的基本单位,而进程是承担分配系统资源的基本单位

(3)进程用来整体申请资源,线程向进程申手要资源

Linux是用进程PCB 来模拟线程有什么好处?简单,维护成本大大降低,可靠性高效

  • 与之相反,真正意义上设置了线程概念的Windows系统,它的维护成本极高,进程与线程之间的关系极其复杂,它伴随的就是问题多,可靠性降低。
  • 带来的结果就是:Linux服务器开几年都不用关闭,依旧流畅,而Windows开机一段时间后,很可能就会卡死,很大原因是这个

Linux内核中有没有真正意义上的线程的缺点

OS 只认线程,用户(程序员)也只认线程,没有轻量级进程的概念,所以Linux无法直接提供线程操作的系统调用接口,而只能给我们提供轻量级进程的接口。

  • 但是用户只认线程,所以Linux给我们提供了一个线程库,这个库是用户级线程库,它底层是调用轻量级进程的接口的,这个线程库对这些接口进行封装,上层用户使用这个库看起来像是Linux拥有线程一样
  • 这个线程库的名字叫 pthread,是用户级线程库
  • 任何的Linux系统,必须要提供这个库,这个线程库是默认携带的,这个线程库也称为原生线程库
  • 所以用户需要关心这个线程库所提供的接口,不用关心底层的接口

下面进行对线程的测试,先见一下线程是什么样子的

2.2 线程控制---线程的创建

创建的线程需要用到的函数是 pthread_create,man 3 pthread_create 进行查看:

解释: 

函数:pthread_create

作用: pthread_create - create a new thread(创建一个新线程)


头文件:#include <pthread.h>

函数原型
 int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                          void *(*start_routine) (void *), void *arg);

参数
    第一个参数thread,代表线程ID,是一个输出型参数,pthread_t是一个无符号整数
    第二次参数attr,用于设置创建线程的属性,传入空表示使用默认属性
    第三个参数start_routine,是一个函数的地址,该参数表示新线程启动后要跳转执行的代码
    第四个参数arg,是start_routine函数的参数,用于传入

返回值
成功返回0,失败返回错误码

 第三个参数说明:void *(*start_routine) (void *)

  • 该参数是一个函数指针,用于设置一个回调函数start_routine
  • 该函数的返回值是 void*,
  • 函数参数是 void*,该参数由第四个参数 arg 传入

测试代码:

展示主线程和新线程同时运行,主线程创建新线程,主线程创建完新线程后继续执行,新线程也继续执行

#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;

//新线程
void* start_routine(void* args)
{
    while(1)
    {
        cout << "我是新线程,我正在运行..." << endl;
        sleep(1);
    }
}

int main()
{
    pthread_t tid;
    int n = pthread_create(&tid, nullptr, start_routine, (void*)"thread one");//该参数的内容 thread one 传递给args

    //主线程
    while(1)
    {
        cout << "我是主线程,我正在运行..." << endl;
        sleep(1);
    }

    return 0;
}

 编译结果如下,找不到该函数

原因是我们编译是没有链接线程库 pthread,从这里也说明了Linux没有线程相关的系统调用,Linux没有线程概念,我们需要链接该线程库才可以使用库中所提供的函数,如何链接?在编译时需要加上 -lpthread

动静态库如何使用,在基础IO篇章已经介绍过了

 添加如下:

再次编译就可以了

ldd查看可执行程序链接的库,确实链接了 pthread.so库,.so是动态库

进行运行,发现主线程和新线程在同时运行

ps 查看,发现只有一个进程的PID

ps axj | head -1 && ps axj | grep mytest | grep -v grep

对进程发送9号信号,发现主线程和新线程都被终止了。信号是直接跟进程直接关联,与线程没有直接关系。进程只要收到信号,假设信号是终止信号,进程里面的线程都会与进程一起被终止

上面我们看到的只有一个进程,要查看轻量级进程如何进行查看??

查看轻量级线程相关信息的命令:ps -aL 

运行程序,进行查看。其中,LWP(Light Weight Process)就是所谓的轻量级进程

  • 主线程的PID与轻量级进程ID是一样的
  • 每个轻量级进程的PID都是一样的
  •  每个轻量级进程LWP的ID都是不一样的

所以,CPU在调度的时候,是以 LWP 的ID作为唯一的标识符用来标识一个执行流的,并不是使用PID 

现在把线程的代码注释掉,只留下主线程代码,主线程代码就是我们以前写的代码,内部只有一个执行流,单进程

编译运行,ps -aL 进行查看,PID = LWP,这就是我们以前写的单执行流的代码,只有一个执行流的时候,PID和LWP是等价的

注意:信号是整体给进程发送的,不能单独发给一个 LWP 

下面进行证明,pthred_create 的第四个参数是给第三个参数发送的

#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;

//新线程
void* start_routine(void* args)
{
    //接收参数
    const char* name = (const char*)args;
    while(1)
    {
        cout << "我是新线程,我正在运行...  " << name << endl;
        sleep(1);
    }
}

int main()
{
     pthread_t tid;
     int n = pthread_create(&tid, nullptr, start_routine, (void*)"thread one");//该参数的内容 thread one 传递给args

    //主线程
    while(1)
    {
        cout << "我是主线程,我正在运行..." << endl;
        sleep(1);
    }

    return 0;
}

 编译运行,新线程确实接收到了该参数,所以我们想给新线程传参的话,可以写到第四个参数里面

pthread_create 函数的第一个参数是一个输出型参数,返回的是线程ID,pthread_t 是一个无符号整数。下面进行验证,该参数输出的是什么

typedef unsigned long int pthread_t;

修改代码

#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;

//新线程
void* start_routine(void* args)
{
    //接收参数
    const char* name = (const char*)args;
    while(1)
    {
        cout << "我是新线程,我正在运行...  " << name << endl;
        sleep(1);
    }
}

int main()
{
    //typedef unsigned long int pthread_t;
     pthread_t tid;//用于接受返回的线程ID
     int n = pthread_create(&tid, nullptr, start_routine, (void*)"thread one");//该参数的内容 thread one 传递给args

    //主线程
    while(1)
    {
        char tidbuffer[64];
        snprintf(tidbuffer, sizeof(tidbuffer), "0x%x", tid);
        cout << "我是主线程,我正在运行... " << "返回新线程的tid是:" << tid << "  对它取地址:" << tidbuffer << endl;
        sleep(1);
    }

    return 0;
}

编译运行,十进制打印的结果无意义,这个 tid 是其实一个地址,与我们在系统里面查的 LWP 的 tid 不一样,所以这个 tid 与 LWP的 tid 没有关系,至于这里的 tid 到底是什么,下面进程控制再谈

这就是线程控制之线程创建

2.3 Linux进程 VS 线程

进程是承担分配系统资源的基本实体,线程是调度的基本单位。线程共享进程数据,但也拥有自己的一部分数据

 那什么资源是线程私有的呢?(第2、3点最重要)

  1. 线程ID,PCB属性私有
  2. 一组寄存器(上下文数据)
  3. 独立栈结构(每个线程都有临时的数据,需要压栈出栈)(注:这个后序详细解释)
  4. errno(C语言提供的全局变量,每个线程都有自己独立errno)
  5. 信号屏蔽字
  6. 调度优先级

进程的多个线程共享同一地址空间,因此Text Segment(代码段)、Data Segment(数据段)都是共享的.

  • 如果定义一个函数,在各线程中都可以调用;
  • 如果定义一个全局变量,在各线程中都可以访问到

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

  • 文件描述符表(进程打开一个文件后,其他线程也能够看到)
  • 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
  • 当前工作目录
  • 用户id和组id

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

说明:

  • 没有学线程之前,我们所写的代码都是单线程进程

与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多,少哪些?

  •  进程切换:需要切换页表 && 切换虚拟地址空间 && 切换PCB && 切换上下文数据
  • 线程切换:需要切换PCB && 切换上下文数据
  • 线程切换cache 不用更新太多,但是进程切换需要全部更新cache(主要体现在这点)
  • cache是集成在CPU里面的,是一个硬件,是CPU很重要的组成部分,它具有数据保存的功能,它的缓存速度比寄存器慢,比内存快
  • 寄存器读取数据是直接在 cache 里面读取的,不是直接从内存读取
  • 一个进程只有运行一段时间后,cache 里面才会缓存大量的热点数据
  • 热点数据就是:进程经常使用、经常访问、经常命中的数据(需要进程跑一段时间才会存在大量的热点数据),热点数据是被整个进程共享的
  • 线程切换的时候,cache内缓存的数据不用切换,线程需要用到新的数据直接缓存进cache即可
  • 而进程切换,cache内缓存的数据需要全部切换,新切换的进程需要重新缓存数据,这样效率就比线程慢得多了

2.4 线程的优点

  • 创建一个新线程的代价要比创建一个新进程小得多
  • 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
  • 线程占用的资源要比进程少很多
  • 能充分利用多处理器的可并行数量
  • 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
  • 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
  • I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

说明:

  • 计算密集型:执行流的大部分任务,主要以计算为主。比如加密解密、大数据查找、算法等
  • IO密集型:执行流的大部分任务,主要以IO为主。比如刷磁盘、访问数据库、访问网络等
  • 现代多核CPU一般指:CPU内部集成了多个运算器
  • CPU的核数决定了线程的个数,CPU的个数决定了进程的个数

2.5 线程的缺点

  • 性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
  • 健壮性降低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
  • 缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
  • 编程难度提高:编写与调试一个多线程程序比单线程程序困难得多

线程的健壮性降低,下面进行测试

测试代码,主线程正常运行,新线程发生空指针异常

#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;

//新线程
void* start_routine(void* args)
{
    //static_cast 安全的进行强制类型转换,C++11
    string name = static_cast<const char*>(args);
    while(1)
    {
        cout << "new thread create success, name: " << name << endl;
        sleep(1);
        //一个线程出问题,会影响其他线程么?
        int *p = nullptr;
        *p = 0;//空指针异常
    }
}

int main()
{
     pthread_t tid;//用于接受返回的线程ID
     int n = pthread_create(&tid, nullptr, start_routine, (void*)"thread new");//该参数的内容 thread one 传递给args

    //主线程
    while(1)
    {
        cout << "new thread create success, name: main thread" << endl;
        sleep(1);
    }

    return 0;
}

编译运行,线程全部终止了,ps -aL 查看也没有了,所以线程出异常,会直接影响其他线程的运行,说明线程的健壮性或鲁棒性较差

注意:信号是叫做进程信号,是整体发给进程的

2.6 线程异常

  • 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
  • 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出

2.7 线程用途

  • 合理的使用多线程,能提高CPU密集型程序的执行效率
  • 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)

线程概念完结,下一篇进入线程控制

--------------------- END ----------------------

「 作者 」 枫叶先生
「 更新 」 2023.4.27
「 声明 」 余之才疏学浅,故所撰文疏漏难免,
          或有谬误或不准确之处,敬请读者批评指正。

猜你喜欢

转载自blog.csdn.net/m0_64280701/article/details/130354493