地址空间是进程能看到的资源窗口:一个进程能看到代码区、共享区、内核区、堆栈区,大部分的资源都是在地址空间上看到的
页表决定进程真正拥有资源的情况:当前进程认为自己有了4GB,可是实际上用了多少由页表决定最终能用多少物理资源
合理的对地址空间与页表进行资源划分,我们就可以对一个进程所有的资源进行分类:通过地址空间分为栈区、堆区…通过页表映射到不同的物理内存
在32位平台下,一共有2^32个地址,也就意味着有2^32个地址需要被映射。
地址空间一共有2的32次方个地址,每个地址单位都是1字节,而页表也得有2的32次方个条目,每个地址都得经过页表映射,都是页表的每个条目(包括物理地址,包括是否命中,包括RWX权限,包括U/K权限,一个条目,假设为6个字节,样例数据),所以,光保存页表所需空间为24GB(4GB约为40亿字节)。
每一个表项中除了要有虚拟地址和与其映射的物理地址以外,实际还需要有一些权限相关的信息,用户级页表和内核级页表实际就是通过权限进行区分的:
虚拟地址:32位下是32位,物理内存:被划分成一块块的数据框,OS也要对物理内存做管理,先描述:struct Page{//内存的属性——4KB},在组织:struct Page mem[],在OS中把物理内存一块块的数据框称为页框,磁盘上编译形成可执行程序的时候,也被划分成一个个4KB的区域称为页帧。当内存和磁盘进行数据交换时也就是以4KB大小为单位进行加载和保存的
所以将数据加载到内存时,在文件系统级别,需要将数据从外设搬到内存,按照
4KB
为单位。最后,OS系统要管理内存除了结构匹配,还要有管理算法,linux常见的管理算法称为伙伴系统
。
虚拟地址转成物理地址:虚拟地址出来后,以10,10,12的二进制构成,页表不止一张,第一级页表页目录建立:前十个在页目录中查找,2的10次方个,指向页表的内容,页表:页表的条目项为2的10次方个,条目写的是指定页框的起始物理地址,页表项指向物理内存中某一页,剩下的12位虚拟地址刚好与页框的大小是等价的(4KB等于2的12次方字节),所以从物理地址的起始处+虚拟地址的低12位(2的12次方偏移量)作为页内偏移就直接在一个页内找到了某个地址
其中页目录项是一级页表,页表项是二级页表。映射过程,由MMU这个硬件完成的,该硬件是集成在CPU内的,页表是一种软件映射,MMU是一种硬件映射,虚拟地址转成物理地址实际上是软硬件结合的。
如果我们去修改一个字符串常量时,虚拟地址必须经过页表映射找到对应的物理内存,但是别忘了,在查表的过程 中会发现其权限只读,此时要进行修改会在MMU内部触发硬件错误,OS识别到是哪一个进程导致的,会给该进程发送信号,进行终止!
在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列 ”
一切进程至少都有一个执行线程;线程在进程内部运行,本质是在进程地址空间内运行
在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
不同平台的多线程底层实现策略不一样,我们讨论Linux平台
进程对应的模型:进程的创建实际上伴随着其进程控制块(task_struct)、进程地址空间(mm_struct)以及页表的创建,虚拟地址和物理地址就是通过页表建立映射的:
进程=内核数据结构+代码和数据,每个进程都有自己独立的进程地址空间和独立的页表,也就意味着所有进程在运行时本身就具有独立性
我们在创建“进程”
时,只创建PCB,并要求创建出来的PCB不在独立创建,与父进程同享PCB,那么创建的结果就是下面这样的:
因为我们可以通过虚拟地址空间+页表的方式对进程进行资源划分,单个“进程”执行力度一定要比之前的进程要细。
上图中每个线程都是当前进程里的一个执行流,线程在进程内部运行,线程在进程的地址空间内运行,拥有该进程的一部分资源。
创建进程时,申请的PCB,虚拟内存空间,一堆的页表,还有加载到物理内存中的代码数据,花费CPU的资源创建进程初始化,浪费内存资源保存内核数据结构、代码和数据,花费CPU的IO资源从外设IO到内存,所以承担分配系统资源的基本实体就是进程。
换而言之,当我们创建进程时OS申请一大堆的内核数据结构占用资源,对应的代码和数据加载到内存里也要占用一部分资源,以及其他占用资源称为进程
我们之前的进程都只有一个PCB,也就是该进程内部只有一个执行流,即单执行流进程,与我们上面所说的并不冲突,如果内部有多个执行流,就是多执行流进程。
不能也不需要,CPU并不关心,CPU以task_struct为单位进行调度,今天我们喂给CPU的task_struct是小于等于过去所说的task_strcut的,比历史的更轻量化了。所以在Linux中,可以把进程和线程做一个统一,CPU看到的task_struct称为轻量级进程
在Linux中,什么是线程:CPU调度的基本单位!
如果OS真的要专门设计“线程”概念,OS那就需要管理线程了:先描述在组织,在Windows下确实是为这个线程专门设计了数据结构表示线程对象TCB。但是线程创建的目的就是为了被执行,执行自然需要被调度,存在ID,状态,优先级,上下文,栈…这与线程调度角度,线程和进程有很多的地方是重叠的!所以Linux中,没有给Linux"线程"去专门设计对应的数据结构!而是直接复用PCB!用PCB来表示Linux内部的“线程”!
也就是说,Linux内核中有没有真正意义的线程,严格上来说是没有的,Linux是用进程PCB来模拟线程的,是一种完全属于自己的一套线程方案。
结论
1.严格上来说是没有的,Linux是用进程PCB来模拟线程的,是一种完全属于自己的一套线程方案。
2.站在CPU的视角,每一个PCB,都可以称为轻量级进程。
3.Linux线程是CPU调度的基本单位,而进程是承担分配系统资源的基本单位
4.进程用来整体申请资源,线程用来伸手向进程要资源
5.Linux中没有真正意义的线程。通过进程模拟。
6.进程模拟线程的好处:PCB模拟线程,为PCB编写的结构与算法都能进行复用,不用单独为线程创建调度算法,
降低维护成本
,复用进程的那一套.可靠高效
OS只认线程,用户(程序员)也只认线程,Linux没有真正意义上线程,**所以Linux便无法直接提供创建线程的系统调用接口,而只能给我们提供创建轻量级进程的接口!(OS提供了clone接口,这个接口我们不需要关心)除了这个,我们还有另一个,**也就是创建进程,共享空间
,其中最典型的代表就是vfork函数:简单看一下:
vfork函数的返回值与fork函数的返回值相同:给父进程返回子进程的PID;给子进程返回0。
vfork函数创建出来的子进程与其父进程共享地址空间,父进程使用vfork函数创建子进程,子进程将全局变量g_val由100改为了200,父进程休眠5秒后再读取到全局变量g_val的值,此时读到的为200:
#include
#include
#include
#include
#include
using namespace std;
int g_val = 100;
int main()
{
pid_t id = vfork();
if(id==0)
{
g_val = 200;
printf("this is child:PID:%d,PPID:%d,g_val:%d\n",getpid(),getppid(),g_val);
exit(0);
}
cout<<"子进程退出,睡眠5秒"<
创建一个新线程的代价要比创建一个新进程小得多
与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多(进程间切换,需要切换页表、虚拟空间、切换PCB、切换上下文,而线程间切换,页表和虚拟地址空间就不需要切换了,只需要切换PCB和上下文,成本较低)
线程占用的资源要比进程少很多
能充分利用多处理器的可并行数量
在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
计算密集型应用(CPU,加密,解密,算法等),为了能在多处理器系统上运行,将计算分解到多个线程中实现
I/O密集型应用(外设,访问磁盘,显示器,网络),为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
健壮性降低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
编程难度提高:编写与调试一个多线程程序比单线程程序困难得多
健壮性举例:一个线程如果出现了异常会影响其他线程:(健壮性、鲁棒性较差)
#include
#include
#include
#include
#include
using namespace std;
void* start_routine(void* args)
{
string name = static_cast(args);//安全的进行强制类型转化
while(true)
{
cout<<"new thread create success,name: "<
线程出现异常会影响其他线程是因为信号是整体发送给进程的。当前线程出现异常,那么OS识别到当前有硬件报错,地址转化出现失败,没有权限的空间进行写入,MMU+页表执行异常,OS识别立马识别是哪个线程、进程出错,而所有的线程的PID是相同的,所以OS直接向所有PID相同的线程的PCB写入11号段错误信号,会把当前的执行流都终止,所有的线程就全退了,因为其他线程所拥有的资源是进程给的,进程没了,其他线程更会退出!
单个线程如果出现除零,野指针问题导致线程崩溃,
进程
也会随着崩溃
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
合理的使用多线程,能提高CPU密集型程序的执行效率
合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
进程是承担分配系统资源的基本实体,线程是调度的基本单位
线程共享进程数据,但也拥有自己的一部分数据:
线程ID、一组寄存器(存储每个线程的上下文信息)、栈(线程的临时数据)、errno、信号屏蔽字、调度优先级
进程的多个线程共享:在同一个地址空间,所以代码段(Text Segment)\数据段(Data Segment)都是共享的:
如果是一个函数,那么在各个线程中都可以进行调用;如果是一个全局变量,在各个线程中都可以访问到
除此之外,各线程还共享以下进程资源和环境:文件描述符表、每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)、
当前工作目录、用户id和组id
进程和线程的关系 :
而之前我们所接触到的都是具有一个线程执行流的进程,即单线程进程。
与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”开头的
要使用这些函数库,要通过引入头文
链接这些线程函数库时要使用编译器命令的“-lpthread”选项
pthread线程库是应用层的原生线程库:
我们说过,在Linux没有真正意义上的线程,无法直接提供创建线程的系统接口,只能给我们提供创建轻量级进程的接口。但是在用户的角度上,当我们想创建一个线程时会使用thread_create这样的接口,而不是我们上面所使用vfork函数,用户不能直接访问OS,所以OS在用户和系统调用之间提供了编写好的用户级线程库,这个库一般称为pthread库。任何Linux操作系统都必须默认携带这个库,这个库称为原生线程库。
原生的线程库本质上就是对轻量级进程的系统调用(clone)进行了封装pthread_create,使用户层模拟实现了一套线程相关的接口
我们认为的线程实际在OS内部会被转化成我们所谓的轻量级进程。
错误检查:
传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回
pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值判定,因为读取返回值要比读取线程内的errno变量的开销更小
pthread_create:创建线程的函数
#include
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
thread:获取线程的ID,该参数是一个输出型参数
attr:用于设置创建线程的属性,传入nullptr表示默认,这个属性基本不管
start_routine:函数地址,表示线程启动后要执行的函数
arg:传给线程例程的参数
返回值:成功返回0,失败返回错误码
下面我们让主线程调用pthread_create函数创建一个新线程,此后新线程就会跑去执行自己的新例程
#include
#include
#include
#include
using namespace std;
void * thread_routine(void *args)
{
const char*name = (const char*)args;
while(true)
{
cout<<"这是新线程,我正在运行!"<
这里编译运行需要注意:这个接口是库给我们提供的,使用的接口如果不是语言上的接口或者操作系统上的接口,如果是库提供的,那在编译时是不通过的,我们需要找到这个库。-L:找到库在哪里,-I:找到头文件在哪里,但是这个库已经在系统里安装好了,除了告诉库和头文件在哪之外,还需要知道链接哪一个库!
此时我们用ps axj
命令查看当前进程的信息时,虽然此时该进程中有两个线程,但是我们看到的进程只有一个,因为这两个线程都是属于同一个进程的:
而使用ps -aL
指令,就可以显示当前的轻量级进程了:
其中,LWP(Light Weight Process)表示的就是轻量级进程
的ID,可以看到显示的两个轻量级进程的PID是相同的,因为它们是属于同一个进程
的。每个轻量级进程都有唯一的LWP。
注意:主线程的PID和LWP是一样的。不一样的就是新线程。所以CPU调度的时候,是以LWP为标识符表示特定一个执行流。
线程一旦被创建,几乎所有的资源都是被所有线程共享的。所以线程之间想交互数据就容易了,直接就能看到。
线程被调度就要有独立的PCB属性私有
线程切换时正在运行,需要进行上下文保存,要有私有的上下文结构
每个进程都要独立的运行,每个线程都要有自己独立的栈结构
我们让主线程一次性创建十个新线程,并让创建的每一个新线程都去执行start_routine函数,也就是说start_routine函数会被重复进入,即该函数是会被重入的:
class ThreadData
{
public:
pthread_t tid;
char namebuffer[64];
};
//创建一批新线程
void* start_routine(void* args)
{
sleep(1);
ThreadData *td = static_cast(args);
int cnt = 10;
while(cnt)
{
cout<<"new thread create success,name: "<namebuffer<<" cnt : "< threads;
#define NUM 10
for(int i = 0;inamebuffer,sizeof(td->namebuffer),"%s:%d","thread",i+1);
pthread_create(&td->tid,nullptr,start_routine,td);
threads.push_back(td);
// sleep(1);
}
for(auto&iter:threads)
{
cout<<"create thread: "<namebuffer<<" : "<tid<<" sucess" <
并且start_routine是可重入函数,没有产生二义性,没有因为一个线程去影响另一个线程。并且在函数内定义的变量都是局部变量具有临时性,在多线程情况下也没有问题。这也说明了每一个线程都有自己独立的栈结构
获取线程ID:1.创建线程时通过输出型参数获得;2.通过pthread_self接口函数获得
#include
pthread_t pthread_self(void);
我们可以打印出主线程打印出新线程的ID,新线程打印自己的ID,看是否相同:结果是相同的
string changeId(const pthread_t &thread_id)
{
char tid[128];
snprintf(tid,sizeof(tid),"0x%x",thread_id);
return tid;
}
void* start_routine(void*args)
{
std::string threadname = static_cast(args);
while(true)
{
cout<
一个线程创建出来,那就要如同进程一样,也是需要被等待的。如果线程不等待,对应的PCB没被释放,也会造成类似僵尸进程的问题:内存泄漏。所以线程也要被等待:1.获取新线程的退出信息 2.回收新线程对应的PCB等内核资源,防止内存泄漏。
可以不关心线程的退出信息
#include
int pthread_join(pthread_t thread, void **retval);
参数:thread:被等待线程的ID,retval:线程退出时的退出码信息
void** retval:输出型参数,主要用来获取线程函数结束时返回的退出结果。之所以是void**,是因为如果想作为输出型结果返回,因为线程函数的返回结果是void*,而要把结果带出去就必须是void**,
返回值:线程等待成功返回0,失败返回错误码
class ThreadData
{
public:
int number;
pthread_t tid;
char namebuffer[64];
};
class ThreadReturn
{
public:
int exit_code;
int exit_result;
};
//创建一批新线程
void* start_routine(void* args)
{
ThreadData *td = static_cast(args);
int cnt = 10;
while(cnt)
{
cout<<"cnt:"<exit_code = 1;//线程退出码
tr->exit_result = 100;//线程退出结果
return (void*)tr;
//return (void*)td->number;//waring void*ret = (void*)td->number;8字节、4字节
}
int main()
{
vector threads;
#define NUM 10
for(int i = 0;inumber = i+1;
snprintf(td->namebuffer,sizeof(td->namebuffer),"%s:%d","thread",i+1);
pthread_create(&td->tid,nullptr,start_routine,td);
threads.push_back(td);
}
for(auto&iter:threads)
{
cout<<"create thread: "<namebuffer<<" : "<tid<<" sucess" <tid,(void**)&ret);
assert(n==0);
cout<<"join : "<namebuffer<<" success,exit_code: "<exit_code<<",exit_result: "<exit_result<
没有看到线程退出时对应的退出信号:这是因为线程出异常收到信号,整个进程都会退出,所以退出信号要由进程来关心,所以pthread_join默认会认为函数会调用成功,不考虑异常问题,异常问题是进程该考虑的问题
一个新创建出来的线程,如果想终止线程而不是整个进程,有三种做法:
1.直接从线程函数结束,return的时候,线程就算终止了
2.线程可以自己调用pthread_exit函数终止自己
3.一个线程可以调用pthread_cancel函数终止同一进程中的另一个线程
注意:exit不能用来终止线程,因为exit是来终止进程的。任何一个执行流调用exit都会让整个进程退出,所以终止线程不能采用exit
,而是采用return来终止线程
class ThreadData
{
public:
pthread_t tid;
char namebuffer[64];
};
//创建一批新线程
void* start_routine(void* args)
{
sleep(1);
ThreadData *td = static_cast(args);
int cnt = 10;
while(cnt)
{
cout<<"cnt:"< threads;
#define NUM 10
for(int i = 0;inamebuffer,sizeof(td->namebuffer),"%s:%d","thread",i+1);
pthread_create(&td->tid,nullptr,start_routine,td);
threads.push_back(td);
}
for(auto&iter:threads)
{
cout<<"create thread: "<namebuffer<<" : "<tid<<" sucess" <
//监控脚本
while :; do ps -aL | head -1 && ps -aL |grep mythread; sleep 1; done
最终新建线程终止
pthread_exit函数的功能就是终止线程:
#include
void pthread_exit(void *retval);
retval:线程退出时的退出码信息,默认设置为nullptr
class ThreadData
{
public:
pthread_t tid;
char namebuffer[64];
};
//创建一批新线程
void* start_routine(void* args)
{
sleep(1);
ThreadData *td = static_cast(args);
int cnt = 10;
while(cnt)
{
cout<<"cnt:"< threads;
#define NUM 10
for(int i = 0;inamebuffer,sizeof(td->namebuffer),"%s:%d","thread",i+1);
pthread_create(&td->tid,nullptr,start_routine,td);
threads.push_back(td);
}
for(auto&iter:threads)
{
cout<<"create thread: "<namebuffer<<" : "<tid<<" sucess" <
线程是可以被其他线程取消的,但是线程要被取消,前提是这个线程是已经运行起来了。pthread_create取消也是线程终止的一种
#include
int pthread_cancel(pthread_t thread);
我们以取消一半的线程为例:
class ThreadData
{
public:
int number;
pthread_t tid;
char namebuffer[64];
};
//创建一批新线程
void* start_routine(void* args)
{
ThreadData *td = static_cast(args);
int cnt = 10;
while(cnt)
{
cout<<"cnt:"< threads;
#define NUM 10
for(int i = 0;inumber = i+1;
snprintf(td->namebuffer,sizeof(td->namebuffer),"%s:%d","thread",i+1);
pthread_create(&td->tid,nullptr,start_routine,td);
threads.push_back(td);
}
for(auto&iter:threads)
{
cout<<"create thread: "<namebuffer<<" : "<tid<<" sucess" <tid);
cout<<"ptheread_cancel : "<namebuffer<<" success"<tid,(void**)&ret);
assert(n==0);
cout<<"join : "<namebuffer<<" success,exit_code: "<<(long long)ret<
线程如果是被取消的,退出码是-1,-1是一个宏,PTHREAD_CANCELED,我们可以查看定义:
#define PTHREAD_CANCELED ((void *) -1)
初步重新认识我们的线程库(语言版)
任何语言,在Linux中,如果要实现多线程,必定要使用pthread库,如何看待C++11中的多线程:C++11的多线程,在Linux环境中本质就是对pthread库的封装。
线程是可以等待的,等待的时候,是join的等待的,阻塞式等待。而如果线程我们不想等待:不要等待,该去进行分离线程处理。
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成内存泄漏
而如果我们不关心线程的返回值,join是一种负担,这个时候我们可以告诉OS,当线程退出时,自动释放线程资源,这种策略就是线程分离
#include
int pthread_detach(pthread_t thread);
下面我们创建新线程,让主线程与新线程运行起来,主线程等待新线程退出,等待完毕返回n,而现在让创建的新线程进行分离,按照我们的预料:此时应该是等待失败:
string changeId(const pthread_t & thread_id)
{
char tid[128];
snprintf(tid,sizeof(tid),"0x%x",thread_id);
return tid;
}
void*start_routine(void*args)
{
string threadname = static_cast(args);
pthread_detach(pthread_self());//线程分离,设置为分离状态
int cnt = 5;
while(cnt--)
{
cout<
这里的结果依旧是等待成功的:当我们创建新线程之后,主线程和新线程不确定谁先运行,所以可能会有这样的场景,当我们创建主线程之后,还没有执行新线程的pthread_detach,而主线程直接去等待了,也就是新线程还没来得及分离自己,也就是分离的太慢了,最后主线程直接去等待了。
所以主线程去join的时候一定要去保证新线程已经是分离的状态,让主线程sleep一下:
此时终于等待失败。也可以直接让主线程直接pthread_detach,而不是让新线程分离:线程运行起来就直接分离了,分离成功之后就去join,此时的新线程就不去等待了,后续工作无需继续