首页 > 技术文章 > 第八章:进程控制

Lioker 2019-05-15 09:21 原文

 

一、进程标识和进程状态

和文件描述符类似,每个进程都有一个非负数的唯一ID来表示它。进程ID可以在不同时刻复用,当一个进程终止后,它的ID就可以复用了。UNIX系统通常会有一个延迟的复用算法,使得新创建的进程ID不同于最近一段时间内终止的进程ID,以避免将新进程误认为是之前已终止的那个进程。

进程ID为1的通常是init进程,它在系统自举结束后由内核创建,该进程是用来初始化系统的,它通常会读取系统初始化的一些配置文件,将系统状态引导至初始化状态,init进程是不可能终止的。它是一个root用户进程,而不是内核进程,因此不是内核的一部分。

内存中的每个进程,都会在/proc目录下建立一个目录,目录名就是进程的ID(PID)。若进程结束,则目录消失。cat命令查看/proc/进程ID/maps就可以看到进程的内存分配情况。

 

除了进程ID,每个进程还有一些其他属性(如进程的父进程),函数原型如下:

#include <unistd.h>

pid_t getpid(void);        // 返回值:进程ID
pid_t getppid(void);    // 返回值:进程的父进程ID
pid_t getuid(void);        // 返回值:进程的实际用户ID
pid_t geteuid(void);    // 返回值:进程的有效用户ID
pid_t getgid(void);        // 返回值:进程的实际组ID
pid_t getegid(void);    // 返回值:进程的有效组ID

上述函数没有出错返回。

 

在命令行中使用命令ps -ef或ps-aux可以查看系统所有进程,如下图:

 

我们可以看到进程状态那一栏有各种字母,这些字母意味着什么呢?

S:休眠状态,进程大多数处于休眠状态;

s:说明该进程有子进程(父进程);

R:正在运行的进程;

Z:僵尸进程(已经结束但资源没有回收的进程),这种进程是很危险的,应该避免。

 

 

二、fork()函数

关于父进程和子进程:如果进程a启动了进程b,a叫b的父进程,b叫a的子进程。

fork()函数原型如下:

#include <unistd.h>

pid_t fork(void);

此函数会返回两次,父进程返回子进程的PID,子进程会返回0;失败返回-1。

 

此函数有以下几个重点需要我们特别注意:

1. fork()是通过复制父进程的内存空间创建子进程,复制除了代码区以外的所有区域,代码区父子进程共享(因为代码区是只读的);

2. fork()会创建一个子进程,子进程从fork()当前位置开始执行代码,fork()之前的代码父进程执行一次,fork()之后的代码父子进程分别执行一次(共两次);

3. fork()创建子进程时,如果父进程有文件描述符,子进程会复制文件描述符,不复制文件表(父子进程共用一个文件表);

4. fork()创建子进程后,父子进程谁先运行不确定,谁先结束也不确定。

 

下面我们来看一个示例代码:

 1 #include <stdio.h>
 2 #include <unistd.h>
 3 
 4 int g_a = 222;
 5 
 6 int main()
 7 {
 8     int b = 111;
 9     pid_t pid;
10 
11     if (pid = fork()) /* 父进程 */ {
12         printf("PID: %d\n", getpid());
13 
14         --g_a;
15         --b;
16         printf("g_a = %d, b = %d\n", g_a, b);
17         printf("&g_a = %p, &b = %p\n", &g_a, &b);
18 
19         sleep(1);    /* 让父进程比子进程晚执行完成 */
20     }
21     else /* 子进程 */ {
22         printf("Father PID: %d, Son PID: %d\n", getppid(), getpid());
23 
24         ++g_a;
25         ++b;
26         printf("g_a = %d, b = %d\n", g_a, b);
27         printf("&g_a = %p, &b = %p\n", &g_a, &b);
28     }
29 
30     return 0;
31 }

此代码执行结果如下图:

通过结果可以发现,父子进程使用相同的虚拟地址,而且两进程的变量值又互不影响。这是由于父子进程把相同的虚拟地址映射到不同的物理地址。

 

对于上述第三个重点,我在此借用UNIX环境高级编程中的图片。子进程只复制了父进程的fd,但和父进程共用一个文件表,也就是共享文件偏移量、文件权限等。其实质类似于dup()类函数。

