《Linux系统编程》18.线程概念与控制

💡Yupureki:个人主页

✨个人专栏:《C++》 《算法》《Linux系统编程》《高并发内存池》《MySQL数据库》

《个人在线OJ平台》


🌸Yupureki🌸的简介:


目录

[1. 线程概念](#1. 线程概念)

[1.1 什么是线程?](#1.1 什么是线程?)

[1.2 分页式内存管理](#1.2 分页式内存管理)

[1.2.1 虚拟地址和页表的由来](#1.2.1 虚拟地址和页表的由来)

[1.2.2 页表](#1.2.2 页表)

[1.2.3 页目录](#1.2.3 页目录)

[1.2.4 两级页表的转化](#1.2.4 两级页表的转化)

[1.2.5 缺页中断](#1.2.5 缺页中断)

[2. 线程VS进程](#2. 线程VS进程)

[2.1 资源占用](#2.1 资源占用)

[2.2 切换开销](#2.2 切换开销)

[2.3 通信方式](#2.3 通信方式)

[2.4 数据同步](#2.4 数据同步)

[2.5 总结](#2.5 总结)

[2.6 如何选择?](#2.6 如何选择?)

[3. 线程控制](#3. 线程控制)

[3.1 线程创建](#3.1 线程创建)

[3.2 线程终止](#3.2 线程终止)

[3.3 线程等待](#3.3 线程等待)

[3.4 分离线程](#3.4 分离线程)

[3.5 线程ID](#3.5 线程ID)

[3.5.1 用户态线程 ID](#3.5.1 用户态线程 ID)

[3.5.2 内核态线程 ID](#3.5.2 内核态线程 ID)

[3.6 进程地址空间布局](#3.6 进程地址空间布局)


1. 线程概念

1.1 什么是线程?

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

本质:

一个进程内的所有线程共享资源,包括虚拟地址空间,时间片等

在Linux中的线程被叫做轻量级进程,本质还是用进程模拟的。但为了迎合大众,还是叫做线程

要真正理解线程,就必须搞清楚,内核是如何进行资源划分的,尤其是代码

1.2 分页式内存管理

1.2.1 虚拟地址和页表的由来

每个进程都有其虚拟地址空间页表,页表中存在虚拟地址到物理地址的映射关系

但胡乱映射,抢占真实的物理内存很容易造成内存使用不充分的情况,即内存碎片问题

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

核心概念:什么是分页?

操作系统将虚拟地址空间物理内存 都划分成大小固定的块,称为 (Page)和页框 (Page Frame)。通常大小是4KB(在某些体系结构上可以是 4MB 或 2MB 等大页)。

1.2.2 页表

页表中的每一个表项,指向一个物理页的开始地址 。在32位系统中,虚拟内存的最大空间是4GB,

这是每一个用户程序都拥有的虚拟内存空间。既然需要让4GB的虚拟内存全部可用,那么页表中就需要能够表示这所有的4GB空间,那么就一共需要4GB/4KB=1048576个表项。

页表中的物理地址,与物理内存之间,是随机的映射关系 ,哪里可用就指向哪里(物理页)。虽然最终使用的物理内存是离散的,但是与虚拟内存对应的线性地址是连续的。处理器在访问数据、获取指令时,使用的都是线性地址,只要它是连续的就可以了,最终都能够通过页表找到实际的物理地址。

但是假设,在32位系统中,地址的长度是4个字节,那么页表中的每一个表项就是占用4个字节。所以页表占据的总空间大小就是:1048576*4=4MB 的大小。也就是说映射表自己本身,就要占用

4MB/4KB=1024个物理页。这还是只是页号的记录,甚至没记录具体在页内的偏移量,那样就更大了。

但是根据局部性原理可知,很多时候进程在一段时间内只需要访问某几个页 就可以正常运行

了。就比如我只让一个程序计算1+1等于多少,至于记录所有的页吗?因此也没有必要一次让所有的物理页都常驻内存。

1.2.3 页目录

为了解决页表浪费的情况,我们专门引入页目录来管理页表 ,一个页目录可以映射1024个页表

页目录中存的是每个页表的物理地址,由于有1024个页表,因此需要1024*4即4kb来存储页表的物理地址

因此,我先建立一个页目录表,一个页目录表可以存1024个页表,而不是像之前直接一次性存1024*1024个页表。如果用的页数大于1024,我们再建一个页目录,再映射1024个页表即可。因此这样是每次1024个页表的创建

页目录的物理地址CR3寄存器指向,这个寄存器中,保存了当前正在执行任务的页目录地址。

1.2.4 两级页表的转化

我们之前说过,物理内存被分为1048576 个页,当我们知道其中一个页的起始地址,要想找到这个页中的具体的地址,只需要知道偏移量即可。那一个页内有多少种偏移量?4kb=4096=2^12,即一共有2^12种偏移量,这对应12个比特位

因此页表 存的是页内偏移量 ,即12个比特位 ,在32位平台下为前12个比特位

一个页目录有1024 个页表,即2^10 个页表,那么页目录 就存中间的10个比特位 ,表示页目录内的页表号

那剩余10个比特位谁存?寄存器 存,寄存器存最后的10个比特位,表示页目录的序号

以上就是MMU的工作流程。MMU(MemoryManageUnit)是一种硬件电路,其速度很快,主要工作是进行内存管理,地址转换只是它承接的业务之一。

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

1.2.5 缺页中断

现代操作系统都使用虚拟内存 。每个进程拥有独立的虚拟地址空间,而物理内存是共享的。CPU中的MMU负责将虚拟地址转换为物理地址,它通过查询页表来完成转换。

当发生以下三种情况时,MMU无法完成转换,就会触发缺页中断:

  1. 页面未映射 :虚拟地址所在的页表项为空(pte_none),说明该地址从未被分配,或者被munmap释放了。

  2. 页面被换出 :页表项存在,但被标记为"不存在"(Present位为0),通常是因为物理页被交换到了磁盘上的Swap分区。

  3. 权限不足 :页表项存在,但尝试的操作违反了权限。例如,试图写入一个只读的页面,或者执行一个被禁止执行的页面(见于mmap的PROT_NONE或写时复制机制)。

当CPU捕获到缺页异常后,会陷入内核态 ,执行内核的do_page_fault函数。简单来说,这个函数会获取上下文数据,分析合法性,最终分配物理空间,重新填充虚拟地址到物理地址的映射

为什么要有缺页中断?

缺页中断的存在,本质上是为了解决计算机体系中一个根本矛盾:物理内存的容量和地址空间是有限且珍贵的,而应用程序需要的是无限、连续、且互不干扰的虚拟地址空间。如果没有缺页中断,虚拟内存就只是一个"空壳",无法真正运行程序。

因此缺页中断最显著的优点便是节约了空间。如果没有缺页中断,程序启动时,操作系统必须将其全部代码和数据(比如一个几十GB的游戏或大型数据库)一次性读入物理内存。

有了缺页中断:程序启动时,操作系统只是建立虚拟地址到文件的映射,并不实际加载数据。只有当CPU执行到某一页代码或访问某一页数据时,才触发缺页中断,内核此时才从磁盘加载这一页。这大大减少了启动时间和物理内存占用,让大程序在有限内存中也能运行。

2. 线程VS进程

2.1 资源占用

理清了分页式内存管理 的机制,我们便能从资源占用角度分析线程和进程的区别

  • 进程拥有独立的地址空间、文件描述符、堆、栈、信号处理等资源。
  • 线程与同进程内的其他线程共享地址空间、堆、全局变量、文件描述符 等;只拥有独立的栈、寄存器和线程局部存储(TLS)。

因此线程占用的资源要比进程小

  • 同时,由于进程拥有独立的进程地址空间,子进程需要复制需要复制父进程的页表、文件描述符表等,使用 fork() 时涉及写时拷贝(COW)机制。
  • 而创建线程(pthread_create)只需分配线程栈和线程控制块(TCB),复用进程的地址空间。

因此创建子线程的开销要比进程的小

但进程的开销大并不一定代表臃肿,独立的空间让进程安全性更大

  • 一个进程崩溃不会波及其他进程,适合高可靠性场景(如浏览器多进程架构)。
  • 而一个线程因野指针、除零等错误导致崩溃,整个进程(包括所有线程)都会终止。

2.2 切换开销

  • 进程切换涉及页表切换、TLB(旁路转换缓冲,Translation Lookaside Buffer)刷新、CPU 上下文切换。
  • 同进程内线程切换时地址空间不变,无需刷新 TLB,仅需保存和恢复寄存器、栈指针等。

2.3 通信方式

  • 进程通信复杂,需借助进程间通信(IPC):管道、消息队列、共享内存、信号、套接字等。
  • 线程通信简单,直接读写全局变量、共享内存,但需加锁(互斥锁、读写锁等)同步。

2.4 数据同步

  • 进程IPC 机制大多内核介入,相对重量级。
  • 线程间数据天然共享,但必须显式使用同步原语(互斥锁、条件变量、自旋锁等)避免竞态条件。

然而过于自由地共享资源也不一定是好事

  • 进程进程间数据天然不共享,无需显式加锁,减少了并发编程的复杂性(但代价是需要设计 IPC)。
  • 虽然线程间天然共享进程的内存空间,数据交换几乎零成本。但是同步带来资源竞争的风险,需要涉及同步,加锁的机制来防止激烈的竞争

2.5 总结

特性 进程 线程
资源开销
切换速度
隔离性
通信复杂度 较高(需 IPC) 低(共享内存,需同步)
编程难度 相对简单(无锁) 较高(需精细的同步设计)
适用场景 稳定优先、隔离性要求高 性能优先、高频数据交互

进程的优缺点

优点

  1. 稳定性与隔离性强

    一个进程崩溃不会波及其他进程,适合高可靠性场景(如浏览器多进程架构)。

  2. 安全性高

    独立的地址空间使得一个进程无法直接访问另一个进程的内存数据(需通过内核授权的 IPC),减少了安全漏洞扩散的风险。

  3. 利用多核 CPU

    通过多进程可以充分利用多核,且不需要担心锁竞争导致的性能下降(但进程间通信仍可能成为瓶颈)。

  4. 编程模型简单

    进程间数据天然不共享,无需显式加锁,减少了并发编程的复杂性(但代价是需要设计 IPC)。

缺点

  1. 资源开销大

    创建和销毁进程都需要申请和回收大量资源(内存页表、文件描述符等),进程切换开销也远高于线程。

  2. 进程间通信效率低

    IPC 机制通常需要内核参与和数据拷贝(共享内存除外),性能低于线程间直接访问共享内存。

  3. 扩展性受限

    进程数过多时,系统负载(调度开销、内存占用)会显著增加。

线程的优缺点

优点

  1. 轻量高效

    创建、销毁和切换速度快,资源占用少,适合高并发场景(如 Web 服务器、数据库)。

  2. 通信便捷

    线程间天然共享进程的内存空间,数据交换几乎零成本,只需注意同步即可。

  3. 资源利用率高

    线程可以充分利用多核并行执行任务,且共享资源(如打开的文件、内存池)避免了重复复制。

缺点

  1. 稳定性差

    一个线程因野指针、除零等错误导致崩溃,整个进程(包括所有线程)都会终止。

  2. 同步复杂

    共享数据带来了竞态条件风险,必须引入锁、条件变量等同步机制,容易产生死锁、性能下降(锁竞争)等问题。

  3. 调试难度大

    多线程程序的 bug(如数据竞争、死锁)往往具有偶发性,难以复现和定位。

  4. 存在安全隐患

    线程间直接共享内存,若某个线程存在漏洞(如缓冲区溢出),可能被攻击者利用来读写其他线程的敏感数据。

2.6 如何选择?

在实际开发中,选择进程还是线程取决于具体场景:

  • 优先选多进程

    • 需要高稳定性、强隔离性(如守护进程、关键服务、Chrome 浏览器标签页)

    • 任务之间关联性低,通信少

    • 希望避免锁的复杂性

  • 优先选多线程

    • 高并发、高吞吐场景(如 Nginx、Redis)

    • 任务之间需要频繁共享大量数据

    • 对资源开销敏感(如嵌入式设备)

  • 混合模型

    实际大型系统中常采用多进程 + 多线程 混合模式。例如,主进程负责管理,多个工作进程各自内部使用线程池处理请求(如 Apache 的 preforkworker 模式、Nginx 的多进程架构)。这样既利用进程隔离提高稳定性,又通过线程提高并发效率。

3. 线程控制

接下来我们通过 POSIX 线程库(pthread) ,在 Linux 环境下进行线程操作的实战演练,涵盖线程的创建、终止、等待和分离。所有示例代码均使用 C/C++语言,编译时需链接 pthread 库(gcc -pthread)。

3.1 线程创建

cpp 复制代码
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine)(void *), void *arg);
  • thread:输出参数,返回新线程的 ID。

  • attr:线程属性,一般传 NULL 使用默认属性。

  • start_routine:线程入口函数,返回 void*,参数为 void*

  • arg:传递给线程函数的参数。

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

创建线程时,最重要的莫过于设计子线程运行的函数 ,在pthread_create指定该函数后,子线程创建后自动从该函数开始运行,同时函数的参数固定 (void* arg),pthread_create传入的arg参数 自动传入子线程运行的函数

cpp 复制代码
void* thread_func(void* arg) {
    .....
}

测试:

cpp 复制代码
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>

// 线程函数
void* thread_func(void* arg) {
    int num = *(int*)arg;
    printf("线程 %d 正在运行\n", num);
    sleep(1);
    printf("线程 %d 结束\n", num);
    return NULL;
}

int main() {
    pthread_t t1, t2;
    int arg1 = 1, arg2 = 2;

    // 创建线程
    if (pthread_create(&t1, NULL, thread_func, &arg1) != 0) {
        perror("pthread_create t1");
        return 1;
    }
    if (pthread_create(&t2, NULL, thread_func, &arg2) != 0) {
        perror("pthread_create t2");
        return 1;
    }

    // 等待线程结束(后面会详细介绍)
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    printf("主线程退出\n");
    return 0;
}

3.2 线程终止

线程可以在以下情况下终止:

  1. 调用 pthread_exit

    cpp 复制代码
    void pthread_exit(void *retval);

    retval:线程退出状态,可由其他线程通过 pthread_join 获取。调用 pthread_exit 后,线程立即终止,不会执行后续代码。

  2. 被其他线程取消(pthread_cancel

    cpp 复制代码
    int pthread_cancel(pthread_t thread);

    向目标线程发送取消请求,目标线程是否响应取决于其取消状态和类型。此处暂不深入。

  3. 从线程函数返回

    线程函数执行 return,返回值作为线程退出状态。

测试:

cpp 复制代码
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

void* worker(void* arg) {
    int* p = (int*)malloc(sizeof(int));
    *p = 42;
    printf("工作线程准备退出,返回地址 %p 值为 %d\n", p, *p);
    pthread_exit(p);  // 返回动态分配的内存地址
}

int main() {
    pthread_t tid;
    void* ret;

    pthread_create(&tid, NULL, worker, NULL);
    pthread_join(tid, &ret);

    printf("主线程获取到返回值:%d\n", *(int*)ret);
    free(ret);  // 记得释放内存
    return 0;
}

3.3 线程等待

为什么需要线程等待?

  • 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
  • 创建新的线程不会复用刚才退出线程的地址空间。

函数原型

cpp 复制代码
int pthread_join(pthread_t thread, void **retval);
  • thread:要等待的线程 ID。

  • retval:输出参数,保存线程退出状态(如果线程通过 returnpthread_exit 返回,则指向返回值;若线程被取消,则 PTHREAD_CANCELED)。

  • 行为:

    • 调用线程阻塞,直到目标线程终止。

    • 自动回收目标线程的资源(栈、寄存器状态等),防止僵尸线程。

    • 一个线程只能被 join 一次,且必须是可连接的(joinable)状态。

测试:

cpp 复制代码
#include <pthread.h>
#include <stdio.h>
#include <cstdlib>

void* add(void* arg) {
    int* nums = (int*)arg;
    int* result = (int*)malloc(sizeof(int));
    *result = nums[0] + nums[1];
    return result;
}

int main() {
    pthread_t tid;
    int input[2] = {10, 20};
    int* sum;

    pthread_create(&tid, NULL, add, input);
    pthread_join(tid, (void**)&sum);
    printf("10 + 20 = %d\n", *sum);
    free(sum);
    return 0;
}

3.4 分离线程

  • 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
  • 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。

函数原型

cpp 复制代码
int pthread_detach(pthread_t thread);
  • 将线程设置为分离状态(detached)。

  • 分离线程终止时,系统自动回收其资源,无需也不允许其他线程对它调用 pthread_join

  • 线程可以在创建时通过属性设置为分离,也可以在运行中自己或由其他线程调pthread_detach 分离。

测试:

cpp 复制代码
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>

void* detached_task(void* arg) {
    printf("分离线程开始工作\n");
    sleep(2);
    printf("分离线程结束,资源自动回收\n");
    return NULL;
}

int main() {
    pthread_t tid;
    pthread_attr_t attr;

    // 初始化线程属性
    pthread_attr_init(&attr);
    // 设置为分离状态
    pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);

    if (pthread_create(&tid, &attr, detached_task, NULL) != 0) {
        perror("pthread_create");
        return 1;
    }

    pthread_attr_destroy(&attr);  // 属性不再需要

    // 主线程继续执行,不等待分离线程
    printf("主线程继续运行,无需 join\n");
    sleep(3);  // 让分离线程有机会执行完
    printf("主线程退出\n");
    return 0;
}

3.5 线程ID

在 Linux 的多线程编程中,你会遇到两种"线程 ID":

  1. 用户态线程 ID(pthread_t)

  2. 内核态线程 ID(tid,即 task_struct 中的 pid)

3.5.1 用户态线程 ID

pthread_t用于进程内,是用来标识进程内唯一线程的

  • 类型pthread_t(通常是一个结构体或指针,取决于实现)

  • 获取方式pthread_self()

  • 作用域 :只在当前进程中有效,用于 pthread 库的函数(如 pthread_join, pthread_cancel

  • 本质 :由线程库(NPTL)维护的一个句柄,不一定是整数 ,在不同平台可能不同(例如 glibc 中它是一个 unsigned long int,实际是指向一个 struct pthread 的指针)。

  • 特点 :两个不同进程中的线程可以有相同的 pthread_t 值,但彼此无关。

3.5.2 内核态线程 ID

tid用于系统中,用来标识唯一的进程。因为一开始进程内只有一个线程,这个线程被称为主线程。我们用tid用来表示进程,也相当于表示其中的主线程

  • 类型pid_t(即 int

  • 获取方式 :系统调用 gettid()(glibc 未直接封装,需用 syscall(SYS_gettid)

  • 作用域 :系统全局唯一(同一时刻),在 /proc/[pid]/task/ 目录下可见,也是 ps -eLf 命令中显示的 LWP(轻量级进程)列。

  • 本质 :Linux 内核中每个线程由一个 task_struct 表示,它的 pid 字段就是内核线程 ID。而进程 ID(PGID)则是线程组中第一个线程的 PID。

注意pthread_create 返回的线程 ID 和内核线程 ID 是一一对应 的,但它们是不同的值。你可以通过 pthread_self() 获取用户态 ID,通过 gettid() 获取内核态 ID。

3.6 进程地址空间布局

一个进程的虚拟地址空间(Virtual Address Space)是操作系统为每个进程提供的独立内存视图。在多线程环境下,所有线程共享同一个地址空间,但每个线程拥有独立的栈和线程局部存储(TLS)。

在多线程中,创建子线程时,线程库会通过**mmap** 在内存映射段分配一块内存作为线程栈(默认大小通常为 8MB)

因此每个线程 拥有独立的栈(位于地址空间的某个位置,通常由 mmap 分配)和寄存器上下文。线程局部存储(TLS)也是每个线程独立的,但位于共享区域(如通过 __thread 关键字修饰的变量)。

相关推荐
SimonKing2 小时前
紧急自查!Apifox被投毒,使用者速看:你的Git、SSH、云密钥可能已泄露
java·后端·程序员
相醉为友2 小时前
000 Linux个性操作记录——存储焦虑时期如何wsl2中配置安全的软件优化操作
linux·安全
帅得不敢出门2 小时前
Android Framework中调用由java编译成的jar接口
android·java·framework·jar
Luna-player2 小时前
Linux利用三块新硬盘在Linux中构建LVM
linux
CylMK2 小时前
题解:UVA1218 完美的服务 Perfect Service
数据结构·c++·算法·深度优先·图论
墨^O^2 小时前
并发控制策略与分布式数据重排:锁机制、Redis 分片与 Spark Shuffle 简析
java·开发语言·c++·学习·spark
丶小鱼丶2 小时前
数据结构和算法之【阻塞队列】上篇
java·数据结构
zb200641202 小时前
MySQL——表操作及查询
java