【Linux】从零开始认识多线程 --- 线程概念与底层实现

CSDN 2024-08-09 16:07:18 阅读 97

在这里插入图片描述

人间没有单纯的快乐

快乐总是夹带着烦恼和忧愁

人间也没有永远,我们一生坎坷。

-- 杨绛 《我们仨》


从零开始认识线程

1 背景知识1.1 再谈地址空间1.2 页表底层1.3 理解代码数据划分的本质

2 线程的概念3 澄清与统一线程和进程4 总结4.1 线程的缺点4.2 线程的优点4.3 LWP与线程的关系4.4 注意

1 背景知识

在学习多线程之前,我们先来了解一些背景知识,我们需要这些背景知识来辅助我们理解多线程!

1.1 再谈地址空间

首先,物理内存并不是连续的一整块大空间,物理内存实际上是被划分为很多块(4KB空间),是一个大数组。操作系统进行内存管理不是以字节为单位,而是以内存块为单位,默认大小为4KB!之前文件系统中,系统与磁盘文件进行基础IO的基本单位也是4KB(8个扇区),这都啥经过精心设计的!

之前我们的可执行程序中会存在地址!可执行程序是储存在磁盘的文件,就会有对应<code>inode,天然的就按4KB储存好了。所以未来进行内存与磁盘的IO交互时,就直接将磁盘的数据块加载到内存块中!

在这里插入图片描述

所以一切都是设计好的! 所以IO的基本单位是4KB,无论是磁盘到内存,还是内存到磁盘都是4KB!而内存中的内存块我们称之为" 页框 / 页帧 "

那操作系统对内存的管理工作就是对4KB空间的管理! 也就是说改变一个变量(假如是int类型 4字节)时,会会直接将整个页框进行写时拷贝,而不是单单一个int类型。这是因为OS系统认为当你修改一个变量时,其周围的变量会有很大概率也发生改变,所以将整个页框进行写时拷贝!从而避免多次写时拷贝(空间换时间)。

4GB的内存中共有 <code>1024 *1024 = 1048576个页框,那么操作系统然后管理这么多的页框呢???

其实每个页框在内核里都有一个struct page结构体来管理,这里面会有该页框的对应属性。那么管理起来就可以通过一个大数组struct page memory[1048576]来管理。所以物理内存也就是这个大数组了!!!

CPU可以通过虚拟地址转换为物理地址,这是怎么进行的。接下来我们就来谈谈页表,按我们之前的理解页表是虚拟地址映射物理地址的。那这样储存一个页表就需要2^32个地址映射,这就以及32GB了,所以很显然,操作系统不会以这种方式来储存页表。接下来我们就来学习页表的底层是什么样子的!!!

1.2 页表底层

物理内存中的每个物理地址一定是有对应的页的,也就是只要找到了对应页就能访问其物理地址。虚拟地址有2^32个地址:每个地址都是这样的32位序列 0000 0101 0010 0000 0110 1001 1100 1000。那么虚拟地址是如何转换为物理地址的呢???这个转换不是直接进行转换,而是按照一定规则进行划分查找:

虚拟地址共有32位比特位,分为三部分:A部分前10位 ,B部分中间十位,C部分最后12位。

在这里插入图片描述

一个地址分为了三部分,并且页表也不止有一张!前10位对应页目录的1024个元素,以A部分作为索引对应每个元素,而这个元素是指向另一张页表的指针。这个页表也有1024个元素,以B部分作为索引,而这个元素是内存中的页框的起始地址(大小为4KB,4096字节),而C部分恰好有4096种组合,作为索引对应每个内存块中的字节!!!C部分中的12位作为页内偏移,与页框的起始地址进行加和,就能找到对应字节!

在这里插入图片描述

这样就将虚拟地址转换为了物理地址!!!

也就看出来:页表的本质就是搜索页框!在通过最后12位来找到对应字节!这样算下来这个页表只花费了<code>页目录 1024 * 4 + 页表 1024 *(1024 * 4) = 4 MB这可比32GB小的太多太多了

但是这样只能找到一个字节啊!一个int都有4个字节,更别说更大的类对象了。这可怎么来找到对应的数据?我们还有“类型”这一关键一步,类型 + 起始地址就能从内存中找到对应的数据!

CPU可以通过MMU帮助我们将虚拟地址转换为物理地址,页表是储存在CR3寄存器中:

CR3寄存器通常被称为页目录基址寄存器(Page Directory Base Register)。

它存储了当前任务页目录表(Page Directory Table, PDT)的物理地址。页目录表是一个数据结构,用于在启用分页时转换虚拟地址到物理地址。

通过这个寄存器与MMU的硬件电路配合,就可以成功转换为物理地址!

1.3 理解代码数据划分的本质

地址空间的各个分区是通过限定一批虚拟地址空间的范围来实现分区。如果我们将代码区的代码拆分为20个函数,让我们的代码来并行运行,这样在技术上可行吗?首先函数也有地址,函数地址是代码的入口地址(函数第一行地址)函数内部每行代码都有地址。连续的代码块就是函数,那么一个函数就对应一批虚拟地址,如果要拆分函数,就只需要拆分页表就可以了!

