【Linux系统】线程(上)

目录

引入

到这里进程想必大家都再熟悉不过了,那么什么是线程呢?线程和进程有什么关联?有什么区别?

带着这些问题我们正式开始对线程的学习!!!

一、线程概念

1. 教材角度:

进程 = 内核数据结构 + 代码和数据

线程:是进程内部的一个执行分支
2. 内核和资源角度:

进程:是承担分配系统资源的实体

线程:CPU调度的基本单位

上面的概念有点抽象,我们通过一个例子来理解:同进程线程

对于一个家庭而言,整个家庭就是进程,与其他家庭相互独立。家庭是分配社会资源的实体,其主线任务就是让家庭更加美好,而一个家庭中又有许多的成员,这些成员就类似一个个的执行分支(线程),他们可能同时会扫地,做饭,洗衣服等,这些行动可能并发执行,大大提高了效率。与此同时,家庭中厕所,冰箱,洗衣机等等,所有成员共享,而每个成员的房间,零花钱等等又独自拥有。

在Linux中,每个线程都有自己独立的task_struct,同进程线程共享虚拟地址空间mm_struct。这也是其设计的精妙之处,说白了就是Linux中其实并不真正区分进程与线程,线程 = 共享资源的轻量级进程

总结几个结论:

1️⃣为什么是轻量级?

创建进程:需要拷贝完整的地址空间等资源,开销大,慢;

创建线程(轻量级进程):只需要创建一个新的task_struct,与父进程共享虚拟地址空间等资源,不需要拷贝,创建快,占用资源少,实现了轻量化。

2️⃣线程如何运行?

进程访问大部分资源,都是通过地址空间来访问的,而所有线程共享地址空间,此时地址空间就是一个共享窗口。

我们需要线程去执行一段代码时,就将这部分资源划分给线程,而代码其实就是虚拟地址的集合(编译时对代码进行平坦模式编址),只需要将函数入口地址交给线程,通过入口地址被线程定位,线程凭 程序计数器(PC) 在共享的代码段中"游走"执行,栈则记录了它的调用路径和局部状态。

3️⃣资源划分:
虚拟地址就代表资源,因为虚拟地址就是访问资源的入口,资源划分本质就是对虚拟地址空间的范围进行划分

4️⃣为什么这样设计?

Linux不区分进程与线程,而是用轻量级进程模拟,调度线程就可以完美地复用进程调度的那一套,不再需要额外实现线程调度算法,Linux 内核中只有统一的 task_struct 结构。

5️⃣怎么理解前面的进程呢?

不就是进程内部只有一个线程嘛,单线程进程,只有一个执行流去执行所有的代码,今天变成了多线程进程,可以让多个执行流去执行我们的代码。

二、分页式存储管理

我们希望操作系统提供给用戶的空间必须是连续的,但是物理内存最好不要连续。,所以有了分页式存储管理。

2.1、重谈地址空间

让进程对物理内存的访问从无序变为有序,用户使用虚拟地址,虚拟地址连续,逻辑上访问的是连续有序的物理内存,即用户空间是连续的。

2.2、页和页框

2.2.1 虚拟地址空间管理------页

进程(用户)角度来看,地址空间被划分为栈,共享区,堆区等,但在操作系统管理角度来看,地址空间就是一个一个的页,即一定范围虚拟地址的集合,32位下一个页的大小一般为4KB。栈不就是其中连续的n个页嘛!

虚拟地址又是用户数据的访问入口,那页就成为了一个数据块。

OS通过struct mm_struct中的struct vm_area_struct来管理连续的页。

2.2.2 物理内存管理------页框

我们的代码,数据大小不一,如果对物理内存不加以管理,一旦这些资源被释放,就会导致非常多的内存碎片。因此对物理内存进行分页管理:

把物理内存按照固定大小的页框进行分割,一个页框与一个页的大小相同,页框是物理内存的一个存储区域。

页框:用来存储数据的区域;

页:是一个数据块,可以存储到页框或磁盘。

