目录

一、进程标识符

二、fork()函数

1.文件共享:

2.进程属性

4.fork使用思路

三、vfork函数

四、exit函数

五、wait和waitpid函数

六、waitid、wait3、wait4函数

1.waitid函数

2.wait3、wait4函数

七、竞争条件

八、exec函数

九、更改用户ID和组ID

setgid和setuid函数

setreuid和setregid函数

seteuid和setegid函数

十、system函数

十一、进程会计

十二、进程时间


一、进程标识符

        每个进程都有一个非负整形表示的唯一进程ID。ID为0的通常是调度进程,也被称为交换进程。该进程是内核的一部分,它并不执行任何磁盘上的程序,因此也被称为系统进程。进程ID为1的通常是init进程,在自举过程结束时由内核调用,此进程负责在自举内核后启动一个UNIX系统。init通常读与系统有关的初始化文件(/etc/rc*文件或/etc/inittab文件,以及init.d中的文件),并将系统引导到一个状态(例如多用户)。init绝不会终止,它是一个普通的用户进程,但以超级用户特权运行。

除了进程ID,每个进程还有一些其他的标识符:

#include <unistd.h>

pid_t getpid(void);
//返回调用进程的进程ID

pid_t getppid(void);
//返回调用进程的父进程ID

uid_t getuid(void);
//返回调用进程的实际用户ID

uid_t geteuid(void);
//返回调用进程的有效用户ID

gid_t getgid(void);
//返回调用进程的实际组ID

gid_t getegid(void);
//返回调用进程的有效组ID

二、fork()函数

创建一个新的进程:fork函数

#include <unistd.h>

pid_t fork();
//子进程中返回0,父进程中返回子进程ID,出错返回-1

        fork函数被调用一次,但返回两次(在子进程和父进程中分别返回)。子进程中返回0,父进程中返回子进程ID。子进程是父进程的副本,例如,子进程获得父进程数据空间、堆和栈的副本(注意,是副本,父子进程并不共享这些空间)

        由于fork之后经常跟随exec(子进程去执行一个新程序),所以现在的很多实现并不执行一个父进程数据段、栈和堆的完全复制,而是使用了写时复制技术。这些区域由父子进程共享,而内核将他们的权限改变为只读,如果父进程或子进程试图修改这些区域,就创建一个副本,使父子进程的数据独立开来。

#include "apue.h"

int glob = 6;
cahr buf[] = "a write to studout\n"

int main(void){
    int var;
    pid_t pid;

    var = 88;
    if(write(STDOUT_FILENO,buf,sizeof(buf)-1)!=sizeof(buf)-1)
        err_sys("write error\n");
    printf("before fork\n");

    if((pid=fork())<0){
        err_sys("fork error");
    }else if(pid == 0){
        glob++;
        var++;
    }else{
        sleep(2);
    }

    printf("pid = %d,glob = %d,var = %d\n",getpid(),glob,var);
    exit(0);

}

        示例中,调用fork函数之后实际上是两个进程在运行相同的代码,所以最后的printf会打印两次,一次是父进程一次是子进程;因为if条件不同,父子进程会根据fork函数的返回值去执行不同的代码,在本例中,父进程休眠,子进程将两个变量的值递增,这两个值的改变并不影响父进程中的变量,他们的存储空间是独立的。

        另外,我们无法确定父进程和子进程谁先执行,所以需要打印进程ID加以辨别(后续会讲解如何使用信号使父子进程同步),父进程的ID一定比子进程的ID小,因为父进程比子进程创建的更早。输出如下:

a write to stdout
before fork
pid = 430,glob = 7,var = 89
pid = 429,glob = 6,var = 88

pid为429的是父进程,变量值没有改变,pid为430的是子进程,变量值递增。

1.文件共享:

        对于上述的例子,再重定向父进程标准输出时(终端输入./a.out > test.txt),子进程的标准输出也被重定向。实际上,fork的一个特性是父进程所有打开文件描述符都被复制到子进程中,父、自进程的每个相同的打开描述符共享一个文件表项。

        一个进程拥有三个文件描述符:标准输入、标准输出和标准出错。当该进程fork之后,如下图:

