【Linux线程】Linux系统多线程(一):线程概念

🎬 个人主页艾莉丝努力练剑
专栏传送门 :《C语言》《数据结构与算法》《C/C++干货分享&学习过程记录
Linux操作系统编程详解》《笔试/面试常见算法:从基础到进阶》《Python干货分享

⭐️为天地立心,为生民立命,为往圣继绝学,为万世开太平


🎬 艾莉丝的简介:


文章目录

  • [1 ~> Linux线程概念](#1 ~> Linux线程概念)
    • [1.1 直入主题](#1.1 直入主题)
    • [1.2 Linux线程的一种实现方式:轻量级进程("轻一点")](#1.2 Linux线程的一种实现方式:轻量级进程(“轻一点”))
      • [1.2.1 什么是轻量级进程?](#1.2.1 什么是轻量级进程?)
      • [1.2.2 重新定义进程](#1.2.2 重新定义进程)
      • [1.2.3 CPU如何看待?](#1.2.3 CPU如何看待?)
      • [1.2.4 创建进程和创建线程本质上是不一样的。](#1.2.4 创建进程和创建线程本质上是不一样的。)
      • [1.2.5 整理](#1.2.5 整理)
    • [1.3 执行流资源划分的本质:内存管理](#1.3 执行流资源划分的本质:内存管理)
      • [1.3.1 这个结构体并不大!](#1.3.1 这个结构体并不大!)
      • [1.3.2 三个主要结论](#1.3.2 三个主要结论)
      • [1.3.3 文件到物理页框的映射](#1.3.3 文件到物理页框的映射)
      • [1.3.4 执行流资源划分本质的思维导图](#1.3.4 执行流资源划分本质的思维导图)
    • [1.4 理解页表和虚拟地址到物理地址映射](#1.4 理解页表和虚拟地址到物理地址映射)
      • [1.4.1 页表](#1.4.1 页表)
      • [1.4.2 二级页表](#1.4.2 二级页表)
      • [1.4.3 实验:创建线程](#1.4.3 实验:创建线程)
      • [1.4.4 交付概念](#1.4.4 交付概念)
    • [1.5 Linux线程的验证](#1.5 Linux线程的验证)
  • 结尾

1 ~> Linux线程概念

1.1 直入主题

必须先谈一下进程是什么?进程 = 内核数据结构 + 自己的代码和数据(具体实现角度去谈的,抽象 -> 具体)

今天给进程再下一个定义:从内核视角出发的

  • 进程是承担分配系统资源的基本单位。

我们再给线程下个定义:

  • 线程是进程内部的一个执行分支(概念)

线程的粒度比进程要细一些、小一些,或者更轻一些?

1.2 Linux线程的一种实现方式:轻量级进程("轻一点")

1.2.1 什么是轻量级进程?

这里的task_struct叫做轻量级进程

系统当中,线程个数比进程更多。

线程是在进程的地址空间内部:

线程也有自己的唯一性标识------线程也需要被操作系统管理起来,那必然要有一个结构体,struct TCB {}

线程在OS内如果存在,那么就一定会存很多?OS也就必然要对线程进行管理------要管理,那必然是"先描述,再组织"。

  • TCB:Thread Ctrl Block

线程内部是不是有自己的结构呢,也有状态也有优先级。

线程要作为进程的一部分,PCB 里面是不是还会维护一个线程链表来维护呢?这个系统一定会设置的非常复杂,Windows 就是这样干的所以有时候会有莫名其妙的错误。Linux 这里就比较聪明了,没有给创建内核级的 TCB,并且要搞的话是不是还得给你整调度算法啊!

所以复用进程的 PCB,实现线程的效果------轻量级进程。

1.2.2 重新定义进程

我们需要重新定义一下进程了:

  • 进程:内部的执行流 + 地址空间 + 代码和数据,合起来叫做进程。

线程(轻量级进程)和进程的关系。

一个进程有多个执行流进程。

今天谈的和之前的概念完全适配,今天的进程叫做多执行流进程,内部可能有一个或多个PCB。

我们以前学的是特殊情况,今天是普遍情况。

创建线程只需要创建PCB,然后分配就行啦,所以说线程比进程更小、更细、更轻。

故事:全世界的社会资源分配的单位如果是家庭,假设每个家庭有一个自己的房子,今天内部通常会存在很多的人,每一个人都在做各自的事情,今天内部,会有各种各样的成员,各自做着各自的事情------其实都在共同实现同一个目标:我们都在完成把家里的日子过好这样一个目标------社会交给家庭的任务。

今天的线程其实是轻量级进程的多执行流进程,以前的"进程"只是单执行流的特殊情况,相当于一个家里只有一个家庭成员,而线程就是有各种各样的家人,为了一个同样的目标------"把家里面的日子过好"------把家庭看成进程,把家庭内部的一个一个成员叫做线程。

  • 概念回归:进程是承担分配系统资源的基本单位。

每一个进程内部创建线程,在进程内部再分配资源。

创建进程和创建线程本质上是不一样的。

我们以前学习到的进程算一个家庭里面只有一个人了!

这就是线程,一个执行流可能是曾经的一部分。

什么叫是进程内部的执行分支,具体来说就是在进程的 PCB 中再单独创建一块地址空间,把代码给他一部分,就是线程。这个线程是在进程的地址空间内部,每创建一个线程都是复用,然后给一块代码和数据。这样我直接复用进程的就能实现线程的效果。

1.2.3 CPU如何看待?

CPU 调度进程的时候会看到很多很多 task_struct,有可能执行的是某一个单独的进程,也可能是某一个进程中的一部分(某一个执行分支)(线程)。Linux 中我们也可以把 task_struct 叫做轻量级进程(不喜欢叫做线程)------之前的调度算法,状态切换依旧是适用的。

进程状态的调度、切换、优先级,依旧适用。

1.2.4 创建进程和创建线程本质上是不一样的。

要创建执行流,创建地址空间,创建等等。进程承担系统分配资源,这都是为了未来给线程分配。

以前创建进程就是我生了个儿子,但是他独立出去了,有了自己的家庭。

我们以后不把PCB叫做进程了,而是把那一整块叫做进程(一个或者多个PCB,地址空间,页表等)。

我们说把进程的资源分配一份给线程(执行流),咋分配的呀?怎么分配啊?怎么让不同的执行流执行进程中的不同代码呢?

1.2.5 整理

1.3 执行流资源划分的本质:内存管理

重谈虚拟地址空间(谈页表)------具体查表的过程。

我们从磁盘中读写以 4KB 为单位,这个之前说过好多次了。

关键是读写磁盘时,读到哪里,从哪里来写到磁盘?是和物理内存在进行 IO,数据从内存来。IO 基本单位 4KB。物理内存逻辑上也是以 4KB 为单位管理的,为了方便计算,物理内存是被划分成了一个个块的。比如我们内存 4GB,4GB/4KB ,我们把一个 4KB 的这块叫做页框或者页帧,一页数据。我们在内存管理视角更喜欢叫页框。

写时拷贝,也不是变量,而是以4KB为单位的一块进行写时拷贝的。

那我们难道物理内存是像巧克力板子一样一块一块这样的嘛,当然不可能。硬件上其实是没有这样 4KB,4KB 的东西的,我们可以想成一个空房子,里面放啥是我们决定的,软件决定的。有的内存块是数据可读可写,有的是代码可读可执行,有的是其它的属性数据,有的是保存的 OS 的代码,有的是被划分到内核级缓冲区里面。有的内存块是缓冲文件的代码和数据,处于被占用状态。有的是脏的需要被刷新,等等。所以不同的内存块,都会有自己的状态。OS 需要对所有的物理内存块进行管理,OS 必须对多个数据页框进行管理------如何进行管理?------"先描述再组织"。

  • "先描述,再组织"。必然有内核数据结构
c 复制代码
// 不会被设置的太大
struct Page 
{
   int flags; // 标志位,最重要的
}

必然有内核数据结构------

1.3.1 这个结构体并不大!

这个结构体本身不大!

再组织怎么组织,不同的系统差别比较大。在 Linux 中,在内核里往往会存在一个 --采用的是数组的方式,会在 OS 定义一个全局的数组,有多少个页框就是多大。

看这棵树:

树根,包含一个节点,类似于一个数组,slots 指向一个个 struct page。只要我们知道一个 page的地址想知道下标的话,转换一下。

不一定长这样,但是是个数组!

每个 page 是不是都会有下标,0 对应第一个 4KB 依次下去。我必须知道物理地址才能访问它。下标可以转换为物理地址嘛?

Index << 12 || index * 4KB,我们就直接把下标转成物理地址。

地址 4KB 对齐,那么低 12 位是全 0。

我知道一个页的地址能不能直接转成下标?物理地址 >> 12

理解物理内存的管理,知道了任意一个页的地址,能不能转成下标?Index >> 12就行了,低12位清零。

任意一个地址(高20位:页框地址,低12位无非就是任意地址在这个页框中的偏移量)------任意一个地址呢?

  • 1、对应那一个页框?任意地址 && 0xFFFFF000
  • 2、对应的 struct page 怎么找?本质是如何对应数组下标:>>12 位(4KB = 2^12)

1.3.2 三个主要结论

  • 结论一:OS 管理物理内存,是以 4KB 为基本单位的。(写时拷贝也不是变量而是一块进行写时拷贝的)
  • 结论二:页地址,任意地址,数组下标可以互相转换
  • 结论三:无论是进程,还是文件,还是其他模块,要向物理内存申请内存的过程,本质就是申请 struct page 结构体和下标的过程。

1.3.3 文件到物理页框的映射

我们来看看页表的标志位:

  • 使用不同的比特位表示不同的状态

所谓的文件缓冲区就是一个或多个 Page。我们可以看到 Page 里面也是引用计数的。

c 复制代码
Struct Page    // 不会被设置的太大
{
  int flags    --->标志位,Page里面最重要的
}
  • 地址4KB对齐,地址低12位是全0。

所谓的文件缓冲区就是一个或者多个Page。

Page里面也是引用计数的。

只要是联合体------只会用其中一个字段------不会太大。

Page就64字节,不大!

在Linux中,归根结底在内核里会存在一个struct page。

操作系统内部定义一个全局的数组(大数组):

1.6%内存空间用来保存这个大数组。

对内存块的管理转换成对这个大数组的管理。

每个Page是不是都会有一个下标,0对应第一个4KB,依次下去。

光知道下标有什么用呢?必须知道物理地址才能访问它啊!

下标可以转换成物理地址嘛?

  • 可以。把下标*4KB(Index * 4KB)或者左移12位(Index << 12),就可以转换成物理地址了。

右移是位操作,对CPU来说是非常快的!

  • 本质是右移12位(>>12)

低12位清零高20位保留------右移12位,把任意地址转换成数组下标。

页框地址、任意地址,数组下标可以互相转换。

页框的地址找到了,到时候就告诉操作系统:物理内存申请好了!

这个内核结构也是一个树形结构:

字典树的一种,没听过字典树,我知道多叉树,树根包含一个节点信息,包含一个slots。

slots内部是一个个节点。

这样Struct page就知道自己在哪个页框里了。

1.3.4 执行流资源划分本质的思维导图

1.4 理解页表和虚拟地址到物理地址映射

虚拟、物理内存都有了,因为页表是虚拟到物理的映射,页表左边是虚拟地址右边是物理地址,页表还有标志位,假设这样一行页表按10字节算,做虚拟地址到物理地址的映射,10字节2^32,这样算下来就是4GB10=40GB!40GB的1%,也有400MB,光一个进程的一张页表,我的物理内存都装不下!页表在逻辑上是key-value虚拟到物理的映射这样的结构,但是物理上一定不是。

  • 理解了页表,才能够理解进程是怎么做资源划分的呢?饶了这么大一圈就是为了理解什么是线程!

1.4.1 页表

单纯使用页表,访问到某个列。

这段代码是 Linux 内核中关于 ARM 架构(特别是 v6/v7 时代)页表项(PTE) 定义的核心片段。简单来说,它定义了 CPU 如何理解一段内存的"性格"(能不能读、能不能写、是不是缓存)。

1~12个标志位对应12个比特位,和page的很多标志位是对应的。

  • 对于一个进程来说:进程拥有多少资源,本质是取决于这个进程拥有多少个有效虚拟地址(本质就是虚拟地址经过页表映射的页表条目越多)!

虚拟地址空间本质是一个窗口------进程拥有的资源有多少,就是虚拟地址空间的这个窗口开得多大,有效的虚拟地址越多,窗口越大(------本质就是虚拟地址经过页表映射的页表条目越多),拥有的资源就越多。

所以,把一个进程的资源进行划分,本质是:把这个进程相关有效虚拟地址进行划分,就可以了!

再本质一点:就是大家在 划分页表所映射的页框


1.4.2 二级页表

下面是质变内容!非常非常重要!

怎么把一个资源绑定给一个执行流。

数组下标可以和物理地址进行映射,可以进行转换。

32个比特位,前10个比特位是一块,中间10个是一块,后面12个是一块,每个虚拟地址被划分成了101012三个部分一共32个比特位。

虚拟地址不是铁板一块,而是被MMU,把虚拟地址看成了三块,分别是10/10/12(1024,1024,4096【刚好是页框大小】)。

页目录(4KB,1024项的,每一项4字节)的下标就是虚拟地址的前10位------未来可以用前10位来索引。

页目录保存的也是页框的地址。

页表中也存特定页表的地址。

这就叫做【两级页表】机制,64位平台是四级页表机制,不考虑。

  • 特定页表的地址,我可以随便填写。

拿着虚拟地址的前10位就可以索引页目录,在页目录里就可以索引页目录数组,页目录、页表都是做数组下标的,数组内容是特定页框的地址,换言之,拿着虚拟地址前20位就可以索引到具体哪一个页框了!数组内容是页表里面特定页框的地址,可以随便填写!

我拿着对应物理页框的起始地址 + 虚拟地址低12位(页内偏移量)就可以索引物理内存中的地址了。

结论:页表映射,只会先映射到对应的页框, 对应物理页框的起始地址 + 虚拟地址低12位作为页内偏移就可以拿到具体提供字节的物理地址了。

  • 多叉树,树形结构

CPU内部有硬件转换单元MMU、寄存器。

虚拟地址是在编译代码的时候就映射好了的。

这样设定的话,页表的大小就能够符合要求了吗?

一个页表(4KB)对应1024个页框(4MB),打满的话,两级页表,1024*1024------整个页表最大就是4MB

不存在大页表,页表拆成了两份。

是在进程加载的时候创建页表,只要使用了内存,这个特定页框的地址也填写进去了,是操作系统OS帮我填充。

编译代码的时候,很多的连续代码和数据都会聚集在同一个页内,因为属于低12位,虚拟地址在页框也就直接能够找,连续编址是4KB4KB的进行编址的。

并没有到字节级的映射,而是页框级别的映射:

虚拟地址本身不是铁板一块,被MMU把虚拟地址看作成了三块10,11,12(1024,1024,4096(刚好是页框地址大小))。搞个页目录(4KB,1024项,每一系项4字节),拿虚拟地址前10位来索引页目录,确定是那一个页表,再拿中间10位去索引页表,页表里面保存的是页框的地址。其实我们把页目录 + 页表叫做两级页表的结构,64位是四级先不考虑。所以前20位就可以索引到具体那个页框了(数组内容是页表里面页框的地址,可以随便填)。拿着对应物理页框的起始地址 + 虚拟地址的低12位(页内偏移)

页表映射,只会先映射到对应的页框,拿着特定页框的地址 + 虚拟地址低12位(页内偏移)就可以拿到具体提供字节的地址。

是在进程加载的时候创建页表,加载代码和数据的时候虚拟地址啥的有了之后页表创建好了,只要使用了内存的话这个特定页框的地址也就填写进去了,谁填的其实是OS来操作的。

编译代码的时候连续的代码和数据都会聚集在同一个页内部,因为他属于低12位。

虚拟地址可以映射到物理内存的任意一个页框。页表条目4字节。32个比特位。其实页表当中只能前20个比特位就可以,那还有12个呢,可以作为权限标志位(不能和我们描述一个Page的标志位矛盾,必须相辅相成的)。

对于一个进程来讲:进程拥有多少资源,本质是看这个进程拥有多少个有效虚拟地址(虚拟地址经过页表映射的条目越多)。

虚拟地址空间本质是一个窗口,有效虚拟地址越多窗子越大,拥有的资源就越多。

所以把一个进程的资源进行划分,本质是:把这个进程的相关的有效虚拟地址进行划分就可以了------划分页表所映射的页框。把代码资源划分给指定线程,本质是只要让不同的线程执行不同的函数即可,因为函数在编址的时候,已经做了地址空间的划分。

1.4.3 实验:创建线程

在Linux当中,创建线程需要调用:

属性设为nullptr,我不管它。

下面要看到快速创建一个线程的结果。

参数为void*(这个参数就是第四个参数,即void *restrict arg,作为回调函数的参数)返回值也是void*的入口函数:

总结一下:

新线程执行上面的逻辑:

主线程执行下面的逻辑:

代码如下:

两个死循环不可能在一个单进程里面同时跑。

Ubuntu是可以编译通过的(支持第三方库):

  • -l选项

我们学的线程其实是库级别的,为什么之后说。

依赖哪些库我们是看不出来的:

同时打印:

可能存在单执行流里两个死循环一起跑?不可能,这里肯定是多执行流。

主线程和新线程都在同一个进程里面,我们查也是查同一个进程,所以我两个执行流执行的是同一个进程的同一份。

运行验证一下,是不是同一个pid:


运行一下:

确实是同一个进程,但是还是看不出来

  • -a:系统中的所有的执行流
  • -L:查看轻量级进程

Linux中,多线程是用轻量级进程来模拟的。

在操作系统和CPU的视角,操作系统和CPU调度的基本单位是:线程(在Linux当中,就相当于"轻量级进程")。

进程:承担分配系统资源的基本实体!

PID是进程级别的,看ID我主要看的是LWP。

以前看PID还是LWP无所谓,两者相等,是特殊情况,因为以前只有一个执行流,所以PID等于LWP。

  • 修改一下代码,看看是不是把新线程的内容打过去了
cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <pthread.h>    // 线程

// 两个死循环,一个执行流不可能同时跑两个死循环,验证确实是多个执行流
void *hello(void *args)
{
    const char *name = (const char*)args;
    while(true)
    {
        std::cout << "我是新线程...,pid : " << getpid() << std::endl;
        sleep(1);
    }
}

int main()
{   // 创建线程
    pthread_t tid;
    pthread_create(&tid,nullptr,hello,(void*)"new-thread"); // 调用hello

    while(true)
    {
        std::cout << "我是主线程...,pid :  " << getpid() << std::endl;
        sleep(1); 
    }

    return 0;
}

运行一下:

打通一下应用层。

反汇编查看一下:

也就是说:我 把代码资源划分给指定的线程,本质是只要让不同的线程执行不同的函数即可!因为函数在编址的时候,已经做了地址空间的划分。

不同的虚拟地址,做物理地址映射的时候,映射到不同的页框,不就是划分了进程的资源,执行了不同的函数,这不就是我们说的执行了一部分代码嘛!

1.4.4 交付概念

共同完成一个工作,拆成多个线程,各自完成各自的函数。

什么叫做进程资源的合理分配?一人一个函数,每个线程可以main函数为入口,以hello函数为入口......。

1.5 Linux线程的验证

上面我学习的都是Linux下的线程!在Linux系统内部有没有存在真正意义上的struct TCB这样的结构?没有!PCB模拟线程~~>轻量级线程(Linux内部,所有的PCB执行流都叫做轻量级进程,本质上是用轻量级进程平替了线程的概念)。

Linux内部只有轻量级进程的概念,没有线程的系统调用,只有提供创建轻量级进程的创建接口,这个接口在Linux当中叫做:

创建执行流有两种方式,一种是fork(底层封装了这个clone)------(只不过是标志位让我复制拷贝了页表等结构,本质上是创建了一个新的进程)创建全新进程,就是没有直接创建线程的接口,轻量级进程和线程在模式上是一样的,但是我现在不要把两个混为一谈了,在Linux内部就是没有线程只有轻量级进程,意味着对上层也只会提供了创建轻量级进程的接口。

并不存在TCB这样的接口。

不是世界上所有的程序员都是Linux的工程师,所以,也不是所有人都懂Linux。怎么线程到你Linux这里成轻量级进程了呢?内核层面上也没有线程啊,操作系统层面上就不存在线程了嘛!这不是没有线程感了吗?这不就被其它系统比下去了吗?在应用层,Linux对上会封装一层软件层,表现出线程的概念,所以我就看到了LWP,这个封装的软件层就叫做 pthread库(原生线程库)!这样用户在这个库之上就可以进行线程操作了:

  • Linux线程,属于用户级线程!
  • 内核有的是轻量级线程,系统调用之上,用户级线程。

C++也提供了线程,也可以创建线程:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <pthread.h>    // 线程

void hello()
{
    const char *name = (const char*)args;
    while(true)
    {
        std::cout << "我是新线程...,pid : " << getpid() << std::endl;
        sleep(1);
    }
}

int main()
{   
    std::thread t(hello);

    while(true)
    {
        std::cout << "我是主线程...,pid :  " << getpid() << std::endl;
        sleep(1); 
    }

    t.join();

    return 0;
}

单进程代码不可能让两个死循环跑,所以一定存在多执行流:

C++里面的多线程和我这个Linux中的pthread库有什么关系?

C++中的线程库也是对pthread库(C语言,无法做到面向对象)做了进一步地封装,面向对象的封装,就可以面向对象了。


结尾

uu们,本文的内容到这里就全部结束了,艾莉丝在这里再次感谢您的阅读!

|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ### 艾莉丝努力练剑 C/C++ & Linux 底层探索者 | 一个正在努力练剑的技术博主 *** ** * ** *** 👀 【关注】 跟随我一起深耕技术领域,见证每一次成长。 ❤️ 【点赞】 让优质内容被更多人看见,让知识传递更有力量。 ⭐ 【收藏】 把核心知识点存好,在需要时随时查、随时用。 💬 【评论】 分享你的经验或疑问,评论区一起交流避坑! 不要忘记给博主"一键四连"哦! "今日练剑达成!" "技术之路难免有困惑,但同行的人会让前进更有方向。" |

结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主"一键四连"哦!

往期回顾

【Linux信号】Linux进程信号(下):可重入函数、Volatile关键字、SIGCHLD信号

🗡博主在这里放了一只小狗,大家看完了摸摸小狗放松一下吧!🗡 ૮₍ ˶ ˊ ᴥ ˋ˶₎ა

相关推荐
低保和光头哪个先来2 小时前
Axios 近期安全版本
开发语言·前端·javascript·前端框架
无籽西瓜a2 小时前
【西瓜带你学设计模式 | 第十期 - 外观模式】外观模式 —— 子系统封装实现、优缺点与适用场景
java·后端·设计模式·软件工程·外观模式
@Mr.h2 小时前
(源码)基于Spring Boot + Vue志愿者服务平台的设计与实现
java·vue.js·spring boot·后端
嵌入式小企鹅2 小时前
Claude开源风暴?半导体设备突破?
大数据·人工智能·学习·开源·嵌入式·半导体·ai芯片
C语言小火车2 小时前
Linux 操作系统八股文(2026最新完整版)
java·linux·运维
zzwq.2 小时前
深入理解Python闭包与装饰器:从入门到进阶
开发语言·python
Deitymoon2 小时前
linux——消息队列进程间通信
linux
2501_920627612 小时前
Flutter 框架跨平台鸿蒙开发 - 数学学习助手
学习·flutter·华为·harmonyos