1、进程与程序的区别
程序,是指编译好的二进制文件,是静态的。在磁盘上,不占用系统资源例如:cpu,内存等。
进程,是一个抽象的概念,与操作系统原理紧密相关。进程是活跃的程序,占用系统资源。在内存中执行。包括了动态创建、调度和消亡的整个过程。一旦程序运行起来就会产生一个进程。在linux中,通过进程控制块来描述进程。例如:当我们编写一个c程序并将其编译之后,此时可执行的二进制文件并不能叫做进程,只能叫做程序。只有当我们运行编译好的可执行文件之后,才会拉起一个进程。
2、进程控制块(PCB)
每个进程在内核中都有一个进程控制块(PCB)来维护进程相关的信息,Linux内核的进程控制块是task_struct结构体。
struct task_struct
{
long state; /*任务的运行状态(-1 不可运行,0 可运行(就绪),>0 已停止)*/
long counter;/*运行时间片计数器(递减)*/
long priority;/*优先级*/
long signal;/*信号*/
struct sigaction sigaction[32];/*信号执行属性结构,对应信号将要执行的操作和标志信息*/
long blocked; /* bitmap of masked signals */
/* various fields */
int exit_code;/*任务执行停止的退出码*/
unsigned long start_code,end_code,end_data,brk,start_stack;/*代码段地址 代码长度(字节数)
代码长度 + 数据长度(字节数)总长度 堆栈段地址*/
long pid,father,pgrp,session,leader;/*进程标识号(进程号) 父进程号 父进程组号 会话号 会话首领*/
unsigned short uid,euid,suid;/*用户标识号(用户id) 有效用户id 保存的用户id*/
unsigned short gid,egid,sgid; /*组标识号(组id) 有效组id 保存的组id*/
long alarm;/*报警定时值*/
long utime,stime,cutime,cstime,start_time;/*用户态运行时间 内核态运行时间 子进程用户态运行时间
子进程内核态运行时间 进程开始运行时刻*/
unsigned short used_math;/*标志:是否使用协处理器*/
/* file system info */
int tty; /* -1 if no tty, so it must be signed */
unsigned short umask;/*文件创建属性屏蔽位*/
struct m_inode * pwd;/*当前工作目录i 节点结构*/
struct m_inode * root;/*根目录i节点结构*/
struct m_inode * executable;/*执行文件i节点结构*/
unsigned long close_on_exec;/*执行时关闭文件句柄位图标志*/
struct file * filp[NR_OPEN];/*进程使用的文件表结构*/
/* ldt for this task 0 - zero 1 - cs 2 - ds&ss */
struct desc_struct ldt[3];/*本任务的局部描述符表。0-空,1-代码段cs,2-数据和堆栈段ds&ss*/
/* tss for this task */
struct tss_struct tss;/*本进程的任务状态段信息结构*/
};
3、进程状态切换
Linux中,进程的各种状态如下:
*TASK_RUNNING:进程当前正在运行,或者正在运行队列中等待调度。
*TASK_INTERRUPTIBLE:进程处于睡眠状态,正在等待某些事件发生。进程可以被信号中断。接收到信号或被显式的唤醒呼叫唤醒之后,进程将转变为 TASK_RUNNING 状态。
*TASK_UNINTERRUPTIBLE:类似于 TASK_INTERRUPTIBLE,只是它不会处理信号,不可被中断。因为它可能正在完成某些重要的任务。当它所等待的事件发生时,进程将被显式的唤醒呼叫唤醒。
*TASK_STOPPED:进程已中止执行,它没有运行,并且不能运行。接收到 SIGSTOP 和 SIGTSTP 等信号时,进程将进入这种状态。接收到 SIGCONT 信号之后,进程将再次变得可运行。
*TASK_TRACED:正被调试程序等其他进程监控时,进程将进入这种状态。
*EXIT_ZOMBIE:进程已终止,它正等待其父进程收集关于它的一些统计信息。
*EXIT_DEAD:最终状态。将进程从系统中删除时,它将进入此状态,因为其父进程已经通过 wait() 或 waitpid() 调用收集了所有统计信息。
进程切换示意图:
4、进程创建
进程的创建与调度执行
不同操作系统所提供的进程创建的原语的名称和格式不尽相同,但执行创建进程原语后,操作系统所做的工作大致相同;分为以下几个方面:
①、给新创建的进程分配一个内部标示,并分配一个空白的PCB,同时在系统进程表中增加一个表目;
②、为该进程分配内存空间,包括进程映射所需要的所有元素(包括栈,程序,数据等)。复制父进程的内存空间的内容到该进程的内存空间中;
③、初始化PCB。如果该进程是用户进程创建的子进程,那它将共享父进程的资源;
④、置该进程的状态为就绪,插入就绪队列中;
⑤、生成其他相关数据结构。
linux 中终端用户进程的创建与调度执行
启动linux的过程中,当操作系统被加载后,首先创建0#进程init_task。除了0#进程是在系统初启时由系统创建外,其余所有进程由其他进程调用fork()函数创建。如0#进程调用fork()创建1#进程,再由1#进程调用fork()为每个可用于用户登录的通信窗口创建一个为用户服务的进程,如等待用户登录等。
当用户使用端口时,系统进程创建一个login进程来接受用户标示和口令,并通过系统文件 /etc/passwd中的信息核对用户身份,若登录成功,则login改变当前目录到用户主目录,并执行相应的shell程序,以便用户直接通过shell界面与login系统进行交互。
当用户在输入可执行文件名时,操作系统为该文件创建一个相应的用户进程投入运行。
linux中用户子进程的创建与调度执行
子进程创建时,操作系统做了以下工作:
①、检查同时运行的进程数目,若超过系统预设值,则创建失败,并返回-1;
②、为子进程分配进程控制块task_stuct结构,并赋予唯一的进程标识符pid;
③、子进程继承父进程打开的文件及资源,对父进程当前目录和所有以打开系统文件表项中的引用记数+1;
④、为子进程创建进程映像:
创建子进程静态映射部分;复制父进程动态映射部分;
创建子进程动态映射部分,初始化task_struct结构体;
创建结束,置子进程为内存就绪状态,插入就绪队列,作为一个独立的进程被系统调度。
⑤、若调用进程(父进程)返回,则返回创建的子进程pid值(此时返回值>0);
⑥、若子进程被调度执行,则将其U区计时字段初始化然后返回(此时返回值为0)。
说明:u区包括与进程相关的内容。它可以是进程表自身的一部分,但要单独维护。具体的可自行查询。
fork()函数
作用:创建一个子进程。
定义:pid_t fork(void);
返回值:失败返回-1;成功返回:① 父进程返回子进程的ID(非负) ②子进程返回 0
pid_t类型表示进程ID,但为了表示-1,它是有符号整型。(0不是有效进程ID,init最小,为1)
注意:返回值,不是fork函数能返回两个值,而是fork后,fork函数变为两个,父子需各自返回一个。
获取进程相关信息有四个函数可以使用,分别是:getpid()、getppid()、getuid()、getgid();下面是对其介绍。
①、getpid函数
作用:获取当前进程ID
定义:pid_t getpid(void);
②、getppid函数
作用:获取当前进程的父进程ID
定义:pid_t getppid(void);
③、getuid函数
作用:获取当前进程实际用户ID
定义:uid_t getuid(void);
作用:获取当前进程有效用户ID
定义:uid_t geteuid(void);
④、getgid函数
作用:获取当前进程使用用户组ID
定义:gid_t getgid(void);
作用:获取当前进程有效用户组ID
定义:gid_t getegid(void);
5、进程共享
我们同过fork()函数可以创建一个子进程。但是,这样就引申出一个问题:父子进程之间在fork后,有哪些相同,那些相异之处呢?
刚fork之后:
相同: 全局变量、.data、.text、栈、堆、环境变量、用户ID、宿主目录、进程工作目录、信号处理方式…
不同: 1.进程ID 2.fork返回值 3.父进程ID 4.进程运行时间 5.闹钟(定时器) 6.未决信号集
从上面我们可以看出:似乎,子进程复制了父进程用户空间内容,以及父进程的PCB,但pid不同。真的每fork一个子进程都要将父进程的用户地址空间完全拷贝一份,然后在映射至物理内存吗?
结论当然不是!因为这样无疑会增加开销,影响效率;实际上父子进程间遵循读时共享写时复制的原则。这样设计,无论子进程执行父进程的逻辑还是执行自己的逻辑都能节省内存开销。
特别的,fork之后父进程先执行还是子进程先执行不确定。取决于内核所使用的调度算法。但经不可靠测试,基本都是父进程先执行。
写时拷贝(copy-on-write)
Linux的fork()使用写时拷贝(copy-on-write)页实现。写时拷贝是一种可以推迟甚至避免拷贝数据的技术。内核此时并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间。只用在需要写入的时候才会复制地址空间,从而使各个进行拥有各自的地址空间。也就是说,资源的复制是在需要写入的时候才会进行,在此之前,只有以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候。在页根本不会被写入的情况下—例如,fork()后立即执行exec(),地址空间就无需被复制了。fork()的实际开销就是复制父进程的页表以及给子进程创建一个进程描述符。在一般情况下,进程创建后都为马上运行一个可执行的文件,这种优化,可以避免拷贝大量根本就不会被使用的数据(地址空间里常常包含数十兆的数据)。由于Unix强调进程快速执行的能力,所以这个优化是很重要的。
那么,父子进程之间是否共享全局变量呢?下面是个测试的小程序:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int a = 100;
int main(void)
{
pid_t pid;
pid = fork();
if(pid == 0){
a = 200;
printf("child, a = %d\n", a);
}
else {
sleep(1); //确保子进程先运行
printf("parent, a = %d\n", a);
}
return 0;
}
测试结果是:child,a=200 ; parent, a = 100;结果显而易见:父子进程之间不共享全局变量。
循环创建n个进程
我们知道一次fork函数调用可以创建一个子进程。那么创建N个子进程应该怎样实现呢?简单的,for(i = 0; i < n; i++) { fork() } 即可。但这样创建的是N个子进程吗?
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
int i;
pid_t pid;
for (i = 0; i < 3; i++) {
pid = fork();
}
printf("i an child, pid = %u\n", getpid());
return 0;
}
运行结果:
可以看出:程序执行完总共生成了8个子进程,这和我们预期的完全不一样,究竟是咋回事呢?
这是因为,父进程创建子进程之后,父子进程的各自代码块的程序各自私有,其余部分,包括创建前后的程序段,父子进程共享。所以当第二次循环时,不但有父进程调用fork()函数,还有第一次创建的子进程也调用fork(),以此类推:不加以控制的话,n次循环,会创建(2^n)-1个子进程,而不是n个子进程。
因此,需要在循环的过程,保证子进程不再执行fork ,因此当(fork() == 0)时,子进程应该立即break;才正确。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
int i;
pid_t pid;
for (i = 0; i < 5; i++) {
pid = fork();
if (pid == 0) {
break;
}
}
if (i < 5) {
sleep(i);
printf("I'am %d child , pid = %u\n", i+1, getpid());
}
else {
sleep(i);
printf("I'm parent\n");
}
return 0;
}
运行结果:
gdb调试
在linux下,当我们为编写的程序出现逻辑上的错误时,需要使用gdb调试的时候,gdb只能跟踪一个进程。可以在fork函数调用之前,通过指令设置gdb调试工具跟踪父进程或者是跟踪子进程。默认跟踪父进程。
set follow-fork-mode child 命令设置gdb在fork之后跟踪子进程。
set follow-fork-mode parent 设置跟踪父进程。
注意,一定要在fork函数调用之前设置才有效。
6、exec函数族
fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
将当前进程的.text、.data替换为所要加载的程序的.text、.data,然后让进程从新的.text第一条指令开始执行,但进程ID不变,换核不换壳。
exec函数:
有六种以exec开头的函数,统称exec函数:
①、int execl(const char *path, const char *arg, …);
②、int execlp(const char *file, const char *arg, …);
③、int execle(const char *path, const char *arg, …, char *const envp[]);
④、int execv(const char *path, char *const argv[]);
⑤、int execvp(const char *file, char *const argv[]);
⑥int execve(const char *path, char *const argv[], char *const envp[]);
以下是对其中几个函数的详细介绍:
(1)、execl函数
作用:加载一个进程, 通过 路径+程序名 来加载。
定义: int execl(const char *path, const char *arg, …);
返回值:成功:无返回;失败:-1
(2)、execlp函数
作用:加载一个进程,借助PATH环境变量
定义:int execlp(const char *file, const char *arg, …);
返回值: 成功:无返回;失败:-1
参数: 参数1:要加载的程序的名字。该函数需要配合PATH环境变量来使用,当PATH中所有目录搜索后没有参数1则出错返回。
用途:该函数通常用来调用系统程序。如:ls、date、cp、cat等命令。
**execl与execlp对比:
execlp(“ls”, “ls”, “-l”, “-F”, NULL); 使用程序名在PATH中搜索。
execl(“/bin/ls”, “ls”, “-l”, “-F”, NULL); 使用参数1给出的绝对路径搜索。
(3)、execvp函数
作用:加载一个进程,使用自定义环境变量env
定义:int execvp(const char *file, const char *argv[]);
返回值: 成功:无返回;失败:-1
变参形式: ①… ② argv[] (main函数也是变参函数,形式上等同于 int main(int argc, char *argv0, …))
变参终止条件:① NULL结尾 ② 固参指定
execvp与execlp参数形式不同,原理一致。
exec函数族一般规律
exec函数一旦调用成功即执行新的程序,不返回。只有失败才返回,错误值-1。所以通常我们直接在exec函数调用后直接调用perror()和exit(),无需if判断。
事实上,只有execve是真正的系统调用,其它五个函数最终都调用execve。
system函数调用与exec的区别 :
exec函数一但调用成功,即执行新程序不返回,而system函数则会返回
system函数的原型为: int system (const char *string);
它的作用是,运行以字符串参数的形式传递给它的命令并等待该命令的完成。命令的执行情况就如同在shell中执行命令:sh -c string。如果无法启动shell来运行这个命令,system函数返回错误代码127;如果是其他错误,则返回-1。否则,system函数将返回该命令的退出码。
注意:system函数调用用一个shell来启动想要执行的程序,所以可以把这个程序放到后台中执行,这里system函数调用会立即返回。
7、回收子进程
在这里,我们首先要提到两个概念:孤儿进程和僵尸进程。
孤儿进程
父进程先于子进程结束,则子进程成为孤儿进程,子进程的父进程成为init进程,称为init进程领养孤儿进程。
僵尸进程
进程终止,父进程尚未回收,子进程残留资源(PCB)存放于内核中,变成僵尸(Zombie)进程。
特别注意,僵尸进程是不能使用kill命令清除掉的。因为kill命令只是用来终止进程的,而僵尸进程已经终止。
一般僵尸进程很难直接kill掉,不过您可以kill僵尸进程的父进程。父进程死后,僵尸进程成为”孤儿进程”,过继给1号进程init,init始终会负责清理僵尸进程.它产生的所有僵尸进程也跟着消失。
wait函数
一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用wait或waitpid获取这些信息,然后彻底清除掉这个进程。我们知道一个进程的退出状态可以在Shell中用特殊变量$?查看,因为Shell是它的父进程,当它终止时Shell调用wait或waitpid得到它的退出状态同时彻底清除掉这个进程。
父进程调用wait函数可以回收子进程终止信息。该函数有三个功能:
① 阻塞等待子进程退出
② 回收子进程残留资源
③ 获取子进程结束状态(退出原因)。
定义:pid_t wait(int *status);
返回值:成功:清理掉的子进程ID;失败:-1 (没有子进程)
当进程终止时,操作系统的隐式回收机制会:1.关闭所有文件描述符 2. 释放用户空间分配的内存。内核的PCB仍存在。其中保存该进程的退出状态。(正常终止→退出值;异常终止→终止信号)可使用wait函数传出参数status来保存进程的退出状态。借助宏函数来进一步判断进程终止的具体原因。宏函数可分为如下三组:
1. WIFEXITED(status) 为非0 → 进程正常结束
WEXITSTATUS(status) 如上宏为真,使用此宏 → 获取进程退出状态 (exit的参数)
2. WIFSIGNALED(status) 为非0 → 进程异常终止
WTERMSIG(status) 如上宏为真,使用此宏 → 取得使进程终止的那个信号的编号。
*3. WIFSTOPPED(status) 为非0 → 进程处于暂停状态
WSTOPSIG(status) 如上宏为真,使用此宏 → 取得使进程暂停的那个信号的编号。
WIFCONTINUED(status) 为真 → 进程暂停后已经继续运行
waitpid函数
作用:作用同wait,但可指定pid进程清理,可以不阻塞。
定义:pid_t waitpid(pid_t pid, int *status, in options);
返回值:成功:返回清理掉的子进程ID;失败:-1(无子进程)
特殊参数和返回情况:
参数pid:
①、> 0 : 回收指定ID的子进程
②、-1 : 回收任意子进程(相当于wait)
③、0 : 回收和当前调用waitpid一个组的所有子进程
④、< -1 : 回收指定进程组内的任意子进程
参数options:
①、WNOHANG 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若结束,则返回该子进程的ID。
②、WUNTRACED 若子进程进入暂停状态,则马上返回,但子进程的结束状态不予以理会。WIFSTOPPED(status)宏确定返回值是否对应与一个暂停子进程。
参数 option 可以为 0 或可以用”|”运算符把它们连接起来使用,
比如:ret=waitpid(-1,NULL,WNOHANG | WUNTRACED);
也可以把options设为0,如:ret=waitpid(-1,NULL,0);
返回0:参3为WNOHANG,且子进程正在运行。
注意:一次wait或waitpid调用只能清理一个子进程,清理多个子进程应使用循环。