这种共享方式使得父子进程对同一文件使用了一个文件偏移量。如果父、子进程写道同一文件描述符,但又没有任何形式的同步,那么他们的输出就会混合。考虑之前的例子,假如我们在终端输入如下命令:./a.out >temp.out将标准输出重定向到temp.txt文件中,fork之后,父进程和子进程分别调用了一次printf,如果父进程没有sleep两秒等待子进程printf完成,那么父进程和子进程的输出很可能会混在一起。

        在fork之后处理文件描述符有两种常见的情况:

        (1)父进程等待子进程完成。这种情况下父进程无需对文件描述符做任何的处理,等子进程终止之后,它层进行过读、写操作的任一共享描述符的文件偏移量以及执行了相应的更新。

        (2)父子进程各自执行不同的程序段。这种情况下在fork之后父子进程各自关闭它们不需要的文件描述符。

2.进程属性

        除了打开文件之外,父进程的很多其他属性也由子进程继承,包括:

        - 实际用户ID、实际组ID、有效用户ID、有效组ID

        - 附加组ID

        - 进程组ID

        - 会话ID

        - 控制终端

        - 设置用户ID标志和设置组ID标志

        - 当前工作目录

        - 根目录

        - 文件模式创建屏蔽字

        - 信号屏蔽和安排

        - 针对任一打开文件描述符的在执行时关闭(close-on-exec)标志

        - 环境

        - 连接的共享存储段

        - 存储映射

        - 资源限制

        父子进程之间的区别是:

        - fork的返回值

        - 进程ID不同

        - 两个进程具有不同的父进程ID

        - 子进程的tms_utime、tms_stime、tms_cutime以及tms_ustime均被设置为0

        - 父进程设置的文件锁不会被子进程继承

        - 子进程的未处理的闹钟被清除

        - 子进程的未处理信号集被设置为空集

        fork失败的主要原因:1.系统中进程数超过限制 2.该实际用户ID的进程总数超过了系统限制

        fork的两种用法:

4.fork使用思路

        1. 一个父进程希望复制自己,使父子进程同时执行不同的代码段。这在网络服务进程中是常见的,父进程等待客户端的服务请求。当这种请求到达时父进程创建子进程处理此请求,父进程继续等待下一个服务请求到达。

        2. 一个进程要执行一个不同的程序,使用exec函数(后续会讲到)。这种情况下fork之后立即调用exec函数

三、vfork函数

        vfork函数的调用序列和返回值与fork相同,但语义不同。vfork创建一个新进程,新进程的目的是exec执行新程序(与上面讲到的fork使用思路的第二点相同)。vfork和fork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec,于是也就不会访问该地址空间。相反,子进程在调用exec或exit之前,它们在父进程的空间中运行。这些措施优化在某些UNIX的页式虚拟存储器实现中提高了效率。vfork保证子进程先运行,在它调用exec或exit之后父进程才可能被调度,如果调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。

四、exit函数

        回顾前面讲到的进程终止的八种情况(五种正常情况三种异常情况),不管进程如何终止,最后都会执行内核中的同一段代码。该代码为相应进程关闭所有打开描述符,释放它所用的存储器等等。

        对于所有的终止状态,我们都希望终止进程能够通知父进程其终止的原因,对于exit、_exit、和_Exit函数,将退出状态作为参数传递给三个函数。在异常终止的情况下,内核产生一个指示其异常终止的原因的终止状态。在任意一种终止情况下,父进程都能使用wait函数或waitpid函数获取子进程的退出状态。

        刚才讲到父进程可以获取子进程的退出状态,但是如果父进程在子进程终止之前就已经终止呢?对于父进程已经终止的子进程,我们称其为孤儿进程,所有孤儿进程都会被init进程领养,也就是孤儿进程的父进程变为init进程。

        父进程使用wait或wiatpid函数获取子进程的终止信息,包括:进程ID、该进程的终止状态、以及该进程使用的CPU时间总量。内核可以释放终止进程所使用的所有存储区,关闭其打开的文件。如果一个子进程已经终止且没有被父进程使用wait函数回收终止信息,则变为僵死进程。ps命令将僵死进程的状态打印为Z。

        init进程被编写成无论何时只要有一个子进程终止,init进程就会调用一个wait函数获取其终止状态。所以被init收养的孤儿进程终止后不会称为僵死进程