总结一下,其思想是将虚拟内存下的逻辑地址空间分为若干页,将物理内存空间分为若干页框,通过页表便能把连续的虚拟内存,映射到若干个不连续的物理内存页。这样就解决了使用连续的物理内存造成的碎片问题。

1️⃣我们加载程序到内存
  1. 编译链接:程序被平坦编址,生成连续的虚拟地址,这些地址自然划分成连续的页(虚拟地址的集合);
  2. 运行(按需调页):CPU 在虚拟地址上执行,遇到未映射的页触发缺页中断。内核分配页框,从磁盘读取对应代码/数据到页框,建立页(虚拟地址集合)与页框(物理存储)的映射,然后恢复执行;
  3. 后续访问:同一页内的地址直接通过页表翻译,无需再次缺页。
2️⃣OS管理页框

依旧先描述,再组织!!!

描述页框的内核数据结构为struct page,其中有几个关键的参数:

  1. flags:存放页框的状态;
  2. _mapcount:表示在页表中有多少个项(指针)指向该页框,引用计数,当计数值变为-1时,就说明当前内核并没有引用这一页,于是在新的分配中就可以使用它。
  3. virtual:页框的虚拟地址。
    我们可以算算管理这些页框需要多少个struct page对象,占多少内存:
    一个struct page算40个字节,而一个页框4KB,系统有 4GB 物理内存,就有4GB/4KB = 1048576 个,所占内存就是1048576*40,大概40MB,这个代价不算大。

如何组织?
全局数组: struct page 类型的mem_map数组:
数组下标就是页框号(PFN),页框大小位4KB,那么页框地址 = PFN*4096。

如何理解申请物理内存?

查mem_map数组,找到未被使用的页框,然后标记flags为已使用。

2.3、深入理解"页表" & "页目录"

这是我们第一次真正学习页表,前面我们只知道页表是完成虚拟地址转换物理地址,但实际要更复杂一点。

32位系统虚拟地址空间最大4GB,可寻址的物理内存最大4GB,实际我们要将数据加载到物理内存,即页框中,就要通过页表找到页框,通过计算我们知道有1048576(2^20)个页框,那么就需要这么多页表项 指向页框。一个指针4字节,1048576*4也就是4MB,32位下一张页表通常是4KB ,与页的大小适配,那么就需要4MB/4KB = 1024张页表。

与此同时,我们还需要一张表来索引页表,即页目录。那么页目录一定有1024个页目录项,每个页目录项对应一张页表。

这其实就是一个二级页表结构

现在看来,页表并不是通过将虚拟地址与物理地址都存储下来来完成转换的,而是一种更加巧妙的方式。

2.4、两级页表的地址转换

当我们拿到一个虚拟地址,如何找到对应的物理地址呢?

假设有个虚拟地址(32位):0000 1000 0000 0100 1000 0000 0000 0100

c 复制代码
二级页表索引:
┌──────────┬──────────┬──────────┐
│ 页目录索引 │  页表索引  │  页内偏移  │
│  10位     │   10位    │   12位    │
│  (32)    │   (72)   │  (4)     │
└──────────┴──────────┴──────────┘

OS会将高10位作为页目录索引,中间10位作为页表索引,低12位作为页内偏移。具体什么意思看下图:

🔄两级页表的地址转换完整过程:

通过CR3寄存器找到页目录物理地址,将页目录加载到内存,根据虚拟地址高10位在页目录中索引到页表地址,然后通过中间10位在页表中索引到页框号,最后页框地址加上低12位就得到了真实的物理地址,然后进行单字节访问。

以上过程由硬件------内存管理单元MMU完成,速度很快。

到这里其实还有个问题,MMU要先进行两次页表查询确定物理地址,在确认了权限等问题后,MMU再将这个物理地址发送到总线,内存收到之后开始读取对应地址的数据并返回。

那么当页表变为N级时,就变成了N次检索+1次读写。可见,页表级数越多查询的步骤越多,对于CPU来说等待时间越长,效率越低

