linux线程概念与控制

Linux线程概念

什么是线程

  • 在⼀个程序⾥的⼀个执⾏路线就叫做线程(thread)。更准确的定义是:线程是“⼀个进程内部的控制序列”

把 “一个程序” 想象成 “一家餐厅”,那 “执行路线” 就是餐厅里的 “一位服务员”:

  • 餐厅的目标是 “服务顾客”(程序的核心目标是完成业务逻辑,比如计算、读写文件);
  • 每位服务员都有自己的 “独立工作路线”:比如服务员 A 负责 “点单→传菜→结账”,服务员 B 负责 “收桌→补餐具→回应顾客呼叫”,两条路线独立进行,互不干扰,但共享餐厅的资源。
  • 服务员越多,餐厅能 “同时处理” 的任务就越多(比如同时点单、同时传菜),这就是多线程的价值。(并不是越多越好)
  • ⼀切进程⾄少都有⼀个执⾏线程
  • 线程在进程内部运⾏,本质是在进程地址空间内运⾏
  • 在Linux系统中,在CPU眼中,看到的PCB都要⽐传统的进程更加轻量化
  • 透过进程虚拟地址空间,可以看到进程的⼤部分资源,将进程资源合理分配给每个执⾏流,就形成了线程执⾏流

线程的理解

线程是可被 CPU 调度的最小执行单元,它的核心作用是让程序在不浪费资源的前提下,“ 并发 ” 处理多个任务。(就像餐厅里的服务员:数量合适能提高翻台率,数量太多反而会互相抢资源、拖慢节奏。)

在Linux当中,并不存在真正的 “线程”, 这是因为Linux的设计者认为,线程和进程都是执行流,具有高度相似性,没必要为线程单独设计数据结构与算法,所以 Linux线程是使用进程来模拟线程的! 有些操作系统有真实的线程,比如windows。

而在CPU看来,不论是进程还是线程,当CPU拿到时并不会明确的区分,因为CPU拿到的都是执行流,所以对于CPU来说进程还是线程根本就不重要。Linux中的所有调度执行流,都可称为 轻量级进程(Light Weight Process, 简称LWP)

分⻚式存储管理

虚拟地址和⻚表的由来

计算机发展初期(比如单道程序、简单多道程序时代),程序直接使用物理地址访问内存,就导致了以下问题:

  • 程序必须 知道物理内存地址,开发难度极高

  • 物理内存 连续空间稀缺,物理内存利用率极低

多道程序并发时,物理内存会被多个程序分割占用(比如程序 A 占2KB,程序 B 占 1 KB, 程序 C 占 2 KB,倘若B被回收,而新程序 D 需要 2 KB,由于内存大小不匹配的原因,D不会分配到原来B的内存空间),早期操作系统的 “连续分配机制” 有个硬性要求:程序必须加载到一整块 “无断裂的连续物理内存” 中,因为程序编译后使用的是 “绝对物理地址”。当一个需要 2KB 连续空间的程序运行时,哪怕物理内存总空闲空间有 3KB,但没有连续的 2KB 块,程序也无法运行。

  • 程序直接访问物理内存,安全性极差

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

把物理内存按照⼀个固定的⻓度的⻚框进⾏分割,有时叫做物理⻚。(⼀个⻚的⼤⼩等于⻚框的⼤⼩。⼤多数 32位 体系结构⽀持 4KB 的⻚,⽽ 64位 体系结构⼀般会⽀持 8KB 的⻚。区分⼀⻚和⼀个⻚框是很重要的。)
有了这种机制,CPU 便并⾮是直接访问物理内存地址,⽽是通过虚拟地址空间来间接的访问物理内存地址。所谓的虚拟地址空间,是操作系统为每⼀个正在执⾏的进程分配的⼀个逻辑地址(虚拟地址),在32位机上,其范围从0 ~ 4G-1。
操作系统通过将虚拟地址空间和物理内存地址之间建⽴映射关系,也就是⻚表,这张表上记录了每⼀对⻚和⻚框的映射关系,能让CPU间接的访问物理内存地址。
总结⼀下,其思想是将虚拟内存的逻辑地址空间分为若干页(存储程序的实际数据 / 代码),将物理内存空间分为若干页框(存储虚拟页加载过来的数据 / 代码),通过页表(页表项中存储物理页框的首地址)便能把连续的虚拟内存,映射到若干个不连续的物理内存页框。这样就解决了使⽤连续的物理内存造成的碎⽚问题。