五、wait和waitpid函数

        当一个进程正常或异常终止时,内核就向其父进程发送SIGCHLD信号。父进程可以选择忽略,也可以选择执行指定的程序作出反应。对于这种信号,默认的动作是忽略。当程序调用wait或waitpid函数时可能会:

        (1)如果其子进程都还在运行,则阻塞

        (2)如果一个子进程已终止,正等待父进程获取其终止状态,则取得的该子进程的终止状态然后立即返回

        (3)如果它没有任何子进程,则以及出错返回

#include <sys/wait.h>

pid_t wait(int *statloc);
pid_t waitpid(pid_t pid,int *statloc,int options);
//成功则返回进程ID,出错则返回-1

两个函数的区别:

        - 在一个子进程中之前,wait使其调用者阻塞,而waitpid有一个选项,可使调用者不阻塞

        - waitpid并不等待在其调用后的第一个终止子进程,它可以选择自己指定的要等待的终止子进程

        这两个函数的参数statloc是一个整形指针,如果statloc不是一个空指针,则终止进程的终止状态就存放在它所指向的单元内。如果不关心终止状态,则可直接将该指针指定为空指针。

示例:

#include "apue.h"
#include <sys/wait.h>

void pr_exit(int status){
    if(WIFEXITED(status))
        printf("normal termination,exit status = %d\n",WEXITSTATUS(status));
    else if(WIFSIGNALED(status))
        printf("normal termination,exit status = %d%s\n",
        WTERMSIG(status),
        #ifdef WCOREDUMP
            WCOREDUMP(status) ? "(core file generated)" : "");
        #else
         "" );
        #endif
        else if(WIFSTOPPED(status))
            printf("child stopped,signal number = %d\n",WSTOPSIG(status));
}

int main(void)
{
    pid_t pid;
    int status;

    if((pid = fork())<0)
        err_sys("fork error");
    else if(pid == 0)
        exit(7);
    
    if(wait(&status)!=pid)
        err_sys("wait error");
    pr_exit(status);


    if((pid = fork())<0)
        err_sys("fork error");
    else if(pid == 0)
        sbort();
    
    if(wait(&status)!=pid)
        err_sys("wait error");
    pr_exit(status);


    if((pid = fork())<0)
        err_sys("fork error");
    else if(pid == 0)
        status / = 0;
    
    if(wait(&status)!=pid)
        err_sys("wait error");
    pr_exit(status);

    exit(0);
    
}

waitpid中的pid参数:

        - pid == -1等待任一子进程

        - pid > 0 等待其进程ID与PID相等的子进程

        - pid ==0 等待其组ID等于调用进程组ID的任一子进程

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

而参数options可以进一步控制该函数:

对于一个程序,可能父进程创建一个子进程后就继续工作,不能确定子进程什么时候会终止,所以不能确定调用wait的时机,如果调用太早的话会使得父进程长时间阻塞;如果调用太晚的话可能使得子进程长时间处于僵死状态,这时候就需要使用“双fork”来解决这个问题:

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

int main(){
    printf("祖先进程 PID:%d\n",getpid());
    
    pid_t pid1 = fork();

    if(pid1 == 0){
        //第一次fork的子进程(中间进程,它的任务是创造真正的工作进程)
        printf("中间进程 PID:%d(父进程:%d)\n",getpid(),getppid());
    }

    pid_t pid2 = fork();
    
    if(pid2 == 0){
        //第二次fork的子进程(真正的工作进程)
        printf("工作进程: 执行实际工作...\n");
        sleep(2);  // 模拟工作
        printf("工作进程: 工作完成,退出\n");
        exit(0);
        }else if(pid2>0){//中间进程
    printf("中间进程: 创建了工作进程 PID: %d\n", pid2);
    printf("中间进程: 立即退出,让工作进程成为孤儿\n");
    exit(0);//这个时候中间进程变成了僵尸进程,不过不用担心,祖先进程会立刻wait回收它
    } else if (pid1 > 0) {
        // 祖先进程
        printf("祖先进程: 创建了中间进程 PID: %d\n", pid1);
        
        // 等待中间进程(立即会结束)
        waitpid(pid1, NULL, 0);
        printf("祖先进程: 中间进程已结束,不会变成僵尸\n");
        
        // 此时工作进程已被 init 收养
        printf("祖先进程: 工作进程已被 init 收养\n");
        printf("祖先进程: 可以继续执行其他任务\n");
        
        // 模拟祖先进程继续工作
        for (int i = 0; i < 5; i++) {
            printf("祖先进程: 工作中... (%d/5)\n", i + 1);
            sleep(1);
        }
        
        printf("祖先进程: 所有工作完成\n");
    }
    
    return 0;

}

