Linux之线程概念,理解和控制
一.线程
1.1线程的概念
在我们了解了进程的同时我们在学校上课时可能还听说过线程的概念,那么进程和线程又有什么区别他们俩又有什么联系呢?
在我们的书本中对于线程的概念是:线程是比进程更加轻量级的一种执行流或者是线程是在进程内部执行的执行流。
但是书本中的概念有点不好理解如果直白一点好理解一点的话线程和进程的概念是:线程是CPU调度的基本单位,进程是分配系统资源的基本单位。现在让我们带着这些概念来学习线程到底是什么它和进程之间又有什么联系呢?
这是我们在学习了进程之后我们所了解的整个运行逻辑,如果让我们来设计一个线程呢?大家又会怎么设计呢?
我们知道进程=属性+内容,而进程的属性是存储在pcb中的并且我们在调度进程时也是通过将进程的pcb移到各个队列中来进行调度的。在我们所了解的进程中我们只知道进程有一个pcb,那么如果我们假设一个进程的代码中有五个函数我们是否可以创建五个pcb来分别对应其中的一个函数呢?这些pcb分别指向不同的函数而且在cpu进行调度时也是分别调度这五个pcb。所以这五个pcb就相当于五个执行流而这些存在于进程中的执行流就是线程。
1.2线程的理解
那么在大概了解了线程后我们来想一下我们要如何看待以前的进程呢?
很简单,我们把之前的进程当作只有一个执行流的进程即可也叫做单线程进程,而我们今天学习的这种在进程内部有多个线程的进程就是多线程进程。
我们再回过头来对照一下书本中对于线程的概念以及我们对于线程和进程概念的总结看是否和我们图中花的线程是相符合的。
书中说线程是一种比进程更加轻量级的一种执行流,在之前的描述中我们知道cpu对于进程的调度就是调度它的pcb那么调度pcb是不是就是调度进程中的线程呢?所以线程肯定是比进程更加轻量级的。书中还说线程是在进程内部执行的一种执行流,同样从图中我们可以看出来线程肯定是处于进程内部的因为线程参与了进程资源的分配所以它在执行的时候使用的就是进程内部的资源。同时我们要注意一件事情:线程是参与了进程资源的分配的其中这个资源不止是指进程的代码数据,还包括了进程的时间片。我们知道当CPU调度一个进程时是有调度的时间的一旦到了这个时间后CPU就会切换进程而这个时间就是时间片。所以线程同样会参与对时间片的分配导致如果是一个多线程进程那么每个线程的调度时间是在原来的时间片的基础上均分的。
我们对于线程的概念的总结是线程是CPU调度的基本单位,我们知道CPU调度进程是调度其中的pcb那么如今pcb就等于是线程所以CPU调度的就是线程。对于进程的概念的总结是进程是参与系统资源分配的基本单位,当我们从磁盘中每次想要执行一个可执行文件时就要创建出一个进程来执行它,所以会产生进程的pcb,虚拟地址空间,页表等结构。
在知道了进程的存在后我们就要思考一个问题:我们可以创建多个线程是吧,那么如果我们真的支持这样的线程的话线程是否要被管理呢?要如何管理呢?
对线程的管理是很简单的我们只需要遵守先描述再组织的规则就可以将对线程的管理变成对数据结构的增删查改,但是我们知道如果你使用这种方法来管理线程我们就需要为线程设计一整套的管理方案其中包括线程的属性,内容以及对于线程的调度算法。这又是一个不小的工作量而且我们发现线程的属性很多都是可以继承进程的属性的毕竟线程是存在于进程内部的。那么属性可以继承进程的调度方法是不是也可以继承进程的呢?但是这样的话它还是线程吗?
这件牵涉到了不同操作系统对于线程的不同处理方法,在我们日常使用的Windows系统下真的为线程设计了一整套的方法,而对于Linux系统来说它不想为了个线程浪费太多的功夫所以它将线程改了个名字叫做轻量化进程,这个轻量化进程就继承了进程的部分属性和调度方法。如果要给这两种方法分个优劣的话肯定是Linux的方法更好更加的优雅。
在知道了在Linux中线程其实就是轻量化进程后我们就有疑问了,这个轻量化到底是轻量在什么地方呢?
线程创建的更加简单了。
以前我们想要创建一个进程时我们需要pcb,虚拟地址空间和页表但是现在我们创建一个线程只需要再创建出一个pcb来分配进程的资源即可。
线程切换的效率更高。
- 我们之前使用单线程进程时CPU想要切换进程就必须将进程在CPU内部的寄存器内容全部保存起来,但是如今使用多线程进程时我们想要切换进程就只需要保存少量的寄存器内容即可,因为线程是处于进程内部的导致线程会共享一部分的进程数据所以那些寄存器内容是不用保存的。
- 不仅是需要切换的寄存器数量少了线程切换还不需要重新更新缓存即cache。在了解其中的原理之前我们先解释一个计算机界的一个现象:局部性原理。
局部性原理是计算机科学中的一个重要概念,它描述了一个现象:在一段时间内,程序倾向于仅使用一部分代码或数据。这种倾向性分为两类:时间局部性和空间局部性。时间局部性指的是如果某个数据项被访问,那么它不久后可能会被再次访问。空间局部性则是指如果访问了某个存储单元,那么其附近的存储单元也很可能不久后会被访问。
因为有局部性原理这个现象所以CPU中存在了缓存即cache这个部件,在我们使用CPU执行代码时其实不是一条一条的来读取代码的而是将要执行的代码以及其附近的代码一并保存在cache中,而这些被存储在cache中的数据就被叫做热数据。由于局部性原理当其中的一条代码被执行后附近的代码也很大的可能性被执行这样CPU就只需要在cache中读取下一条代码后执行即可从而来提升读取执行的效率。
而在我们进行进程切换的时候因为每个进程的代码不同所以我们需要更新cache中的数据,这个操作也被叫做热更新。但是当切换线程时因为都是处于一个进程之下所以代码是相同的就不需要更新cache中的内容从而加快效率。
在使用线程之前我们还需要更新一次对于虚拟地址空间的认识,从认识虚拟地址空间到我们知道是CPU使用页表和mmu来进行虚拟到物理的地址转换再到我们今天要重新认识虚拟地址空间,我们已经三顾虚拟地址空间了。
对于以往的虚拟地址空间我们可以用一张图来概括
所以在我们的理解里页表中的每一行即页表项是包含了虚拟地址和物理地址的映射以及物理内存是否有数据是否被使用的标识符的。假设我们将虚拟地址和物理地址分别视作4字节的数据,后面的标识符视作2字节的数据,那么一个页表项加起来是10字节的数据。
对于虚拟地址空间从0000 0000到FFFF FFFF一共是2的32次方个字节也就是4GB的内容,如果我们要让每个虚拟地址都对物理内存进行映射那么需要有2的32次方个页表项而每个页表项又是10字节所以一个页表的大小就达到了40GB,这可能吗?这不可能,所以我们对于页表的构成是完全错误的!!
那么真实的页表又是什么样的呢?这就要先聊到我们之前学习文件系统时说到的文件块了,我们知道在文件系统中最小的存储单元是4kb也就是一个文件块,所以对于在磁盘中可执行文件我们是否也可以将其视作大量的文件块组成的一个文件呢?那么我们是否也可以将物理内存视作以文件块为最小存储单位从而组成的一个内存呢?
同时我们也能发现系统是如何对内存进行管理的了,如今的内存不就是由4GB/4KB即1,048,576个页框组成的吗,那么遵守先描述再组织的原理我们只需要对每个页框创建一个结构体对象其中存储了这个页框是否使用以及页框的属性即可,之后将每个页框的结构体对象存储到一个数组中,这样数组的下标就是对于着是哪个页框同时对于对内存的管理也就转换为了对数组内容的增删查改。
那么在内存的最小存储单元都是4KB的时候我们要如何通过虚拟地址来找到对应的物理内存呢?也就要说到我们重新认识的页表了。
其实在操作系统内部是将虚拟地址分为了三部分来进行虚拟到物理的转换的,一个地址是4个字节也就是32个bit位所以系统将其分为了10,10,12的三部分。其中的第一部分是由页目录进行管理的,页目录是由2的10次方即1,024个地址组成的一个数组其中数组的下标是代表了数据的前十个比特位而数组中每个成员的内容则是指向一个页表。而这个页表也是一个由2的10次方即1024个地址组成的一个数组,页表的下标则是数据的中间十位的bit位,其每个成员内容则是对应的页框的起始地址。而最后12个比特位也就是2的12次方即4,096个地址就是页框内的偏移量。光说可能不太好理解其中的原理我们可以通过图来演示。
所以通过前20个比特位来查找到页框的起始地址,再通过最后的12个比特位来确定页内的偏移量从而找到内存中的每一个数据。
那么通过比特位来划分页表不就是来划分地址空间吗。所以在进程的眼中虚拟地址空间也是资源的一种,所以线程参与进程资源的分配也就是包括了对应虚拟地址空间的划分也就是对页表的划分。
1.3线程的优缺点,异常和用途
线程的优点:
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
线程的缺点:
- 性能损失
当我们使用过多的线程来执行代码时会导致同步和调度的开销过大从而造成效率的损失 - 健壮性下降
对于多线程代码我们需要细致入微的来编写因为一旦有一个线程出现错误就会造成整个进程的崩溃。 - 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。 - 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
线程的异常:
当一个线程出现异常时会导致其他的线程也发生崩溃,并且会让整个进程也崩溃。这是因为当线程异常时收到的信号是对进程产生作用一旦异常就会让整个进程退出那么其他的线程也就退出了。也就是为什么多线程进程的健壮性会下降的原因。
为了证明这个现象同时也提前让大家熟悉一下线程的代码,我来用代码给大家演示一下。
#Makefile
pthread:pthread.cc
g++ -o $@ $^ -lpthread
.PHONY:clean
clean:
rm -f pthread
//phread.cc
#include <iostream>
#include <pthread.h>
#include <unistd.h>
//线程的异常
void* ThreadRoutine(void* args)
{
//新线程
int cnt = 5;
while(cnt--)
{
std::cout << "我是新线程" << std::endl;
sleep(1);
}
int i = 10;
i /= 0;
}
int main()
{
pthread_t tid;
pthread_create(&tid,NULL,ThreadRoutine,NULL);
//主线程
while(true)
{
std::cout << "我是主线程" << std::endl;
sleep(1);
}
return 0;
}
在学习了信号后我们知道Floating point exception就是因为发出了8号信号而产生的
如果我们想要查看是否产生了新线程的话我们可以使用ps -aL来查看
线程的用途:
- 合理的使用多线程,能提高CPU密集型程序的执行效率
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
1.4线程和进程
线程和进程的区别
- 进程是资源分配的基本单位
- 线程是调度的基本单位
- 线程共享进程数据,但也拥有自己的一部分数据:
- 线程ID
- 一组寄存器(重要)
- 栈(重要)
- errno
- 信号屏蔽字
- 调度优先级
- 各线程还共享以下进程资源和环境:
- 文件描述表
- 每种信号的处理方法
- 当前工作目录
- 用户id和组id
那么如何证明线程是由自己的栈的呢?
我们知道Linux为了支持线程所以搞了一个pthread原生线程库,这个库里封装的都是系统调用接口从而来让用户把其当作线程的控制接口。而这个pthread_create封装的就是clone这个系统调用接口。
所以线程和的关系我们可以用一张图来概括
1.5线程的控制
通过上面的代码我们大概知道如何使用线程,但是大家是否仔细观察了Makefile中我在g++编译器后加了一个选项-lpthread。这是不是看着很眼熟,这不就是我们之前学习动态库时通过编译器来链接动态库的方法吗,可是为什么我们创建线程需要链接一个动态库呢?难道线程的函数不是由我们系统内部实现的?
这就要说到Linux中实现线程的方法了,之前我们说过Linux中是通过轻量化进程来实现线程的功能的所以在Linux中其实是不存在线程这个概念的!
那么问题就来了如果我们是一个从Windows下转来Linux的一个程序员,在Windows中我们使用的就是线程结果来了Linux后你告诉我Linux里没有线程只有轻量化进程那我不是还得去学习轻量化进程的概念还有使用方法吗?这也太麻烦了吧。所以Linux为了避免这种麻烦它在系统内部实现了一个叫做pthread原生线程库的一个动态库,只要使用编译器链接上这个动态库你就可以直接和其他系统一样创建线程最多就是创建线程的函数方法不同而已。
想要证明这个结论我们在后续学习线程的控制接口的时候会再次提到。
1.4.1线程的创建
通过线程的创建我们发现创建线程是会返回线程的id的那么这个id是否和我们之前用ps -aL查询的LWP是一个呢?
大家想想我是怎么介绍LWP的,LWP是轻量化进程的ID不是线程的ID,在Linux中没有线程的概念只有轻量化进程。所以我们通过ps -aL查到的都是轻量化进程的id不是线程的id,那么这个线程的id到底是什么呢?
我们知道原生线程库是一个动态库所以它是存放在虚拟地址空间中的共享区的,而我们想要搞清楚这个id到底是什么就必须要知道pthread_create的第一个参数到底是什么意思,我们知道第一个参数是一个输出性参数所以我们只需要定义一个pthread_t变量再传入到函数中即可,那么函数到底做了什么让他变成了线程的id呢?
其实当我们创建一个线程时会在原生线程库中让一段空间存储线程的属性,线程的局部存储以及线程的栈地址。而函数会将这段空间的首地址赋予到第一个参数上,所以我们说的那个线程的id其实就是虚拟地址空间中原生线程库的一个地址。
那么LWP和这个id的区别就很明显了,LWP是内核级别的轻量化进程的id,而这个线程id则是进程调度所使用的一个id,因为我们在进行线程切换的时候肯定需要让一个唯一的数值来指向一个线程从而方便线程的切换。
1.4.2线程的终止
如果想要一个线程终止则有三种方法
- 线程自己return
这个方法就很简单了,自己return了不就终止了 - 线程调用pthread_exit函数
参数是当调用这个函数退出后的返回值,需要配合线程的等待来使用,也可以设为NULL。 - 一个线程使用pthread_cancel函数来终止另外一个线程
// 线程的终止
static int number = 0;
void *ThreadRoutine(void *args)
{
std::string ThreadName = (char *)args;
// 新线程
while (true)
{
sleep(1);
std::cout << "i am a new thread, my name is:" << ThreadName << number << std::endl;
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, ThreadRoutine, (void *)"Thread-");
// 主线程
int cnt = 5;
while (true)
{
if (cnt-- == 0)
{
// 主线程让新线程终止
// 一般最好让主线程最后终止
// 所以建议不要让新线程调用pthread_cancle函数让主线程终止
pthread_cancel(tid);
}
std::cout << "i am a main thread" << std::endl;
sleep(1);
}
return 0;
}
1.4.3线程的等待
为什么要进行线程的等待呢?其实原因和为什么要进行进程的等待是相似的,都是为了防止僵尸状态的产生从而发生数据泄露的问题,但是线程我们没办法用代码让大家看到线程的“僵尸状态”。
而线程等待的函数就是pthread_join
// 线程的等待
static int number = 0;
void *ThreadRoutine(void *args)
{
std::string ThreadName = (char *)args;
// 新线程
// 1.通过return终止
// int cnt = 3;
// while(cnt--)
// {
// std::cout << "i am a new thread" << std::endl;
// sleep(1);
// }
// return (void*)"new thread quit";
// 2.通过pthread_exit终止
// int cnt = 3;
// while (cnt--)
// {
// std::cout << "i am a new thread" << std::endl;
// sleep(1);
// }
// pthread_exit((void *)"new thread done");
// 3.通过pthread_cancel终止
while (true)
{
std::cout << "i am a new thread" << std::endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, ThreadRoutine, (void *)"Thread-");
// 主线程
// 1.通过return终止
// void *c;
// int n = pthread_join(tid, &c);
// std::cout << "i am a main thread\n"
// << "n:" << n << std::endl;
// std::cout << (char*)c << std::endl;
// return 0;
// 2.通过pthread_exit终止
// void *c;
// pthread_join(tid, &c);
// std::cout << "i am a main thread\n";
// std::cout << (char*)c << std::endl;
// return 0;
// 3.通过pthread_cancel终止
sleep(5);
pthread_cancel(tid);
void *c;
int n = pthread_join(tid, &c);
std::cout << "i am a main thread\n"
<< "n:" << n << std::endl;
std::cout << (int64_t)c << std::endl;
return 0;
}
1.4.4线程的分离
在学习了线程的等待后我们就必须要等待其他的线程了吗?有没有什么方法可以让新线程不需要被等待呢?
有,那就是将新线程分离出去,默认情况下我们创建的线程都是joinable的也就是需要进行等待的,但是如果我们使用pthread_detach将新线程进行分离那么就等于是告诉系统这个线程我不需要它的返回值,你直接在它退出的时候给它资源释放了吧。
//线程的等待
static int number = 0;
void *ThreadRoutine(void *args)
{
std::string ThreadName = (char *)args;
// 新线程
//我们可以使用pthread_self函数来获取当前线程的id
//从而实现自己分离
pthread_detach(pthread_self());
int cnt = 5;
while (cnt--)
{
sleep(1);
std::cout << "i am a new thread, my name is:" << ThreadName << number << std::endl;
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, ThreadRoutine, (void *)"Thread-");
// 主线程
while (true)
{
std::cout << "i am a main thread" << std::endl;
sleep(1);
}
return 0;
}