结论:操作系统不仅要管理物理内存的页框(实际存储载体),还要管理虚拟内存的(程序的逻辑存储单元)。

物理内存管理

假设⼀个可⽤的物理内存有 4GB 的空间。按照⼀个⻚框的⼤⼩ 4KB 进⾏划分, 4GB 的空间就是 4GB/4KB = 1048576 个⻚框。有这么多的物理⻚,操作系统肯定是要将其管理起来的,操作系统 需要知道哪些⻚正在被使⽤,哪些⻚空闲等等.

内核⽤ struct page 结构表⽰系统中的每个物理⻚,出于节省内存的考虑, struct page 中使
⽤了⼤量的联合体union:

/* 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;//表⽰在⻚表中有多少项指向该⻚,也就是这⼀⻚被引⽤了多少次。
//当计数值变为-1时,就说明当前内核并没有引⽤这⼀⻚,于是在新的分配中就可以使⽤它。
        unsigned int page_type;
        unsigned int active; /* SLAB */
        int units;           /* SLOB */
    };
    ...
#if defined(WANT_PAGE_VIRTUAL)
        /* 内核虚拟地址(如果没有映射则为NULL,即⾼端内存) */
        void *virtual;//是⻚的虚拟地址。
#endif /* WANT_PAGE_VIRTUAL */
    ...
}

单个 4KB 页框的剩余空间是否会分配给其他程序?

页框是物理内存的 “最小分配单元”,一旦分配给某个程序,整页都归该程序独占,哪怕只用到 1 字节。

  1. 要注意的是 struct page 与物理⻚相关,⽽并⾮与虚拟⻚相关。⽽系统中的每个物理⻚都要分配⼀个这样的结构体,让我们来算算对所有这些⻚都这么做,到底要消耗掉多少内存。算 struct page 占40个字节的内存吧,假定系统的物理⻚为 4KB ⼤⼩,系统有 4GB 物理内存。那么系统中共有⻚⾯  1048576 个(1兆个),所以描述这么多⻚⾯的page结构体消耗的内存只不过40MB ,相对系统 4GB 内存⽽⾔,仅是很⼩的⼀部分罢了。因此,要管理系统中这么多物理⻚⾯,这个代价并不算太⼤。
  2. 要知道的是,⻚的⼤⼩对于内存利⽤和系统开销来说⾮常重要,⻚太⼤,⻚内必然会剩余较⼤不能利⽤的空间(⻚内碎⽚)。⻚太⼩,虽然可以减⼩⻚内碎⽚的⼤⼩,但是⻚太多,会使得⻚表太⻓⽽占⽤内存,同时系统频繁地进⾏⻚转化,加重系统开销。因此,⻚的⼤⼩应该适中,通常为 512B -8KB ,windows/Linux系统的⻚框⼤⼩为4KB。

⻚表

⻚表中的每⼀个表项,指向⼀个物理⻚的开始地址。在 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 个⻚表就⾜够了(10MB / 4KB /1024 = 2.5)。

⻚⽬录结构

到⽬前为⽌,每⼀个⻚框都被⼀个⻚表中的⼀个表项来指向了,那么这 1024 个⻚表也需要被管理起来。管理⻚表的表称之为⻚⽬录表,形成⼆级⻚表。

  • 所有⻚表的物理地址被⻚⽬录表项指向
  • ⻚⽬录的物理地址被 CR3 寄存器 指向,这个寄存器中,保存了当前正在执⾏任务的⻚⽬录地址。

所以操作系统在加载⽤⼾程序的时候,不仅仅需要为程序内容来分配物理内存,还需要为⽤来保存程序的⻚⽬录和⻚表分配物理内存。

两级⻚表的地址转换

我们来模拟一下逻辑地址转换物理地址

以下⾯逻辑地址为例。将逻辑地址( 0000000000,0000000001,11111111111 )转换为物理地址的过程:

CR3 寄存器 读取⻚⽬录起始地址通过逻辑地址的前十位一级页号(2^10 = 1024)转换成数字下标,以数组的形式来访问页目录表项,而页目录表项中存储的是页表的地址。 再根据下⼀级⻚号(2^10 = 1024)中间十位二进制数字来查找⻚表项,⻚表项中存储的是物理内存中页框的首地址,通过最后十二位偏移量(2^12 = 4096, 刚好概括页框中每一字节)可以找到在物理内存中存放位置。

以上所描述的其实就是 MMU 的⼯作流程。MMU(Memory Manage Unit)是⼀种硬件电路,其速度很快,主要⼯作是进⾏内存管理,地址转换只是它承接的业务之⼀。

总结:单级⻚表对连续内存要求⾼,于是引⼊了多级⻚表,但是多级⻚表也是⼀把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。

有没有提升效率的办法呢?计算机科学中的所有问题,都可以通过添加⼀个中间层来解决。 MMU 引⼊了新武器,江湖⼈称快表的 TLB (其实,就是缓存)当 CPU 给 MMU 传新虚拟地址之后, MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存,⻬活。但 TLB 容量⽐较⼩,难免发⽣ Cache Miss ,这时候 MMU 还有保底的⽼武器 ⻚表,在⻚表中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录⼀下刷新缓存。

缺页中断

CPU 给 MMU 的虚拟地址,在     和⻚表都没有找到对应的物理⻚,该怎么办
其实这就是缺⻚中断 Page Fault。

假如⽬标内存⻚在物理内存中没有对应的物理⻚或者存在但⽆对应权限,CPU 就⽆法获取数据,这种情况下CPU就会报告⼀个缺⻚错误。 由于 CPU 没有数据就⽆法进⾏计算,CPU罢⼯了⽤⼾进程也就出现了缺⻚中断,进程会从⽤⼾态切换到内核态,并将缺⻚中断交给内核的  Page Fault Handler  处理。

  • Hard Page Fault,这时物理内存中没有对应的物理⻚,需要CPU打开磁盘设备读取到物理内存中,再让MMU建⽴虚拟地址和物理地址的映射。
  • Soft Page Fault,这时物理内存中是存在对应物理⻚的,只不过可能是其他进程调⼊的,发出缺⻚异常的进程不知道⽽已,此时MMU只需要建⽴映射即可,⽆需从磁盘读取写⼊内存,⼀般出现在多进程共享内存区域
  • Invalid Page Fault 翻译为⽆效缺⻚错误,⽐如进程访问的内存地址越界访问,⼜⽐如对空指针解引⽤内核就会报 segment fault 错误中断进程直接挂掉。

线程的优点

  • 创建⼀个新线程的代价要⽐创建⼀个新进程⼩得多
  • 与进程之间的切换相⽐,线程之间的切换需要操作系统做的⼯作要少很多

最主要的区别是线程的切换,虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下⽂切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。

  • 线程占⽤的资源要⽐进程少
  • 能充分利⽤多处理器的可并⾏数量
  • 在等待慢速I/O操作结束的同时,程序可执⾏其他的计算任务
  • 计算密集型应⽤,为了能在多处理器系统上运⾏,将计算分解到多个线程中实现
  • I/O密集型应⽤,为了提⾼性能,将I/O操作重叠。线程可以同时等待不同的I/O操作

计算密集型:执行流的大部分任务,主要以计算为主:加密解密,排序查找。
IO密集型:执行流的大部分任务是以IO为主的:刷磁盘,访问数据库,访问网络。

线程的缺点

性能损失

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

健壮性降低

  • 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。

缺乏访问控制

  • 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。比如:在多线程进程中,其中一个线程进行了一次I/O调用,这导致从用户态切换到内核态,把该进程置于阻塞状态,并切换到另一个进程(对用户级线程)。

编程难度提高

  • 编写与调试一个多线程程序比单线程程序困难得多

线程异常

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

线程⽤途

  • 合理的使⽤多线程,能提⾼CPU密集型程序的执⾏效率
  • 合理的使⽤多线程,能提⾼IO密集型程序的⽤⼾体验

Linux线程控制

创建线程

pthread_create 是 POSIX 线程库中用于创建新线程的核心函数,它允许程序在进程内并发执行多个任务。

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

参数:

  • thread:返回线程ID
  • attr:设置线程的属性,attr为NULL表⽰使⽤默认属性
  • start_routine:是个函数地址,线程启动后要执⾏的函数
  • arg:传给线程启动函数的参数

