Linux 多线程原理深剖

本文深入探讨Linux中的线程概念,从线程的定义、创建、页表、优缺点等方面展开,揭示Linux如何使用进程模拟线程。介绍了pthread库的使用,如pthread_create、pthread_join等函数,并讨论了线程ID的获取和线程等待。文章还提到了Linux中二级页表的必要性和工作原理,以及线程在进程地址空间中的布局。
摘要由CSDN通过智能技术生成

传统艺能😎

小编是双非本科大二菜鸟不赘述,欢迎米娜桑来指点江山哦
在这里插入图片描述
1319365055

🎉🎉非科班转码社区诚邀您入驻🎉🎉
小伙伴们,满怀希望,所向披靡,打码一路向北
一个人的单打独斗不如一群人的砥砺前行
这是和梦想合伙人组建的社区,诚邀各位有志之士的加入!!
社区用户好文均加精(“标兵”文章字数2000+加精,“达人”文章字数1500+加精)
直达: 社区链接点我


在这里插入图片描述

Linux 线程🤔

在一个程序里的一个执行路线就叫做 线程,准确的定义是 线程是一个进程内部的控制序列 \color{red} {线程是一个进程内部的控制序列} 线程是一个进程内部的控制序列

首先一切进程至少都有一个执行线程。线程在进程内部运行,本质是在进程地址空间内运行。在 Linux 中,CPU 看到的 P C B \color{red} {PCB} PCB 要比传统的进程更轻量化。透过进程虚拟地址空间可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了 线程执行流 \color{red} {线程执行流 } 线程执行流

需要明确的是,一个进程的创建实际上伴随着其进程控制块(task_struct)、进程地址空间(mm_struct)以及页表的创建:
在这里插入图片描述
每个进程都有自己独立的进程地址空间和页表,这代表着运行本身就具有独立性。

但是我们在创建进程时,创建 task_struct ,并要求只创建 task_struct 和父 task_struct 的共享地址空间和页表:

在这里插入图片描述

以上动作其实就是在执行四个线程:每一个线程都是当前进程的一个执行流,也就是我们理解的 “执行分支” \color{red} {“执行分支” } 执行分支,这里也很容易看出,线程运行的本质就是在这个进程地址空间运行,也就是这个进程的所有资源几乎是所有线程共享的

单纯从技术角度,这是一定能实现的,因为线程粒度比进程更小,所以它比创建原始进程更轻松。

那么再来重新理解一下之前的进程概念:

在这里插入图片描述
因此,进程不仅通过task_struct来衡量,还要有进程地址空间、文件、信号等等,这些合起来称为一个进程,从内核角度来看 进程就是系统资源分配的基本单位 \color{red} {进程就是系统资源分配的基本单位 } 进程就是系统资源分配的基本单位,直白的说就是我们是创建一个 task_struct 并且创建进程地址空间,维护页表,然后再物理内存中开辟空间,映射地址,打开相关文件和注册信号对应的解决方案等等

而我们之前接触到的进程都只有一个 task_struct,也就是该进程内部只有一个执行流,即单执行流进程,反之,内部有多个执行流的进程叫做多执行流进程

那么问题来了:CPU 是否识别当前调度的 task_struct 是进程还是线程?

No!答案是不能,而且也不需要,CPU 只会关心一个独立的执行流,无论进程内部是一个还是多个执行流,CPU 都是以 task_struct 为单位进行来调度的,不妨对比一下单执行流和多执行流:

单执行流
在这里插入图片描述
多执行流
在这里插入图片描述
这就是就是我们所说的CPU看到的还是 task_struct ,只是更加轻量化。

但是!Linux 里并不存在真正的多线程,他的多线程是用进程模拟的

操作系统中存在大量的进程,一个进程内又存在多个线程,因此线程的数量一定远超进程。如果一个操作系统要真正的支持线程,就必定有某种结构对线程进程管理,比如线程的创建,终止,转换,调度和释放回收等等,为什么说 Linux 是神Linux 相比另起炉灶去创建一套线程管理体系,他选择直接就地取材用进程搭建一条平行的线程管理机制

其实这就是一个复用的思想,因此在 Linux 看来线程的控制块和进程的控制块是类似的,他并没有单独搞出一个数据结构来描述他,而是直接对进程取而用之,这也是为什么我们将所有进程·的执行流都叫做轻量化进程,Windows 是支持线程操作的,这也是为什么windows操作系统逻辑比 Linux 复杂的多