这样编程的好处在于,祖先进程不必知晓孙进程(工作进程)何时退出,它只需要waitpid回收中间进程即可,而中间进程在fork孙进程(工作进程)之后会立即退出,所以祖先进程waitpid阻塞的时间不会太久,而且是可以预计的。

六、waitid、wait3、wait4函数

1.waitid函数

#include <sys/wait.h>

int waitid(idtype_t idtype,id_t id,siginfo_t *infop,int options);
//若成功则返回0,否则返回-1

        回顾前面讲到的,waitpid既可以等待指定ID的进程,也可以等待组ID进程,但是需要根据ID数来判别;waitid则可以使用idtype指定等待的是组ID还是进程ID:

opetions参数是下列标志的按位或:

        infop参数是指向siginfo结构的指针,包含了有关引起子进程状态改变的生成信号的详细信息,后面会详细讲解。

2.wait3、wait4函数

        这两个函数在原有功能的基础上,要求内核返回由终止进程及其所有子进程所使用的资源汇总:

pid_t wait3(int *statloc,int options,struct rusage);
pid_t wait4(pid_t pid,int *statloc,int options,struct rusage *rusage);

资源统计信息包括用户CPU时间总量、系统CPU时间总量、页面出错次数、接收到的信号的次数等等

七、竞争条件

        当(a)多个进程都企图对共享文件进行某种处理,而(b)最后的结果又取决于进程运行的顺序时,我们认为这发生了竞争条件

        竞争条件经常发生在父子进程之间,如果一个父进程要阻塞等到子进程终止,可以使用wait函数,而如果一个子进程要阻塞等到父进程终止,可以调用如下循环:

while(getppid()!=1)
    sleep(1);

这种循环的问题是它浪费了CPU的时间,因为调用者每隔一秒都被唤醒,然后进行条件测试。后面会讲述如何使用信号来解决竞争

八、exec函数

        讲解fork的时候曾提到,fork之后子进程往往要调用exec函数执行另一个程序,新程序从main函数开始执行。调用exec实际上是用一个全新的程序替换了当前进程的正文、数据、堆和栈段。

        exec一族有六个函数:

#include <unistd.h>

int execl(const char *pathname,const char *arg0, ...);

int execv(const char *pathname,char *const argv[]);

int execle(const char *pathname,const char *arg0, ...);

int execve(const char *pathname,char *const argv[],char *const envp[]);

int execlp(const char *filename,const char *arg0, ...);

int execvp(const char *filename,char *const argv[]);
//出错返回-1,成功不返回值

这些函数之间的第一个区别是前四个取路径名作为参数,后两个取文件名作为参数,当指定filename作为参数时:

        - 若filename中包含 / ,则将其视为路径名

        - 否则就按PATH环境变量,在他所指定的个目录中搜寻可执行文件。

PATH变量包含了一张目录表(称为路径前缀),目录之间用冒号分隔。例如,name=value的环境字符串:

        PATH = /bin:/usr/bin:/usr/local/bin: .

指定在四个目录中进行搜索。最后的路径前缀表示当前目录.

        如果execlp或execvp使用路径前缀中的ige找到了一个可执行脚本,但该文件不是由连接编辑器产生的机器可执行文件,则认为该文件是一个shell脚本,于是试着调用/bin/sh,并以该filename作为shell的输入。

