【Linux手册】线程 && 页表:搞懂Linux线程核心!从创建线程到虚拟地址映射

Linux线程与页表核心解析

请添加图片描述


半桔个人主页

 🔥 个人专栏: 《Linux手册》《手撕面试算法》《C++从入门到入土》

🔖生命的意义本不在向外的寻取,而在向内的建立。 -史铁生-

前言

在现代计算机系统的繁忙 “工厂” 里,线程如同穿梭于各个工序的工人,承担着执行任务的核心职责。从早期单任务处理的 “孤军奋战”,到如今多核处理器时代的 “协同作战”,线程的演化始终与计算效率的突破紧密相连。

作为操作系统调度的基本单位,线程以轻量性和共享性重塑了程序的执行模式 —— 它既继承了进程对系统资源的封装特性,又通过共享内存空间实现了更高效的通信与协作。
本文将从线程的基本概念出发,逐步揭开其运行机制的神秘面纱,探讨多线程编程的核心技术与实
践经验。

本文将分为5个部分进行介绍:

  1. 线程的基本概念;
  2. 页表的映射逻辑;
  3. 线程与进程对比;
  4. 进程的相关接口;
  5. 线程的ID是什么;

一. 线程的基本概念

一个进程内部可以有多个执行流,线程是进程的一个执行分支,就是其中的一个执行流。

线程在进程内进行执行,也有分配资源,而一个进程的资源就是该进程的进程地址空间,因此线程在被调度的时进程要为线程分配地址空间,该线程在自己的地址空间中执行代码。

示意图如下:
请添加图片描述

  • 地址空间是进程的资源窗口,在Linux中,线程是在进程"内部"执行的,可以理解为线程在进程的地址空间中运行

  • 因为线程只需要执行进程内的一部分代码,所以说线程的执行力度比进程要更细

所以根据上面的描述,可以对线程进行一个简单的总结:线程是操作系统调度的基本单位,线程是进程的一个执行分支,线程的执行力度比进程更细

当有了线程,线程成为进程中的执行流,此后进程就只需要承担分配资源的功能了

如果一个进程中只有一个执行流,就只有一个task_struct.

通过ps -aL可以查看一个进程中的线程的个数,其中PID指的是进程得PID ,LWP指的是线程得PID,属于同一个进程的线程其,线程的PID相同:

单线程:
请添加图片描述

多线程:
请添加图片描述

一个进程至少有一个执行流,也就是说:一个进程至少有一个线程

在一个进程中可能存在着多个线程,因此线程也要被管理起来,也要向描述在组织,线程是不是也有自己的结构体呢?是的,在Windows操作系统中,就专门为了管理线程,使用了struct tcb结构体来进行描述和组织。

线程属于进程的一部分,进程有的线程也有,像ID,状态,优先级,内存资源,文件描述符表,信号处理、调度信息等。也就是说进程结构体中的各个字段,线程也应该有,只不过可能内部资源的多少不同

根据上面的描述,我们知道实际上并不需要一个新的结构体来描述线程,使用进程的结构体来描述线程就足够了,因此在Linux中并没有真正的线程tcb结构体,而是使用"进程"内核数据结构来模拟线程的,毕竟线程就是轻量化的进程,而进程分配资源实际上就是在分配该进程地址空间

在CPU调度的时候,在它眼中,线程和进程都是一样的,都属于执行流;
对于执行流,线程,进程可以根据大小进行排序:线程 <= 执行流 <= 进程

二. 页表的映射逻辑

在上面我们谈到分配资源都是对虚拟地址进行分配的,虚拟地址通过页表映射到内存地址上。
以下将对这一过程进行详细介绍:

2.1 页和页框

在物理内存上数据的存放是随机存放的,对于一个进程中的数据也并不是整块存储的,使用随机存储,否则如果内存中有多个进程,并且这些进程的数据都是连续存储的,就会导致物理内存将会被分割成各种离散的、大小不同的块,导致物理内存被大量浪费。

我们希望操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续。 此时就需要将虚拟地址与物理地址建立映射,就是通过也也标记进行映射的。

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

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

假设一个可用的物理内存有4GB的空间。按照一个页框的大小4KB进行划分,4GB
的空间就是4GB/4KB=1048576个页框。有这么多的物理页,操作系统肯定是要将其管理起来的,操作系统需要知道哪些页正在被使用,哪些页空闲等等。内核用struct page结构表示系统中的每个物理页,出于节省内存的考虑,struct page结构体很小:

struct page {
	page_flags_t flags;		/* Atomic flags, some possibly
					 * updated asynchronously */
	atomic_t _count;		/* Usage count, see below. */
	atomic_t _mapcount;		/* Count of ptes mapped in mms,
					 * to show when page is mapped
					 * & limit reverse map searches.
					 */
	unsigned long private;		/* Mapping-private opaque data:
					 * usually used for buffer_heads
					 * if PagePrivate set; used for
					 * swp_entry_t if PageSwapCache
					 * When page is free, this indicates
					 * order in the buddy system.
					 */
	struct address_space *mapping;	/* If low bit clear, points to
					 * inode address_space, or NULL.
					 * If page mapped as anonymous
					 * memory, low bit is set, and
					 * it points to anon_vma object:
					 * see PAGE_MAPPING_ANON below.
					 */
	pgoff_t index;			/* Our offset within mapping. */
	struct list_head lru;		/* Pageout list, eg. active_list
					 * protected by zone->lru_lock !
					 */
	/*
	 * On machines where all RAM is mapped into kernel address space,
	 * we can simply calculate the virtual address. On machines with
	 * highmem some memory is mapped into kernel virtual memory
	 * dynamically, so we need a place to store that address.
	 * Note that this field could be 16 bits on x86 ... ;)
	 *
	 * Architectures with slow multiplication can define
	 * WANT_PAGE_VIRTUAL in asm/page.h
	 */
#if defined(WANT_PAGE_VIRTUAL)
	void *virtual;			/* Kernel virtual address (NULL if
					   not kmapped, ie. highmem) */
#endif /* WANT_PAGE_VIRTUAL */
};
  1. flags:用来存放页的状态。这些状态包括页是不是脏的,是不是被锁定在内存中等。
  2. mapcount:表示在页表中有多少项指向该页,也就是这一页被引用了多少次。当计数值变
    为-1时,就说明当前内核并没有引用这一页,于是在新的分配中就可以使用它。
  3. virtual:是页的虚拟地址。通常情况下,它就是页在虚拟内存中的地址。有些内存(即所谓
    的高端内存)并不永久地映射到内核地址空间上。在这种情况下,这个域的值为NULL,需要的
    时候,必须动态地映射这些页。

2.2 虚拟地址转内存地址

那么内存地址是如何与虚拟地址建立关系的呢???

以32位机器为例:

32位机器下,一个地址有32个比特位,也就是说每个地址的存储需要4个字节,那么如果要存储所有的虚拟地址就需要 2 32 − 1 ∗ 4 2^{32-1}*4 23214 也就是大约4GB的空间,虚拟地址要存4GB,对应的内存地址也要存4GB,因此内存上要用8GB的空间完成映射。

毫无疑问,这种直接将虚拟地址和内存地址一一对应的方法肯定是不行的,因为这太浪费空间了。

==所以页表将32个地址进行拆分:前10个一组,中间10个一组,最后12个一组:==将虚拟地址通过三次映射找到物理地址,示意图如下:

请添加图片描述

  1. 先将地址划分出前10位,使用一个 2 10 2^{10} 210的数组来存储二级页表的地址,而一级页表的下标就代表地址的前10位;
  2. 同理二级页表大小也是 2 10 2^{10} 210,下标表示中间10位地址的大小,其中存储的是内存中每个页的起始位置,通过中间10位就可以找到数据在内存中的哪一个页框中
  3. 还剩最后12位,而 2 12 2^{12} 212恰好是4KB,一个页框的大小,所以根据随后12位就可以知道在页框中的哪一个位置

通过上面三次映射来在目标位置,需要多少空间:一级页表 1024 ∗ 4 = 4 K B 1024*4 = 4KB 10244=4KB ,二级页表有1024个,所以二级页表总大小是 1024 ∗ 1024 ∗ 4 = 4 M B 1024*1024*4 = 4MB 102410244=4MB ,也就是说存这两个表只需要大约4MB空间。

并且因为一个进程并不会使用所有内存,因此一级页表中有些位置为空,也就是说二级页表数量少于1024个,空间会更小。所以按照这种方式进行映射能有效节约空间。