既然在 Linux 没有真正意义的线程,那也绝对没有线程相关的系统调用!

但是 Linux 提供了创建轻量级进程的接口,其中最典型的代表就是 vfork 函数,函数功能是创建子进程,并且父子共享空间:

pid_t vfork(void);

vfork 返回值与 fork 一样,父进程返回子进程的 PID,子进程返回 0,比如下面代码中父进程使用 vfork 创建子进程,子进程修改了全局变量 g_val,父进程休眠 3 秒后会读取到全局变量 g_val 的值证明父子进程共享地址空间:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int g_val = 100;
int main()
{
   
	pid_t id = vfork();
	if (id == 0){
   
		//child
		g_val = 200;
		printf("child:PID:%d, PPID:%d, g_val:%d\n", getpid(), getppid(), g_val);
		exit(0);
	}
	//father
	sleep(3);
	printf("father:PID:%d, PPID:%d, g_val:%d\n", getpid(), getppid(), g_val);
	return 0;
}

在这里插入图片描述

phread🤔

phread 原生线程库,虽然在内核角度没有对应的接口可以调用,但是在用户角度,我们在实际操作是会更希望使用 thread_create 这样的接口而不是 vfork 函数,因此系统提供了原生线程库这个原生线程库其实就是对轻量级线程进行了封装然后再用户层模拟实现了一套相关的接口。

所以现在我们的根本目标并非是学习操作系统的接口,而是学习这套用户层模拟实现的接口

二级页表🤔

众所周知 32 位平台下有 2^32 个地址,这就代表着有 2^32 个地址需要映射。我们说过页表就是一个简单的表结构,那么一张表就要有 2^32 个映射关系:
在这里插入图片描述
每一张表的内容除了映射关系之外,还包括一些权限信息,比如页表分为了内核级页表和用户级页表,这就是通过权限信息来进行区分的:

在这里插入图片描述
每个表项中存储了一个物理地址和一个虚拟地址,这里需要消耗 8 字节,,考虑到权限相关信息,这里粗略按照 10 字节计算。2^32 个字节,我们就需要 2^32 * 10 个字节,也就是 40 GB,但是在 32 为平台下只有最多 4 GB空间, 也就是要存储这样一张表是不可能的 \color{red} {也就是要存储这样一张表是不可能的} 也就是要存储这样一张表是不可能的

所以不能直接将页表就看成一个单纯的表,在 32 位平台下,页表映射过程是这样的:

  1. 虚拟地址前 10 个比特位在页目录中进行查找,找到对应的页表
  2. 再选择 10 个比特位在对应页表中进行查询,找到物理内存中对应页框的其实地址
  3. 将最后剩余 12 个比特位作为偏移量从页框对应地址处向后偏移,找到物理内存中的某一个对应的字节数据

物理地址也绝对不是饼粘一团的,它被划分为了一个个 4 kb大小的页框的,磁盘上的程序又会被分成一个个 4 kb 大小的页帧,当内存和磁盘进行数据交换时也是以 4 kb为单位的。不难发现,其实 4 kb就是 2^12 个字节,也就是说一个页框中会有 2^12 个字节,访问内存的基础大小是 1 字节,所以最多可以有 2^12 个地址,最后 12 为作为偏移量查找即可,从而找到物理内存中某一个对应字节数据

在这里插入图片描述

这里就是一个二级页表结构,页目录就是一个一级页表,而表项是二级页表,每一个表大小是 10 字节,页目录和页表的表项都是 210 个,因此一个表的大小就是 210 * 10 个字节,也就是10 KB。而页目录有 210 个表项也就意味着页表有 210 个,也就是说一级页表有1张,二级页表有210张,总共算下来大概就是 10 MB,内存消耗不会太高,因此 Linux 中实际就是这样映射的

所有的映射过程都是由 M M U \color{red} {MMU} MMU 这个硬件完成的,该硬件集成在CPU内。页表是一种软件映射,MMU是一种硬件映射,所以计算机进行虚拟地址到物理地址的转化采用的是软硬件结合的方式

在 Linux 中,32位平台下用的是二级页表,而64位平台下用的是多级页表这就可以解释一下为什么修改字符串常量会出发段错误:我们修改一个常量虚拟必须找到对应的物理地址,但是查表时发现它是只读的,此时就会在 MMU 触发硬件错误,操作系统识别到报错后,就会发送信号对齐进行终止。

