进程概念和进程控制

1.1 概念

进程的定义:

1. 进程是程序的一次执行过程。

2. 进程是一个程序及其数据在处理机上顺序执行时所发生的活动。

3.进程是具有独立功能的程序在一个数据集合上运行的过程,它是系统进行资源分配和调度的一个独立单元。

但这样直接读概念并不有利于理解进程,我更愿意从进程的目的(为什么要这么设计)出发,理解进程。

1.2 如何理解进程

首先,我们要先知道程序是这么被加载到执行的:
 

程序,本身是一个二进制文件,它会被操作系统加载(拷贝)到其他外设上,这个时候,我们需要注意几个基本事实:
1. 我们可以同时启动多个进程 -》将多个exe文件加载到内存
2. 那么操作系统就需要管理这些被加载到内存的程序
3. 这些运行的程序,就被成为进程,要管理他们,就要对他们进行先描述,再组织 

至此,我们就理解了进程的目的(或者说意义,原因);但还不够,接下来要了解操作系统怎么描述进程,怎么组织?

1.2.1 描述进程

首先,操作系统基本都是用C语言写的,所以,描述进程就是用结构体struct,例如:
 

struct PCB
{
    //状态
    //优先级
    //内存指针字段
    //其他..
    //struct PCB* next;
}

所以,当程序以二进制文件的形式加载进内存时,操作系统就会用这样的结构体对象去描述这些程序,每个程序都有一个对象;最后,这种数据结构,在操作系统的层面上,我们把它叫做PCB。全称:process ctrl block 进程控制块,也就是说,操作系统就是用进程控制块来描述进程的。

同时,我们也可以得出结论:
既然对象被创建来描述程序,那必然会开辟空间,也就是说,加载到内存里的程序比原来的程序大!

还没结束,描述完了还需要组织,PCB结构体里一般会有struct PCB* next 来指向下一个PCB对象,这样就把所有PCB以链表的形式管理起来了。

至此,就完成了转化:
对进程的管理 转化为 对PCB对象的管理
在操作上,对进程的管理 转化为 对链表的增删查改!

从代码的角度来看,进程 = 内核PCB对象 + 可执行程序; 更加全面的地说,

进程 = 内核数据结构 + 可执行程序

图解:

如图所示,CPU去调度可执行程序时,也不是直接去找可执行程序,操作系统会提过一个运行队列表,里面含有程序数量和指向以一个PCB对象的指针,当然还有不全部列出;CPU是去找PCB而不是程序!CPU是这样,操作系统也是这样,都不直接访问可执行程序。

总结:所有对进程的操作,都止与进程的PCB有关,而与进程的可执行程序无关! 

1.2.2 内核数据结构

PCB是操作系统学科的叫法,Windows、Linux、MacOS也有,但不叫PCB;当然逻辑上都是先描述,再组织。这里以Linux系统的为例,来介绍内核数据结构。

这是早期版本(linux-2.6.11.12)源码中的task_struct ,Linux中的PCB叫做task_struct。

字段数量非常夸张:

task_ struct内容分类:
标示符: 描述本进程的唯一标示符,用来区别其他进程。
状态: 任务状态,退出代码,退出信号等。
优先级: 相对于其他进程的优先级。
程序计数器: 程序中即将被执行的下一条指令的地址。
内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
上下文数据: 进程执行时处理器的寄存器中的数据。
I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
其他信息...
这些内容不一一说明,随着学习进程的深入再逐个了解。

2.1 查看进程

环境:腾讯云服务器提供的CentOS7,使用Xshell7进行连接!

进程的信息可以通过 /proc 系统文件夹查看:

要获取PID1的进程信息,你需要查看 /proc/1 这个文件夹。
大多数进程信息同样可以使用top和ps这些用户级工具来获取:

图中以列表显示出来的都是进程

其中,PID 是进程的标识符,就相当于一个人的身份证,是唯一的;PPID是该进程的父亲进程的PID,其余的后面再说明。

用代码来演示进程(环境:centOS7)
创建文件:

文件内容如下:
makefile:

myprocess:myprocess.c
        gcc -o $@ $^ -std=c99
.PHONY:clean
clean:
        rm -f myprocess

process.c:

#include <stdio.h>
#include <unistd.h>

int main()
{
    while(1)
    {
        printf("I am a process!\n");
        sleep(1);
    }

    return 0;
}

 输入命令make,编译并生产可执行程序myprocess, ./myprocess运行程序;

 复制会话,再另一个会话中输入ps axj | head -1 && ps axj |grep myprocess 查看进程

从图中观察发现,myprocess的确变成了进程,但是还有一个带有myprocess名称的进程,这其实是grep命令,它用来筛选出myprocess进程;从中也可以看出几乎所有命令都是进程。

这样观察进程还不够全面,进程不止会运行,还会退出等

输入命令 while :; do ps axj | head -1 && ps axj |grep myprocess | grep -v grep; sleep 1; done

进程从无到有再无,进程是有生命的。 

2.1.2 通过系统调用获取进程标示符

内核数据结构是操作系统管理,不可以让其他人直接访问,因此要访问进程的标识符,就要用系统调用接口。

C语言提供的接口是 getpid 和 getppid 
 

其中pid_t 是系统定义的类型,本质是C语言的int类型

运用到代码:

myprocess.c:
 

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
    while(1)
    {
        printf("I am a process! pid:%d ppid:%d\n", getpid(),getppid());
        sleep(1);
    }

    return 0;
}

如法炮制再运行并监视:

可以看到进程的pid为615,ppid为18255;一般在Linux,任何普通进程,都有他的父进程。 

多次运行再结束进程,发现pid一直再变,而ppid不变;

因为myprocess进程每次运行都是一个进程,而父进程,我们查看一下:

 是bash进程,一直都在,一直没变。

2.2 通过系统调用创建进程-fork初识 

fork的作用是创建一个进程 

用man fork认识 fork:

关于fork的返回值:

  1. 在父进程中,fork返回新创建子进程的进程ID;
  2. 在子进程中,fork返回0;
  3. 如果出现错误,fork返回一个负值;

如果成功,fork就有两个返回值,为什么会有两个返回值?因为fork之后,会创建一个子进程,代码父子进程共享,数据各自开辟空间,私有一份(写时拷贝)。

例:
 

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
    printf("before fork: I am a prcess, pid: %d, ppid: %d\n", getpid(), getppid());
    sleep(5);
    printf("开始创建进程啦!\n");
    sleep(1);
    pid_t id = fork();
    if(id < 0) return 1;
    else if(id == 0)
    {
        // 子进程
        while(1){
            printf("after fork, 我是子进程: I am a prcess, pid: %d, ppid: %d, return id: %d\n", getpid(), getppid(), id);
       sleep(1);
        }
    }
    else{
        // 父进程
        while(1){
            printf("after fork, 我是父进程: I am a prcess, pid: %d, ppid: %d, return id: %d\n", getpid(), getppid(), id);
            sleep(1);
        }
    }


    sleep(2);
    return 0;
}

运行:

 可以看到,fork()之后确实创建了子进程,也确实是两个进程在独立运行;而fork确实有两个返回值,给父进程返回子进程的pid,给子进程返回0;

图解:

从图中可以看出进程是具有独立性的,进程之间独立运行,互不干扰,即使是fork创建后的子进程,运行先后也是看策略;进程创建时的拷贝其实不是直接拷贝。而是写时拷贝,这个之后解释。

fork函数给父进程返回子进程的PID,给子进程返回0,为什么?
一个父进程可以有多个子进程,但他需要PID这个唯一标识符识别;可子进程可以直接识别自己的父进程,不如传递自己是否创建成功。

fork为什么可以返回两次?

 首先,fork创建了子进程,子进程拷贝父进程代码,它也有了fork;此时两进程并发执行,到return语句时,系统内核数据会根据当前进程的身份来决定返回的值。

id这个变量,怎么做到既等于0又大于0?

这个涉及到进程地址虚拟空间,之后会解释;现在可以理解为在Linux中,可以用同一个变量名,表示不同内存。

3.1 进程状态

3.1.1 进程有哪些状态 

1.简单认识一下进程排队:

首先,进程不一定一直运行,即使在CPU上,可能要等待某种资源(比如等待键盘输入);

其次,排队排的是task_struct(内核数据结构);

最后,一个task_struct可以连入多种数据结构中;

具体如何实现?task_struct是像双链表那样连接,可当要去其他队列里,就不能这样连接了;这时候会有对于队列的结构体,task_struct中会有该队列的节点,通过节点访问task_struct(ask_struct可以进一步调用进程);这样,进程排队转化为对节点的排队。

图解:

从图中可以看出,只需将代表不同队列的节点连起来,就实现了对进程的排序,而且队列之间互不干扰!

问题来了,顺序是保证了,怎么访问task_struct P? 可以用节点n的地址,减去n到task_struct起始地址的量(偏移量),就得到P的地址;

                                &n =  &P + 偏移量

                                偏移量 = &n  -  &(task_struct*)0->n

                                 &P = &n  - 偏移量

2.进程状态的描述:运行、阻塞,挂起

先理解状态本身。所谓的状态,本质就是一个在task_struct中的变量!

例: 

#define Ready 1
#define Running 2
#define Block 3

task_struct
{
    int status;
    //......
}

状态的作用是什么?状态决定了进程的下一步的动作;Linux可能有多个进程都要根据状态进行下一步动作,这时候就要排队了。

此时,CPU会有一个运行队列,进程会按照上文的方式排起队来;处于这个队列的进程就属于运行队列。所以这个“R”状态,描述的是已经准备好随时被调用的进程。 

对于阻塞状态,这里用操作系统管理硬件相关的设备的例子说明:

硬件设备也对应的PCB,也按照某种队列排序;假设,运行队列里的D对象被调度,但是需要等待硬件操作(比如等待键盘输入),但是它现在是运行状态,不可能不做事占着CPU;此时操作系统就会把它变成阻塞状态,同时回到硬件PCB队列中;如果硬件操作完成,操作系统再把它重新放进运行队列中,再把状态改为运行状态。你可能会觉得这样以来回,这个进程运行会变慢,其实不然,CPU调度非常快,没有影响)

挂起状态:
挂起状态有一个前提,计算机资源非常吃紧;此时操作系统为了利用资源,会把未处于运行状态的进程的代码与数据放到磁盘里;等到该进程需要运行时再从磁盘中取出来。

注:

进程的PCB不会因挂起被释放;

把一个进程加载到内存,优先加载它的PCB而不是代码和数据 ;