所以说:虚拟地址本质是一种资源,可以进行分配!

只要将虚拟地址分配清楚,就可以将代码数据进行拆分!

2 线程的概念

先来看官方概念:线程:在进程内部运行,是CPU调度的基本单位。

进程我们很熟悉:是由PCB描述,通过地址空间与页表获取物理内存中的代码与数据。

在这里插入图片描述

今天如果我们想要创建一个进程,但不给它分配对应的地址空间,只创建一个<code>task_struct与先前的进程共享地址空间:

在这里插入图片描述

线程就是这许多的<code>task_struct,可是这样进程又是什么呢?之前我们学习的是进程 = 内核数据结构 + 进程代码与数据

但是今天,要进行重新矫正。这样多个task_struct不叫进程,叫做进程的执行流!那什么是进程呢?

在这里插入图片描述

这一整套是进程!之前我们学习的就是只有单个<code>task_struct的特殊情况!!!

进程从内核来看,是承担分配系统资源的基本实体!

3 澄清与统一线程和进程

在我们这个社会中,家庭是经营的基本单位,家庭中每个人都有对应的责任:孩子好好学习健康生活,父母勤奋工作,爷爷奶奶安心养老。所有人都在执行自己的事情,但所有的人把自己的事情做好,就能产生将家庭过幸福的结果!

而家庭就是进程,家庭成员就是线程!这就是他们之间的关系!

刚才我们所说的是Linux内核下的线程,对于线程来说,也一定要和进程一样需要对应操作方法:新建,暂停 ,销毁,调度。那么线程会不会与进程产生关联呢? 接下来我们就来了解线程如何管理。

线程我们一般称为tcb (进程是pcb),那么该结构体struct tcb中就需要:线程id ,优先级,状态,上下文,链接属性…

在Windows下,pcbtcb是相对独立的,其通过数据结构来关联起来,是两套不同的控制体系!CPU在进行处理时,就要先选择一个进程再选取一个线程,这就需要两个不同的调度算法:

在这里插入图片描述

这样就使操作过程十分的复杂!

而Linux吸取Windows的经经验,发现<code>tcb与pcb里面的属性是一致的,并且两个都是执行流,为什么不用一个模块来统一管理呢?!这样就不需要单独设计线程的模块了。

所以Linux是用进程模拟的线程!

我们再来从CPU的角度来看,CPU调用一个task_stuct是小于等于 进程的,进程里面有很多的task_struct!那么CPU需不需要来区分task_stuct是进程还是线程?当然不需要,执行进程和线程和CPU有什么关系?!你要执行什么就给我CPU什么!给CPU什么执行流(进程或线程),它就执行什么!可以说线程是CPU调度的基本单位。

我们在实践中见一见:

在这里插入图片描述

这是创建线程的系统调用,参数有四个:

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,

void *(*start_routine) (void *), void *arg);

pthread_t *thread : 输出型参数,线程idconst pthread_attr_t *attr : 线程对应属性,目前设置为nullptr就可以void *(*start_routine) (void ): 这是一个函数指针,函数返回值是void ,函数参数是void

<code>#include <iostream>

#include <pthread.h>

#include <unistd.h>

// 新线程

void *threadStart(void *args)

{

while (true)

{

std::cout << "new thread running..." << std::endl;

sleep(1);

}

}

int main()

{

pthread_t tid;

pthread_create(&tid, nullptr, threadStart, (void *)"sthread-ew");

// 主线程

while (true)

{

sleep(1);

std::cout << "main thread running..." << std::endl;

}

return 0;

}

我们编译的时候会报一个这样的错误:

(.text+0x1b): undefined reference to main线程未定义,之所以会出错是因为Linux下使用线程需要引用线程库:

在这里插入图片描述

这个库的详细信息我们后面再说。编译的时候链接上动态库<code>pthread就可以了

g++ -o testthread testthread.cc -std=c++11 -lpthread

这样主线程和新线程就可以同时跑了:

在这里插入图片描述

我们查看进程信息:

在这里插入图片描述

发现只有一个进程:

再来让这两个线程打印一下自己的pid:

在这里插入图片描述

会发现,他们两个虽然是两个不同的执行流,但是却是同一个进程!原因不就是这两个进程属于同一个进程内部!我们可以使用<code>ps -aL就可以查看不同线程

在这里插入图片描述

这个pid是对应进程的pid,这个<code>LWP其实就是这个线程的id!!!操作系统调度的时候,是通过LWP调度的。CMD是主线程。

有个问题:多进程调度和单进程调度相互影响吗? 进程调度时通过pid来,每个进程都不一样,都有自己的pid,所以并不影响。

下面我们来解决一下几个疑问:

已经有多进程了,为什么还要有多线程??

