我们先说一个程序是怎么执行的:
我们编写好一个代码,经过预编译,编译,汇编,连接,形成一个二进制文件被写进磁盘中,通常我们把他叫做可执行程序。 我们可以双击运行,运行需要经过几个步骤,先将二进制程序从磁盘加载到内存中,然后操作系统建立进程PCB。
我们知道,程序被编译连接好后,都是一些二进制指令,这些指令在被加载到内存中后,每个指令在内存中都可以用一个地址来定位,这个地址叫做物理地址。同时,我们编译好的指令,在这个可执行文件中,有一个相对位置,具体点说,汇编形成的指令我们从0开始给它编号,每条指令在可执行程序文件中的位置,就是虚拟地址。
进程在liunx中用一个结构体来定义,该结构体中有一个成员叫做页表,每一个程序都有自己各自的页表,页表的作用是完成虚拟地址到物理地址的映射。通俗的说,在文件中编号为1的指令,在物理地址中的位置是0x00456,当我运行1号指令是,通过页表查询,我知道1号指令在内存的0x00456位置,然后加载到cpu执行。如果程序中访问了一个虚拟地址,但是该虚拟地址在页表中没有相应的物理地址,操作系统就会给你抛异常或者报错。该页表的映射关系,是由操作系统来完成的。通过页表映射,一个程序只能访问内存中自己映射部分的地址。
页表对于程序的意义在于提供了程序运行之间的高隔离性和独立性。假如说没有通过页表映射,程序可以直接访问物理内存,我在程序中随便来一个野指针随机的访问数据,你随机访问可能刚好是别的程序的数据,A程序随意篡改B程序的数据,这就会导致程序崩溃。所以,页表对于保护程序运行安全有着重要意义。在liunx中实现多线程,页表起着关键作用,所以这里详细说一下。
说完页表,我们再回来。程序执行肯定是根据指令在文件中的相对位置顺序执行的,cpu每次执行完成一条指令的计算。我们假设程序中有两个函数,C和D,我想要计算机同时运行这一个文件中的两个功能怎么办?现实中的很多场景都是这样,就像在微信聊天的时候,你发信息的时候同样能接收信息。一个程序中同时运行两个函数,这就是线程的功能,也就是我们常说的并行执行。
我们先来观察现象,下面是代码:
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;
void* threadRun(void* argc)
{
while(1)
{
cout<<"new thread :"<<getpid()<<endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,threadRun,nullptr);
while(1)
{
cout<<"main thread : "<<getpid()<<endl;
sleep(1);
}
return 0;
}
主函数的前两行代码,在进程中创建了一个线程,创建线程肯定是要让他并行的执行一个功能,我们把threadRun函数传递给它,意思就是我创建一个线程,该线程完成的功能就是运行threadRun函数,但是该函数是一个死循环。然后再接着执行一个死循环。
正常的逻辑是,代码顺序运行,只会运行第一个死循环,但是:
看到没, 两个死循环是同时运行的。
我们下面再说,liunx中的线程是如何设计的。
想一下我cpu执行一个程序需要什么?代码,与代码运算相关的数据(保存在cpu的寄存器中),执行时我还可以能读取页表来访问内存中的数据等等,这些运行需要的所有东西都被囊括在PCB结构体中。如果我们对程序A建立一个PCB,然后A程序的代码中会有一行代码调用系统调用再创建一个PCB,把它叫做B,该PCB的结构中的代码与A相同,页表也相同,但是B只执行A文件中的一个功能,注意此时A程序的代码和数据都被加载到了内存中,也就是说,B可以直接通过页表访问A的代码和数据,并且只执行一部分功能。
我们在进程说过,系统调用执行程序本质上就是加载程序相应的PCB到CPU中,当通过A创建好B的PCB之后,那么B,也就是对应到A程序的一部分功能就可以被系统调用执行。
对于CPU来说,他调度的基本单位是一个PCB,我们为运行一个程序先创建了一个PCB,CPU调度该PCB又基于同一份代码文件创建了多个PCB,也就是线程,相应的线程执行这个代码文件的部分功能,那么通过这个操作,将原本一个较大的程序,分割成了多个较小的执行流,这些多个执行流共同构成了该程序(进程),所以我们说,线程具有比进程更小的粒度,线程是CPU调度的基本单位。一个进程可以不创建额外的线程而直接顺序执行代码。
再补充一下,CPU调度进程,会为该进程的执行分配一个时间片,也就是这个进程可以在CPU上运行多长时间:
单核时,实际上,一个进程被分割成多个线程执行时,线程也是轮询的在时间片范围内被CPU调度,只不过情况变成了,该程序在一个时间片上可能顺序执行整个程序的四分之一,变成多线程后,是代码中的每个功能都被执行了四分之一。
从这也能看出,线程的执行不论是在时间上还是所占的资源上,粒度都比进程小。也能体现出来CPU执行的基本单位就是一个线程。
还需要指出一点,创建多线程需要首先创建一个PCB,这个PCB结构体是基于整个代码文件来初始化自己内部的属性,包括在内存所占的空间,一个页表,文件inode等等,以上初始化属性的过程实际上就是系统在为程序的执行分配资源,我想说的是,CPU执行的基本单位是线程,但是为资源分配是以进程为单位的,以后线程的执行所需要的资源都相当于直接访问创建的第一个PCB(主线程)。
在Liunx中,怎么将线程由概念描述并组织成具体数据结构呢?事实上,线程的结构与进程无论是在调度上还是在管理上都有着极高的相似性,liunx设计者在实现线程时复用了进程的数据结构和相应的一套算法,这只是设计者的选择,你当然也可以重新定义一个数据结构来描述线程并且适配一套算法。liunx直接用task_struct来组织描述tcb(thread chotrol block)。so,liunx中的执行流被叫做轻量级进程。
到这里,线程的概念基本建立起来了,接下来对于线程的同步与互斥,锁,临界资源等概念都是在线程在具体实践中为出现的某些情形或者为解决某一问题而定义和建立的。