CPU中与内存相关的寄存器:

  • 在CPU中有CR3寄存器存储当前进程的一级页表地址;
  • CPU还有一个CR2寄存器,专门用来存放发生缺页中断的地址,来保证当数据加载到内存中能从原位置继续执行。

**MMU(Memory Manage Unit)**是一种硬件电路,其速度很快,是它来进行内存管理的,地址转换,用虚拟地址找物理地址的。

在操作系统中为了让查找效率更快,还会使用TLB,就是缓存,将经常使用的地址放到缓存中,在缓存中地址是直接映射的,查找更快,但缓存只能存放一部分映射关系。

当要访问物理内存时,先去TLB中查看是否已经缓存了,如果没有才通过MMU来查找物理内存。

三. 线程与进程对比

线程比进程更轻量化:

  1. 创建和释放更轻量化,线程占据的资源更少;
  2. 切换更轻量化,CPU中切换调度的进程需要更换寄存器,页表,虚拟地址等,而线程的切换调度只需要更换物理内存即可。

关于上面的切换轻量化还有一个重要的原因:

CPU中不仅仅有寄存器,还有一块CPU级别的缓存cache,被称为热缓存,通过cat \proc\cpuinfo也可以进行查看:
请添加图片描述

该缓存中存放着进程中经常使用的一些数据,因此放切换进程后,要重新进行缓存,而切换线程就无需重新缓存,还有上面页表的TLB缓存也要从0开始。

线程是进程的一个分支,所以线程执行就代表这进程执行,所以当一个线程挂了会直接导致进程挂掉。

线程也是有身份的:主线程,新线程;如果一个线程的LWP和进程的PID一样,则该线程就是主线程;线程替换是根据每个线程中时间片的使用情况进行切换的,在主线程中不仅仅有自己线程的时间片,还有整个进程的时间片,决定了当前线程什么时候切换。

线程的缺点:

  1. 健壮性比高,当一个线程出现问题的时候,会直接影响到其他线程,线程就代表整个进程;
  2. 编程难度更高,需要考虑不同线程间的同步互斥问题。

两个概念:

  • 计算密集型:任务的速度取决于CPU的运算能力;
  • IO密集型:任务的执行速度,主要受输入输出操作速度限制。

对于计算密集型的程序,只用单线程更好;而对于IOO密集型的任务,使用多线程效率更高。线程的用途,与多进程一样,都是为了提高并发度。

线程与进程的概念:

  1. 进程是资源分配的基本单位;
  2. 线程是CPU调度的基本单位;
  3. 线程间共享进程资源,进程之间相互独立。

线程有些资源可以共享使用,但是有些资源只能自己进行使用:

线程独立的资源:

  1. 栈空间;
  2. 一组寄存器,用来存储线程的上下文;
  3. 线程ID;
  4. errno存储错误号;
  5. 信号屏蔽字。

线程间共享的资源:

  1. 文件描述符表;
  2. 地址空间,代码段,数据区;
  3. 全局变量,函数;
  4. 信号的处理方式。

四. 线程调用接口

上面我们谈到Linux中没有线程的概念,所以没有提供线程的系统接口,只提供了轻量级进程的系统调用;Linux程序员对这些接口进行封装,在应用层,提供pthread库,属于Linux的原生线程库,Linux系统中自带的有,只不过在使用一些接口的时候需要主动连接相应的库

以下介绍的都是pthread原生线程库中的接口。

4.1 创建线程

