深入了解linux系统—— 线程概念

线程

什么是线程呢?线程和之前学习到的进程又有什么关系呢?

从概念的角度来说

进程 = 内核数据结构 + 代码和数据(执行流)

线程 = 进程内部的一个执行分支(执行流)

在内核与资源的角度

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

线程:CPU调度的基本单位;

那线程到底是什么呢?在Linux操作系统中线程具体是什么呢?

首先,对于进程我们知道,有独立的内核数据结构(task_struct)、进程地址空间(mm_struct)、页表映射和物理内存中的代码和数据。

在我们之前的认知中,一个进程有一个task_struct,和进程地址空间mm_struct;也就是说这个task_struct是独享这个进程地址空间的。

而线程是进程内部的一个执行分支,那是不是就可以为线程创建task_struct,并且多个task_struct共享进程地址空间mm_struct;这样每一个task_struct执行自己的那部分代码,每一个task_struct就是一个执行流。

这里进程访问资源,大部分都是通过进程地址空间来访问的;进程地址空间就犹如一个窗口,进程通过窗口来访问资源。

所以,创建多个进程(task_struct),共享同一个窗口(进程地址空间),将资源分配给不同的task_struct,这样就可以使用进程来模拟线程的。

  • Linux操作系统中,线程就是用进程模拟实现的。
  • 对于资源的分配,本质就是对进程地址空间的虚拟地址的划分。(虚拟地址就是资源)
  • 对于代码区的划分,函数也是虚拟地址的集合;让线程执行不同的函数。

轻量级进程

说了这么多,在Linux操作系统中其实是没有线程这一概念的,线程是用进程模拟实现的。

那在Linux中线程和进程如何区分呢?

进程:内核数据结构 + 代码和数据;而在Linux是没有线程的概念的;

对于一个进程,其中有一个或者多个内核数据结构;而其中的一个task_struct又被称为 轻量级进程

所以说,在Linux内核中,task_struct结构体对象又被称为轻量级进程;而进程是内核数据结构对象 + 代码和数据。

在一个进程中,可能存在多个task_struct结构体对象;之前所了解的只是单执行流的进程。

所以,在Linux操作系统下,并没有线程这一概念,只有轻量级进程(task_struct);

Linux系统的线程是使用进程来模拟实现的:

应该轻量级进程task_struct就相当于进程中的一个线程;

线程是进程的一个执行流,而一个进程可能存在多个task_struct,分别执行不同的代码,也就是多个执行流。

分页式存储

在深入理解线程之前,先来简单了解一下分页式存储:

1. 虚拟地址和页表

如果没有虚拟地址和页表,为了能够保证在物理内存上找到对应的代码和数据,每一个程序在物理内存上所对应的空间就必须是连续的;

而代码和数据的长度是不一致的,这样去映射,物理内存就势必会以很多碎片的形式存在,这样内存利用率非常低。

而想要操作系统提供给用户的空间是连续的,而对应的物理内存尽量不要连续;就有了虚拟地址和页表分页:

将物理内存按照固定的长度的页框 进行分割,也称为物理页;每一个页框包含一个物理页,一个页的大小等于页框的大小。

大部分32位体系结构支持4KB 的页,64位体系结构一般会支持8KB的页。

  • 页框是一个存储区域
  • 页是一个数据块,可以存放在任何页框或磁盘中。

有了虚拟地址和页表分页机制,CPU就能够通过虚拟地址空间来间接访问物理地址,而不是直接访问物理地址。

虚拟地址空间:就是操作系统在位每一个正在执行的进程分配的一个物理地址,在32位下,范围是[0,4]GB

操作系统将虚拟地址空间和物理地址之间建立映射关系,就是页表 ,页表中记录了每一对页和页框的映射关系;CPU能够根据虚拟地址,通过页表映射访问物理内存地址。

简单来说就是:

将虚拟内存下的逻辑地址空间分为若干页,将物理内存空间分为若干个页框,通过页表将连续的虚拟内存映射到若干个不连续的物理内存页

2. 物理内存

