【Linux篇】线程深度解析:概念、原理与实践

📌 个人主页: 孙同学_

🔧 文章专栏: Liunx

💡 关注我,分享经验,助你少走弯路!

文章目录

    • [一. Linux线程概念](#一. Linux线程概念)
      • [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 缺页异常)
      • [1.3 线程的优点](#1.3 线程的优点)
      • [1.4 线程的缺点](#1.4 线程的缺点)
      • [1.5 线程异常](#1.5 线程异常)
      • [1.6 线程用途](#1.6 线程用途)
    • [二. Linux进程VS线程](#二. Linux进程VS线程)
      • [2.1 进程和线程](#2.1 进程和线程)
      • [2.2 进程的多个线程共享](#2.2 进程的多个线程共享)
    • [三. Linux线程控制](#三. Linux线程控制)
      • [3.1 POSIX线程库](#3.1 POSIX线程库)
      • [3.2 创建线程](#3.2 创建线程)
      • [3.4 线程等待](#3.4 线程等待)
      • [3.5 线程传参和返回值](#3.5 线程传参和返回值)
      • [3.6 线程终止](#3.6 线程终止)
      • [3.7 线程分离](#3.7 线程分离)
    • [四. 线程ID及进程地址空间布局](#四. 线程ID及进程地址空间布局)
      • [4.1 线程栈](#4.1 线程栈)
      • [4.2 线程局部存储](#4.2 线程局部存储)
    • [五. 线程封装](#五. 线程封装)

一. Linux线程概念

1.1 什么是线程

1.概念角度,感性的理解线程

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

线程:是进程内部的一个执行分支(线程在进程地址空间内运行)
2.内核和资源角度

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

线程:CPU调度的基本单位

创建一个进程时,操作系统要为我们创建进程的内核数据结构task_struct,地址空间mm_struct,同时还要构建一张页表完成虚拟到物理地址之间的转换,还要有对应的物理内存来承装我们申请的资源。未来一个进程加载时也要有自己的ELF可执行程序。

进程要访问它对应的资源的话就需要经过虚拟地址空间,经过页表映射到自己的物理内存。物理内存会在操作系统的驱使之下把磁盘中的数据加载到物理内存当中。

一个进程访问的大部分资源都是通过地址空间访问的(地址空间是"窗口")。所以一个进程拥有多少资源就是能通过地址空间看到多少资源。

如果我们今天创建一个进程共享"窗口"呢?

我们只需要新创建的进程指向同一个地址空间,那么注定了这些进程会看到同一份资源。如果我们可以将资源分配给不同的task_struct,我们就可以用进程模拟出线程了。

初步理解:

我们说地址空间是资源的'窗口',所以划分地址空间就是对资源进行分配。我们将来可以让第一个进程执行第一块代码,第二个进程执行第二块代码...,所以本来的一整块代码,原本需要串行执行,我们就能并行执行起来了。

那么如何把代码区划分成这样一块一块的呢?

我们的地址空间将来都是经过页表进行映射的,所以划分地址空间本质上就是在划分虚拟地址,本质就是划分页表。所以我们就实现了让每一个执行流并行的执行整块代码。对于代码划分是如此,对于数据划分也是如此。我们就可以把整个进程的资源划分为每一个子区域。这就是用Linux设计的线程。

🍉 结论一: Linux线程可采用进程模拟

🍉 结论二: 对资源的划分,本质上是对地址空间虚拟地址范围的划分。虚拟地址就是资源的代表!!!

🍉 结论三: 代码如何划分?

我们把代码写完毕,我们的代码归根结底是由一个一个的函数构成,每个函数都有各自的入口地址。函数有入口地址代表的函数不只有一个入口地址,比如说我们的函数有十行c语言代码,编译形成一百行汇编代码,所以函数的本质就是代码块,代码块里面的每一行代码都有地址,只不过第一份的地址叫函数的入口地址。函数将来编址采用平摊模式编址的,函数本身就是代码块,代码块本身就是虚拟地址的集合,所以函数就是虚拟地址空间的集合!!!

我们未来让不同的线程执行不同的代码块的本质就是让不同的线程未来执行ELF程序的不同的函数!!

🍉 结论四: 线程 vs 进程

进程=内核数据结构+自己的代码和数据,一个裸的task_struct不叫进程。一个进程包括下图所包含的部分

以前的进程:内部只有一个线程的进程。

✒️问题: 其他平台,比如Windows也是这样设计的吗?有没有自己的实现方案?

答案是不同的平台对进程的实现大同小异,对线程的实现还是大有差异的。

线程在操作系统内部也需要管理,先描述,再组织。线程的描述结构体TCB。windows里面专门为线程创建TCB,用链表将TCB连接起来放在PCB中,所以在windows内核里TCB和PCB是同时存在的概念。这样设计出来典型的特点是更加复杂。

在Linux中,因为线程的属性和进程的属性是极其相似的,所以Linux中的线程并没有重新设计,复用task_struct,用进程来模拟线程。进程的内核代码全部复用。这样实现的线程更加健壮。因为是复用进程的 task_struct,所以线程的调度结构和调度算法直接复用进程的。

在操作系统的视角: 就不用再区分task_struct是进程还是线程了,进程和线程都是执行流,所以它认为的执行流是 进程的。

硬件CPU视角: 轻量级进程。Linux中一个一个的task_struct(PCB)叫做轻量级进程。

🍉 结论五: Linux中的线程就是轻量级进程,或者用轻量级进程模拟实现的。

进程强调独占,部分共享(比如通信的时候)

线程强调共享。

1.2 分页式存储管理

1. 2.1 物理内存管理

我们以前就说过我们的磁盘是以4kb来划分单元的,可执行程序就是文件,文件就在磁盘中存储,所以可执行程序存储的时候天然就是以4kb为单位存储的,无论是内容还是属性。

物理内存被操作系统也划分成了4kb的数据块

所以物理内存和磁盘也是以4kb为单位进行IO交互的,我们把这4kb称之为页框 或者页帧
4kb划分是操作系统划分的,所以操作系统要有描述这4kb的结构体,在内核中描述这4kb的结构我们叫做struct_page

cpp 复制代码
/* include/linux/mm_types.h */
struct page {
	/* 原⼦标志,有些情况下会异步更新 */
	unsigned long flags; //标记位,表示某一个4kb是否被占用,表示当前的区域是可读可写还是可执行的等
	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 */
	...
}

其中比较重要的几个参数:

  1. flags :用来存放页的状态。这些状态包括页是不是脏的,是不是被锁定在内存中等。flag的每一位单独表示一种状态,所以它至少可以同时表示出32种不同的状态。这些标志定义在<linux/page-flags.h>中。其中一些比特位非常重要,如PG_locked用于指定页是否锁定,PG uptodate用于表示页的数据已经从块设备读取并且没有出现错误。
  2. _mapcount:表示在页表中有多少项指向该页,也就是这一页被引用了多少次。当计数值变2为-1时,就说明当前内核并没有引用这一页,于是在新的分配中就可以使用它。
  3. virtual:是页的虚拟地址。通常情况下,它就是页在虚拟内存中的地址。有些内存(即所谓的高端内存)并不永久地映射到内核地址空间上。在这种情况下,这个域的值为NULL,需要的时候,必须动态地映射这些页。

📚要注意的是 struct page 与物理页相关,而并非与虚拟页相关。而系统中的每个物理页都要分配一个这样的结构体,让我们来算算对所有这些页都这么做,到底要消耗掉多少内存。假设struct page 占40个字节的内存,假定系统的物理页为 4KB 大小,系统有 4GB 物理内存。那么系统中共有页面 1048576个(1兆个),所以描述这么多页面的page结构体消耗的内存只不过40MB ,相对系统 4GB 内存而言,仅是很小的一部分罢了。因此,要管理系统中这么多物理页面,这个代价并不算太大。

📚要知道的是,页的大小对于内存利用和系统开销来说非常重要,页太大,页内必然会剩余较大不能利用的空间(页内碎片)。页太小,虽然可以减小页内碎片的大小,但是页太多,会使得页表太长而占用内存,同时系统频繁地进行页转化,加重系统开销。因此,页的大小应该适中,通常为5126B-8KB,windows系统的页框大小为4KB

1.2.2 页表

如何组织呢?

我们可以把一个一个的page先描述起来,然后再定义一个数组,所以对内存进行管理本质就是对内存空间的增,删,查,改,转化成为对数组的操作。因为它是一个数组,所以每个page都有对应的下标,所以每一个page的起始物理地址就天然知道了。index(下标)*4kb就是每个page的起始物理地址。具体的物理地址 = 起始物理地址 + 页内(4kb)的偏移地址。所以我们不用在page中记录page的起始物理地址,因为它天然就知道。

申请物理存就是:1.查数组,改page 2.建立内核数据结构的对应关系。

32位下:

页表中的每⼀个表项,指向⼀个物理页的开始地址。在 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个页表就足够了。

计算过程:

每一个页表项指向一个 4KB 的物理页,那么一个页表中 1024 个页表项,一共能覆盖 4MB 的物理内存;那么 10MB 的程序,向上对齐取整之后(4MB 的倍数,就是 12 MB),就需要3个页表就可以了。

1.2.3 页目录结构

到目前为止,每一个页框都被一个页表中的一个表项来指向了,那么这 1024 个页表也需要被管理起来。管理页表的表称之为页目录表 ,形成二级页表。如下图所示:

所有页表的物理地址被页目录表项指向

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

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

1.2.4 两级页表的地址转换

32位的虚拟地址在逻辑划分上被划分成了3个区域。

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

  1. 在32位处理器中,采用4KB的页大小,则虚拟地址中低12位为页偏移,剩下高20位给页表,分成两级,每个级别占10个bit(10+10)
  2. CR3 寄存器 读取页目录起始地址,再根据一级页号查页目录表,找到下一级页表在物理内存中存放位置。
  3. 根据二级页号查表,找到最终想要访问的内存块号。
  4. 结合页内偏移量得到物理地址。
  5. 注:一个物理页的地址一定是 4KB 对齐的(最后的 12 位全部为0),所以其实只需要记录物理页地址的高 20 位即可。
  6. 以上其实就是 MMU 的工作流程。MMU(Memory Manage Únit)是一种硬件电路,其速度很快,主要工作是进行内存管理,地址转换只是它承接的业务之一。

到这里其实还有个问题,MMU要先进行两次页表查询确定物理地址,在确认了权限等问题后,MMU再将这个物理地址发送到总线,内存收到之后开始读取对应地址的数据并返回。那么当页表变为N级时,就变成了N次检索+1次读写。可见,页表级数越多查询的步骤越多,对于CPU来说等待时间越长,效率越低。

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

✏️有没有提升效率的办法呢?

计算机科学中的所有问题,都可以通过添加一个中间层来解决。 MMU 引入了新武器,江湖人称快表的TLB(其实,就是缓存)当CPU 给 MMU 传新虚拟地址之后,MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存。但 TLB 容量比较小,难免发生 cache miss ,这时候 MMU 还有保底的老武器页表,在页表中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到 TLB,让它记录一下刷新缓存。

所以查页表的过程:

第一阶段:先查找到虚拟地址对应的物理页框(物理页框的起始地址为全0,最后的 12 位全部为0)

第二阶段:根据虚拟地址低12位,作为页内偏移,访问具体字节。

✨细节1:申请内存 ---> 查找数组 ---> 找到没有被使用的page ---> 找到page的index ---> 找到该page的物理页框地址!!---> 然后在内存中构建页表,把页框地址填充进去

✨细节2:当进行写时拷贝,缺页中断,内存申请等操作,背后都是可能要重新建立新的页表和新的映射关系的操作。

✨细节3:对于进程来讲我们的进程是由一张页目录 + n张页表构成的映射体系,其中虚拟地址是索引,物理地址页框是目标,虚拟地址(低12位) + 页框地址 = 物理地址

✨细节4:为什么数字是12?又为什么是低12位?

页框的大小是4kb4kb的取值范围[0,4095]一共4096个,由于2的12次方就是4kb,所以就能用低12位充分的覆盖整个范围。用低12位作页内偏移地址,那么就意味着高20位是一样的,高20位一样的话就意味着我们的内存整体上是聚集在一起的。

所以执行流看到的资源的本质是在合法的情况下,你拥有多少虚拟地址,虚拟地址就是资源的代表。

虚拟地址空间mm_struct + vm_area_struct本质:进程资源的统计数据和整体数据

页表是一张从虚拟到物理转化的地图

所谓资源划分:本质就是地址空间的划分

所谓资源共享:本质就是地址空间的共享

1.2.5 缺页异常

设想,CPU给 MMU的虚拟地址,在TLB 和页表都没有找到对应的物理页,该怎么办呢?

其实这就是缺页异常 Page Fault ,它是一个由硬件中断触发的可以由软件逻辑纠正的错误。

假如目标内存页在物理内存中没有对应的物理页或者存在但无对应权限,CPU就无法获取数据,这种情况下CPU就会报告一个缺页错误

由于 CPU 没有数据就无法进行计算,CPU罢工了用户进程也就出现了缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler 处理。

缺页中断会交给 PageFaultHandler 处理,其根据缺页中断的不同类型会进行不同的处理:

  1. Hard Page Fault 也被称为 Major Page Fault ,翻译为硬缺页错误/主要缺页错误,这是物理内存中没有对应的物理页,需要CPU打开磁盘设备读取到物理内存中,再让MMU建立虚拟地址和物理地址的映射。
  2. Soft Page Fault 也被称为 Minor Page Fault ,翻译为软缺页错误/次要缺页错误,这是物理内存中是存在对应物理页的,只不过可能是其他进程调入的,发出缺页异常的进程不知道而已,此时MMU只需要建立映射即可,无需从磁盘读取写入内存,一般出现在多进程共享内存区域。
  3. Invalid Page Fault 翻译为无效缺页错误,比如进程访问的内存地址越界访问,又比如对空指针解引用内核就会报 segment fault 错误中断进程直接挂掉。

线程的深刻理解:

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

线程进行资源共享:本质是对地址空间的共享,再本质就是对页表条目的共享。

💥如何区分是缺页了,还是真的越界了?

一个问题,越界了一定会报错吗?
页号合法性检查: 操作系统在处理中断或异常时,首先检査触发事件的虚拟地址的页号是否合法。

  1. 如果页号合法但页面不在内存中,则为缺页中断;
  2. 如果页号非法,则为越界访问。

内存映射检查: 操作系统还可以检查触发事件的虚拟地址是否在当前进程的内存映射范围内。

  1. 如果地址在映射范围内但页面不在内存中,则为缺页中断;
  2. 如果地址不在映射范围内,则为越界访问。

线程资源划分的真相: 只要将虚拟地址空间进行划分,进程资源就天然被划分好了。

1.3 线程的优点

  • 创建一个新线程的代价要比创建一个新进程小得多

  • 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多

  • 最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。
  • 另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。
  • 还有一个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓冲 TLB(快表)会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有硬件cache

    比如说让我们的代码中定义了一些变量,int a = 1,int b = 2,int c = 3;当把int a = 1,加载在cpu的同时还会把int a,周边的int b,int c加载到cache缓存中。
  • 线程占用的资源要比进程少很
  • 能充分利用多处理器的可并行数量
  • 在等待慢速I/0操作结束的同时,程序可执行其他的计算任务
  • 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
  • I/0密集型应用,为了提高性能,将I0操作重叠。线程可以同时等待不同的I/0操作。

1.4 线程的缺点

🐾 (创建太多线程)性能损失

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

🐾 健壮性降低

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

🐾缺乏访问控制

进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。

🐾编程难度提高

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

1.5 线程异常

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

1.6 线程用途

  • 合理的使用多线程,能提高CPU密集型程序的执行效率
  • 合理的使用多线程,能提高10密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)

二. Linux进程VS线程

2.1 进程和线程

🌵进程是资源分配的基本单位

🌵线程是调度的基本单位

线程共享进程数据,但也拥有自己的一部分"私有"数据:

  • 线程ID
  • 一组寄存器,线程的上下文数据 (证明线程是可以被独立调度的)
  • 栈 (每个线程都要有独立的栈结构)
  • errno 错误码
  • 信号屏蔽字
  • 调度优先级

2.2 进程的多个线程共享

同一地址空间,因此Text SegmentData Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:

  • 文件描述符表
  • 每种信号的处理方式(SIG IGN、SIGDFL或者自定义的信号处理函数)
  • 当前工作目录
  • 用户id和组id

进程和线程的关系如下图:

三. Linux线程控制

3.1 POSIX线程库

  • 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以"pthread "打头的
  • 要使用这些函数库,要通过引入头文<pthread.h>
  • 链接这些线程函数库时要使用编译器命令的 -lpthread 选项

3.2 创建线程

在Linux中创建多线程,我们必须使用一个第三方库来创建多线程

函数原型

c 复制代码
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine)(void *), void *arg);

参数说明

  • thread:输出型参数,未来的线程ID。
  • attr :指向 pthread_attr_t 结构的指针,用于设置线程属性(如栈大小、调度策略等)。若为 NULL,则使用默认属性。
  • start_routine :线程启动时执行的函数指针,该函数必须返回 void * 并接受一个 void * 参数。新线程要执行的函数入口。
  • arg :传递给 start_routine 的参数,类型为 void *

返回值

  • 成功时返回 0
  • 失败时返回错误码(非 errno,需通过 strerror 或直接检查错误码)。

测试代码:

当我们一旦pthread_create成功了,我们内部就创建出来了多线程,新线程就转而去执行threadrun了,主线程继续向下运行。所以threadrun就是新线程的入口函数,编译完之后就变成了一组虚拟地址,表示的代码和数据。我们曾经的main函数在执行时,不就是另一组虚拟地址,表示的代码和数据。

我们在编译的时候它会出现未定义引用错误,这个是链接时报错

因为我们今天用的pthread_create它并不是系统调用,而是外部提供的库,所以我们要带上 -lpthread 选项带这个库。

查看线程
ps -aL

它们的pid相同,说明它们属于同一个进程。
LWP:light weight process 轻量级进程!!

分别为 1967546,1967547,表明当前进程有两个轻量级进程。
lwppid相同的为主进程

📌所以CPU调度的时候看的是LWP

📘关于创建时的时间片的问题: 等分给不同的线程。

比如我们创建的一个进程的时间片为10ms,创建了两个线程,那么每个线程的时间片就是5ms。

📘关于线程异常问题: 任何一个线程崩溃,都会导致整个进程崩溃。

📘关于两个线程往显示器上打印的消息混乱问题: 显示器的本质就是文件,而两个线程在访问文件时都是访问的同一个终端文件,两个线程向显示器上打印本质上就是往显示器文件做写入,而我们的显示器文件被两个线程都能看到,所以显示器文件的本质就是共享资源。

在没有加保护的前提下,我们在读写时会发生原子性错误。显示器本身就是共享资源,后面我们如果想让两个线程分开打印的话我们可以加锁。

线程属性设置

可以通过 pthread_attr_t 自定义线程属性。以下是设置线程栈大小和分离状态的示例:

c 复制代码
pthread_attr_t attr;
pthread_attr_init(&attr);

// 设置栈大小为 2MB
size_t stack_size = 2 * 1024 * 1024;
pthread_attr_setstacksize(&attr, stack_size);

// 设置为分离线程(无需调用 pthread_join)
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);

pthread_t thread_id;
pthread_create(&thread_id, &attr, thread_function, NULL);

pthread_attr_destroy(&attr); // 销毁属性对象

错误处理

常见错误码包括:

  • EAGAIN:资源不足(如线程数超过系统限制)。
  • EINVAL:无效的属性或参数。
  • EPERM:权限不足。

可通过 strerror 打印错误信息:

c 复制代码
if (pthread_create(&thread_id, NULL, thread_function, NULL) != 0) {
    fprintf(stderr, "Error: %s\n", strerror(errno));
}

注意事项

  • 线程函数(start_routine)的返回值必须为 void *,如需返回其他类型,需通过动态内存分配或全局变量传递。
  • 主线程退出时,所有子线程会立即终止,除非调用 pthread_exit 或使用 pthread_join 等待。
  • 共享数据时需使用互斥锁(pthread_mutex_t)或其他同步机制避免竞态条件。

Linux创建线程为什么有这个库?这个库是什么东西?
Linux系统,不存在真正意义上的的线程,它所谓的概念是使用轻量级进程模拟的,但操作系统中,只有轻量级进程,所谓的模拟线程,只是我们的说法。

所以操作系统只会提供创建轻量级进程的库。

所以操作系统如果提供系统调用,也只会提供创建轻量级进程的系统调用,比如说vfork

区别fork: fork创建出来的父子进程的地址空间是分开的,vfork创建出来的地址空间是共享的。

作为用户只认线程,操作系统只提供轻量级进程的接口,所以在Linux中,所以用户和操作系统之间会出现概念上的鸿沟。Linux的设计者为了解决用户的问题,也为了把轻量级进程封装起来,提供了一层软件层(计算机中任何问题都可以通过添加软件层来解决),添加的这层软件层就是pthread库,把创建轻量级进程封装起来给用户提供一批创建线程的接口,从此我们的用户创建线程用的就是pthread库的接口,底层的轻量级进程的概念用户再也不需要关心了。

所以Linux线程实现我们称之为用户级线程。所以我们把pthread库称之为原生线程库。

C++11的多线程,在Linux下,本质是封装了pthread库

在windows下封装了windows创建多线程的接口

3.4 线程等待

线程创建好之后,新线程要被主线程等待。不等待就会出现类似僵尸进程的问题,内存泄漏。

💦 为什么要线程等待?

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


函数原型

cpp 复制代码
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
  • thread: 目标线程的标识符(pthread_t 类型)。
  • retval: 指向指针的指针,用于存储目标线程的返回值。若不需要返回值,可设为 NULL。
    返回值
  • 成功时返回 0。
  • 失败时返回错误码(非 errno),常见错误包括:
    • ESRCH: 目标线程不存在。
    • EINVAL: 目标线程是分离线程(detached)或已有其他线程调用pthread_join 等待它。
    • EDEADLK: 线程尝试等待自身(自死锁)。
cpp 复制代码
#include<iostream>
#include<cstdio>
#include<string>
#include<unistd.h>
#include<pthread.h>

void showtid(pthread_t &tid)
{
    //printf("tid:%ld\n",tid);
    printf("tid:0x%lx\n",tid);
}


void *routine(void* args)
{
    std::string name = static_cast<const char*>(args);
    int cnt = 5;
    while(cnt)
    {
        std::cout << "我是一个新线程:我的名字是:" << name <<std::endl;
        sleep(1);
        cnt--;
    }
    return nullptr;
}

int main()
{
    pthread_t tid; //线程id
    int n = pthread_create(&tid,nullptr,routine,(void*)"thread-1");
    (void)n;

    showtid(tid);
    pthread_join(tid,nullptr);
    return 0;
}

我们可以看到这个线程的id是这么长的一串数。

线程的tid:不暴露lwp概念

可是我们怎么知道我们打印出来的线程的tid就是对的呢?

获得调用线程的id,谁调用这个函数就获得谁的id
函数原型

cpp 复制代码
#include <pthread.h>
pthread_t pthread_self(void);

返回值

  • 返回当前线程的线程标识符(pthread_t 类型)。
  • 无错误返回值,该函数始终成功。
cpp 复制代码
#include <iostream>
#include <cstdio>
#include <string>
#include <unistd.h>
#include <pthread.h>

void showtid(pthread_t &tid)
{
    // printf("tid:%ld\n",tid);
    printf("tid:0x%lx\n", tid);
}

std::string FormatId(pthread_t &tid)
{
    char id[64];
    // 我们今天要把一个整数数字转换成16进制的风格,我们可以用snprintf
    // 表示把要输出的字段,格式化输出到指针指向的函数里
    snprintf(id, sizeof(id), "0x%lx", tid);
    return id;
}
void *routine(void *args)
{
    std::string name = static_cast<const char *>(args);
    pthread_t tid = pthread_self();
    int cnt = 5;
    while (cnt)
    {
        std::cout << "我是一个新线程:我的名字是:" << name << "我的ID是:" << FormatId(tid) << std::endl;
        sleep(1);
        cnt--;
    }
    return nullptr;
}

int main()
{
    pthread_t tid; // 线程id
    int n = pthread_create(&tid, nullptr, routine, (void *)"thread-1");
    (void)n;

    showtid(tid);
    pthread_join(tid, nullptr);
    return 0;
}

证明了创建出来的线程,给main执行流主线程返回的id就是当前线程的线程id

main函数也是一个线程,所以main函数也有自己的线程id

所以不管是新创建出来的线程,还是主线程,它们都有自己的线程ID

新主线程都能访问公共的方法FormatId,因为新主线程的地址空间是共享的。

3.5 线程传参和返回值

  • 等待的目标线程如果异常了,整个进程都退出了,包括main线程,所以join异常时我们看不到退出码。
  • join都是基于线程健康跑完的情况,不需要处理异常信号,因为异常信号是进程要处理的话题。

3.6 线程终止

  1. 线程的入口函数进行return 就是线程的终止。
    注意:线程终止不能用exit,因为exit是终止进程的。线程调用exit本质就是进程调用exit
  2. pthread_exit终止一个线程

    参数: void *retval等价于return void*类型,等同于线程的返回值
  3. pthread_cancle线程取消。

    常规的做法都是主线程去取消新线程的,取消的时候一定要保证新线程已经启动。
    线程如果被取消,它的推出结果就是-1【PTHREAD_CANCELED】

3.7 线程分离

有没有一种可能是主线程不再关心新线程的状态,让它结束后自己释放呢??

答案是设置线程分离

技术层面: 一个线程被创建出来,默认是需要被等待joinable的,如果不想让主线程等待新线程,让新线程结束后自己退出,将此线程设置为分离状态(!joinable / detach)

理解层面: 线程分离主线程可以分离新线程,新线程也能自己分离自己。

线程被分离后线程依旧在进程的地址空间中,进程中的所有资源被分离的线程依旧可以访问。

作用:分离指定的一个线程

被分离的线程不需要被join,即使join也会失败

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

linux中没有线程,而是用轻量级进程模拟的,操作系统提供的接口不会直接提供创建线程的接口,在用户层封装轻量级进程,形成原生线程库。所以我们创建线程访问线程本质都是访问用户级别的库。

每个线程必须得有自己的栈空间,它的栈空间在ptread库内部,自己的管理块当中有自己独立的栈,这个栈也有自己的起始虚拟地址。主线程用地址空间中的栈,而新创建出来的线程用自己的线程栈,所以每个线程都有独立的栈结构。

所以:

  1. 线程ID
    线程id是我们在pthread_create的时候,在库中创建的描述线程控制块的起始虚拟地址
  2. 线程的返回值
    线程执行完,把线程的执行结果写入到线程控制块的void* 变量里。
  3. 线程分离
    在线程的控制块里面有一个线程状态joinable,默认情况下joinable=1,如果为1表示线程结束时必须joinjoinable=0,在线程结束时会自动将这个控制块释放掉。

4.1 线程栈

独立的上下文:有独立PCB(内核) + TCP(用户层,pthread库内部)

独立的栈:每个线程都有自己独立的栈,要么是线程自己的,要么是库中创建进程时mmap申请出来的

其实线程中每一个线程都能访问到其他线程的任意一个资源,因为共享地址空间。其他线程如果知道另一个线程的起始虚拟地址就能访问该线程中的资源了。

虽然 Linux将线程和进程不加区分的统一到了 task_struct,但是对待其地址空间的 stack 还是有些区别的。

  • 对于 Linux 进程或者说主线程,简单理解就是main函数的栈空间,在fork的时候,实际上就是复制了父亲的 stack 空间地址,然后写时拷贝(cow)以及动态增长。如果扩充超出该上限则栈溢出会报段错误(发送段错误信号给该进程)。进程是唯一可以访问未映射页而不一定会发生段错误--超出扩充上限才报。
  • 然而对于主线程生成的子线程而言,其 stack 将不再是向下生长的,而是事先固定下来的。线程栈一般是调用glibc/uclibc等的 pthread 库接口 pthread_create 创建的线程,在文件映射区(或称之为共享区)。其中使用 mmap 系统调用,这个可以从glibc的nptl/allocatestack.c中的allocate stack 函数中看到:
cpp 复制代码
mem = mmap (NULL, size, prot, MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0); 

此调用中的 size 参数的获取很是复杂,你可以手工传入stack的大小,也可以使用默认的,一般而言就是默认的 8M。这些都不重要,重要的是,这种stack不能动态增长,一旦用尽就没了,这是和生成进程的fork不同的地方。在glibc中通过mmap得到了stack之后,底层将调用sys_clone 系统调用:

cpp 复制代码
int sys_clone(struct pt_regs *regs)
{
	unsigned long clone_flags;
	unsigned long newsp;
	int __user *parent_tidptr, *child_tidptr;

	clone_flags = regs->bx;
	//获取了mmap得到的线程的stack指针
	newsp = regs->cx;
	parent_tidptr = (int __user *)regs->dx;
	child_tidptr = (int __user *)regs->di;
	if (!newsp)
		newsp = regs->sp;
	return do_fork(clone_flags, newsp, regs, 0, parent_tidptr, child_tidptr);
}

因此,对于子线程的 stack,它其实是在进程的地址空间中map出来的⼀块内存区域 ,原则上是线程私有的,但是同一个进程的所有线程生成的时候,是会浅拷贝生成者的 task_struct的很多字段,如果愿意,其它线程也还是可以访问到的,于是一定要注意。

4.2 线程局部存储

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

int count = 1;

std::string Addr(int &c)
{
    char addr[64];
    snprintf(addr,sizeof(addr),"%p",&c);

    return addr;
}

void *Routine1(void *args)
{
    (void)args;
    while (true)
    {
        std::cout << "thread -1,count = " << count << "[我来修改count]" << ",&count:" << Addr(count) << std::endl;
        count++;
        sleep(1);
    }
}

void *Routine2(void *args)
{
    (void)args;
    while (true)
    {
        std::cout << "thread -2,count = " << count << ",&count:" << Addr(count) << std::endl;
        sleep(1);
    }
}
int main()
{

    pthread_t tid1, tid2;
    pthread_create(&tid1, nullptr, Routine1, nullptr);
    pthread_create(&tid1, nullptr, Routine2, nullptr);
     
    pthread_join(tid1,nullptr);
    pthread_join(tid2,nullptr);

    return 0;
}

证明全局变量count本身就是被两个线程所共享,所以在这里一个线程修改一个打印不会发生写时拷贝,因为他们两个的资源是共享的。

但是给count前面添加 __thread

很明显现在变成了两个变量,也就是说我们如果给count前添加__thread修饰,表示该count 是线程的局部存储。

线程的局部存储有什么用?

我们有时候在创建线程的时候希望有全局变量,但我们又不想这个全局变量被其他线程看到。

线程的局部存储只能存储内置类型和部分指针。


设置线程名称的函数

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

// Linux系统常见原型
int pthread_setname_np(pthread_t thread, const char *name);

// macOS/iOS系统
int pthread_setname_np(const char *name);

原理:把线程的名字放到局部存储里,局部存储只有你一个线程访问,所以不存在并发问题。

五. 线程封装

thread.hpp

cpp 复制代码
#ifndef __THREAD_H__
#define __THREAD_H__

#include <iostream>
#include <string>
#include <pthread.h>
#include <cstdio>
#include <cstring>
#include <functional>
namespace ThreadModlue
{
    static uint32_t number = 1; // 计数器

    template <typename T>
    class Thread
    {
        using func_t = std::function<void(T)>;

    private:
        // 开启线程分离
        void EnableDetach()
        {
            std::cout << "线程被分离了" << std::endl;
            _isdetach = true;
        }
        // 更改线程状态为running
        void EnableRunning()
        {
            _isrunning = true;
        }

        // Routine属于类内的成员变量,默认包含this指针!类默认的第一个参数就是this指针,所以Routine有两个参数,所以参数类型不兼容
        // 方法一:设置为static,设置为static就没有this指针了
        static void *Routine(void *args)
        {
            Thread<T> *self = static_cast<Thread<T> *>(args);
            self->EnableRunning();
            // 如果线程是分离的
            if (self->_isdetach)
                self->Detach();
            self->_func(self->_data                          ); // 回调处理,但是这里无法回调,因为static没有this指针,它就无法访问当前成员的成员变量了

            return nullptr;
        }

    public:
        // 构造函数
        Thread(func_t func, T data)
            : _tid(0), _isdetach(false), _isrunning(false), res(nullptr), _func(func), _data(data)
        {
            _name = "thread-" + std::to_string(number++);
        }

        void Detach()
        {
            if (_isdetach)
                return;
            if (_isrunning)
                pthread_detach(_tid);
            EnableDetach();
        }

        // 线程开始
        bool Start()
        {
            if (_isrunning)
                return false;
            int n = pthread_create(&_tid, nullptr, Routine, this);
            if (n != 0)
            {
                std::cout << " create thread error:" << strerror(n) << std::endl;
                return false;
            }
            else
            {
                std::cout << _name << " create success" << std::endl;
                return true;
            }
        }

        // Stop的前提是这个线程处在running状态
        bool Stop()
        {
            if (_isrunning)
            {
                int n = pthread_cancel(_tid);
                if (n != 0)
                {
                    std::cout << " stop thread error:" << strerror(n) << std::endl;
                    return false;
                }
                else
                {
                    _isrunning = false;
                    std::cout << _name << " stop success" << std::endl;
                    return true;
                }
            }
            return false;
        }

        void Join()
        {
            // 如果线程是分离的就不能join
            if (_isdetach)
            {
                std::cout << "你是线程已经是分离的了,不能被join了" << std::endl;
                return;
            }
            // join不管是线程运行的还是死亡的都能join
            int n = pthread_join(_tid, &res);
            if (n != 0)
            {
                std::cout << "join thread error:" << strerror(n) << std::endl;
            }
            else
            {
                std::cout << " join success" << std::endl;
            }
        }
        // 析构函数
        ~Thread()
        {
        }

    private:
        pthread_t _tid;
        std::string _name; // 线程的名字
        bool _isdetach;    // 线程的状态
        bool _isrunning;   // 线程是否是跑起来的
        void *res;         // 线程的返回值
        func_t _func;      // 让主线程执行我们指定的方法
        T _data;           // 让线程执行什么回调
    };

}

#endif

👍 如果对你有帮助,欢迎:

  • 点赞 ⭐️
  • 收藏 📌
  • 关注 🔔
相关推荐
Sleepy MargulisItG3 小时前
Linux 基础开发工具详解(Yum, Vim, GCC, Make, GDB, Git)
linux·git·vim
猫豆~9 小时前
软件包管理——2day
linux·运维
大白菜132412 小时前
进程的信号
linux
XH-hui12 小时前
【打靶日记】群内靶机Secure
linux·网络安全
Shingmc312 小时前
【Linux】进程控制
linux·服务器·算法
视觉装置在笑71312 小时前
Shell 变量基础与进阶知识
linux·运维
Web极客码13 小时前
如何通过命令行工具检查 Linux 版本信息
linux·运维·服务器
欢鸽儿13 小时前
Vitis】Linux 下彻底清除启动界面 Recent Workspaces 历史路径
linux·嵌入式硬件·fpga
繁华似锦respect13 小时前
C++ 智能指针底层实现深度解析
linux·开发语言·c++·设计模式·代理模式