int pthread_create(pthread_t* thread , const pthread_attr_t *attr , void* (*start_routinue)(void*) , void* arg

  1. 参数一:一个输出型参数,用来输出线程的ID;
  2. 参数二:设置线程的属性,像优先级,栈属性,以及回收方式等,一般不进行设置,使用NULL;
  3. 参数三:是一个函数指针,该函数返回值和参数都是void*,当线程创建成功之后,会去执行该函数中的代码;
  4. 第四个参数,一个void*的指针,用来作为函数的参数。
  5. 返回值,成功返回0,失败返回错误码。

因为给线程调用函数传参的时候,传的是指针并且是void*类型的,所以我们不仅可以传内置类型,还可以传结构体/类指针等。

下面写一个demo代码进行演示:

void *thread_func(void *arg)
{
    while (1)
    {
        std::cout << "the new thread is running" << std::endl;
        sleep(1);
    }
    return nullptr;
}

int main()
{
    pthread_t thread;
    pthread_create(&thread, nullptr, thread_func, nullptr);
    while (1)
    {
        std::cout << "the main thread is running" << std::endl;
        sleep(1);
    }
    return 0;
}

注意:在进行编译的时候,要连接pthread库:g++ -o test test.cc -std=c++11 -lpthread
现象:

请添加图片描述

可以看到两个线程在同时打印数据。

4.2 线程等待

与进程等待类似,线程也需要进行等待,否则也会出现类似于僵尸进程的现象,有时主线程也希望知道新线程的运行情况

在线程库中也提供了对应的接口,让主进程来回收新进程:
int pthread_join(pthread_t thread , void** reval)

  1. 参数一:线程ID;
  2. 参数二:是一个输出型参数,接收线程的返回信息;
  3. 返回值:成功返回0,失败返回错误码。

在一个新线程中也允许主动终止当前线程的执行,并可选地向其他等待该线程的线程传递退出状态:

void pthread_exit(void* retval):与exit()类似,只不过该结构终止的是线程。

在操作系统中好提供了一个特殊的接口:int pthread_cancel(pthread_t thread),其核心作用是向指定线程发送取消请求,请求终止该线程的执行。它允许一个线程主动请求终止另一个线程,但这种终止并非强制即时生效,而是取决于目标线程的 “取消状态” 和 “取消类型” 设置。

五. 线程的ID

我们可以使用pthread_self()接口来获取线程的ID,对上面的程序代码进行修改,分别获取主线程和新线程的ID,用16进制打印出来:
请添加图片描述

可以看到这些数字都很大,不想进程ID一样。这些数都是什么意思,为什么和我们使用ps -aL查看的数不一样???

实际上使用ps -aL命令查看的轻量化进程的ID,也就是线程的ID。
而此处使用pthread_self()得到的ID并不是线程的ID,而是一个地址,下面进行详细解释:

pthread线程库是一个动态库,也要加载到内存中,映射到进程的共享区中。因为操作系统没有线程的概念,因此所有关于线程的概念及接口都由这一个动态库进行维护,当一个进程中有多个线程的时候,动态库也要负责将他们组织起来,库中使用一个struct pthread结构体来进行组织。

那么该库如何标定每一个线程,以及记录每一个线程结构体的位置呢???,既要让外界能拿到进程的唯一标识符,又要能找到进程结构体的位置

因此,直接向用户返回一个对应线程结构体的起始地址即可,既保证了唯一性,又让外界能够通过这一个地址获取线程属性。

示意图如下:

请添加图片描述

每个线程都有自己的调用链,所以每个线程都有独立的栈结构:

  1. 主线程直接使用进程地址空间的栈;
  2. 新线程的栈在共享区,具体来说在线程库中如上图,在其自己的结构体中。

栈实际上是完成整个执行链而临时使用的空间,线程之间堆空间是共享的,可以相互访问,但是栈空间都是各自使用自己的。

但是注意:线程之间没有秘密,虽然各自使用自己的栈空间,但是这些数据都还是在进程地址空间中的,所有进程都可以看到,只要拿到对应的地址就可以进行访问。

六. 线程局部存储

如果一个线程希望使用一个变量,来让它要调用的函数都可以看到,应该用什么方式比较好???

使用一个全局变量可以吗,当然对于一个线程来说,使用一个全局变量是足够的,但是如果又很多线程都希望这样做,那是不是要定义很多全局变量,还要区分这些全局变量。

为了解决这一问题,编译器提供了一个关键字__thread,通过在一个变量定义前面加上__thread让该变量放在线程局部存储的位置(见上图),使得对应的线程在任意位置都可以看到,并且每个线程都有一份自己的

注意:局部存储只允许定义内置类型。

七. 线程分离

如果我们不需要从新线程中获取线程的退出信息,有没有什么方法可以不然主线程进行等待???

  • 默认情况下,创建的进程都是joinable需要被等待的,线程退出后,需要主线程pthread_jion()等待;
  • 当然如果不希望等待新线程,可以使用int pthread_detach(pthread_t thread)表示让主进程不再等待thread新线程,新线程执行完,直接退出即可。

进行线程分离后,必须保证主线程是最后一个退出的线程。
线程分离可以主线程调用,也可以新线程自己调用。

评论 46
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

半桔

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值