人间没有单纯的快乐
快乐总是夹带着烦恼和忧愁
人间也没有永远,我们一生坎坷。
-- 杨绛 《我们仨》
从零开始认识线程
1 背景知识
在学习多线程之前,我们先来了解一些背景知识,我们需要这些背景知识来辅助我们理解多线程!
1.1 再谈地址空间
首先,物理内存并不是连续的一整块大空间,物理内存实际上是被划分为很多块(4KB空间),是一个大数组。操作系统进行内存管理不是以字节为单位,而是以内存块为单位,默认大小为4KB!之前文件系统中,系统与磁盘文件进行基础IO的基本单位也是4KB(8个扇区),这都啥经过精心设计的!
之前我们的可执行程序中会存在地址!可执行程序是储存在磁盘的文件,就会有对应
inode
,天然的就按
4KB
储存好了。所以未来进行内存与磁盘的IO交互时,就直接将磁盘的数据块加载到内存块中!
所以一切都是设计好的! 所以IO的基本单位是4KB,无论是磁盘到内存,还是内存到磁盘都是4KB!而内存中的内存块我们称之为" 页框 / 页帧 "
那操作系统对内存的管理工作就是对4KB空间的管理! 也就是说改变一个变量(假如是int类型 4字节)时,会会直接将整个页框进行写时拷贝,而不是单单一个int类型。这是因为OS系统认为当你修改一个变量时,其周围的变量会有很大概率也发生改变,所以将整个页框进行写时拷贝!从而避免多次写时拷贝(空间换时间)。
4GB的内存中共有
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位来找到对应字节!这样算下来这个页表只花费了
页目录 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描述,通过地址空间与页表获取物理内存中的代码与数据。
今天如果我们想要创建一个进程,但不给它分配对应的地址空间,只创建一个
task_struct
与先前的进程共享地址空间:
线程就是这许多的
task_struct
,可是这样进程又是什么呢?之前我们学习的是
进程 = 内核数据结构 + 进程代码与数据
但是今天,要进行重新矫正。这样多个
task_struct
不叫进程,叫做进程的执行流!那什么是进程呢?
这一整套是进程!之前我们学习的就是只有单个
task_struct
的特殊情况!!!
进程从内核来看,是承担分配系统资源的基本实体!
3 澄清与统一线程和进程
在我们这个社会中,家庭是经营的基本单位,家庭中每个人都有对应的责任:孩子好好学习健康生活,父母勤奋工作,爷爷奶奶安心养老。所有人都在执行自己的事情,但所有的人把自己的事情做好,就能产生将家庭过幸福的结果!
而家庭就是进程,家庭成员就是线程!这就是他们之间的关系!
刚才我们所说的是Linux内核下的线程,对于线程来说,也一定要和进程一样需要对应操作方法:新建,暂停 ,销毁,调度。那么线程会不会与进程产生关联呢? 接下来我们就来了解线程如何管理。
线程我们一般称为
tcb
(进程是
pcb
),那么该结构体
struct tcb
中就需要:线程id ,优先级,状态,上下文,链接属性…
在Windows下,
pcb
与
tcb
是相对独立的,其通过数据结构来关联起来,是两套不同的控制体系!CPU在进行处理时,就要先选择一个进程再选取一个线程,这就需要两个不同的调度算法:
这样就使操作过程十分的复杂!
而Linux吸取Windows的经经验,发现
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 : 输出型参数,线程id
- const pthread_attr_t *attr : 线程对应属性,目前设置为nullptr就可以
- void *(*start_routine) (void ): 这是一个函数指针,函数返回值是void ,函数参数是void
#include<iostream>#include<pthread.h>#include<unistd.h>// 新线程void*threadStart(void*args){while(true){
std::cout <<"new thread running..."<< std::endl;sleep(1);}}intmain(){
pthread_t tid;pthread_create(&tid,nullptr, threadStart,(void*)"sthread-ew");// 主线程while(true){sleep(1);
std::cout <<"main thread running..."<< std::endl;}return0;}
我们编译的时候会报一个这样的错误:
(.text+0x1b): undefined reference to main
线程未定义,之所以会出错是因为Linux下使用线程需要引用线程库:
这个库的详细信息我们后面再说。编译的时候链接上动态库
pthread
就可以了
g++ -o testthread testthread.cc -std=c++11 -lpthread
这样主线程和新线程就可以同时跑了:
我们查看进程信息:
发现只有一个进程:
再来让这两个线程打印一下自己的pid:
会发现,他们两个虽然是两个不同的执行流,但是却是同一个进程!原因不就是这两个进程属于同一个进程内部!我们可以使用
ps -aL
就可以查看不同线程
这个pid是对应进程的pid,这个
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 注意
进程的多个线程共享 同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程
中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
- 进程是资源分配的基本单位
- 线程是调度的基本单位
- 线程共享进程数据,但也拥有自己的一部分数据: - 线程ID- 一组寄存器(最重要):硬件上下文数据 — 线程可以动态运行!- 栈(最重要):线程中可以处理自己的临时变量,临时变量储存在自己独立的栈区,可以独立完成任务。- errno信号屏蔽字- 调度优先级
版权归原作者 叫我龙翔 所有, 如有侵权,请联系我们删除。