【linux】线程概念
喜欢的点赞,收藏,关注一下把!
1.储备知识
1.1再谈页表
在上一篇博客说过,页表除了用户级页表还有内核级页表,今天在扩展一点。
页表中还有很多其他的属性,诸如物理地址,是否命中,RWX权限,U/K权限(你是用户的还是内核的)。
不管是用户级页表/内核级页表大家用的数据结构都是一样的。
页表也要被OS管理起来,怎么管理呢?先描述,在组织。
所以页表中每一个条目就是一个数据结构,相当于你定义一个struct类型属性里面包括物理地址、是否命中等。
由这些知识,我们终于知道了以前我们在写诸如下面代码时,运行时报错。
char* str="hello world";
*str='H';
在C/C++语言层面,这个字符串是字符串常量不能修改,*str解引用时去找到这个字符串起始地址,然后对它修改, 因为这个字符常量区在已初始化和代码区之间的。为什么你在写入的时候不让你写了,现在原因就特别清楚 了。当你对字符串常量写的时候需要做虚拟地址到物理地址的转换, 当查到物理地址了还要继续查页表的其他属性,如RWX权限,比如只有R权限,而虚拟到物理寻址你可是写操作,所以地址转换单元MMU直接将你当前行为终止,怎么终止的呢?向硬件直接报错,OS识别到硬件报错,把这个硬件报错转换成信号,俗称段错误,发送11号信号给当前进程,进程在合适的时候处理这个信号,处理动作默认是终止,所以最后你的进程就直接终止了。在C/C++语言层面不能清楚解释,只能在OS层面才能解释它。
页表我们也清楚了,虚拟地址空间上篇博客也做了进一步解释。
那如何看待地址空间和页表
- 地址空间是进程能看到的资源窗口
- 页表决定,进程真正拥有资源的情况
- 合理的对地址空间+页表进行资源划分,我们就可以对一个进程所有的资源进行分类
聪明的你一定已经知道我们的页表并不简单。
以前一直说通过页表进行虚拟地址到物理地址的转换 ,那到底是怎么转换的呢?
下面我们就仔细说说这个问题。
页表有一条条的条目。
以32为机器为例,虚拟地址共有2^32个。 那页表是不是就要有2^32个条目。 刚才说了一个条目是一个数据结构,里面有物理地址等其他属性,地址4个字节其他属性加一起算它共有2个字节,总共6个字节,2^32大约4G大小,4X6=24G,光保存页表就需要24G空间(样例数据)。
所以我们以前这种页表映射理解,作为入门级说明一些问题还可以,但是它并不是一种正确的理解。
所以真实的页表是什么样子呢?
先说一说虚拟地址和物理内存。
这里虚拟地址以32位为例。
物理内存也不像我们想象的那么复杂,物理内存在我们一般进行OS级别的操作时,物理内存早已经被划分成了一个一个的数据块,更专业的说法是数据页。
OS也要对物理内存做管理,先描述,在组织。
怎么描述呢?
再以数组形式进行管理
一个页的大小4KB,这些一个个小块的物理内存,我们称之为页框,
我们对于的磁盘上编译形成可执行程序时,可执行程序在编译的时候也被划分一个一个4KB数据块的区域,这种区域称为页帧,所以可执行程序加载到内存时不是按1字节,1比特或其他方法,而是以4KB为单位搬到物理内存
其中我们虚拟地址转换成物理地址的转法,并不是看成整体转换的,而是10、10、12为单位构建的。而我们的页表也不就只是一张页表了。
第一张页表:页目录。
当有虚拟地址时,它首先会拿着前10个比特位在页目录中查找。
(2^10=1024,约1KB,假设一个条目10字节,这这个页目录也才10KB大小。)1024,下标从0开始到1023。
页目录可能匹配很多的页表,然后再拿第二批虚拟地址中10个比特位,再去页表中索引。
每张页表也是2^10大小。这个页表条目指向物理内存中的某一页。
然后还有剩下的12位比特位。就拿到了这里
为什么我们一块块物理内存大小是4KB,因为4KB的大小刚好是2^12次方。这都是精心设计过的。
页表里条目填的是指定页框的起始物理地址。
我们已经定位到了页框的起始地址。剩下的12位正好和页框,页帧的大小一样,但我们要访问的可不是4KB大小,而是某一字节,怎么访问某一字节?所以最终变成,从物理地址的起始地址处,再加上2^12次方这个偏移量,直接再一个页框内找到某一地址。
所以虚拟地址到物理地址怎么转换的?
先查找虚拟地址前10个比特位,索引页目录找到对应指定页表,然后再拿10个比特位,再去对应页表中找到指定位置,直接就找到页框的起始物理地址,找到某个页框,再拿剩下的12个比特位,这12个比特位充当页内偏移,就直接找到物理内存某一个具体的物理地址了。
只要一个地址找到了,你要连续找4个,8个,就根据你的类型,从刚才的起始地址处开始连续往后找。
就如int a=10;a有4个字节,对a取地址只拿到一个地址(起始地址),根据虚拟地址通过物理地址的转换,找到某一页框内a的起始物理地址,再根据整型连续取4给字节不就把整型拿出来了吗。
并且可能整个进程再使用时,有可能只使用页目录和一个页表,没有建立映射关系的页表就不给你创建,你只有需要的时候才给你创建,所以在物理内存中加载页表得到内容就大大减少了,也就可以解决刚刚内存空间不足够的问题了。
预备知识学完,我们也就可以理解在一个进程内进行资源划分。只要这个不困惑了,后面理解线程就不难了。
2.线程概念
之前我们学过进程,进程=内核数据结构+进程对应的代码和数据
那什么是线程呢?线程和进程有什么关系呢?
教材里对线程的常规说法都是这样说的。
线程:进程内的一个执行流。
当我们看到这句话的时候,肯定是很懵的。主要原因是:OS的描述都太宏观了,太抽象了。放在其他系统这话也没错。
那我们今天就具体化,只谈Linux这一款操作系统它的多线程实现。
那按照这个说法,不同平台的多线程底层实现不一样,是的,确实不一样。
但是我们把Linux系统下的多线程说完,它也一定满足任何操作系统给的上面那个概念。
以前我们说创建子进程,要把父进程的PCB,虚拟地址空间,页表这些内核结构都拷贝一份给子进程。在没有写时拷贝时父进程的代码和数据也和子进程共享。
上面我们也把页表和虚拟内存都说了。
现在在重温一下如何看待虚拟内存:虚拟内存里面决定了进程能够看到的资源。
进程可以想象成一个房间里的人,虚拟内存想象成这个房间里的窗户,进程想看到外面的风景,完完全全由虚拟内存决定。虚拟内存里划分一块一块的区域,这些都是进程所能看到的资源。当然还有其他的资源但这些先不管。
可以通过虚拟地址和页表可以访问对应物理内存的加载的代码和数据这些资源。
以前我们曾经说过,一个进程它其实是可以把自己的代码划分一部分让另一个执行流执行,例如fork创建子进程,通过if判断让父子进程执行不同的代码块或者是执行流。这样让子进程访问当前父进程代码和数据的一部分。
这样当然是不错的。
但是这样创建子进程需要把父进程的内核数据结构都拷贝一份给子进程。
那我想在创建进程的时候,只创建对应的PCB,剩下的虚拟地址空间,页表这些内核结构我都不在创建了。并且这些进程创建的PCB和父进程指向同一个虚拟内存。
只要和父进程指向同一个虚拟内存,那么每一个PCB都可以通过虚拟内存这个窗口,看到这个进程的资源。我们可以把这个进程的资源进行划分,
分给这些PCB,让这些创建出的进程只需要执行代码中的一部分,访问代码中一部分资源就可以,我们把这种只创建PCB,从父进程中给它分配资源的这种执行流,我们就可以叫做线程。
关于这块知识的理解,我们首先要形成一个共识,一个进程所对应的资源可以通过虚拟地址空间+页表来将自己的部分资源划分给特定线程的,就像我们以前说的通过if,else 判断这个代码块是父进程交给子进程来执行。
因为我们通过虚拟地址空间+页表方式对进程进行资源划分,所以单个’进程’执行力度一定比之前进程要细。
当我们真的把这些PCB创建出来,站在CPU的角度它会如何看待这里一个个task_struct呢?
CPU可不管这些,它曾经调度就是看到的一个个PCB,那么现在有这么多PCB,每一个PCB都有自己对应的代码和数据,跟我有什么关系,我很傻,你只要给我,我就帮你运行,没有我就不跑,跑什么由你决定,至于你给我的代码是这个进程的还是其他创建的量级更轻的进程从父进程哪里拿出的一部分代码和数据,我不管。我只关注调度的时候看到的task_struct,你给什么就跑什么,并不会感觉执行你这个比执行它量级更轻。
那我们现在来思考一些问题。
如果我们的OS真的要专门设计"线程"概念,OS未来要不要管理这个线程呢?肯定是要的,怎么管理?先描述,在组织。一定要为线程设计专门的数据结构表示线程的对象。而我们的Windows就是这样的,设计出一个专门表示线程的对象TCB。
那既然线程也要被执行,被调度,它也一定要有自己的id,状态,优先级,上下文,栈…
单纯从线程调度角度来说,线程和进程有很多的地方都是重叠的!
所以,我们的Linux工程师,不想给Linux"线程"专门设计对应的数据结构!而是直接复用PCB!用PCB来表示Linux内存的"线程"。
看完这段话,就是想说明一件事情,线程本来是有对应的线程控制块的,只不过Linux不想专门设计这个结构了。
基于上面这些知识,我们再谈一些概念。
目前我们有一个共识:
我们的进程可以通过一定的方式划分成若干块,如果不太理解,可以简单粗暴理解,我们页表里有很多的映射关系,我们把一部分映射关系给归给这个新创建出来的一个PCB,然后再把一部分映射关系再归给那一个创建出来的PCB等等。这样就很好的做了资源划分了。虽然大家用的是同一个虚拟内存,但是可以做到这一点。
再下来我们进程创建有两种方式,一种是把父进程所有PCB,虚拟地址空间等内核结构都拷贝一份。另一种直接创建对应的PCB就行了指向行父进程的虚拟地址空间,让每一个对应的执行流只能够访问页表中的一部分。访问特定区域。
所以我们把内部这批只创建PCB这些,我们称为Linux线程。
谁是线程,线程是进程内的一个执行流,什么执行流呢。这句话的大白话就是,线程在进程内部运行,更准确的是线程在进程的地址空间内运行!拥有该进程的一部分资源。
现在肯定有很多疑问。
我们先回到下面的问题。
1.什么叫做进程呢
以前我们说进程=内核数据结构+进程对应的代码和数据,这种说法一点都没错,只不过我们现在把概念在重构一下。
所谓的进程就是一堆的PCB,一个地址空间,一堆页表,还有所对应的物理内存一部分,这一个整体我们称之为进程。换句话说,今天我们给一个进程概念新的视角。
内核视角:承担分配系统资源的基本实体
你创建进程时所申请的一个PCB,一个虚拟地址空间,一堆页表还有加载到物理内存的代码和数据有没有花费系统的资源呢?IO资源?CPU调用所用的资源?这些肯定有的。所有这些所支出的资源,我们整体称之为进程,
所以进程是一个承担分配系统资源的基本实体。
今天在创建进程我们要想到在系统里创建一大批数据结构要占用资源,还要加载对应的代码和数据也要占用系统一部分资源,还有CPU,IO资源等等,这一堆我们统称为进程。
2.在Linux中,什么叫做线程
线程:CPU调度的基本单位。
也就是说一个线程才是OS执行所对应的代码和数据时常见的基本单位。
现在脑子有点乱,那以前我还以为CPU调用的是按进程为单位进行调度,以前说的切换,阻塞,挂起等这些概念我都是按照进程理解的,那是不是错了。
并没有。
3.如何看待我们之前学习的进程时,对应的进程概念呢?和今天讲的冲突吗?
现在我再问什么是进程呢,你如果回答一个PCB,再加其他内核数据+代码和数据是进程,这是不对的。它只是进程内的一块小资源。
今天进程我们知道可能有一堆PCB,再加其他内核数据结构+对应物理内存的代码和数据。按照今天内核视角来说以前进程是承担分配系统资源的实体。只不过内部只有一个执行流。和今天讲的并不冲突,
今天进程依旧是承担分配资源的实体,也要创建一大堆的内核数据结构,对应的数据和代码依旧要被加载,只不过以前只创建一个PCB,而今天创建多个PCB,也就是一个进程内部可以有多个执行流,包括以前只有一个执行流的情况,
站在CPU的角度,
历史 vs 今天
1.历史:进程
2.今天:进程内的一个分支
但是CPU并不关心这些你给我就跑。
今天我们喂给CPU的task_struct<=历史上的task_struct的含义!
你可能一个进程内就一个执行流,还像我们以往的一样,或者一个进程内多个执行流,但CPU不管这些,给就跑。
在Linux中CPU看来凡是给我的task_struct都是轻量级进程(不管是进程还是线程)
下面我们的结论就出来了
1.Linux内核中有没有真正意义的线程呢?没有的,Linux是用进程PCB来模拟实现线程的,是一种完全属于自己的方案。
2.站在CPU角度,每一个PCB,都可以称之为轻量级线程。
3.Linux线程是CPU调度的基本单位,而进程是承担分配资源的基本单位(实体)
4.进程用来整体申请资源,线程用来伸手向进程要资源。(分配的资源不够线程还可以以进程的角色向OS要资源)
5.Linux中没有真正意义上的线程,好处是什么呢?缺点是什么呢?
好处:简单,维护成本低(不需要重新创建对应的数据结构写算法然后与其他数据结构耦合等等,而只需要拿着曾经写好的PCB一整套算法,结构全部都能复用上了,什么阻塞,上下文切换等,以前进程的那一套东西全部干到线程里)-----可靠高效!
坏处:
为什么我要创建线程呢?原因是未来我们可能会面临一个进程内可能要并行的执行不同任务,如用某APP一边想下载电影,一边还想让它播放。这是两个任务,两个任务只能在进程中串行的走,所以如果有多线程了,我们可以让一个线程去下载,另一个线程去播放,虽然CPU看到是两个执行流,但是CPU高频的切换给你的感觉就是这两个任务在同时跑。而这两个任务恰好都是在这一个APP内实现。
我们所说的轻量级进程只是Linux设计的一种方案,操作系统根本不认,
操作系统只认线程。你这个线程如何实现和我没关系,反正我只认识线程。
对于我们程序员用户也只认线程。不管是在概念和操作都只认线程。你说的轻量级进程根本不懂。
而我们刚才说了Linux没有真正意义上的线程,所以Linux便无法直接提供创建系统调用接口!而只能给我们提供创建轻量级进程的接口!
那在Linux如何创建线程呢?下面说。
2.1如何理解多线程
下面举个例子,理解一下多线程。
我们的社会是以一个家庭为基本载体来分配社会的资源的。但是一个家庭内的成员,每个人都做着不同的事情,父母在赚钱,爷爷奶奶在养老,而你和你的兄弟姐妹在上学,虽然做得事情完全不一样,但是每个人其实都是在做一件事情,那就是让家里的日子过得更好。换句话说:
家庭:进程
家庭成员:线程
2.2如何证明
上面说了一大堆东西。如线程是在进程内部运行的等,这些东西你怎么证明?
那我们写一些的代码见一见猪跑
首先创建一个线程,这是我们原生线程库给我们提供的线程。
pthread_create 创建一个新线程
pthread_t *thread:线程ID
pthread_t *thread:线程属性(一般置为nullptr)
void *(*start_routine) (void *):start_routine是一个函数指针,设置一个回调函数,当线程创建出来之后,可以让线程执行这样一个任务
void *arg:传给回调函数的参数
成功返回0,失败返回错误码。
未来我们创建出来的新线程就跑过去执行上面那份代码,而主线程继续向下执行。
其实这些代码都属于进程的,不过是把start_routine这个函数划给了这一个新线程。
我们新创建出来的新线程不在属于父子关系。新创建是新线程,直接往下走的是主线程
#include<iostream>
#include<pthread.h>
#include<cassert>
#include<unistd.h>
using namespace std;
//新线程
void* start_routine(void* args)
{
while(true)
{
cout<<"我是新线程, 我正在运行!"<<endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
int n=pthread_create(&tid,nullptr,start_routine,(void*)"thread one");
assert(n == 0);
(void)n;
//主线程
while(true)
{
cout<<"我是主线程, 我正在运行!"<<endl;
sleep(1);
}
return 0;
}
运行报了这个错误,
再看一下这个函数
这个函数并不是操作系统给我们直接提供的系统调用。
为什么呢?刚才我们就说了Linux无法直接提供创建线程系统调用接口!而只能给我们提供创建轻量级进程的接口!
如果有系统调用接口,头文件一包,然后直接调用就行了。以前说过如果使用的是库提供的接口,编译时不能直接通过,那不通过是什么原因呢?
你需要告诉我们你的库在哪里。
我们创建线程使用的库就是pthread库
mythread:mythread.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm -f mythread
这就是我们所使用的pthread动态库和静态库。
这个库我们就称之为原生线程库。
现在回答上面遗留的问题。
任何一款Linux操作系统,都必须携带这个库(原生线程库)。
这里也就说明了,这个进程内部并不是单个执行流,如果是单个执行流在while死循环里根本出不来,而现在两个都在跑,说明一定两个执行流。
如何查看呢?还像以前那样吗
ps ajx | head -1 && ps ajx | grep mythread
不好意思就看到了进程。
那就想看到两个执行流怎么办呢?
ps -aL //查看轻量级进程
这里我们就看到了两个执行流了。
那这都是什么意思呢?
我们说CPU调度的时候线程是调用的基本单位,那前提线程是不是得有自己的标识符,能够保证自己的唯一性。
并且还注意到,第一个执行流的PID和LWP是一样的,这就是传说中的主线程。下面就是所对应的新线程。
那CPU调度的时候,是以哪一个id为标识符表示特定一个执行流的呢?
其实并不是PID,而是LWP。
所以操作系统它在内部就直接拿LWP,就可以完成对进程的区分,我们之前所学的所有的概念就可以迁移过来了,状态优先级,上下文,进程切换等,我们看的是LWP。
那以前我们理解的时候 ,可是PID,今天又说的是LWP,是不是以前的错误了。
我把线程代码注释掉,重新编译运行。
int main()
{
// pthread_t tid;
// int n=pthread_create(&tid,nullptr,start_routine,(void*)"thread one");
// assert(n == 0);
// (void)n;
//主线程
while(true)
{
cout<<"我是主线程, 我正在运行!"<<endl;
sleep(1);
}
return 0;
}
当前不就是只有一个进程再跑
并且LWP只有一个
并且你仔细点还会发现,当你只有一个单进程的时候,你的LWP和PID是相等的。所以当你内部只有一个执行流的时候,用PID或LWP是等价的。
进程:之前的进程只不过内部只有一个执行流,
线程:一个进程内部有多个执行流
刚才讲pthread_create的时候,
说过void* arg是回调函数的参数,下面就来看一下。
//新线程
void* start_routine(void* args)
{
const char* name=(const char*)args;
while(true)
{
cout<<"我是新线程, 我正在运行! name: "<<name<<endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
int n=pthread_create(&tid,nullptr,start_routine,(void*)"thread one");
assert(n == 0);
(void)n;
//主线程
while(true)
{
cout<<"我是主线程, 我正在运行!"<<endl;
sleep(1);
}
return 0;
}
pthread_t *thread:线程ID,是一个输出型参数,现在我想看看这个ID
这个线程ID是一个无符号长整型数
int main()
{
pthread_t tid;
int n=pthread_create(&tid,nullptr,start_routine,(void*)"thread one");
assert(n == 0);
(void)n;
//主线程
while(true)
{
cout<<"我是主线程, 我正在运行!,我创建出来的线程的tid: "<<tid<<endl;
sleep(1);
}
return 0;
}
那这个打印出来的是LWP吗?
可惜并不一样。
这个数字太大了,把它以16机制打印出来看看
int main()
{
pthread_t tid;
int n=pthread_create(&tid,nullptr,start_routine,(void*)"thread one");
assert(n == 0);
(void)n;
//主线程
while(true)
{
char tidbuffer[64];
snprintf(tidbuffer,sizeof(tidbuffer),"0x%x",tid);
cout<<"我是主线程, 我正在运行!,我创建出来的线程的tid: "<<tidbuffer<<endl;
sleep(1);
}
return 0;
}
10进制,数那么大,16进制,数又没那么大了。
那这到底是什么呢?
实际上它是一个地址。这里没办法说明,后面我们在解释。
刚才我们一直说的是代码,那其他东西已初始化数据,未初始化数据,堆这些东西呢?
线程一旦被创建,几乎所有资源都是被所有线程共享的。
string func()
{
return "我是一个独立的方法";
}
//新线程
void* start_routine(void* args)
{
const char* name=(const char*)args;
while(true)
{
cout<<"我是新线程, 我正在运行! name: "<<name<<" : "<<func()<<endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
int n=pthread_create(&tid,nullptr,start_routine,(void*)"thread one");
assert(n == 0);
(void)n;
//主线程
while(true)
{
char tidbuffer[64];
snprintf(tidbuffer,sizeof(tidbuffer),"0x%x",tid);
cout<<"我是主线程, 我正在运行!,我创建出来的线程的tid: "<<tidbuffer<<" : "<<func()<<endl;
sleep(1);
}
return 0;
}
函数可以共享
string func()
{
return "我是一个独立的方法";
}
int g_val=0;
//新线程
void* start_routine(void* args)
{
const char* name=(const char*)args;
while(true)
{
cout << "我是新线程, 我正在运行! name: " << name << " : "<< func()\
<< " : " << g_val++ << " &g_val : " << &g_val << endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
int n=pthread_create(&tid,nullptr,start_routine,(void*)"thread one");
assert(n == 0);
(void)n;
//主线程
while(true)
{
char tidbuffer[64];
snprintf(tidbuffer,sizeof(tidbuffer),"0x%x",tid);
cout << "我是主线程, 我正在运行!, 我创建出来的线程的tid: " << tidbuffer \
<< " : " << g_val << " &g_val : " << &g_val << endl;
sleep(1);
}
return 0;
}
首先它俩地址一样,这没问题,因为用的是同一块地址空间,所有值一定是一样的。
现在新线程把g_val的值改了,主线程看到了吗?
看到了。你一改我就看到了,这说明什么,这个全局的资源是两个线程共享的。
刚才举得两个例子,再一次说明
线程一旦被创建,几乎所有资源都是被所有线程共享的。
线程之间交换数据容不容易,现在就知道了很容易,比进程之间通信容易太多了。
那有没有是自己私有的?是有的。
线程也一定要有自己私有的内部属性,什么资源应该是线程私有的呢?
1.PCB属性私有(id,状态,优先级等)
2.要有一定私有上下文结构(线程要切换的时候代码可能就没跑完,要切换一定要进行上下文保存,一个进程内的线程要进行上下文保存,是不是一定要有自己上下文结构)
刚才写了一份代码主线程在main函数里,新线程在自己的函数里,每个线程都有自己的函数,它在运行的时候会不会形成临时变量?也就是局部变量,以前我们所学过的临时变量在哪里保存?栈区。
3.每一个线程都要有自己独立的栈结构
如果今天有人问你线程那些资源是共享的,其实凡是在地址空间里大部分都是共享的,但是如果问你什么是私有的,2,3其实是最重要的,当然还有其他东西是私有的。但是重要性都没他俩高。
上下文私有代表线程是动态运行的,都有独立的栈结构,这也能线程是动态运行的。
那你现在还有问题吗?肯定是有
1,2能理解,3独立的栈结构,从当前我们的地址空间我们看到栈区就只有一个啊,怎么保存每个线程都有自己的私有栈结构呢?
这个问题也留着下面说。
下面再补充 一下关于线程的知识。
2.3什么是线程
- 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”
- 一切进程至少都有一个执行线程
- 线程在进程内部运行,本质是在进程地址空间内运行
- 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
-
2.4线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
创建线程只需要创建PCB,创建进程需要一大堆内核数据结构 - 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
1.进程:切换PCB && 切换虚拟地址空间 && 切换页表 && 上下文切换
2.线程:切换PCB && 上下文切换
可是也说过,页表和虚拟地址空间不过就是个指针,像页表就是CPU寄存器中的一个值,虚拟地址空间就是PCB中的地址,只要把对应PCB切了虚拟地址空间也跟着切了。这些成本好像不高啊。
那怎么说线程切换比进程切换需要操作系统做的要少很多呢?
CPU除了有各种各样的寄存器,还有一个非常重要的东西,叫做硬件级的cache,比寄存器慢一些比内存快很多,cache就相当于CPU内部硬件级缓存,就是传说中的高速缓存。这个硬件和内存一样也具有数据的保存功能。
软件存在一种属性叫做局部性原理,就是当前正在访问的代码或数据附近的代码有较大概率被访问到。
当前进程访问到的代码和数据其实是预先或整体被放到了cache中。
然后CPU在读取时可以不用访问内存,而在cache中进行访问,如果cache没有命中再去内存中读取,读取之后先缓存到cache里再从cache中读。
一个正在运行的进程内部cache里已经缓存了很多热点数据,当多个线程在切换时,这些热点数据本来就是由这些线程共享,所以线程切换cache不需要切换,但是进程cache缓存了很多热点数据,切换进程缓存数据立马失效,新进程要重新缓存,而下一次切换到该进程还需要在重新缓存。
- 线程切换cache不用太更新,但是进程切换,全部更新
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
计算密集型应用:主要是线程或者进程使用的资源是CPU资源,如加密,解密,自己写的算法等
I/O密集型应用:主要是线程或者进程使用的资源是外设资源,如访问磁盘,显示器,网络等
2.4线程的缺点
- 性能损失
-
- 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
也就是说计算密集型的多线程并不是越多越好,比如说你是双核,四核的一般你创建线程数要和你的核数是一样的,创建进程数最好和你的CPU个数是一样的,如果创建太多的。比如就是单CPU,单核,那你此时创建了三个四个五个线程,那不好意思除了线程计算的成本,还有线程切换的成本。但是如果你只有一个线程那就没有切换的成本。所有并不是线程越多越好。
- 健壮性降低
-
- 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
以前我们说进程的时候,一个进程挂掉会不会影响另一个进程呢?一般是不会的。但是在线程这里呢,只要一个线程出现了问题,可能会影响其他线程,所有多线程写的代码往往健壮性比较低。
- 缺乏访问控制
-
- 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
刚才我们写的代码,定义了一个全局变量,一个线程对这个全局变量++,另一个线程打印。我们可以看到一个线程对全局变量做修改,另一个全局变量能立马看到。虽然这能减少我们通信的成本,但是它可能会因为一个线程正在访问而影响另一个线程。这叫做缺乏访问控制
- 编程难度提高
-
- 编写与调试一个多线程程序比单线程程序困难得多
接下来写一段代码验证一下多线程的健壮性
一个线程如果出现了异常,会影响其他线程吗?为什么?
#include<iostream>
#include<pthread.h>
#include<string>
#include<unistd.h>
using namespace std;
void* start_rountine(void* args)
{
//安全的进行强制类型转化
string name=static_cast<const char*>(args);
while(true)
{
cout<<"new thread create success, name: "<<name<<endl;
sleep(1);
int* p=nullptr;
*p=0;
}
}
int main()
{
pthread_t id;
int n=pthread_create(&id,nullptr,start_rountine,(void*)"thread new");
while(true)
{
cout<<"new thread create success, name: main thread"<<endl;
sleep(1);
}
return 0;
}
由运行结果就知道,会的,这就是健壮性或鲁棒性较差。
那为什么呢?
从信号这方面来说,为什么叫做进程信号。因为信号是整体发给进程的!
所有线程的pid都是相等的,所以OS向pid相同的所有pid全部写入11号信号,默认动作都退出了。
另一个视角,新线程是这个进程创建创建的,新线程出现异常了,是不是你自己就有问题,线程是进程内部的一个执行流,本身就属于进程的一部分,线程出现异常了,本身就是属于进程出现了异常。
2.5线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
如果是多进程,一个进程出现问题,会不会影响其他进程?
不会的,子进程崩了,父进程比谁都高兴,终于可以回收了可以往下跑了,
进程之间具有独立性,因为用的是独立的内核数据结构,独立的代码和数据。
2.6进程vs线程
- 进程是资源分配的基本单位
- 线程是调度的基本单位
- 线程共享进程数据,但也拥有自己的一部分数据
-
- 线程ID
一组寄存器
栈
errno
信号屏蔽字
调度优先级
- 线程ID
进程的多个线程共享 同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
进程和线程的关系如下图:
历史上我们所写的C/C++都属于单线程进程。
刚才所写的代码就是单进程多线程
父子进程就是多个单线程进程
未来我们可以先创建多进程,在在每个进程里创建多线程就是多个多线程进程。
上面还遗留了一个问题,
Linux无法直接提供创建线程系统调用接口,而只能给我们提供创建轻量级进程的接口,OS提供的是什么接口呢?
clone 运行创建一个进程,也允许创建一个轻量级进程
int (*fn)(void *): 新的执行流要执行的代码
void *child_stack:表示子栈
以前还学了一个fork创建子进程,其实还有一个函数
vfork 创建子进程
只不过这个函数创建出的子进程与父进程共享一个地址空间。如你创建一个全局变量,父子进程都能看到,你修改对方就能看看到。
只不过这两个函数我们不用,
但是OS确实给我们提供了创建轻量级进程的接口(clone)
其实我们调用的fork,vfork都是用这个这个clone。
目前我们已经把线程的概念都说完了,下一篇文章主要学线程控制。以及把这篇文章没有填的坑给填上(每个线程都有独立的栈结构,这个栈在哪?)。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!