概览
函数名 参数传递方式 环境变量 路径搜索 典型用途
execl 列表(list) 继承当前 已知完整路径时
execv 数组 继承当前 已知完整路径,参数动态构建
execlp 列表 继承当前 使用PATH搜索可执行文件
execvp 数组 继承当前 使用PATH搜索,参数动态
execle 列表 自定义 需要自定义环境变量
execve 数组 自定义 系统调用,最底层

execl函数:

execl("/bin/ls","ls","-l","/home",NULL);
//参数:
//    const char *path : "/bin/ls"可执行文件的路径
//    const char *arg0 : "ls"程序名
//    ... : "-l","/home"参数列表,以NULL结尾
//新程序将执行 ls -l /home命令

        特点是必须知道可执行文件的完整路径,最后一个参数必须是NULL

execv函数:

char *args[] = {"ls","-l","home",NULL};//静态参数
execv("/bin/ls",args);

char **args = malloc(4 * sizeof(char *));//动态参数
args[0]="grep";
args[1]="-r";
args[2]="pattern";
args[3]=NULL;
execv("/bin/gerp",args);

        特点是输入参数可以动态创建,参数以指针数组形式传递

execlp函数:

execlp("ls","ls","-l","/home",NULL);
//系统会在PATH中寻找ls

execlp("bash","bash","-c","echo $PATH",NULL);

        特点是不需要知道完整路径

execvp函数:

char *args[] = {"grep","error","logfile.txt",NULL};
execvp("grep",args);//在PATH搜索grep

        特点:最常用的exec函数,结合了PATH搜索和动态参数

execle函数:

char *env[] = {//自定义环境变量
    "PATH=/usr/local/bin:/usr/bin",
    "HOME=/home/user",
    "MYVAR=custom_value",
    NULL
};

execle("/usr/bin/env","env",NULL,env);

        特点是可以指定新的环境变量不会继承当前进程的环境

execve函数:

char *args[] = {"program", "arg1", "arg2", NULL};
char *env[] = {"PATH=/bin", "TERM=xterm", NULL};

execve("/path/to/program", args, env);

        特点:这是六个exec函数中唯一的系统调用,其他都是库函数,这个函数的功能最完整最底层,前五个函数最终都需要调用execve:

当PATH路径中存在多个同名的可执行文件时,execlp和execvp函数会按照PATH环境变量的顺序选择第一个找到的可执行文件

九、更改用户ID和组ID

        UNIX系统中,特权(读写文件、修改日期表示法等等)是基于用户和组ID的。当程序需要增加特权以访问当前不允许访问的资源时,我们需要更换自己的用户ID和组ID。

        一般而言,我们的程序只应当具备为完成给定任务所需的最小特权。这样减少了安全性收到损害的可能性。

setgid和setuid函数

        可以用setuid函数设置实际用户ID和有效用户ID,sitgid设置实际组ID和有效组ID:

#include <unistd.h>

int setuid(uid_t uid);
int setpid(gid_t gid);
//成功则返回0,失败返回-1

        改变用户ID的规则(本文所有关于用户ID所说明的一切都适用于组ID):

        (1)若进程具有超级用户特权,则setuid函数将实际用户ID、有效用户ID、以及保存的设置用户ID设置为uid

        (2)若进程没有超级用户特权,但uid等于实际用户ID或保存的设置用户ID,则setuid只将有效用户ID设置为uid,不改变实际用户ID和保存的设置用户ID

        (3)若上面两条都不满足,则将errno设置为EPERM,并返回-1

        关于内核所维护的三个用户ID,还需要注意一下几点:

        (1)只有超级用户进程可以更改实际用户ID。

        (2)仅当对程序文件设置了设置用户ID位时,exec函数才会设置有效用户ID(回顾前面讲过的设置用户ID权限)。

        (3)保存的设置用户ID是由exec复制有效用户ID而得来的。

前面讲到的getuid和getpid只能获得实际用户ID和有效用户ID,不能获得设置用户ID