SWAP区一般和内存一样大,可以自己调节大小;但是,SWAP分区的数据交互本质是数据拷贝,太大会影响计算机速度;

3.Linux下的进程状态:

看看Linux内核源代码的定义
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在 Linux内核里,进程有时候也叫做任务)。
下面的状态在kernel源代码里定义:
/*
 * 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 *task_state_array[] = {
	"R (running)",		/*  0 */
	"S (sleeping)",		/*  1 */
	"D (disk sleep)",	/*  2 */
	"T (stopped)",		/*  4 */
	"T (tracing stop)",	/*  8 */
	"Z (zombie)",		/* 16 */
	"X (dead)"		/* 32 */
};
R运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
S睡眠状态(sleeping): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠 (interruptible sleep)。
D磁盘休眠状态(Disk sleep):有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的 进程通常会等待IO的结束。
T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
解释
R 状态就是操作系统里的运行状态;
S睡眠状态(sleeping)。它等价于操作系统中的阻塞状态;为什么叫做S睡眠状态,你可以理解为,操作系统中的阻塞状态是一个统一的概念,S睡眠状态就是这个概念在不同系统下的具体实现。处于这个状态下的进程可以被Ctrl + C命令终止
D磁盘休眠状态(Disk sleep)。本质与S状态一样,但是它不可以被操作系统自发地终止(如果资源太紧张,操作系统会直接中断进程的)
T停止状态(stopped),它等价于操作系统中的阻塞状态;目的是,当一个进程要进行的操作是危险的,操作系统就会让它变成T(stopped)状态;
T停止状态(tracing stop),它也等价于操作系统中的阻塞状态;目的是,当我们需要一个进程等待不要运行,让它待命时;操作系统就会让它变成(tracing stop);例如,我们调试代码时,就需要代码不要一直运行,等我慢慢调试,这样的进程就是T停止状态(tracing stop)。
Z(zombie)-僵尸进程
僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程没有读取到子进程退出的返回代码时就会产生僵死()进程。
僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态
一个进程退出并且它的资源被回收了才算真正的死亡
僵尸进程危害
进程的退出状态必须被维持下去,因为它运行所产生的信息(运行结果等)和资源(数据,代码等)必须被它的父进程回收。可父进程如果一直不读取,那子进程就一直处于Z状态。
维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话 说,Z状态一直不退出,PCB一直都要维护,一直占用内存,会导致内存泄漏。
那一个父进程创建了很多子进程,就是不回收,是不是就会造成内存资源的浪费,因为数据结构 对象本身就要占用内存,想想C中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间!
孤儿进程
父进程如果提前退出,那么子进程后退出,进入Z之后,那该如何处理呢?
父进程先退出,子进程就称之为孤儿进程
孤儿进程将被被1init进程领养,当然也被init进程回收。
状态之间的联系:

4.1 进程优先级 

先区分一下优先级和权限的区别。优先级是决定使用资源的先后顺序;而权限是决定一个进程能做什么,能使用什么资源;

4.1.1基本概念

cpu资源分配的先后顺序,就是指进程的优先权(priority)。
优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。
为什么要有进程优先级?因为资源是相当较少的,必须合理分配资源。
怎么实现进程优先级?
Linux系统中,进程优先级的本质就是数字,数字越小,优先级越高。
例:
task_struct 
{
    //优先级
    int PRI;
    //....
}

4.1.2查看系统进程

linux或者unix系统中,用ps –l命令则会类似输出以下几个内容:

我们很容易注意到其中的几个重要信息,有下:
UID : 代表执行者的身份
PID : 代表这个进程的代号
PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
PRI :代表这个进程可被执行的优先级,其值越小越早被执行
NI :代表这个进程的nice值
PRI 与 NI:
Linux默认一个进程的优先级是80;
Linux优先级是可以被修改的,但不允许直接修改PRI,还是修改NI(也就是nice值);
但是优先级有范围,Linux的优先级范围为 [60,  99] ;
修改规则: PRI(new)  =  PRI(old)  + nice  ; 注意这个PRI(old)一直是进程最开始的那个PRI;
例如,一个进程的PRI是20,现在nice值修改为5,新的PRI就是25(20 + 5),现在nice值修改为10,新的PRI是30(20 + 10)!
需要强调一点的是,进程的nice值不是进程的优先级,他们不是一个概念,但是进程nice值会影响到进程的优先级变化。

4.1.3查看进程优先级的命令


top命令更改已存在进程的nice:
进入top后按“r”–>输入进程PID–>输入nice值
注:实际应用中,我们几乎不会去调一个进程的优先级
极值测试:
编写如下一个程序,用top修改nice,看看的nice极值。
#include <stdio.h>
#include <unistd.h>

int main()
{
    while(1)
    {
        printf("I am a process! pid:%d\n", getpid());
        sleep(1);
    }

    return 0;
}

运行程序,输入ls -la,进程一开始PRI是80

用top, 再r,输入PID,先测上限,直接nice值改为100:

 可以看到,nice值修改为100,PRI实际上增加19,nice上限是19;

如法炮制,nice值改-100,测下限:

 可以看到,nice值修改为-100,PRI实际上减少20,nice下限是20;

所以nice的范围是[-20, 19]!

为什么要设置PRI的范围限制和nice值的范围限制呢?

操作系统分配资源要合理,不加限制的优先权,可能会出现进程一直享用资源或者一直无法享用资源的情况,可能会产生进程饥饿问题等!