有没有什么办法提高效率?

添加一个中间层来解决。 MMU 引入了新武器,江湖人称快表的 TLB (其实,就是缓存,Translation Lookaside Buffer,学名转译后备缓冲器)。

当 CPU 给 MMU 传新虚拟地址之后, MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存,齐活。

但 TLB 容量比较小,难免发生 Cache Miss ,这时候 MMU 还有保底的老武器页表,在页表中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录一下刷新缓存。

三、深刻理解线程

线程进行资源划分: 本质是划分地址空间,获得一定范围的合法的虚拟地址,再本质,就是在划分页表!!!
线程进行资源共享: 本质就是共享地址空间,再本质,就是对页表项的共享。

3.1、线程优缺点

优点:

  1. 线程即轻量级进程,OS在进行线程切换时工作量小,不需要像进程那样将页目录,页表,地址空间等资源切换;
  2. 同一个进程下的线程在切换时,不会扰乱硬件缓存:TLB页表缓存映射,CPU 的Cache缓存,页表本身也在缓存中。而进程切换相当于缓存需要重新开始,效率就会降低;
  3. 线程占用资源少;
  4. 线程可以在多处理器上并行,效率非常高;
  5. 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现;
  6. I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

缺点:

  1. 线程共享地址空间中的数据,如果不加以保护,就会出现数据不一致的问题。
  2. 单个线程出现除零,野指针访问,可能导致整个进程崩溃。

3.2、线程 VS 进程

进程是相互独立的;

线程共享地址空间,即共享资源。

进程是分配系统资源的实体;

线程是CPU调度的基本单位。

💥💥💥什么数据是线程独占的:

  1. 线程id;
  2. 寄存器上下文数据(线程是独立调度的);
  3. 线程有自己的(函数调用时创建自己独立的栈帧)。

进程与线程关系图如下:

四、线程控制

4.1、POSIX线程库

• 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以pthread_ 打头的

• 要使用这些函数库,要通过引入头文 <pthread.h>

• 链接这些线程函数库时要使用编译器命令的-lpthread选项

4.2、系统接口介绍

接下来我们从代码层面介绍然后创建和管理一个线程:

4.2.1 创建线程

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


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

void *routine(void *args)
{
    std::string name = static_cast<const char *>(args);
    while (true)
    {
        sleep(1);
        std::cout << "我是新线程: " << name << std::endl;
    }
    return nullptr;
}
int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, routine, (void *)"pthread-1");
    std::cout << "线程id = " << tid << std::endl;
    while (true)
    {
        sleep(1);
        std::cout << "我是主线程" << std::endl;
    }
    return 0;
}

打印出来的tidpthread_t 类型,pthread 库层面的抽象。

这个"ID"是 pthread 库给每个线程定义的进程内唯一标识 ,是 pthread 库维持的。由于每个进程有自己独立的内存空间,故此"ID"的作用域是进程级而非系统级(内核不认识)。通过pthread库中的pthread_self方法可以获得:


使用ps命令查看线程信息:

bash 复制代码
ps -aL | head -1 && ps -aL | grep test

-L 选项:打印线程信息


LWP又是什么呢?LWP(Light Weight Process),即轻量级进程,他才是真正的线程ID,内核用它来标识线程 。可以看到主线程main的LWP与进程pid相同。

通过系统接口gettid可以获得LWP:


4.2.2 线程终止

如果需要只终止某个线程而不终止整个进程,可以有三种方法:

  1. 从线程函数routine中return;
  2. 线程调用pthread_exit终止自身;
  3. 主线程调用pthread_cancel终止新线程。
c 复制代码
功能:线程终止
原型:void pthread_exit(void *value_ptr);
参数:value_ptr:value_ptr不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)

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

c 复制代码
功能:取消一个执行中的线程
原型:int pthread_cancel(pthread_t thread);
参数:thread:线程ID
返回值:成功返回0;失败返回错误码

4.2.3 线程等待

Why?