创建一个进程需要创建PCB,地址空间,页表,加载代码与数据,创建文件缓冲区等很多操作,但创建一个线程,只需要创建一个PCB,复用原本的地址空间创建进程的成本比创建线程高很多!切换进程时不仅仅要更换上下文数据,更换地址空间等很多操作,切换线程只需要切换PCB!!!线程删除成本也很低。但是线程也有缺陷,一个线程出错(野指针)就是这个进程出错了,因为他们使用同一个地址空间,所以其他的线程也会报错退出!!! 线程的健壮性很差!而进程是独立的互不影响!进程和线程各有特长!不同操作系统对线程的实现不一样,那为什么操作系统课本只有一本???

操作系统是一个指导书,会对操作系统的实现给出一些规定,但是具体的做法并不限制,只有满足规定就可以!线程的调度为什么成本更低???

进程调度会通过CPU一系列寄存器来进行调度,对于CPU来说,多调用几个寄存器应该 不算什么大事,那为什么会成本更高呢?因为CP中存在一个cache会储存热点数据(进程相关数据) ,要访问数据时,会先在cache中寻找,如果命中直接访问,反之进行置换。 所以进程之间切换时,会将cache的数据全部作废操,重新读取,切换线程就不需要进行切换。所以线程的调度成本更低!!!线程的本质是代码块!只使用函数的对应代码,即拿页表的一部分来执行!!!

4 总结

4.1 线程的缺点

性能损失:

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

编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了

不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。缺乏访问控制:

进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。比如线程访问同一个全局变量,所有线程访问一个全局变量,互相修改会相互影响!编程难度提高:

编写与调试一个多线程程序比单线程程序困难得多(这个不一定)

4.2 线程的优点

创建一个新线程的代价要比创建一个新进程小得多与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多线程占用的资源要比进程少很多能充分利用多处理器的可并行数量在等待慢速I/O操作结束的同时,程序可执行其他的计算任务计算密集型应用(进行大量技术,比如加密解密),为了能在多处理器系统上运行,将计算分解到多个线程中实现I/O密集型应用(大量读取写入,下载上传),为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

对于多线程和单线程来说,是要合适最好!对于一个2线程的CPU那么创建两个线程是最好的!

4.3 LWP与线程的关系

LWP,即轻量级进程,是一种高级的并发抽象,它允许在单个进程内创建多个执行流。LWP的关键特点包括:

共享地址空间:LWP与其父进程共享内存和资源,这减少了数据交换的开销,并简化了数据共享。独立的执行上下文:每个LWP拥有自己的task_struct(进程控制块),这使得它们可以在操作系统的调度下独立执行。并发执行:LWP支持并发操作,虽然它们不是真正的并行执行(除非在多核系统上),但通过快速上下文切换,可以实现高效的并发处理。

总的来说,LWP提供了一种轻量级的并发解决方案,它既保持了进程的独立性,又实现了线程的资源共享优势,适用于需要高并发处理的应用场景。

LWP(轻量级进程)和线程在概念上非常相似,它们都是用于实现并发执行的编程抽象。以下是它们之间的关系和区别:

相似性:

并发执行:LWP和线程都是允许在单个程序中同时执行多个任务的技术。资源共享:LWP和线程通常共享它们所属进程的地址空间和其他资源,如打开的文件描述符、信号处理等。上下文切换:在操作系统的调度下,LWP和线程都可以进行上下文切换,以实现多任务处理。

区别:实现层次

LWP通常是由操作系统内核直接支持的,它们是内核级的线程。线程可以是内核级的(如Linux的pthread),也可以是用户级的(如用户空间线程库)。用户级线程的调度不由操作系统内核直接管理。 调度和管理

LWP通常由操作系统内核调度,每个LWP都对应一个内核调度实体。用户级线程的调度可能由线程库完成,不直接由操作系统调度。 性能开销

LWP的上下文切换通常比用户级线程的上下文切换开销大,因为LWP的切换涉及到内核态的操作。用户级线程的上下文切换通常更快,因为它们不需要进入内核态。 与进程的关系

在一些系统中,一个进程可以创建多个LWP,每个LWP可以看作是该进程的一个执行流。线程是进程内的一个执行单元,在多线程的进程中,每个线程共享进程的资源,但可能有自己的栈空间。

在Linux系统中,LWP通常与内核线程(kernel thread)是同义的,而pthread库提供的线程接口则是对这些轻量级进程的封装,使得程序员可以在用户空间方便地使用线程。

总结来说,LWP可以看作是线程在特定操作系统(如Solaris)中的实现方式,而线程是一个更通用的概念,其具体实现可能依赖于操作系统的支持。在某些情况下,LWP和线程可以互换使用,但在技术细节上它们有着不同的实现和性能特性。

4.4 注意

进程的多个线程共享 同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程

中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:

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

进程是资源分配的基本单位线程是调度的基本单位线程共享进程数据,但也拥有自己的一部分数据:

线程ID一组寄存器(最重要):硬件上下文数据 — 线程可以动态运行!(最重要):线程中可以处理自己的临时变量,临时变量储存在自己独立的栈区,可以独立完成任务。errno信号屏蔽字调度优先级



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。