4.1.4其他概念

竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高 效完成任务,更合理竞争相关资源,便具有了优先级
独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰
并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行
并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为 并发

4.2 Linux的调度与切换

概念准备:现代操作系统,都是基于时间片进行轮转执行的,进程再运行时,代码不一定全部执行完。

接下来,进一步了解进程是怎么切换的

4.2.1 进程切换

解释:

进程在CPU上运行时,寄存器上会产生临时数据,这个临时数据会被进程保存起来(拷贝方式);实际上,这个保存过程很复杂,但可以这样简化理解

时间片完,必须调度下一个进程进入CPU,这时候会进行判断,如果是首次调度该进程,寄存器产生新的临时数据(覆盖原来的);如果是二次调度该进程,则把数据恢复给寄存器。

注:

CPU内的寄存器只有一套;

寄存器内部保存的数据,可以有多套;

虽然寄存器数据放在了一个共享的CPU里,但是所有的数据,都是被进程私有的!

寄存器  != 寄存器的内容;

这就是进程切换的具体过程,看起来有点麻烦,但实际上非常快!

4.2.2 进程调度

研究进程调度,其实就是研究调度队列

Linux2.6内核进程调度队列来说明,无关的字段被我省略:
 

解释:
运行队列里有一个数组,大小为140,为了配合PRI的范围,只用100下标到139下标的内容;

每个下标的内容再指向进程的PCB,PCB直接再互相连接,这样每个下标就是PRI,下标对应的内容就是一个队列;

数组为task_struct类型,每一步的遍历消耗较大,而且要遍历40步;我们只关心一个下标的内容中有无队列,就把有“1”标记,没有用“0”标记;考虑到遍历的消耗要尽可能小,比特位最适合。所以用5个整数大小的比特位去标记每个下标的情况(运行对列数组共大小为140);用比特位去对应数组下标(具体由算法完成,类似哈希) ;这样去遍历比特位比遍历数组效率高了;

当然,还没完,如果一直有优先级高的进程“插队”,那么低优先级的进程不就一直等了吗?

解释:

将队列描述并组织起来,创建两个队列,一个是活跃队列(上一张图),一个是过期队列(本图),这两个队列其实一模一样,没有区别。

再创建队列数组,用两个指针active和exptired来管理;

active指向的队列将为活跃队列,让活跃队列的进程运行,并且新来的进程无论优先级不能再进入这个队列;

exptired指向的队列将为过期队列,新来的运行状态队列就按照优先级进入这个队列;

CPU调度进程,active指向的队列,进程再不断减少;exptired指向的队列,进程不断增加;当时间片完,让exptired和active指向的内容交换;

重复次过程,就是进程调度!

5.1 环境变量

5.1.1 命令行参数

命令行为什么会显示一些系统信息?为什么看得懂命令?这关联到环境变量,但先来看看命令行参数。

#include <stdio.h>

int main(int argc, char* argv[])
{
    int i = 0;
    for(i = 0;argv[i];i++)
    {
	printf("argv[%d]: %s\n", i, argv[i]);
    }
    return 0;   
}

 argc 和 argv都是main函数自带的参数,不使用时一般不写;运行一下看看这些参数有什么用:

从图中可以观察到,命令解析被解析成了几段,存在argv中。(以空格切割字符)

这样做的目的是为了可以通过不同的选项,执行同一个程序的不同命令!

例:用下面代码模拟这个过程:(argv最后一个参数默认为NULL)

#include <stdio.h>

int main(int argc, char* argv[])
{
    if(argc == 1)
    {
	printf("Usage: \n\t%s -[1|2|3]\n", argv[0]);
	return 1;
    }

    if(argc > 2)
	{
          printf("最多一个选项!\n ");  
	}
    else if(strcmp("-1", argv[1]) == 0)
	{
	  printf("function 1\n");
	}     	
    else if(strcmp("-2", argv[1]) == 0)
	{
	  printf("function 2\n");
	}
    else if(strcmp("-3", argv[1]) == 0)
	{
	 printf("funtion 3\n");
	}
    else
	 printf("Unkonwn!\n");
	

    return 0;   
}

当然,命令具体的解析方式要根据具体需要。

5.1.2 基本概念

环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数
如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但 是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性
用具体的例子来看看环境变量:
一开始我们在  '/' 目录下,cd  home,直接到家目录了,可是系统怎么知道家目录的路径呢?
查看环境变量:
echo $NAME //NAME:你的环境变量名称

定义环境变量:
        1. export: 设置一个新的环境变
        2. env: 显示所有环境变量
        3. unset: 清除环境变量
        4. set: 显示本地定义的shell变量和环境变量
环境变量的组织方式:
                    

每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串。

 总结:

定义变量的本质,其实是开辟空间

操作系统的环境变量,本质是系统自己开辟的空间,然后给它命名和内容

5.1.3 特性

环境变量具有全局属性,可以子进程继承

用下面代码来演示:

#include <stdio.h>
#include <stdlib.h>

int main()
{
     char * env = getenv("MYENV");
     if(env)
 	{	
 	    printf("%s\n", env);
 	}

     return 0;
}

首先,先不给父进程导出环境变量MYENV(这里的父进程指的是bash),直接运行;

没有输出,说明没有继承环境变量,因为父进程没有MYENV;输入export MYENV="hello world",再运行:

子进程继承了父进程的环境变量

注:
使用export VAR=value;不仅会在当前shell中设置该变量,还会导到子进程的环境中。

VAR=value;仅在当前Shell可用,不影响子进程。

5.1.4 代码获取和设置环境变量

1.用命令行第三个参数
#include <stdio.h>
int main(int argc, char *argv[], char *env[])
{
 int i = 0;
 for(; env[i]; i++){
 printf("%s\n", env[i]);
 }
 return 0;
}

 运行结果:

2.通过第三方变量environ获取

libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时 要用extern声明。
#include <stdio.h>
int main(int argc, char *argv[])
{
 extern char **environ;
 int i = 0;
 for(; environ[i]; i++){
 printf("%s\n", environ[i]);
 }
 return 0;
}

运行结果:

3.通过系统调用获取或设置环境变量 

#putenv , 后面讲解
getenv , 本次讲解
常用getenvputenv函数来访问特定的环境变量。
#include <stdio.h>
#include <stdlib.h>
int main()
{
   printf("%s\n", getenv("PATH"));
   return 0;
}

运行结果:

6.1  进程地址空间

研究背景:32位平台

6.1.1 程序地址空间

 为了更好地理解进程地址空间,先来回顾一下程序地址空间。

学习C语言应该看过这张图,了解过栈,堆等。 

可是我们对他并不理解!

用代码进行更深的理解:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int g_val = 0;

int main()
{
    pid_t id = fork();
    if(id < 0)
    {
        perror("fork");
        return 0;
    }
    else if(id == 0)
    { //child
        printf("child[%d], g_val: %d , &g_val: %p\n", getpid(), g_val, &g_val);
    }
    else
    { //parent
        printf("parent[%d], g_val: %d, &g_val: %p\n", getpid(), g_val, &g_val);
    }     
         
    sleep(1);
         
    return 0;
}