线程优点🤔

  1. 创建一个线程的代价远比一个进程小的多
  2. 相比进程之间的切换,线程之间的切换所需要的系统操作会少很多
  3. 线程占的资源比进程少得多
  4. 充分利用处理器的可并行数量
  5. 在等待慢速IO结束的同时,程序可执行其他的计算任务
  6. 计算密集型任务,在多处理器上运行,会分成多个线程运行
  7. IO密集行任务,将IO操作重修,可提高效率,线程可以同时等待不同的IO操作

线程缺点🤔

  1. 性能损失,如果计算密集型线程的任务比可用的处理器多,会产生较大的性能损失,性能包括额外的同步和调度开销,但是可用资源是不变的
  2. 健壮性降低,多线程需要全面的思考,因时间分配上的细微偏差或者因共享了不该共享的变量会造成不良影响,也就是说线程之间是缺乏保护的
  3. 缺乏访问控制,进程是访问控制的基本粒度,线程中调用 os 函数会对整个进程产生影响
  4. 难度提高,调试和编写都涉及更复杂

线程异常🤔

比如一个线程出现除0,野指针问题,会直接全部垮掉;且线程是进程的执行分支名,线程出现异常,就像进程出现异常一样,会触发信号机制,此时所有的线程会全部退出。因此合理使用多线程,能提高 CPU 密集型程序的执行效率与用户体验

进程与线程🤔

进程是分配系统资源的基本单位,线程是系统调度的基本单位
在这里插入图片描述

线程共享进程数据,但也拥有自己的一部分数据,比如线程ID,线程栈(每个线程都有临时的数据,需要压栈出栈),全局变量 errno,信号屏蔽字与调度优先级

多线程共享🤔

同一个地址空间里面,代码段和数据段和数据段都是共享的,如果定义一个函数在各线程中都可以调用。如果定义一个全局变量,在各线程中都可以访问到

各线程还共享一些进程资源和环境,文件描述符表(进程打开一个文件后,其他线程也能够看到)每种信号的处理方式,用户ID和组ID

Linux线程控制🤔

POSIX线程库😋

pthread线程库是应用层的原生线程库:

应用层指的是这个线程库并不是系统接口直接提供的,而是由第三方帮我们提供的。原生指的是大部分Linux系统都会默认带上该线程库。
与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的,要使用这些函数库,就要引入头文件<pthreaad.h>,链接这些线程函数库时,要使用编译器命令的“-lpthread”选项

传统的一些函数是,成功返回0,失败返回-1,并且对全局变量 errno 赋值以指示错误。pthreads 出错时不会设置全局变量 errno,因为大部分POSIX函数会这样做,而是将错误代码通过返回值返回。
pthreads同样也提供了线程内的errno变量,以支持其他使用errno的代码。对于 pthreads 的错误,建议通过返回值来判定,因为读取返回值要比读取线程内的 errno 变量的开销更小

线程的创建😋

创建线程用到 pthread_create 函数:

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

thread:获取创建成功的线程ID,该参数是一个输出型参数
attr:用于设置创建线程的属性,传入NULL表示使用默认属性
start_routine:该参数是一个函数地址,表示线程例程,即线程启动后要执行的函数。
arg:传给线程例程的参数

当一个程序启动时,就有一个进程被操作系统创建,与此同时一个线程也立刻运行,这个线程就叫做主线程,主线程产生其他子线程,主线程一般最后要完成某些操作,比如各种关闭动作

主线程调用 pthread_create 创建一个新线程,此后新线程会跑去执行自己的代码,而主线程则继续执行后续代码:

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

void* Routine(void* arg)
{
   
	char* msg = (char*)arg;
	while (1){
   
		printf("I am %s\n", msg);
		sleep(1);
	}
}
int main()
{
   
	pthread_t tid;
	pthread_create(&tid, NULL, Routine, (void*)"thread 1");
	while (1){
   
		printf("I am main thread!\n");
		sleep(2);
	}
	return 0;
}

运行代码后,可以看到新线程每隔一秒就会执行一次打印操作,但是主线程每隔两秒执行一次打印操作:

在这里插入图片描述
使

  • 216
    点赞
  • 354
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 192
    评论
评论 192
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

乔乔家的龙龙

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

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

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

打赏作者

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

抵扣说明:

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

余额充值