【Linux】线程概念

Linux线程概念

1. 什么是线程

在谈线程之前,首先回顾一下进程,在Linux操作系统中,CPU要执行多个加载到内存中的程序,但这些程序并不能杂乱无章的加载到内存中,而CPU又没有办法管理这些加载到内存中的应用程序,所以作为软硬件管理者的操作系统就提出了进程的概念,用OS内部的进程来管理加载到内存中的应用程序。进程 = 内核数据结构 + 程序的代码和数据(执行流)


线程:是进程内部的一个执行分支,也是执行流。

从内核和资源的角度上理解:

  • 进程:承担分配系统资源的基本实体(进程 = 内核数据结构 + 进程的代码和数据,进程的创建需要在内存中为数据结构以及代码和数据申请分配内存资源,因此说进程是承担分配资源的基本实体)
  • CPU调度的基本单位

在虚拟地址空间中,[0,3GB]是用户空间,通过页表可以映射到进程在物理内存中的地址,进而去执行指令;(3GB,4GB]属于内核空间,进程调用系统调用触发软中断会进入内核空间,再通过页表可以映射到物理内存中的内核的数据。因此,进程访问的大部分内存资源都需要通过页表转换地址映射,因为页表是通过mm_struct中的指针指向的,所以进程访问资源都需要通过地址空间。进程地址空间是进程访问内存资源的"窗口"。进程拥有多少资源,本质是进程通过地址空间可以看到多少资源。

在Linux系统中,创建进程的时候,会创建task_struct,也就有了虚拟地址空间,因为虚拟地址空间的不同,所以不同的进程可以看到的资源也就不同,那如果创建"进程"时共享同一块地址空间,那么这些"进程"不就可以看到同一块内存资源了嘛。将不同的资源分配给task_struct,这样就可以用进程模拟出来线程了,这就是Linux设计出来的线程。所谓的资源划分实际上就是划分地址空间,也就是划分虚拟地址范围,就是划分页表。也就是说,一个进程中可以有多个线程,因此也就可以有多个task_struct结构体,但是这些task_struct执行进程的代码不同。有了线程由一个进程对代码的串行执行,就变成了对代码的并行执行

结论1:Linux系统可以用进程来模拟实现线程
结论2:对资源的划分,本质上就是对地址空间虚拟地址范围的划分。虚拟地址就是资源的代表,当malloc的时候,可以不在物理内存开辟,当需要使用的时候,会触发缺页中断,再在物理内存中开辟,因此拥有多少虚拟内存资源就可以看到多少物理内存资源

如何对代码区资源划分?
我们写的代码,归根结底都是由一个一个函数构成,而每一个函数都有自己的入口地址,所以才在语言层面有了回调以及函数指针的概念。而函数的入口地址,并不是说函数只有一个地址,函数的本质就是代码块,函数的代码块里的每一行代码都有地址,只是第一份地址叫做函数的入口地址。而函数在编址的时候采用的是平坦模式(起始地址为0的模式),所以入口地址加ELF文件中的偏移量就能找到函数中每一行代码的地址。所以函数就是代码块,也就是虚拟地址(磁盘上逻辑地址)的集合!数据也是这样!

结论3:函数就是代码块,也就是虚拟地址(磁盘上逻辑地址)的集合。所以让不同的线程执行不同的代码就是让线程未来执行ELF程序的不同函数即可!

给线程设置不同的入口函数, 让线程执行不同的函数,这样线程就会访问进程地址空间和页表不同区域的内容,资源就这样自然的被划分好了。

既然线程也是task_struct,那应该如何理解进程呢?

进程 = 内核数据结构 + 进程的代码和数据,没有说进程就是task_struct进程是多个task_struct(就是线程的集合)+ 其他内核数据结构 + 进程的代码和数据,也就是说task_struct是用来描述线程或者单线程进程的结构体。之前的进程是只有一个线程的进程!单线程进程是特殊的多线程,单线程进程内部只有一个执行流。