举例:

        man程序,man程序可能需要执行许多其他命令,以处理包含需显示手册页的文件。为了防止被欺骗运行错误的命令或重写错误的文件,man命令不得不在两种权限之间切换:运行man命令的用户的权限,以及拥有man可执行文件用户的权限,工作步骤如下:

        (1)man程序文件是由名为man的用户拥有的,并且其设置位用户ID已设置,当host用户在输入./man命令运行该程序时:

                实际用户ID = host(启动该程序的用户)

                有效用户ID = man(拥有man可执行文件的用户)

                保存的设位用户ID = man

        (2)man程序访问man用户拥有的文件,此时有效用户ID是man,可以访问

        (3)先调用geteuid保存当前的有效用户ID为euid(下一步要用),然后调用setuid(getuid()),根据前面讲到的setuid用法的第二条,我们是非root用户进程,所以这个操作只将实际用户ID何有效用户ID设置为实际用户ID:

                实际用户ID = host

                有效用户ID = host

                保存的设置位用户ID = man

        现在我们可以以host的用户权限运行,访问host所拥有的文件

        (4)执行完上述访问操作之后,man调用setuid(euid)(euid是上一步保存的原有效用户ID,根据前面讲到的setuid函数使用方法第二条,设置的uid与保存的设置用户ID相同,则setuid只将有效用户ID设置为uid,不改变实际用户ID和保存的设置用户ID。这就是为什么需要保存的设置用户ID的原因):

                实际用户 = host

                有效用户ID  = man

                保存的设置用户ID = man

        (5)因为man程序的有效用户ID是man,所以它现在可以对其文件进行操作

以上的流程其实是man程序的有效用户ID利用实际用户ID和保存的设置用户ID在两个权限之间跳变,达到可以在相应时候操作对应文件的目的

setreuid和setregid函数

        交换实际用户ID和有效用户ID的值:

#include <unistd.h>

int setreuid(uid_t ruid,uid_t euid);
int setregid(gid_t rgid,gid_t egid);
//成功返回0,失败返回-1

        一个非特权用户总能交换实际用户ID和有效用户ID。这允许一个设置用户ID程序转换为只具有用户的普通权限,以后可再次转换回设置用户ID所得到的额外权限。

seteuid和setegid函数

        类似于setuid和setgid,但之更改有效用户ID和有效组ID

#include <unistd.h>

int seteuid(uid_t uid);
int setegid(gid_t gid);
//成功返回0,出cup返回-1

一个非特权用户可将其有效用户ID设置为其实际用户ID或其保存的设置用户ID。

        本章讲述的各个函数提供了不同用户在三个用户之间跳转的方法:

十、system函数

        system函数是一个标准库提供的用于执行shell命令的函数,system函数会启动一个新进程,在这个shell中执行你传入的命令字符串,等待命令执行完成,返回命令的退出状态

#include <stdlib.h>

int system(const char *cmdstring);

如果cmdstring是一个空指针,则仅当命令处理程序可用时,system返回非0值,这一特征可以确定在一个给定的操作系统上是否支持system函数。

        system实现中调用了fork、exec和watipid函数,有三种返回值:

        (1)若fork失败或者waitpid返回除EINTR之外的出错,则system返回-1,而且errno中设置了错误类型值。

        (2)若exec失败,则返回值如同shell执行了exut(127)一样。

        (3)否则所以三个函数(fork、exec、waitpid)都执行成功,system返回值是shell(子进程)的终止状态

十一、进程会计

        大多数UNIX系统提供了一个选项以进行进程会计处理。启用该选项后,每当进程结束时内核就写一个会计记录。典型的会计记录包括总量较小的二进制数据,一般包括命令名、所使用的CPU时间总量、用户ID和组ID、启动时间等。

        accton命令调用acct函数启动进程会计,将会计记录写入特定的路径,linux中,会计记录写入/var/account/pacct,会计记录的形式如下:

十二、进程时间

我们可以测量的时间有三种:墙上时钟时间、用户CPU时间和系统CPU时间,任一进程都可以调用times函数以获得它自己以及终止子进程的上述值

#include <sys/times.h>

clock_t times(struct tms *buf);

此函数填写由buf指向的tms结构:

struct tms{

        clock_t tms_utime;

        clock_t tms_stime;

        clock_t tms_cutime;

        clock_t tms_cstime;

}

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