运行结果:(结果与环境相关,观察现象即可

我们发现,输出出来的变量值和地址是一模一样的,因为子进程按照父进程为模版,父子并没有对变 量进行进行任何修改。
可是将代码稍加改动:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int g_val = 0;

int main()
{
    pid_t id = fork();
    if(id < 0)
    {
        perror("fork");
        return 0;
    }
    else if(id == 0)
    { //child
        g_val = 100;//子进程肯定先跑完,也就是子进程先修改,完成之后,父进程再读取
        printf("child[%d], g_val: %d , &g_val: %p\n", getpid(), g_val, &g_val);
    }
    else
    { //parent
        printf("parent[%d], g_val: %d, &g_val: %p\n", getpid(), g_val, &g_val);
    }     
         
    sleep(1);
         
    return 0;
}

运行结果:

可以发现,父子进程,输出地址是一致的,但是变量内容不一样!能得出如下结论:
1.变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
2.但地址值是一样的,说明该地址绝对不是物理地址!
3.在Linux地址下,这种地址叫做 虚拟地址
4.我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理
OS必须负责将 虚拟地址 转化成 物理地址 。

6.1.2 进程地址空间

之前说程序的地址空间是不准确的,准确的应该说成 进程地址空间:

解释:
一开始,父进程有g_val变量,有它对应的虚拟地址和物理地址;创建子进程后,子进程继承父进程的代码数据,也有g_val变量,也有对应的虚拟地址,可是物理地址不一样;这样内容就被映射到了不同的物理地址。(物理地址不一样这一点涉及写时拷贝,后面解释)
虽然认知到了进程地址空间,但是对它的理解不够,还需要更多了解。
1.什么是地址空间
直接看定义,每个运行的目标程序都有自己的逻辑地址空间,这个逻辑地址空间就是地址空间。
只看概念就太“干燥”了!
操作系统肯定要管理这个地址空间,怎么管理,先描述,再组织!
所以,进程地址空间就是数据结构,具体到特定的进程中,就是特定的数据结构的对象!
例:

所以,进程地址空间就是数据结构对象“连接”在一起,对进程地址空间的管理就变成了对“链表”的管理;

那么进程怎么知道哪个空间是自己的?进程的PCB会有一个指针指向对应的空间;

2.如何理解进程地址空间的属性

从上面的图中,可以看到栈、堆等字段,本质是区域划分!

所以,在计算机中设计地址空间,必有这样的字段:

struct XXX
{
    int code_start, code_end;
    int stack_start,stack_end;
    //...
}

 其次,学习语言时,我们都遇到过越界访问的问题;所以区域划分,还具有判断是否越界和进行改变范围的属性。

linux-2.6.11.12中进程地址空间结构体示例:

区域划分并不是单纯划分空间,它表示区域内的各个地址,都可以使用!

地址空间不具备对代码和数据的保存能力!需要在物理内存上保存!

操作系统会提供页表,将地址空间上的地址转化到物理内存中!

CPU中的有一个叫MMU的硬件,当CPU的指令需要访问物理内存时,它就会只直接访问页表;而且是通过物理内存访问页表;虚拟地址基本是给用户看的,操作系统给内部的基本都是物理内存。

3.地址空间和页表

为什么要有地址空间和页表这种组合,来访问物理内存,为什么不直接访问物理内存?

a. 将物理内存从无序变有序,让进程以统一的视角看待内存;

有了页表,物理内存可以随意储存,即使不连续也因为页表的存在而不影响进程; 

b. 将进程管理和内存管理解耦合

有了页表加地址空间,进程和内存可以做到互不干扰!

这两点也方便操作系统的设计

c. 地址空间和页表是保护内存安全的重要手段

拦截非法访问等。

4.解释内存申请

现在,我们可以解释内存的申请了。

首先操作系统一定要为效率和资源使用率负责!所以:

申请内存,不一定使用,申请的内存是虚拟内存 ;如果使用,开辟物理内存建立映射,判断合法后允许使用;

好处:1.充分保证内存的使用率;2.提高new或malloc的速度

7.1 进程控制

1.进程创建

linuxfork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
fork函数的详细解释已经在上文描述过。
写时拷贝:
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副 本。具体见下图:

解释:

父进程创建了子进程,此时父子都不写入,数据代码共享;子进程进行数据写入,拷贝一份父进程的数据,重新建立映射关系,页表权限修改。

为什么要写时拷贝?(终极目的是提高效率和资源利用率)

(1). 创建子进程时,数据不一定会被用到;即使要用,可能也不是立刻使用;

(2). 先拷贝一份再修改,为了进程的独立性。

页表是有权限限制的,比如在C语言中,尝试对字符串常量进行修改,就会经过写时拷贝;可是,页表映射的条目,权限是只读,不让你修改。

2. 进程终止

进程终止也就是进程退出,退出时会有退出码;

对于一个进程而言,mian函数的返回值,叫做进程的退出码;其他函数的退出,仅仅表示函数调用完毕;想知道函数执行情况,一般使用函数的返回值。

退出码:

一般情况下,退出码为0,表示进程执行成功;

非0,表示失败;一般用不同的数字表示不同的失败原因,这种退出码也被叫做错误码。

设计这些退出码就是为了知道进程执行的情况,所以所有退出码应该有对应的信息。

错误码转化为错误信息:

(1)使用语言和系统自带的方法转化

例如:C语言的strerror函数

(2)自定义

例如:

enum{
    success=0,
    open_err,
    malloc_err
};

const char* errorToDesc(int code)
{
    switch(code)
    {
        case success:
            return "success";
        case open_err:
            return "file open error";
        case malloc_err:
            return "malloc error";
        default:
            return "unknown error";
    }
}
进程退出场景:
进程退出场景,或者说是退出码的情况
1.代码运行完毕,结果正确
2.代码运行完毕,结果不正确
3.代码没有运行完毕,进程异常终止

这种情况一般是进程收到了信号,信号也类似退出码,不同的信号有不同的编号,表示不同的异常原因。

所以,任何进程,我们可以用两个具体的数字来表明它的执行情况!

如果是非0信号,退出码就没有意义了 

进程常见退出方法
正常终止(可以通过 echo $? 查看进程退出码):
1. main返回
2. 调用exit
3. _exit
异常退出:
ctrl + c,信号终止
exit与_exit:
使用上没有什么区别,都是进程退出时会返回括号内输入的退出码
区别:
exit其实在最后会调用_exit,但是在这之前会进行其他工作:
1. 执行用户通过 atexiton_exit定义的清理函数。
2. 关闭所有打开的流,所有的缓存数据均被写入
3. 调用_exit

注:

 这个缓冲区不是关于字符输出输入(printf)的缓冲区

return退出
return是一种更常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返 回值当做 exit的参数。

3.进程等待

进程等待必要性
之前讲过,子进程退出,父进程如果不管不顾,就可能造成僵尸进程的问题,进而造成内存泄漏。
另外,进程一旦变成僵尸状态,那就“刀枪不入”,kill -9 也无能为力,因为谁也没有办法
杀死一个已经死去的进程。
最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对, 或者是否正常退出。
父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息
总结:
父进程通过wait方式,回收子进程的资源是必要的;
通过wait方式,获取子进程的退出信息是可选的;
进程等待的方法
wait方法

status 是一个输出型参数,该函数会在执行完后改变status以输出信息;

返回值,成功返回子进程pid,否则返回-1;

这里实际代码演示wait,着重将waitpid

waitpid方法

pid设置为-1,等待任意一个进程;设置一个PID,等到与PID相同的进程;

status与wait的用法一样;

options:可以选择等待方式,阻塞等待和非阻塞等待;

获取子进程status
waitwaitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
如果传递NULL,表示不关心子进程的退出状态信息。
否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特位):

代码示例:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        // child
        int cnt = 5;
         while(cnt)
        {
                 printf("Child is running, pid: %d, ppid: %d\n", getpid(), getppid());
                 sleep(1);
        	 cnt--;
        }

         exit(1);
     }

    int status = 0;
    pid_t rid = waitpid(id, &status, 0); // 阻塞等待
    if(rid > 0)
    {
        printf("wait success, rid: %d, status: %d\n", rid, status);
    }

    return 0;
}

运行结果:

等待成功,也获取了子进程的信息,256,这其实就是1,一个数值转化问题:

阻塞等待:

当进程发出系统调用(如I/O操作)时,如果需要等待结果,操作系统会将该进程挂起,进入阻塞状态,直到等待的事件完成。这意味着进程在等待期间无法继续执行其他操作。
• 优点:实现简单,适合处理一些必须等待的操作。
• 缺点:进程在等待过程中无法做其他事,可能导致资源的浪费。

非阻塞等待:

进程发出系统调用时,立即返回而不阻塞进程,即使没有得到结果,进程也可以继续执行其他操作。进程通常需要以轮询(polling)或回调的方式反复检查事件是否完成。
• 优点:进程不必因为等待某个事件而被挂起,适合对实时性要求较高的场景。
• 缺点:实现相对复杂,轮询时可能增加系统负担。

 总结来说,阻塞等待会使进程暂停直到事件完成,而非阻塞等待让进程能够继续处理其他任务,不必停下来等结果。

具体代码实现
进程的阻塞等待方式:
#include <stdio.h> 
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>

int main()
{
        pid_t pid;
        pid = fork();
        if(pid < 0)
        {
                printf("%s fork error\n",__FUNCTION__);
                return 1;
        }
        else if( pid == 0 )
        {       //child
                printf("child is run, pid is : %d\n",getpid());
                sleep(5);
                exit(257);
        } 
        else
        {
                int status = 0;
                pid_t ret = waitpid(-1, &status, 0);//阻塞式等待,等待5S
                printf("this is test for wait\n");
        
                if( WIFEXITED(status) && ret == pid )
                {
                         printf("wait child 5s success, child return code is :%d.\n",WEXITSTATUS(status));
                }
                else
                {
                        printf("wait child failed, return.\n");
                        return 1;
                }
        }
    
        return 0; 
}

非进程的阻塞等待方式:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>

#define NUM 5
typedef void(*func)(); //函数指针

func task_list[NUM];//任务列表

//////////任务
void PrintLog()
{
    printf("this is a log print task!\n");
}
void PrintNet()
{
    printf("this is a net print task!\n");
}
void PrintSave()
{
    printf("this is a save print task!\n");
}

void InitTaskList()//初始化任务列表
{
    task_list[0]=PrintLog;
    task_list[1]=PrintNet;
    task_list[2]=PrintSave;
    task_list[3]=NULL;
}

void execute_task()
{
    int i = 0;
    for(i = 0; task_list[i];i++)
    {
        task_list[i]();
    }

}