问题:为什么要这样设计线程呢?其他的操作系统,如Windows也是这样设计的吗?
不同的平台对于进程的实现大同小异但是对于线程的实现差异较大
今天我们知道一个进程内部可以有多个线程,所以我们的OS里面会存在大量的线程而OS需要管理线程就需要先描述再组织,就需要创建描述线程的数据结构,Windows里面管理线程的数据结构叫做TCB!而线程也需要有自己的线程调度算法、线程的切换、线程释放和创建,此时我们的OS设计就会变得异常复杂!因为数据结构变多了还要维护PCB和TCB的关系维护成本也更高了。而复杂的东西可维护性一定不好,代码的健壮性也不好!
Linux设计师从代码的复用角度觉得没有必要为线程单独设计新的数据结构!因为线程也需要调度和切换也需要上下下文保存,也要响应时钟中断、时间片检查。线程和进程的相似度很高,所以没有必要为线程重新设计一套方案,直接复用进程的代码用进程来模拟线程即可!线程的调度算法也复用进程的即可!因为复用所以我们的代码的健壮性和各维护性更好、测试成本更低、Debug成本更低!因为我们在复用历史上无数次日印证的代码所以我们的Linux服务器在后台运行几年!我们今天高频率创建的数据结构就是和进程线程相关的!

看待线程的视角:
OS软件视角:都是执行流
CPU硬件角度:是否区分是进程还是线程?不需要。CPU只需要不断向系统触发时钟中断要求OS调度,给CPU切换的方法就执行切换的代码,给CPU调度的方法就执行调度的代码!所以CPU不关心给的他执行流是进程还是线程,CPU看待的执行流<=进程!(如果一个进程只有一个执行流,那么执行流=进程,如果是多线程进程那么执行流<进程,这里的比较关系不是数量关系,而是执行流和整体进程的大小关系。)CPU看待执行流叫轻量级进程。

结论4:Linux的线程,就是轻量级进程,使用轻量级进程模拟实现的。

线程和进程的关系:线程共享了进程的地址空间,进程中有一个或多个线程。

进程强调独占,部分共享;线程强调共享,部分独占!因为线程是在进程的地址空间上运行的(共享),进程地址空间是进程独占的。

1.2 分页式存储管理

进程加载到内存的时候,其页表的起始地址就被分配好写到mm_struct中了,当需要使用页表映射的时候,页表的起始地址就被写到CPU中的CR3寄存器中,来和MMU进行地址转换。

线程深刻理解:
线程对资源的划分:本质就是对地址空间的划分,获得一定范围的合法虚拟地址,再本质就是划分页表
线程对资源的共享:本质就是对地址空间的共享,再本质就是对页表条目的共享

1.2.1 虚拟地址和页表的由来

思考一下,如果在没有虚拟内存和分页机制的情况下,每一个用户程序在物理内存上所对应的空间必须是连续的,如下图:

物理内存被分割成各种离散的、大小不同的块。经过一段运行时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎片的形式存在。

我们希望操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续。此时虚拟内存和分页便出现了。

把物理内存按照一个固定的长度的页框进行分割,有时叫做物理页。每个页框包含一个物理页(page)。一个页的大小等于页框的大小。大多数32位体系结构支持4KB的页,而64位体系结构一般会支持8KB的页。区分一页和一个页框是很重要的:

  • 页框是一个存储区域;
  • 而页是一个数据块,可以存放在任何页框或磁盘中。
    有了这种机制,CPU便并非是直接访问物理内存地址,而是通过虚拟地址空间来间接的访问物理内存地址。所谓的虚拟地址空间,是操作系统为每一个正在执行的进程分配的一个逻辑地址,在32位机上,其范围从0~4G-1。
    操作系统通过将虚拟地址空间和物理内存地址之间建立映射关系,也就是页表,这张表上记录了每一对页和页框的映射关系,能让CPU间接的访问物理内存地址。
    总结一下,其思想是将虚拟内存下的逻辑地址空间分为若干页,将物理内存空间分为若干页框,通过页表便能把连续的虚拟内存,映射到若干个不连续的物理内存页。这样就解决了使用连续的物理内存造成的碎片问题。

1.2.2 物理内存管理

假设一个可用的物理内存有4GB 的空间。按照一个页框的大小4KB进行划分, 4GB 的空间就是4GB/4KB = 1048576 个页框。有这么多的物理页,操作系统肯定是要将其管理起来的,操作系统

需要知道哪些页正在被使用,哪些页空闲等等。

内核用 struct page 结构表示系统中的每个物理页,出于节省内存的考虑, struct page 中使用了大量的联合体union