返回值:成功返回0;失败返回错误码

pthread_self() 是 POSIX 线程库中用于获取当前线程 ID的函数,它能让线程在运行时识别自身的标识符,常用于线程标识、日志记录、线程特定数据管理等场景。

#include <pthread.h>

       pthread_t pthread_self(void);
  • 功能:返回调用该函数的线程的线程 ID(pthread_t 类型);
  • 返回值:当前线程的唯一标识符

线程的id和LWP

从这个代码中可以看出一个现象,那就是获取的线程的id和LWP是不一样的,这也就意味着LWP和id是两个不同的内容,如果将这个数字转换成十六进制。

#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<stdio.h>

void* routine(void* args)
{
    while(true)
    {
        sleep(1);
        printf("NEW Thread:0x%lx\n", pthread_self());
    }
    return nullptr;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, routine, nullptr);
    while(true)
    {
        sleep(1);
        printf("I am main thread:0x%lx\n", tid);
    }
    return 0;
}

线程终⽌

void pthread_exit(void *value_ptr);
  • 参数:value_ptr:value_ptr不要指向⼀个局部变量。
  • 返回值:⽆返回值,跟进程⼀样,线程结束的时候⽆法返回到它的调⽤者

需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是⽤malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。

int pthread_cancel(pthread_t thread);
  • 参数: thread:线程ID
  • 返回值:成功返回0;失败返回错误码

线程等待

int pthread_join(pthread_t thread, void **value_ptr);
  • 参数: thread:线程ID
  • value_ptr:它指向⼀个指针,后者指向线程的返回值
  • 返回值:成功返回0;失败返回错误码

调⽤该函数的线程将挂起等待,直到id为thread的线程终⽌。thread线程以不同的⽅法终⽌,通过pthread_join得到的终⽌状态是不同的,总结如下:

  1. 如果thread线程通过return返回,value_ ptr所指向的单元⾥存放的是thread线程函数的返回值。
  2. 如果thread线程被别的线程调⽤pthread_ cancel异常终掉,value_ ptr所指向的单元⾥存放的是常数PTHREAD_ CANCELED。
  3. 如果thread线程是⾃⼰调⽤pthread_exit终⽌的,value_ptr所指向的单元存放的是传pthread_exit的参数。
  4. 如果对thread线程的终⽌状态不感兴趣,可以传NULL给value_ ptr参数。

如果thread线程通过return返回,value_ ptr所指向的单元⾥存放的是thread线程函数的返回值。

int pthread_join(pthread_t thread, void **value_ptr);

如果thread线程被别的线程调⽤pthread_ cancel异常终掉,value_ ptr所指向的单元⾥存放的是常数PTHREAD_ CANCELED(-1)。

int pthread_cancel(pthread_t thread);

如果thread线程是⾃⼰调⽤pthread_exit终⽌的,value_ptr所指向的单元存放的是传pthread_exit的参数:

void pthread_exit(void *value_ptr);

倘若主线程也要做自己的事情呢?总不能一直等着吧

我们将目标线程设成分离状态,主线程就不需要等待新线程。

注意:再多执行流下,主执行流要最后退出。

线程分离

  • 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进⾏pthread_join操作,否则⽆法释放资源,从⽽造成系统泄漏。
  • 如果不关⼼线程的返回值,join是⼀种负担,这个时候,我们可以告诉系统,当线程退出时,⾃动释放线程资源。
int pthread_detach(pthread_t thread);

joinable和分离是冲突的,⼀个线程不能既是joinable⼜是分离的:

线程ID及进程地址空间布局

之前我们打印pthread_t tid是感觉像一个地址一样,这是pthread_ create函数会产⽣⼀个线程ID,存放在第⼀个参数指向的地址中。该线程ID和前⾯的LWD不是⼀回事。

pthread_ create函数第⼀个参数指向⼀个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。对于Linux⽬前实现的NPTL实现⽽⾔,pthread_t类型的线程ID,本质就是⼀个进程地址空间上的⼀个地址(TCB的地址)。

而对于其他创建的新线程,它们的栈空间可能由堆空间代替而形成,意味着它们的栈空间是在堆上动态分配的。这种动态分配使得线程栈的大小可以根据需要进行调整,并且可以更灵活地管理内存。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值