• 线程退出后,其空间没有被释放,仍然在进程的地址空间内。类似僵尸进程(内存泄漏)。

• 创建新的线程不会复用刚才退出线程的地址空间。

c 复制代码
功能:等待线程结束
原型int pthread_join(pthread_t thread, void **value_ptr);
参数:thread:线程ID
		value_ptr:它指向一个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码

调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_j_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参数。
c 复制代码
void *routine(void *arg)
{
    printf("thread-1 returning ... \n");
    int *p = (int *)malloc(sizeof(int));
    *p = 1;
    return (void *)p;
}

int main()
{
    pthread_t tid;
    void *ret;
    // 创建线程
    pthread_create(&tid, NULL, routine, NULL);
    // 等待回收线程
    pthread_join(tid, &ret);
    printf("thread return, thread id %lx, return code:%d\n", tid, *(int *)ret);
    free(ret);
    return 0;
}

4.2.4 线程分离

通常我们的线程退出后,想要调用pthread_join来等待回收线程资源,但是如果我们将新线程与主线程分离后,自动释放资源,就不需要再join新线程了。

如果不关心线程退出后的返回值,join是一种负担,就可以将新线程分离。

有两种方法可以分离新线程:

  1. 主线程分离新线程:
c 复制代码
int pthread_detach(pthread);
  1. 新线程主动分离:
c 复制代码
pthread_detach(pthread_self());

4.3、理解线程创建

线程在创建时有tidLWP,其中tid是pthread库形成的,是用户层面对形成的标识,用来pthread_join,phread_detch,pthread_cancel...。

LWP,内核对线程的唯一标识,拿着LWP来调度线程(轻量级进程)。

当我们调用pthread_create
第一步: glibc 在pthread库中为该线程创建一个struct pthread结构体对象,该对象的内容包括:线程id,线程状态,线程局部存储,线程栈,线程栈大小...。

然后,通过 mmap 分配线程栈。
第二步: 主动调用系统调用clone,由int 0x80/syscall发起软中断,陷入内核,执行中断处理方法(copy_process):

  • 分配全新的 task_struct(内核 TCB);
  • 根据 clone 的参数(CLONE_VM、CLONE_THREAD 等),设置资源共享:共享地址空间、文件描述符表、信号处理函数等;
  • 分配 PID 号(即 LWP 号),作为线程唯一标识(内核);
  • 创建独立的寄存器上下文(PC、SP、通用寄存器)等。

第三步:

  • 由OS将线程放入调度队列,等待 CPU 调度。
  • 被调度后,从 glibcstart_thread 入口开始执行,完成线程环境初始化,最终调用用户传入的 routine 方法。

pthread_t 到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。

结语

线程(上)的介绍到此结束,相信大家对线程已经有了深刻的认识,下一期我会接着介绍线程(下) ,我会持续更新,希望你能够多多关注,如果本文有帮助到你,还请三连加关注,你的支持,就是我创作的最大动力!

相关推荐
呉師傅1 小时前
将CD音频抓轨转换成MP3的两种方法【图文解释】
运维·服务器·网络·windows·电脑·音视频
iceman19521 小时前
ubuntu 25.10升级到26.04
linux·服务器·ubuntu
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第54题】【JVM篇】第14题:什么是可达性分析算法?
java·jvm·算法·面试
接着奏乐接着舞1 小时前
java jvm知识点
java·开发语言·jvm
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第55题】【JVM篇】第15题:JVM有哪些垃圾收集算法?
java·jvm·算法·面试
轩轩的学习之路1 小时前
claudecode安装+第三方模型,无root
linux·人工智能·python
金玉满堂@bj1 小时前
pytest+uiautomation+allure 数据驱动桌面自动化项目搭建指南-yaml版本
运维·自动化·pytest
摇滚侠1 小时前
Java 基础面试题 真正的 offer 偏方 Java 基础 Java 高级
java·开发语言
晓蓝WQuiet1 小时前
《鸟哥的Linux私房菜》笔记 第七至十六章
linux·运维·笔记