cpp 复制代码
/* include/linux/mm_types.h */
struct page
{
    /* 原⼦标志,有些情况下会异步更新 */
    unsigned long flags;
    union
    {
        struct
        {
            /* 换出⻚列表,例如由zone->lru_lock保护的active_list */
            struct list_head lru;
            /* 如果最低为为0,则指向inode
             * address_space,或为NULL
             * 如果⻚映射为匿名内存,最低为置位
             * ⽽且该指针指向anon_vma对象
             */
            struct address_space *mapping;
            /* 在映射内的偏移量 */
            pgoff_t index;
            /*
             * 由映射私有,不透明数据
             * 如果设置了PagePrivate,通常⽤于buffer_heads
             * 如果设置了PageSwapCache,则⽤于swp_entry_t
             * 如果设置了PG_buddy,则⽤于表⽰伙伴系统中的阶
             */
            unsigned long private;
        };
        struct
        { /* slab, slob and slub */
            union
            {
                struct list_head slab_list; /* uses lru */
                struct
                { /* Partial pages */
                    struct page *next;
#ifdef CONFIG_64BIT
                    int pages;    /* Nr of pages left */
                    int pobjects; /* Approximate count */
#else
                    short int pages;
                    short int pobjects;
#endif
                };
            };
            struct kmem_cache *slab_cache; /* not slob */
            /* Double-word boundary */
            void *freelist; /* first free object */
            union
            {
                void *s_mem;            /* slab: first object */
                unsigned long counters; /* SLUB */
                struct
                {                        /* SLUB */
                    unsigned inuse : 16; /* ⽤于SLUB分配器:对象的数⽬ */
                    unsigned objects : 15;
                    unsigned frozen : 1;
                };
            };
        };
        ...
    };
    union
    {
        /* 内存管理⼦系统中映射的⻚表项计数,⽤于表⽰⻚是否已经映射,还⽤于限制逆向映射搜
       索*/
        atomic_t _mapcount;
        unsigned int page_type;
        unsigned int active; /* SLAB */
        int units;           /* SLOB */
    };
    ...
#if defined(WANT_PAGE_VIRTUAL)
        /* 内核虚拟地址(如果没有映射则为NULL,即⾼端内存) */
        void *virtual;
#endif /* WANT_PAGE_VIRTUAL */
    ...
}

其中比较重要的几个参数:

  1. flags :用来存放页的状态。这些状态包括页是不是脏的,是不是被锁定在内存中等。flag的每一位单独表示一种状态,所以它至少可以同时表示出32种不同的状态。这些标志定义在<linux/page-flags.h>中。其中一些比特位非常重要,如PG_locked⽤于指定页是否锁定,PG_uptodate用于表示页的数据已经从块设备读取并且没有出现错误。
  2. _mapcount :表示在页表中有多少项指向该页,也就是这一页被引用了多少次。当计数值变
    为-1时,就说明当前内核并没有引用这一页,于是在新的分配中就可以使用它。
  3. virtual :是页的虚拟地址。通常情况下,它就是页在虚拟内存中的地址。有些内存(即所谓
    的高端内存)并不永久地映射到内核地址空间上。在这种情况下,这个域的值为NULL,需要的
    时候,必须动态地映射这些页。
    要注意的是 struct page 与物理页相关,而并非与虚拟页相关。而系统中的每个物理页都要分配一个这样的结构体。

1.2.3 页表

页表中的每一个表项,指向一个物理页的开始地址。在 32 位系统中,虚拟内存的最大空间是4GB,这是每一个用户程序都拥有的虚拟内存空间。既然需要让 4GB 的虚拟内存全部可用,那么页表中就需要能够表示这所有的 4GB 空间,那么就一共需要 ( 4GB/4KB = 1048576 ) 个表项。

页表中的物理地址,与物理内存之间,是随机的映射关系,哪里可用就指向哪里(物理页)。虽然最终使用户的物理内存是离散的,但是与虚拟内存对应的线性地址是连续的。处理器在访问数据、获取指令时,使用的都是线性地址,只要它是连续的就可以了,最终都能够通过页表找到实际的物理地址。在 32 位系统中,地址的长度是 4 个字节,那么页表中的每一个表项就是占用 4 个字节。所以页表占据的总空间大小就是: 1048576*4 = 4MB 的大小。也就是说映射表自己本身,就要占用4MB / 4KB = 1024 个物理页。这会存在哪些问题呢?

  • 回想一下,当初为什么使用页表,就是要将进程划分为一个个页可以不用连续的存放在物理内存
    中,但是此时页表就需要1024个连续的页框,似乎和当时的目标有点背道而驰了...
  • 此外,根据局部性原理可知,很多时候进程在一段时间内只需要访问某几个页就可以正常运行
    了。因此也没有必要一次让所有的物理页都常驻内存"。
    解决需要大量页表的最好方法是:把页表看成普通的文件,对它进行离散分配,即对页表再分页,由此形成多级页表的思想。
    为了解决这个问题,可以把这个单一页表拆分成 1024 个体积更小的映射表。这样一来,1024(每个表中的表项个数)*1024(表的个数),仍然可以覆盖 4GB 的物理内存空间。