int main()
{
    InitTaskList();

    pid_t id = fork();

    if(id == 0)
    {
        int cnt = 5;
        while(cnt)
        {
            printf("I am a child! pid:%d, ppid:%d, cnt:%d\n", getpid(), getppid(),cnt);


    while(1)
    {

        pid_t rid = waitpid(id,&status,WNOHANG);//非阻塞等待

        if(rid>0)//等待成功且子进程退出
        {
            printf("wait success! rid:%d,  exit_code:%d\n", rid,  WEXITSTATUS(status));//取退出码
            break;
        }
        else if(rid == 0)//等待成功但子进程还未退出
        {
            printf("child is runnung! do other things\n");

            printf("###################### task ########################\n");
            execute_task();
            printf("###################### end ########################\n");
        }
        else//等待失败
        {
            perror("waitpid");
            break;
        }

        sleep(1);
    }


    return 0;
}

运行结果: 

 4.进程程序替换

替换原理:
fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数
以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动 例程开始执行。
调用exec并不创建新进程,所以调用exec前后该进程的id并未改变

把父子进程的关系考虑进程:

为什么要有程序替换这样的机制?因为我们想要程序可以执行不同的代码;

这个过程肯定需要操作系统的“同意”,所以程序替换一般需要系统调用,用对应的函数。

替换函数

其实有六种以exec开头的函数,统称exec函数 :

这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
如果调用出错则返回-1
所以exec函数只有出错的返回值而没有成功的返回值。
命名理解
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
l(list) : 表示参数采用列表
v(vector) : 参数用数组
p(path) : p自动搜索环境变量PATH
e(env) : 表示自己维护环境变量
代码示例:
这是多个函数的示例,代码注释了一部分,可以自己一个一个试,这里不展示运行结果,太多了!
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    char* const env[]={
        (char*)"PATH=/", 
        (char*)"HOME=xxx/ccc"
    };

    printf("I am a process! pid:%d\n", getpid());
    
    //putenv("MYVAL=fffffff");//在继承来的环境变量上,再增加新的环境变量

    pid_t id = fork();
    if(id == 0)
    {	
        //char* const argv[]=
        //{
        //    (char*)"ls",
        //    (char*)"-a",
        //    (char*)"-l"
        //    
        //};

		sleep(3);
    
        printf("exec start...\n");

        //execl("/usr/bin/ls", "ls","-a","-l",NULL);//第一个字符是路径,后面是执行方式,最后必须以NULL结尾
        //execl("/usr/bin/top", "top", NULL);
        //execlp("ls", "ls","-a","-l",NULL); 

        //execv("/usr/bin/ls", argv);//第二个参数以传指针数组传递
        //execvp("ls", argv);
        

        //execl("./mytest", "mytest",NULL);//没有传递环境变量,子进程通过地址空间继承环境变量
        //进程替换,不会替换环境变量数据



        // execl("./mytest", "mytest", "-a","-b","-c",NULL);//调用另一个程序    
        //execl("/usr/bin/bash", "bash", "test.sh",NULL);//调用另一个程序    
        //execl("/usr/bin/python2.7", "python", "test.py",NULL);//调用另一个程序    


        execle("./mytest", "mytest",NULL, env);//给子进程设置全新的环境变量
        


        //细节1:程序替换成功,exec*后续代码不再执行 
        //细节2:exec*只有失败返回值
        //细节3:程序替换不会产生新进程
        //细节4:创建一个进程,先创建pcb,地址空间,页表等

        printf("exec end...\n");
		exit(1);
    }

    pid_t rid = waitpid(id, NULL, 0);
	if(rid > 0)
	{
		printf("wait success!\n");
	}


    return 0;
}

代码里的mytest.cc文件,已经其他语言的脚本:

mytest.cc:

#include <iostream>

using namespace std;

int main(int argc, char* argv[], char* env[])
{
    for(int i = 0;env[i];i++)
    {
        printf("env[%d]: %s\n", i, env[i]);
    }



    //for(int i = 0; argv[i]; i++)
    //{
    //    printf("argv[%d]: %s\n", i, argv[i]);
    //}

    cout<<"hello C++"<<endl;
    cout<<"hello C++"<<endl;
    cout<<"hello C++"<<endl;

    return 0;
}

test.sh:

#! usr/bin/bash


echo "hello shell!!"
echo "hello shell!!"
echo "hello shell!!"
echo "hello shell!!"
echo "hello shell!!"

test.py:

#! /usr/bin/python2.7

print("hello python")

注:

细节1:程序替换成功,exec*后续代码不再执行  

细节2:exec*只有失败返回值
细节3:程序替换不会产生新进程
细节4:创建一个进程,先创建pcb,地址空间,页表等

学会了上面内容可以写一个简单的Shell,这里不详细解释,放代码参考:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>


#define SIZE 1024
#define MAX_ARGC 64
#define SEP " "



char* argv[MAX_ARGC];
char pwd[SIZE];
char env[SIZE];
int exitcode = 0;

const char* UserName()
{
    const char* name = getenv("USER");
    if(name)
        return name;
    else
        return "None";
}



const char* HostName()
{
    const char* name = getenv("HOSTNAME");
    if(name)
        return name;
    else
        return "None";
}

char* Home()
{
    char* name = getenv("HOME");
    return name;
}

const char* CurrentWorkDir()
{
    const char* name = getenv("PWD");
    if(name)
        return name;
    else
        return "None";
}

int Interactive(char out[], int size)
{

    //输出命令提示符
    printf("[%s@%s %s]$", UserName(), HostName(), CurrentWorkDir());

    //获取命令字符

    fgets(out, size, stdin);//使用fgets获取一行的字符

    out[strlen(out)-1] = '\0';//处理换行字符

    return strlen(out);
}

void Split(char in[])
{
    
    int i = 0;
    argv[i++] = strtok(in, SEP);
    while(argv[i++] = strtok(NULL, SEP));//"=" 保证argv以NULL结尾

    if(strcmp("ls", argv[0]) == 0)
    {
        //给ls命令加个显示颜色
        argv[i-1] = (char*)"--color";
        argv[i] = NULL;
    }
}


int BuildInCmd()
{
    int ret = 0;//0代表不是

    //1. 判断是否为内键命令(1/0)
    if(strcmp("cd", argv[0]) == 0)
    {
        //2.执行
        ret = 1;
        char* target = argv[1]; //考虑 cd "path"和cd的情况
        if(!target)//target为空,路径变为家目录
            target = Home();
        chdir(target);//改变当前路径

        //3.改变环境变量PWD
        char temp[1024];
        getcwd(temp,1024);//获取新的路径

        snprintf(pwd,SIZE,"PWD=%s",temp);//把新路径写入环境变量
        putenv(pwd);//导入环境变量
    }
    else if(strcmp("export",argv[0]) == 0)
    {
        ret = 1;
        if(strcmp("export", argv[0]) == 0)
        {
            if(argv[1])
            {
                strcpy(env, argv[1]);//argv[1]指向命令行,会被下次命令覆盖
                putenv(env);
            }
        }
    }
    else if(strcmp("echo", argv[0]) == 0)
    {
        ret = 1;
        if(argv[1] == NULL)
        {
            printf("\n");
        }
        else if(argv[1][0] == '$')
        {
            if(argv[1][1] == '?')
            {
                printf("%d\n", exitcode);
                exitcode = 0;
            }
            else
            {
                char* e = getenv(argv[1] + 1);
                if(e)
                printf("%s\n", e);
            }
        }
        else
            printf("%s\n", argv[1]);
    }   

    return ret;
}


void Execute()
{

    pid_t id = fork();
    if(id == 0)
    {
        execvp(argv[0], argv);//选用execvp 
        exit(1);
    }

     int status = 0;
     pid_t rid = waitpid(id,&status,0);

     if(rid == id) 
         exitcode = WIFEXITED(status);

    //printf("run done, rid:%d\n", rid);
}

int main()
{
    while(1)
    {

        char commandline[SIZE];
        //1. 打印命令行提示符,获取用户输入的命令字符串
        int n = Interactive(commandline, SIZE);
        if(n == 0) continue;
        
        //printf("%s\n", commandline);//输出获取的命令
        
        //2. 对命令行字符串进行切割
        Split(commandline);
        
        //3. 处理内键命令
        n = BuildInCmd();
        if(n) continue;

        //4. 执行命令
        Execute();
    }


    //int i = 0;
    //for(i = 0;argv[i];i++)
    //{
    //    printf("argv[%d]: %s", i, argv[i]);
    //}

    return 0;
}

注意处理内键命令!

感谢浏览!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值