示例代码如下:

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 #include <unistd.h>
 4 #include <fcntl.h>
 5 #include <time.h>
 6 
 7 int main()
 8 {
 9 //    pid_t pid = fork();         // 先fork没有复制,而是创建
10     int fd;
11 
12     fd = open("a.txt", O_RDWR|O_CREAT, 0666);
13     if (fd == -1) perror("open"), exit(-1);
14     
15     pid_t pid;
16     if (!(pid = fork())) {        // 子进程会复制fd
17         write(fd, "hello", 5);    // 只复制描述符,不复制文件表
18         close(fd);
19         exit(0);
20     }
21     
22     sleep(1);                     // 让子进程先结束
23     write(fd, "12345", 5);
24     close(fd);
25 }

执行此代码,会发现12345并没有覆盖hello,这是因为它们共享文件表的偏移量。

 

fork()函数产生子进程之后,子进程可以使用exec()类函数来执行新的程序,exec之后新的进程仍然和父进程共享同一个文件描述符。

在exec后,文件描述符默认保持打开状态。除非显式使用fcntl()设置,或者在open()打开文件时显式指定。

 

 

三、进程退出

进程能正常执行结束退出,也可能未执行完毕异常终止。

第一种情况下,exit()类函数会获得一个进程main函数的return返回值作为“退出状态”,然后内核将“退出状态”转换为“终止状态”;

第二种情况下,内核为其产生一个“终止状态”。

这两种情况产生的“终止状态”中含有该进程相关的一些信息,比如进程ID、进程终止状态、进程CPU使用情况、有无core dump等信息。其实就是进程的资源仍然存在,需要使用wait()类函数来对其处理。

 

当子进程终止时,内核会向它的父进程发送一个SIGCHLD信号,该信号是一个异步事件。父进程可以忽略该信号或者加以捕捉处理。wait()和waitpid()可以让父进程等待子进程的结束,并取得子进程的退出状态和退出码(return后面的值或exit中的值)。其函数声明如下:

#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *status);

pid_t waitpid(pid_t pid, int *status, int options);

两函数成功返回处理进程的PID,出错则返回-1。

waitpid()函数出错返回值还有可能是0,此种情况是:当设置了WNOHANG选项(不阻塞等待),并且所要处理的子进程存在,但尚未有子进程需要处理,则返回0,如果没有子进程符合,或者没有子进程,则返回-1。

 

wait()函数等待任意子进程结束后返回;

waitpid()函数可以等待指定子进程结束后返回,其参数pid用于指定等待哪个进程,取值如下:

== -1,等待任意子进程,与wait()等效

> 0,等待指定子进程(指定pid)

== 0,等待本进程组的任一子进程

< -1,等待进程组ID等于pid绝对值的任意子进程

 

宏函数WIFEXITED(status)可以判断是否正常退出,WEXITSTATUS(status)可以取到退出码。示例代码如下:

 1 /* wait()等待 */
 2 int status;
 3 pid_t pid = wait(&status);    // pid是子进程ID
 4 if(WIFEXITED(status)) /* 阻塞等待子进程结束 */ {
 5     printf("返回码%d\n", WEXITSTATUS(STATUS));
 6 }
 7 
 8 /* waitpid()等待 */
 9 int status;
10 pid_t wpid = waitpid(-1/*pid*/, &status, 0);
11 /* 在此处-1表示等到了子进程就退出,pid表示等待到了名为pid的子进程就退出 */
12 
13 if (WIFEXITED(status)) /* 子进程是否正常结束 */ {
14     printf("等到了%d子进程,退出码:%d\n", wpid, WEXITSTATUS(status));
15 }

 

 

四、竞争条件

多个进程对同一个文件读写时,就可能出现读写顺序竞争的问题,对数据的读写取决于进程的访问顺序,这就构成了竞争条件。为了避免竞争,就需要进行进程同步。

同步方式有原子操作,互斥量等,其使用方式可以查看:五、并发控制

 

 

五、exec()函数

如果fork()之后要执行全新的程序,需要使用exec()函数来加载。exec()函数加载的新程序会从main()函数开始重新执行,并且清空之前进程复制的代码段、数据段、堆、栈,但进程的ID不变。有7种不同的exec函数,它们被统称为exec函数,其函数声明如下:

#include <unistd.h>

int execl(const char *path, const char *arg, ...
                       /* (char  *) NULL */);
int execlp(const char *file, const char *arg, ...
                       /* (char  *) NULL */);
int execle(const char *path, const char *arg, ...
                       /*, (char *) NULL, char * const envp[] */);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],
                       char *const envp[]);
int fexecve(int fd, char *const argv[], char *const envp[]);

上述函数失败返回-1,成功则不返回。

 

在此只解释execl()函数,其余函数和execl()函数差别不大。

execl()使用方式如下:

execl("程序的路径", "执行命令", "选项", "参数", "NULL");

只有第一个参数是必须正确的,第二个参数必须存在但可以不正确,第三个和第四个参数可以没有,NULL表示参数结束了。使用示例如下:

execl("./b.out", "b.out", NULL);

 

示例代码如下:

 1 #include <stdio.h>
 2 #include <unistd.h>
 3 #include <sys/types.h>
 4 #include <sys/wait.h>
 5 
 6 char *const ps_argv[] = {"ps", "-o", "pid,ppid,pgrp,comm", NULL};
 7 
 8 int main()
 9 {
10     pid_t pid;
11 
12     pid = fork();
13     if (pid == -1) {
14         perror("fork");
15         return 1;
16     }
17     
18     if (pid == 0) {
19         // 加载新映像
20         //execl("/bin/ps", "ps", "-o", "pid,ppid,pgrp,comm", NULL);
21     
22         //execlp("ps", "ps", "-o", "pid,ppid,pgrp,comm", NULL);
23         execvp("ps",ps_argv);
24     }
25     else {
26         wait(NULL);
27     }
28     
29     return 0;
30 }

 

 

六、vfork()函数

vfork()和fork()在语法上没有区别,唯一区别在于vfork()不复制父进程的任何资源,而是直接占用父进程的资源运行代码从而使子进程线运行,父进程处于阻塞状态,直到子进程结束或者调用了exec()系列函数。

需要注意的是,vfork()如果占用的是父进程的资源,必须用exit()显式退出。

示例代码如下:

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 #include <unistd.h>
 4 
 5 int main()
 6 {
 7     pid_t pid;
 8     if (!(pid = vfork())) {
 9         printf("子进程%d开始运行\n", getpid());
10         sleep(3);
11         printf("子进程%d结束\n", getpid());
12         exit(0);    // vfork()占用父进程资源,必须用exit()退出
13     }
14     else
15         printf("父进程结束\n");
16 
17     return 0;
18 }

执行此代码,一定是子进程执行完成后父进程才执行。

 

vfork()和execl()的合作方式:

vfork()可以创建新的进程,但没有代码和数据;execl()创建不了新进程,但可以为进程提供代码和数据。

示例代码如下:

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 #include <unistd.h>
 4 
 5 int main()
 6 {
 7     pid_t pid;
 8     if (!(pid = vfork())) {
 9         printf("子进程%d开始运行\n", getpid());
10         execl("/bin/ls", "ls", "-l", NULL);
11         printf("子进程%d结束\n", getpid());
12         exit(0);    // 如果execl()出错时,用于退出子进程
13     }
14     
15     printf("父进程开始运行\n");
16     sleep(1);    // 等待子进程结束
17     printf("父进程结束\n");
18 
19     return 0;
20 }

执行此代码,父进程不会阻塞地等待子进程执行完毕。

 

 

七、其它函数

system()函数

通过system()可以能够使用shell来执行命令,相当于用C/C++程序来调用shell。其函数声明如下:

#include <stdlib.h>

int system(const char* cmdstring);

该函数失败返回值较多,具体需参考说明手册。

 

进程调度 

UNIX系统提供了一个API接口,可以用来粗略调整进程运行优先级。其函数声明如下:

#include <unistd.h>

int nice(int inc);

该函数成功返回(nice - NZERO),失败则返回-1。

由于(nice - NZERO)可能为负值,因此对于-1的返回值,需要判断errno。如果nice参数太大,进程优先级会调整到上限;如果nice参数太小,进程优先级会调整到下限;两者都不会给出任何提示,都是静默行为。

 

 

下一章  第十章:信号

 

推荐阅读