从总数上看是这样,但是一个应用程序是不可能完全使用全部的 4GB 空间的,也许只要几十个页表就可以了。例如:一个用户程序的代码段、数据段、栈段,一共就需要 10 MB 的空间,那么使用 3 个页表就足够了。

计算过程:

每一个页表项指向一个 4KB 的物理页,那么一个页表中 1024 个页表项,一共能覆盖 4MB 的物理内存;

那么 10MB 的程序,向上对齐取整之后(4MB 的倍数,就是 12 MB),就需要 3 个页表就可以了。

1.2.4 页目录结构

到目前为止,每一个页框都被一个页表中的一个表项来指向了,那么这1024个页表也需要被管理起

来。管理页表的表称之为页目录表,形成二级页表。如下图所示:

  • 所有页表的物理地址被页目录表项指向
  • 页目录的物理地址被CR3寄存器指向,这个寄存器中,保存了当前正在执行任务的页目录地址。
    所以操作系统在加载用户程序的时候,不仅仅需要为程序内容来分配物理内存,还需要为用来保存程序的页目录和页表分配物理内存。

1.2.5 两级页表的地址转换

下面以一个逻辑地址为例。将逻辑地址(0000 0000 0000 0000 0000 0011 1111 1111)转换为物理地址的过程:

1.在32位处理器中,采用4KB的页大小,则虚拟地址中低12位为页偏移,剩下高20位给页表,分成两级,每个级别占10个bit(10+10)。

2.CR3寄存器读取页目录起始地址,再根据一级页号查页目录表,找到下一级页表在物理内存中

存放位置。

3.根据二级页号查表,找到最终想要访问的内存块号。

4.结合页内偏移量得到物理地址。

  1. 注:一个物理页的地址一定是4KB对齐的(最后的12位全部为0),所以其实只需要记录物理页地址的高20位即可。(这样就能找到起始的页框地址)

  2. 以上其实就是MMU的工作流程。MMU(Memory Manage Unit)是一种硬件电路,其速度很快,

主要工作是进行内存管理,地址转换只是它承接的业务之一。

到这里其实还有个问题,MMU要先进行两次页表查询确定物理地址,在确认了权限等问题后,MMU再将这个物理地址发送到总线,内存收到之后开始读取对应均地址的数据并返回。那么当页表变为N级时,就变成了N次检索+1次读写。可见,页表级数越多查询的步骤越多,对于CPU来说等待时间越长,效率越低。

让我们现在总结一下:单级页表对连续内存要求高,于是引入了多级页表,但是多级页表也是一把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。

有没有提升效率的办法呢?计算机科学中的所有问题,都可以通过添加一个中间层来解决。MMU 引入了新武器,江湖人称快表的 TLB(其实,就是缓存)

当 CPU 给 MMU 传新虚拟地址之后,MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存,齐话。但 TLB 容量比较小,难免发生 Cache Miss,这时候 MMU 还有保底的老武器页表,在页表中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录一下刷新缓存。

1.2.6 缺页异常

设想,CPU给MMU的虚拟地址,在TLB和页表都没有找到双对应的物理页,该怎么办呢?其实这就是

缺页异常PageFault,它是一个由硬件中断触发的可以由软件逻辑纠正的错误。

假如目标内存页在物理内存中没有对应的物理页或者存在但无对应权限,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错误中断进程直接挂掉。

1.3 线程的优点

  • 创建一个新线程的代价比创建一个进程小得多。因为线程的创建一定是在进程创建好的基础上创建,创建进程要创建内核数据结构、打开文件描述符表、建立映射关系将代码和数据加载到内存中,还要调度时处理缺页中断,而创建一个线程只需要创建task_struct结构体即可。

  • 线程占用的资源比进程小得多,因为线程是进程的一部分

  • 线程能充分利用多处理器的可并行数量 因为线程本质是调度的基本单位多CPU时可以创建多线程并行提高CPU利用率

  • 在等待慢速I/O操作结束的同时,程序可执行其他计算任务

  • 计算密集型应用,为了能在对处理器系统上运行,将计算分解到多个线程中实现

  • I/O密集型应用,为了提高效率,将I/O操作重叠,线程可以同时等待不同的I/O操作

是不是线程越多越好呢?

不是

对于计算密集型应用,线程的数量不易超过CPU的数量或者核心的数量,如果线程过多,本可以用来计算的时间被浪费到了线程的切换上。