将物理内存空间分为若干个页框;比如4GB物理内存,每个页框的大小4KB,那就存在``1048576`个页框;这么多物理页,操作系统肯定要将这些物理页管理起来。

Linux内核中,struct page结构表示每个系统中的物理页,在struct page中,存在非常多的联合体union(节省空间)

c 复制代码
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 */
    ...
}

其中存在flags_mapcountvirtual等标志位

  1. falgs:存放页的状态,包括页是否是脏的,是否被锁定在内存中。falgs的每一个比特位单独表示一种状态,至少可以同时表示32种的状态。其中PG_locked表示指定页是否被锁定、PG_uptodate表示页的数据结已经存块设备读取且没有错误。
  2. _mapcount:表示页表中有多少项指向该页,也就是该页被引用了多少次;当计数值为-1时,表示内核并没有引用这一页,就在新分配中使用它。
  3. virtual:页的虚拟地址,一般情况下它就是页中虚拟内存中的地址。(一些内存并不永久映射到内核地址空间中,此时该值为NULL,在需要的时候动态映射这些页。

3. 页表

在上面描述中,通过页表将连续的虚拟地址映射到了不连续的物理地址中;那页表 是什么呢?

页表的每一个表项都指向一个物理页,在32位系统中,虚拟地址的最大空间是4GB,每一个用户程序都存在虚拟地址空间;让4GB空间全部可用,在页表中就要表示出来这4GB空间,也就需要4GB/4KB,也就是1048576个表项。

如果页表中直接存储物理地址,在32位系统下,地址的大小是4KB,这样页表就要占用1048576*4也就是4MB的空间,一个页表就要占用1024个物理页;在内核中存在非常多的进程,每一个进程都有自己的页表,那是不是就占用非常多的内存资源了。

要解决大容量页表,就要把页表看做普通文件,对它进程离散分离,对页表再分页,形成多级页表

简单了解了页表,那虚拟地址又是什么呢?CPU是如何通过虚拟地址获取物理地址的呢?

32位系统为例,地址大小为4字节,也就是32bit位;

32bit位分为三部分:10位,表示页目录表中页表对应的下标、之后的10位,表示页表中物理页对应的下标;后12位表示页内偏移

  • 页表的物理地址被页目录表项指向;
  • 页目录的物理地址被CR3寄存器指向;CR3寄存器保存了当前正在执行任务的页目录地址。

所以说,操作系统在加载用户程序时,不仅需要为程序内容分配物理地址,还要为页目录和页表分配物理内存。

4. 缺页中断

缺页中断简单来说:
缺页中断是当程序试图访问一个当前不在物理内存(RAM)中,而是存放在硬盘(如交换空间或页面文件)中的内存页面时,由硬件(通常是内存管理单元 MMU)触发的一种特殊中断(或异常)。

为什么需要它?

缺页中断是现代操作系统实现虚拟内存 管理的关键机制。虚拟内存让程序以为自己拥有连续的、比实际物理内存大得多的地址空间。操作系统负责在物理内存和硬盘之间按需交换数据块(称为"页")。

发生缺页中断的基本流程:

  1. 程序访问内存: CPU 执行一条指令,需要读取或写入某个内存地址。
  2. MMU 检查页表: 内存管理单元根据该地址查找页表(操作系统维护的数据结构,记录虚拟页到物理页框的映射关系,以及页的状态)。
  3. 发现"缺页": MMU 发现该地址对应的页表项表明:
    • 无效: 该虚拟地址尚未分配或无效(这会导致更严重的错误,如段错误)。
    • 有效但不在内存中: 该虚拟页是合法的(已分配),但它的内容当前不在物理内存中(页表项中的"存在/有效"位被清除)。这就是"缺页"情况。
  4. 触发中断: MMU 检测到缺页条件,向 CPU 发出一个缺页中断信号。
  5. 操作系统接管: CPU 暂停当前程序的执行,保存其上下文(寄存器状态等),并切换到内核态,执行操作系统预先设置好的缺页中断处理程序
  6. 中断处理程序工作:
    • 查找页面位置: 根据触发中断的虚拟地址,在页表中找到对应的项,确定该页当前存放在硬盘上的具体位置(如交换空间)。
    • 分配物理页框: 在物理内存中找到一个空闲的页框(物理内存块)。如果没有空闲页框,则需要使用页面置换算法(如 LRU)选择一个"牺牲"页框,将其内容写回硬盘(如果它是脏的/被修改过)。
    • 调入页面: 发出 I/O 请求,将需要的页面从硬盘读入到分配好的物理页框中。这是一个相对慢速的操作(磁盘 I/O)。
    • 更新页表: 修改页表项,将该虚拟页映射到新的物理页框,并将状态标记为"在内存中"(设置"存在/有效"位)。
    • 可能调整: 如果需要置换页面,还需更新被换出页面的页表项(标记为不在内存)。
  7. 恢复执行: 中断处理程序完成后:
    • 恢复被中断程序的上下文。
    • 重新执行那条导致缺页中断的指令。
    • 这次 MMU 再查页表时,就能找到有效的物理地址映射,指令得以正常执行。

简单来说,缺页中断就是操作系统用来"现场搬救兵"的机制:当程序要用到硬盘上的数据时,硬件喊停,操作系统赶紧去硬盘把数据搬到内存,然后让程序接着运行。这是现代操作系统内存管理高效运作的核心机制之一。

线程与进程

1. 线程的优点

  • 创建与切换成本低
    • 创建代价:线程共享进程资源(内存、文件描述符等),无需复制完整地址空间。
    • 切换代价低
      • 保留虚拟内存:线程切换不刷新TLB(快表)和硬件Cache,避免内存访问效率下降。
      • 寄存器切换为主:仅需保存/恢复线程私有数据(寄存器、栈指针),无需切换页表等内核资源。
  • 资源占用少
    • 共享进程的代码段、数据段、堆、打开文件等,仅需独立维护线程私有栈、寄存器等少量资源。
  • 提升并行能力
    • 多线程可绑定到不同CPU核心,充分利用多处理器并行执行(尤其适合计算密集型任务)。
  • I/O效率优化
    • 线程可并发等待多个I/O操作(如一个线程处理用户输入,另一个线程下载文件),避免阻塞主程序。

2. 线程的缺点

  1. 性能损失风险
    • 计算密集型场景:若线程数 > CPU核心数,频繁切换导致调度开销增大,实际吞吐量反而下降。
    • 同步开销:锁竞争、信号量管理等额外操作消耗CPU时间。
  2. 健壮性降低
    • 共享数据风险:一个线程修改共享变量可能导致其他线程逻辑错误(需严格同步)。
    • 单点崩溃 :单个线程的野指针/除零错误会触发信号机制(如SIGSEGV),终止整个进程
  3. 访问控制缺失
    • 进程是资源分配单位,线程无权限制其他线程访问共享资源(如全局变量、文件句柄)。
  4. 开发复杂度高
    • 需处理竞态条件、死锁、优先级反转等问题,调试难度显著增加。

3. 线程异常

线程是进程的一部分,某个线程如果出现除零、野指针等问题导致线程崩溃,进程也会崩溃。

线程是进程的执行分支,线程如果出现异常,就等同于进程出异常,从而触发信号机制,终止进程进程终止,进程中的所有线程也就随即退出。

4. 线程适用场景

场景类型 应用示例 线程作用
CPU密集型 视频编码/科学计算 分解任务到多核并行,缩短计算时间。
I/O密集型 Web服务器/文件下载工具 重叠I/O等待与计算,提升响应速度。
交互式应用 图形界面程序 后台任务不阻塞用户操作(如边渲染边响应用户输入)。

Linux进程与线程

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

  • 线程共享进程数据,但同时也有自己的一部分数据(线程ID 、一组寄存器、、调度优先级等)

    线程共享进程地址空间

Linux操作系统中,多个轻量级进程公用一个进程地址空间,在同一个进程地址空间内,代码和数据都是共享的;

如果定义一个函数,每一个线程都可以调用这一个函数;定义一个变量,每一个线程都可以访问这个变量;

除此之外,像文件描述符表、每种信号处理方式、当前工作目录、用户id和组id等等都是共享的。

在之前学习进程的过程中,都是单线程进程;也就是只具有一个执行流的进程。

到这里本篇文章内容就结束了,感谢支持