我们常说,程序要加载到内存中才能运行,其原因已经在《冯诺依曼体系结构、操作系统的认识》一文中探讨过了。那么,程序加载到内存后,会发生什么呢?程序如何运行呢?这就涉及到本文将要讨论的重点——进程
本文将初步认识进程以及在Linux操作系统下进程的特性。
由常识我们知道,计算机几乎不可能在一个时刻只运行一个程序,就像我们平时用电脑,会开着各种app,它们是同时运行的。也就是说,内存中的进程不止有一个,而多个进程同时在工作时,操作系统必然要对它们进行管理,使得计算机中的工作有序地进行。怎么管理呢?显然还是操作系统一贯的管理模式——先描述,再组织!
(process control block)
,在Linux操作系统中,这个结构体命名为task_struct。每当一个程序(一个二进制可执行文件)
被加载到内存中,形成进程,操作系统都会生成一个该进程对应的PCB,我们对进程操作基本都是对PCB进行操作,而不是直接对加载入内存的二进制可执行文件进行操作。
task_ struct内容分类
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。 优先级: 相对于其他进程的优先级。
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
- I/ O状态信息:包括显示的I/O请求,分配给进程的I/ O设备和被进程使用的文件列表。
- 记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。 其他信息
总结:
进程的概念可以简化为:进程 = 内核数据结构+程序的代码和数据
我们通常需要查看某个进程的属性、状态等信息,以确定对其下一步的操作。在Linux下如何查看进程?
ps ajx
:查看所有进程ps ajx | grep ...
:ps ajx加上管道和grep筛选,获取我们想要查看的进程PID:描述该进程的唯一标识符,用于区分其它进程。每个进程都有一个独一无二的PID。
PPID:该进程的父进程的PID。
STAT:该进程的状态,后面展开讨论
其中大多数我们尚且不认识,但是其中
exe -> /home/ckf/lesson5/hello
其实就是当前进程对应的二进制文件。 如果进程运行时,我们将对应的在磁盘上的可执行文件删掉,该进程还能运行吗?答案是可以的,因为在磁盘上的可执行文件已经加载到内存中了,外部无法对内存造成影响,CPU依然能找到当前进程的代码和数据。
这三个函数属于系统调用函数
函数 | 功能 |
---|---|
getpid | 获取当前进程的PID |
getppid | 获取当前进程的父进程的PID |
fork | 以当前进程为父进程,为父进程创建子进程 |
man手册中关于fork返回值的详细介绍
RETURN VALUE
On success, the PID of the child process is returned in the parent, and 0 is returned in the
child. On failure, -1 is returned in the parent, no child process is created, and errno is set
appropriately.
利用这三个函数,我们可以对父子进程进行验证:
运行程序,发现两个循环同时运行,说明父进程成功创建了子进程。且子进程的ppid是父进程的pid也得到了验证。
运行过程中,通过ps命令也可以查看父子进程
上面我们初步了解了进程是什么,综上所述我们可以得到,进程是程序加载到内存后的一个执行实体,我们通常称进程在内存中运行。而进程运行过程中,总会出现一些特殊情况,就像人有工作状态、休息状态等等,进程也会有不同的状态。下面我们要探究进程有哪些状态,本着普遍到特殊的探究理念,先看普遍概念的进程状态,再看Linux下的进程状态。
进程状态有很多,运行、就绪、阻塞、挂起、等待、新建等等,这里我们先讨论运行状态、阻塞状态和挂起状态
⭕PS:这里进程PCB和进程的代码数据在内存中的地址空间不会改变,只是建立了指针链接关系。
CPU通过等待队列中的头指针找到当前“队头”的进程PCB,便可找到其对应的代码和数据从而执行任务,当然CPU与进程的“交涉过程”没这么简单,涉及到了进程切换、进程地址空间,后面再作了解,这里我们只先掌握进程运行的状态。
(下面拿外设资源来举例)
,如显示器、磁盘、网卡和键盘等等。因此各种外设资源也需要在内核中有自己的等待队列,供进程排队等待资源的占有使用。我们知道,操作系统通过先描述再组织的方式管理外设,在内核中为每个外设建立了一个结构体存储其属性、信息、操作方法等,类似进程的PCB,而外设的等待队列也就在该结构体当中。总的来说,所谓进程状态,本质上就是进程在不同队列中等待某种资源,而进程何时前往哪个队列,依靠的是操作系统的调度。
了解了抽象的三个进程状态概念,接下来我们要具体化地了解Linux下的进程状态
下面是关于进程状态的Linux内核代码,由一个指针数组(指向字符串)存储,每个字符串表示一个进程状态,而后面的数字是在进程控制块中的状态标识。
/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
static const char * const task_state_array[] = {
"R (running)", /* 0 */
"S (sleeping)", /* 1 */
"D (disk sleep)", /* 2 */
"T (stopped)", /* 4 */
"t (tracing stop)", /* 8 */
"X (dead)", /* 16 */
"Z (zombie)", /* 32 */
};
写一个简单的死循环C程序(程序1)
#include
int main()
{
int a = 0;
while(1)
{
++a;
--a;
}
return 0;
}
运行起来,查看进程,可以看到该进程当前状态为R+,R我们理解了,是运行状态,但为什么会有个+
号呢?
这里涉及到前台进程与后台进程的概念,简单了解一下:
前台进程(带+)
:和用户交互,需要较高的响应速度。前台进程运行时,命令行解析无效。能用ctrl+c结束前台进程。
后台进程(不带+)
:基本上不和用户交互,后台进程运行时,命令行解析依然有效,但不能用ctrl+c结束前台进程。
写一个访问显示器(printf)的C程序(程序2)
#include
int main()
{
while(1)
{
printf("hello world\n");
}
return 0;
}
进程状态为 S 睡眠状态
但我们看到的现象却是进程一直在运行,不断往显示器打印文本,按理来说进程状态应该是R,这里为什么是S呢?
原因很简单,进程执行printf时需要获取显示器资源,会从运行状态(R)变为阻塞状态(S),进程会到显示器资源等待队列中。因为显示器(外设)的读取速度远远慢于CPU的处理速度,所以进程绝大部分时间都是在等待显示器资源,也就是S状态。因此我们会看到进程在我们查看的时刻处于S状态,也有可能是R状态,不过是小概率事件。
D(disk sleep):磁盘休眠状态,有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束,IO结束前不会被操作系统自动回收。
T(stopped):停止状态,可以通过发送SIGSTOP信号(kill -19)使进程停止运行,也可以发送SIGCONT(kill -18)信号使进程继续运行。
关于kill指令
用法:kill -选项 进程PID
⭕让程序2运行起来后,进行如下操作,可以观察到状态T,且SIGSTOP信号会让前台进程转为后台
gdb调试test(程序2),然后打一个断点,运行程序,会在该断点处停下。
⭕查看进程状态,观察到当前进程处于t状态。
Z(zombie)是僵尸状态,我们需要重点关注一下。
僵尸进程指的是处于僵尸状态的进程,子进程为了保留进程退出状态,在退出之后不会立刻被回收,而是处于僵尸(Z)状态,等待父进程(或OS)读取它的退出状态代码。所以,只要子进程退出,父进程还在运行,但父进程尚未读取子进程状态,子进程就会进入Z状态。
子进程退出的方式有多种:程序崩溃退出、调用exit退出、kill指令退出等等
通俗理解就是子进程退出之后要告诉父进程任务完成得怎么样,所以还需留存一段时间,等待父进程获知它的完成情况,这段时间里子进程就处于僵尸状态。
写出如下代码以测试僵尸进程
int main()
{
int id = fork();
if(id > 0) // 父进程
{
while(1)
{
printf("I am parent,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
else if(id == 0) // 子进程
{
int a = 10;
while(1)
{
if(a == 0)
{
int* p;
*p = 10;// 当子进程运行10s后,会因程序崩溃而退出
}
--a;
printf("I am child,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
return 0;
}
⭕观察到僵尸状态
僵尸进程可不是好事,僵尸进程的存在具有危害
父进程如果一直不回收已退出的子进程,读取其退出状态代码,那么子进程的退出状态就要一直被维护下去,一直是Z状态。维护退出状态本身就是要用数据维护,也属于进程基本信息,保存在子进程的PCB中,PCB一直存在,肯定会消耗内存空间。所以,如果父进程创建了多个子进程,又不回收,就可能会导致内存泄漏。
那么如何避免这个问题?在进程控制模块再详谈。
僵尸进程是子进程先退出父进程后退出的情况。而孤儿进程则是父进程先退出子进程后退出的情况,这种情况下的子进程称为孤儿进程。 因为子进程要被父进程回收,所有孤儿进程并不是真正的“孤儿”,在其父进程退出后,它会被1 号进程(pid为1,又称init进程,Liinux操作系统启动后自动创建) 领养,并最终由1号进程回收。
孤儿不是一种进程状态,孤儿进程是一种进程
⭕以如下C程序测试孤儿进程
void test()
{
int id = fork();
if(id>0)
{
int cnt = 5;
while(1) // 父进程
{
if(cnt == 0)
{
printf("父进程已退出\n"); // 父进程运行5s后退出
exit(1);
}
printf("I am parent,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
--cnt;
}
}
else if(id == 0)
{
int cnt = 10;
while(1) // 子进程
{
if(cnt == 0)
{
printf("子进程已退出\n"); // 子进程运行10s后退出
exit(1);
}
printf("I am child,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
--cnt;
}
}
}
分三个时间节点观察进程状态。
上文讨论的是进程是什么,下面要展开谈谈进程加载到内存后,是如何运行的?
进程需要加载到CPU中才能运行,由CPU负责运算工作。而从前面进程状态中运行状态的阐述我们知道,CPU只有一个而进程有多个,因此将要运行的进程需要在CPU的运行队列中等待。可是这样一来,不是每个时刻只能运行一个进程吗?这就与我们的认知相悖了,我们平时使用计算机时,往往会开着多个应用,同时运行,这是为什么呢?
这里需要了解CPU的两个概念——并行、并发
参考文章:不懂并行和并发?一文彻底搞懂并行和并发的区别
真正的“多个进程同时运行”实际上只有多核CPU通过并行的方式才能做到,而单核CPU都是采用并发的方式运行进程的。对于单核CPU来说,每一时刻只能运行一个进程,但是由于进程切换的速度很快,所以用户看起来是“多个进程同时运行”,这是一种OS欺骗用户的现象。
那么进程切换到底如何进行?接下来我们来探讨
进程切换是并发式单核CPU采取时间片轮转的策略,给每个进程分配时间片,快速切换时间片以营造进程同时运行的假象。在每一个时间片内,进程不一定会全部运行完,时间片结束后进程上下文信息会被保存,然后重新参与轮转,CPU运行下一个进程。
举个栗子,若当前CPU运行队列中有五个进程,给每个进程分配时间片10ms,那么,五个进程都分别进行一次需要50ms,若CPU工作1s,则每个进程都会被CPU执行20次。
每个进程的执行时长不同,OS为其分配的时间片数量也不同,CPU运行进程是以时间片为单位而不是以进程为单位。
CPU内有一套寄存器,用以存储当前进程的临时数据。
进程切换时,为了保存当前进程上下文信息数据,保证下次轮转到该进程时正常进行,当前CPU寄存器上的数据会被存入该进程的PCB中。
进程恢复运行时,要进行上下文信息的恢复,即从PCB中读取上下文数据到CPU的寄存器中。
进程需要排队等待CPU运行它,要排队必然就有先后顺序,有先后顺序就会有优先级,就像平时我们到车站、医院等场景都会有军人优先的窗口,这表明在排队过程中军人的优先级高于普通人。进程也有优先级,OS会根据进程的优先级调度进程。
Linux中,可以用ps -l指令查看进程的优先级
Linux中的进程优先级比较特殊,又PRI和NI两个数值组成。
优先级 = 老优先级+nice值(NI)(老优先级值得是未作修改前进程的PRI值)
可以通过top工具修改nice值,从而修改进程的优先级。步骤:进入top后按“r”–>输入进程PID–>输入nice值
nice值的取值范围是-20至19,一共40个级别
PRI越小,进程优先级越高。
概念
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数。
在Linux下,我们平时运行自己写的可执行程序,需要在程序名前面加上
./
如(./test
),这是为了通过相对路径找到对应的程序,能找到才能运行。而平时我们用的指令(如:ls、cd、which等等)说到底也都是程序,执行它们的时候也是运行对应的程序,但用这些指令时却不用加上./
这样的路径去寻找对应的程序。这是为什么呢?
原因就是操作系统中具有PATH环境变量,它储存了一系列的路径,对于直接调用的程序,系统会到PATH中的路径下查找对应程序。而平时用的指令的路径就在PATH中。
⭕测试
使用which指令查看指令所在路径,发现是 /usr/bin
[ckf@VM-8-3-centos lesson6]$ which pwd cd
/usr/bin/pwd
/usr/bin/cd
使用echo指令可以查看PATH环境变量,发现/usr/bin
在其中。(注意:各个路径间以冒号分隔)
[ckf@VM-8-3-centos lesson6]$ echo $PATH
/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/ckf/.local/bin:/home/ckf/bin
可以使用export指令对PATH做修改,加上我们自己写的程序的路径,这样就可以直接运行该程序
[ckf@VM-8-3-centos lesson6]$ ls // 我们在lesson6路径下有如下程序
mycmd process
[ckf@VM-8-3-centos lesson6]$ pwd // 查看lesson6的绝对路径
/home/ckf/lesson6
[ckf@VM-8-3-centos lesson6]$ export PATH=$PATH:/home/ckf/lesson6 // PATH添加lesson6的绝对路径
[ckf@VM-8-3-centos lesson6]$ echo $PATH // 新的PATH值
/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/ckf/.local/bin:/home/ckf/bin:/home/ckf/lesson6
[ckf@VM-8-3-centos lesson6]$ mycmd // 程序现在可以直接运行
hello world
hello world
hello world
^C
[ckf@VM-8-3-centos lesson6]$ process
I am parent,pid:24709,ppid:21254
I am child,pid:24710,ppid:24709
I am parent,pid:24709,ppid:21254
I am child,pid:24710,ppid:24709
^C
[ckf@VM-8-3-centos lesson6]$ echo $HOME // 普通用户
/home/ckf
[root@VM-8-3-centos lesson6]# echo $HOME // root
/root
- echo $name :显式名为name的环境变量值
- export:设置新的环境变量值
- env:查看系统中所有环境变量
- unset:清除环境变量
- set:显示本地定义的shell变量和环境变量
系统中的环境变量以一张表组织起来。这张表是一个字符串指针数组,称之为环境变量表,数组中每个元素都是一个指向环境变量字符串(以’\0’结尾)的指针。
每个进程都会收到一张环境变量表。那么这个变量表中的变量怎么获取的?变量表又是从哪来的?下面主要探讨这两个问题。
了解环境如何获取环境变量,先要认识main函数的三个参数,这是我们平时不太注意的。
main函数其实有三个隐含的参数
int main(int argc,char* argv[],char* env[])
写出以下程序
// test2.c
#include
int main(int argc,char* argv[],char* env[])
{
printf("%d\n",argc);
for(int i = 0;i<argc;++i)
{
printf("argv[%d]->%s\n",i,argv[i]);
}
return 0;
}
⭕加上一些选项运行程序,观察现象
1️⃣ main函数的第三个参数:env
⭕修改test2.c以便于观察env数组,假设env数组就是环境变量表,那么最后一个元素是NULL指针,我们可以以此为结束标志进行遍历数组。
// test2.c
#include
int main(int argc,char* argv[],char* env[])
{
for(int i = 0;env[i];++i)
{
printf("env[%d]->%s\n",i,env[i]);
}
return 0;
}
运行程序,与env指令查看系统中所有环境变量对比,发现env数组中指向的内容就是系统中的环境变量,证明env数组就是环境变量表。
因此,我们可以在程序中通过env数组获取环境变量,这是一种方法。
2️⃣ 第三方变量environ
Linux的unistd.h头文件中包含一个全局变量environ,指向环境变量表的首元素,因此我们也可以直接用environ来获取环境变量。
// man手册中environ的部分摘要
#include
extern char **environ;
#include
int main(int argc,char* argv[],char* env[])
{
extern char** environ; // 或者是直接包含unistd.h,就不用extern引入第三方变量了
int i = 0;
while(environ[i])
{
printf("%s\n",environ[i]);
++i;
}
return 0;
}
⭕程序运行结果与第一种方法相同(截取部分)
通过getenv函数也可以在程序中获取环境变量
// man手册中关于getenv函数的介绍
NAME
getenv, secure_getenv - get an environment variable
SYNOPSIS
#include // 头文件stdlib.h
char *getenv(const char *name);// 参数:表示某环境变量名的字符串
//...
RETURN VALUE // 返回值:成功匹配参数,返回指向参数环境变量值的指针。失败则返回NULL指针
The getenv() function returns a pointer to the value in the environment, or NULL if there is no match.
根据 getenv
的特性,写出以下C程序,功能:若当前用户为root,则成功访问,若为普通用户则禁止访问。
// test.c
int main()
{
char* name = getenv("HOME");
if(strcmp(name,"/root") == 0)
{
printf("success!\n");
}
else
{
printf("not permitted!\n");
}
return 0;
}
⭕测试
[ckf@VM-8-3-centos lesson6]$ ./test // 普通用户下测试
not permitted!
[ckf@VM-8-3-centos lesson6]$ sudo ./test // sudo提权测试(相当于在root下测试)
success!
前面提到,每个进程都会收到一张环境变量表。这是为什么呢?
答:因为环境变量通常具有全局属性,会被子进程继承下去!
也就是说,子进程会接收父进程的环境变量表,这样一层层下去,使环境变量具有全局属性。
事实上,我们平时在shell窗口下输入指令以及各种操作,都是基于有一个进程在此运行——bash。bash是一个命令行解释器,而我们在bash上运行的进程都属于bash的子进程。bash从系统登入时就开始运行了,系统会载入一个环境变量表到bash中,而bash的环境变量又会被子进程继承,这样一来,环境变量便具有全局属性。
⭕测试
导出一个MY_ENY环境变量
[ckf@VM-8-3-centos lesson6]$ export MY_ENV=200
写出如下程序,观察是否继承了环境变量MY_ENV
// myenv.c
#include
#include
int main()
{
char* envname = getenv("MY_ENV");
printf("%s\n",envname);
return 0;
}
验证成功。
[ckf@VM-8-3-centos lesson6]$ ./myenv
200
与之相反的是,若我们直接在bash上定义变量,这个变量是不能被子进程继承的,只在bash有效。我们称这种变量为本地变量,无全局属性。
之前我们讨论的程序的空间、地址等概念,是以下图的布局为标准的。我们默认程序独占了内存空间。
但我们了解了进程概念之后,就必须进一步地了解进程地址空间的概念,很多地方才能解释得通,先来看一段代码。
#include
#include
int g_val = 10;
int main()
{
int id = fork();
if(id > 0) // 父进程
{
while(1)
{
printf("I am parent %d %p\n",g_val,&g_val);
sleep(1);
}
}
else if(id == 0) // 子进程
{
int time = 3;
while(1)
{
printf("I am child %d %p\n",g_val,&g_val);
sleep(1);
if(time == 0)
{
g_val = 20;
printf("子进程修改了g_val!!\n");
}
--time;
}
}
return 0;
}
g_val变量是父子进程共享的数据,试着在运行过程中,子进程修改g_val,看看会发生什么。
⭕运行程序
可以看到,子进程修改g_val前,很好理解,父子进程中的g_val值相同,地址也相同。子进程修改g_val后,子进程中的g_val值变化,但父进程的g_val不变,而g_val的地址依然不变,在父子进程中都相等。一个相同的物理地址,怎么可能存储两个不同的变量值呢?
综上所述我们可以得出结论:
⭕ 事实上,在Linux下,这种地址称之为虚拟地址。我们用C/C++语言写代码时,所用到的内存地址就是这个虚拟地址!物理地址一般是用户看不到的,由OS统一管理,OS负责将虚拟地址转化为物理地址。
每个程序都有两套地址:
- 虚拟地址(又称逻辑地址):程序内部使用的地址
- 物理地址:程序加载入内存中,代码数据的地址。
⭕*虚拟地址从编译器生成可执行文件时就已经在使用了,此时我们一般称其为逻辑地址。逻辑地址是指程序内部用于函数跳转、数据寻址等操作的地址,是用户所能看见的地址。
可以通过反汇编来观察逻辑地址的作用。
写出以下C程序。
#include
int g_val = 100;
void fun()
{
printf("hello world\n");
g_val = 200;
}
int main()
{
fun();
return 0;
}
运行,并转到反汇编观察。
可以看到,程序内部做跳转、寻址时,都会用到地址,这个地址就是程序的逻辑地址(虚拟地址)。当程序从磁盘中加载到内存时,这个地址依然存在,进程开始运行时用的也是这个地址,在内存中,我们称之为进程的虚拟地址!
当然,以上这些数据都是存储在数据区、代码区上的,所以在编译时就已经完成了逻辑地址的布局,而堆区、栈区上的数据则是运行时才载入的。
每个进程都会有属于自己的虚拟地址,此处抛出两个问题
答:根据操作系统先描述再组织的管理思路,进程的虚拟地址会以一个数据结构
mm_struct
来管理。该数据结构中以区间的形式存放进程的虚拟地址。进程一旦被加载到内存中,操作系统会给进程创建一个mm_struct,并与进程的PCB建立链接关系。
答:页表。
页表是一个建立虚拟地址与物理地址关系的表,OS用其完成将虚拟地址转化为物理地址的工作。每个进程都会有一个页表,页表也是在进程载入时由OS构建的。如下图。
页表不仅能完成虚拟地址和物理地址的映射,还能起到拦截的作用,若程序访问到非法的地址(如野指针、数组越界等),在页表处就会被直接拦截,不会访问到物理空间。
这样一来,当CPU运行进程时,会通过进程PCB找到进程的虚拟内存块,获取进程代码数据的虚拟地址,操作系统负责转换为物理地址,使得CPU获取到进程代码数据。总的来说,CPU是不会见到物理地址的,只是在虚拟地址上运行,代码中有需要寻址的操作也是到虚拟内存中找。
总流程图:
至此,我们已经可以回答 5.1引入 中的问题,不过这还涉及到另外一个概念 —— 写时拷贝!
子进程未修改g_val时,父子进程虚拟内存与物理内存关系如下,二者的g_val虚拟地址与物理地址都相同。
⭕由于进程的独立性,各个进程在运行期间互不干扰,而父子进程又共享数据(这里的g_val就是父子进程的共享数据)。因此,父子进程任一方对共享数据做修改时,就会发生写时拷贝,OS在物理空间上开辟一块新的空间,并将欲修改数据拷贝过去,修改数据方对应的虚拟地址不变,物理地址指向新的物理内存空间,然后再做修改。
子进程修改g_val的值为20的过程如下:
完。