对于I/O密集型应用,线程的数量可以多一些,这样在CPU等待读取数据的时候,还可以执行其他的线程(当 39 个线程都在等网络数据时,只要有 1 个线程数据到了,CPU 就能立刻处理它,而不会让 CPU 闲着。)

  • 与进程切换相比,线程之间的切换需要操作系统做的工作要多

    进程的切换需要切换进程的内核数据结构task_struct集合包括页表以及CPU中寄存器里保存的进程的上下文数据,包括CR3寄存器中的内容;但是线程的切换就不用切换这么多,只需要切换task_struct以及CPU寄存器中线程的上下文数据,CR3寄存器中的值不需要更改。切换task_struct结构体实际上是修改OS中的struct tsak_struct current指针的指向,这个指针指向当前进程的task_struct,这个指针通常被优化到CPU内部存储。
    最主要的原因是进程的切换会扰乱CPU的缓存机制,但是线程的切换不会扰乱
  1. CPU在执行代码时会不停的访问内存,这样会造成时间的消耗,所以CPU将所访问代码或数据周围的数据加载到CPU中的Cache寄存器中,结合局部性原理,访问内存的时候先去Cache中找数据。当进程切换的时候会切换CPU中寄存器中的内容,所以切换后的进程需要重新加载Cache寄存器中的内容。

  2. 切换进程也就需要切换虚拟地址空间,这样处理页表缓冲的TLB也会被全部刷新;而切换线程不需要切换地址空间

    进程切换会导致TLB和Cache失效,下次运行需要重新缓存。

1.4 线程的缺点

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

因为线程共享整个地址空间线程共享大部分资源所以如果-一个线程出现异常或修改其他线程的数据OS就会干掉整个进程所以多线程的代码缺乏访问控制健壮性比较低但这也是多线程的优点,正是因为缺乏访问控制我们多线程天然就可以看到同一份资源,而我们进程间通信想看到同一份资源很困难!

1.5 线程异常

单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃

线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出

二. 进程VS线程

2.1 进程和线程

  • 进程是资源分配的基本单位
  • 线程是调度的基本单位
    进程强调独占,部分共享,如进程间通信;线程强调共享,部分独占。
  • 线程共享进程数据,但也拥有自己的一部分"私有"数据:
    • 线程ID
    • 一组寄存器,保存线程的上下文数据,说明线程是可被独立调度的
    • 独立的栈结构(不同的线程执行不同的代码,这些代码需要在栈上开空间,如果使用同一个栈空间,会造成数据的混乱,因此每一个线程都要有自己的栈结构),线程是动态的!
    • errno
    • 信号屏蔽字
    • 调度优先级

2.2 进程的多个线程共享

除了上述资源,其他的资源线程全部共享,因为所有的线程是共享地址空间的,所以代码区和数据区全都是共享的,也就是在全局定义了一个全局变量,A线程和B线程访问的是同一个全局变量;A线程可以调度的函数,B线程也可以调度。除此之外,线程还共享以下进程资源和环境:

  • 文件描述符表
  • 每种信号的处理方式(SIG_IGN、SIG_DFL或者自定义的信号处理函数)
  • 当前的工作目录
  • 用户id和组id
    进程和线程的关系:

2.3 关于进程线程问题

如何看待之前的单进程?单进程是只有一个线程执行流的进程

相关推荐
njsgcs2 小时前
MiniCPM4-0.5B-QAT-Int4-GPTQ-format 小显存llm
linux·人工智能
UP_Continue2 小时前
Linux--命令行参数和环境变量
linux·运维·服务器
重生之绝世牛码2 小时前
Linux软件安装 —— PostgreSQL高可用集群安装(postgreSQL + repmgr主从复制 + keepalived故障转移)
大数据·linux·运维·数据库·postgresql·软件安装·postgresql高可用
Calebbbbb2 小时前
Ubuntu 24.04 + Android 15 (AOSP) 环境搭建与源码同步完整指南
android·linux·ubuntu
STCNXPARM3 小时前
Linux PCI/PCIe子系统深度剖析
linux·运维·服务器·pci/pcie
郝学胜-神的一滴3 小时前
深入理解Linux套接字(Socket)编程:从原理到实践
linux·服务器·开发语言·网络·c++·程序人生·算法
坐怀不乱杯魂3 小时前
Linux - 线程
linux·c++
EverydayJoy^v^3 小时前
RH134学习进程——八.管理存储堆栈
linux·运维·服务器
爱编码的傅同学4 小时前
【线程同步】信号量与环形队列的生产消费模型
linux·windows·ubuntu·centos