Linux系统编程之进程

学习Linux中的进程,主要设计了三个重要的函数:fork(创建子进程),exec(执行命令),wait(回收进程资源)。进程的管理由内核中的task_struct结构体(PCB进程控制块)负责,下面我们来具体了解一下。

PCB及进程环境

进程控制块PCB

我们知道,每个进程在内核中都有一个进程控制块(PCB)来维护进程相关的信息,Linux内核的进程控制块是task_struct结构体。现在我们全面了解一下其中都有哪些信息。

  • 进程id。系统中每个进程有唯一的id,在C语言中用pid_t类型表示,其实就是一个非负整数。
  • 进程的状态,有运行、挂起、停止、僵尸等状态。
  • 进程切换时需要保存和恢复的一些CPU寄存器。
  • 描述虚拟地址空间的信息。
  • 描述控制终端的信息。
  • 当前工作目录(Current Working Directory)。
  • umask掩码。
  • 文件描述符表,包含很多指向file结构体的指针。
  • 和信号相关的信息。
  • 用户id和组id。
  • 控制终端、Session和进程组。
  • 进程可以使用的资源上限(Resource Limit)

进程环境

每个进程在运行时,都会涉及到默认的环境变量。按照惯例,环境变量字符串都是name=value这样的形式,大多数name由大写字母加下划线组成,一般把name的部分叫做环境变量,value的部分则是环境变量的值。环境变量定义了进程的运行环境,一些比较重要的环境变量的含义如下:

PATH

  • 可执行文件的搜索路径。ls命令也是一个程序,执行它不需要提供完整的路径名/bin/ ls,然而通常我们执行当前目录下的程序a.out却需要提供完整的路径名./a.out,这 是因为PATH环境变量的值里面包含了ls命令所在的目录/bin,却不包含a.out所在的目录。PATH环境变量的值可以包含多个目录,用:号隔开。在Shell中用echo命令可以查 看这个环境变量的值:$ echo $PATH

SHELL

  • 当前Shell,它的值通常是/bin/bash。

TERM

  • 当前终端类型,在图形界面终端下它的值通常是xterm,终端类型决定了一些程序的输 出显示方式,比如图形界面终端可以显示汉字,而字符终端一般不行。

LANG

  • 语言和locale,决定了字符编码以及时间、货币等信息的显示格式。

HOME

  • 当前用户主目录的路径,很多程序需要在主目录下保存配置文件,使得每个用户在运 行该程序时都有自己的一套配置。

环境变量相关函数

libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所
以在使用时要用extern声明。例如:

#include <stdio.h>

int main(void) {
    extern char **environ;
    int i;
    for(i=0; environ[i]!=NULL; i++)
    printf("%s\n", environ[i]); return 0;
}

用environ指针可以查看所有环境变量字符串,但是不够方便,如果给出name要在环境变量 表中查找它对应的value,可以用getenv函数。

获取环境变量getenv
#include <stdlib.h>

char *getenv(const char *name); 
getenv的返回值是指向value的指针,若未找到则为NULL。

设置及取消

#include <stdlib.h>

int setenv(const char *name, const char *value, int rewrite); 
void unsetenv(const char *name);
putenv和setenv函数若成功则返回为0,若出错则返回非0。

setenv将环境变量name的值设置为value。如果已存在环境变量name,那么若rewrite非0,则覆盖原来的定义; 若rewrite为0,则不覆盖原来的定义,也不返回错误。unsetenv删除name的定义。即使name没有定义也不返回错误。

示例代码

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

int main(void)
{
    // 获取
    char* path = getenv("PATH");
    printf("PATH : %s \n", path);
    // 设置
    setenv("PATH","what fuck you!",1);
    printf("NEW PATH: %s\n",getenv("PATH"));
    return 0;
}

进程资源限制

PCB由内核实施管理,其为每个进程分配的资源大小也是固定有限的,不能无限制的增加,一般来说软限制不得大于硬限制,设置进程资源的函数如下(root权限):

#include <sys/time.h> #include <sys/resource.h>

int getrlimit(int resource, struct rlimit *rlim);
int setrlimit(int resource, const struct rlimit *rlim);

查看资源限制

cat /proc/self/limlts
ulimit -a

设置进程打开文件上限值

ulimit -n ?

进程原语

linux涉及进程的函数主要包括复制进程fork,执行命令exec以及回收进程资源wait(waitpid)。程序一般的流程是通过fork得到一个子进程,然后再子进程中执行exec命令操作,之后通过wait或者waitpid回收进程执行的结果。

进程状态

linux中进程存在多种状态,运行,就绪,睡眠,停止,各种状态存在转换关系,例如运行态可以切换到任意其他三种状态,具体切换状态如下:

运行: 可以切换到就绪,睡眠,停止任意状态
就绪: 可以切换到就绪,停止,无法直接切换到运行
就绪: 同运行,可以切换任意其他状态
停止: 无法切换状态

相关命令

查看系统所有程序: ps aux
动态查看进程: top
进程组查看: ps ajx

fork

fork的作用是根据一个现有的进程复制出一个新进程,原来的进程称为父进程(Parent Process),新进程称为子进程(Child Process)。系统中同时运行着很多进程,这些进程都是从最初只有一个进程开始一个一个复制出来的。在Shell下输入命令可以运行一个程序,是因为Shell进程在读取用户输入的命令之后会调用fork复制出一个新的Shell进程,然后新的Shell进程调用exec执行新的程序。

概述
#include <unistd.h>

pid_t fork(void);
参数介绍

void

返回值

fork调用一次,返回两次。调用成功后,父进程中返回0,子进程中返回系统分配的pid(非负整数)。如果调用失败,父进程中返回-1,并且设置errno,子进程不被创建。

EAGAIN: 当前的进程数已经达到了系统规定的上限,这时errno的值被设置为
ENOMEM: 系统内存不足,这时errno的值被设置为
相关说明
  1. fork调用一次,返回两次,成功时,父进程中返回0,子进程中返回进程id。
  2. fork执行后,父子进程的执行代码直接从返回值处pid开始,父子进程的执行的先后顺序不确定,由操作系统调度。父进程先于子进程结束,子进程的父进程转换成init守护进程。
  3. 父子进程的拷贝采用读时共享,写时复制机制(copy on write)。
参考代码
#include <sys/types.h> 
#include <unistd.h> 
#include <stdio.h> 
#include <stdlib.h>
int main(void) {
    pid_t id = fork();
    pid_t pid; char *message; int n;
    pid = fork(); 
    if (pid < 0) {
        perror("fork failed");
        exit(1);
    }
    if (pid == 0) {
        message = "This is the child\n"; n = 6;
    } else {
        message = "This is the parent\n"; n = 3;
    }
    for(; n > 0; n--) {
        printf(message);
        sleep(1); 
    }
    return 0; 
}

fork调用完成后,此时父子进程都从if判断开始,由于父子进程执行顺序不确定,在各自逻辑处理完成后,睡眠等待 1s 后,在结束父进程,后续可以通过wait函数进行回收进程资源。其代码执行流程见下图:

exec

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

#include <unistd.h>
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,所以exec函数只有出错的返回值而没有成功的返回值。

相关说明

exec函数族记忆方式:

l 命令行参数列表
p 搜素file时使用path变量
v 使用命令行参数数组
e 使用环境变量数组,不使用进程原有的环境变量,设置新加载程序运行的环境变量

execvp常用,使用数组将命令组织,调用时直接使用系统路径进行

代码举例:

char *const ps_argv[] ={"ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL}; 
char *const ps_envp[] ={"PATH=/bin:/usr/bin", "TERM=console", NULL}; 

execl("/bin/ps", "ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL); 
execv("/bin/ps", ps_argv);
execle("/bin/ps", "ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL, ps_envp); 
execve("/bin/ps", ps_argv, ps_envp);
execlp("ps", "ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL);
execvp("ps", ps_argv);

事实上,只有execve是真正的系统调用,其它五个函数最终都调用execve,所以execve 在man手册第2节,其它函数在man手册第3节。这些函数之间的关系如下图所示:

示例代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        // 子进程执行打开网页
        execlp("firefox","firefox","www.baidu.com",NULL);
    }
    else if(id > 0)
    {
        // 父进程执行 ls
        execl("/bin/ls","ls","-la", "./../",NULL);
    }
    return 0;
}

wait(waitpid)

一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止 则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用wait或waitpid获取这些信息,然后彻底清除掉这个进程。

我们知道一个进程的退出状态可以在Shell中用特殊变量$?查看,因为Shell是它的父进程,当它终止时Shell调用wait或waitpid得到它的退出状态同时彻底清除掉这个进程。如果一个进程已经终止,但是它的父进程尚未调用wait或waitpid对它进行清理,这时的进程状态称为僵尸(Zombie)进程。任何进程在刚终止时都是僵尸进程,正常情况下,僵尸进程都立刻被父进程清理了。

  • 僵尸进程: 子进程退出,父进程没有回收子进程资源(PCB),则子进程变成僵尸进程
  • 孤儿进程: 父进程先于子进程结束,则子进程成为孤儿进程
  • 领养孤儿进程: 成为孤儿的子进程,其父进程变成成为1号init进程
函数原型
#include <sys/types.h> 
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);

对于 waitpid 的 pid 而言:
< -1 回收指定进程组内的任意子进程
-1 回收任意子进程
0 回收和当前调用waitpid一个组的所有子进程 
> 0 回收指定ID的子进程

若调用成功则返回清理掉的子进程id,若调用出错则返回-1。父进程调用 wait或waitpid 时可能出现情况:

  • 阻塞(如果它的所有子进程都还在运行)。
  • 带子进程的终止信息立即返回(如果一个子进程已终止,正等待父进程读取其终止信息)。
  • 出错立即返回(如果它没有任何子进程)

这两个函数的区别是:

  • 如果父进程的所有子进程都还在运行,调用wait将使父进程阻塞,而调用waitpid时如果在options参数中指定WNOHANG可以使父进程不阻塞而立即返回0。
  • wait等待第一个终止的子进程,而waitpid可以通过pid参数指定等待哪一个子进程。

可见,调用 wait和waitpid 不仅可以获得子进程的终止信息,还可以使父进程阻塞等待子进程终止,起到进程间同步的作用。如果参数 status 不是空指针,则子进程的终止信息通过这个参数传出,如果只是为了同步而不关心子进程的终止信息,可以将 status 参数指定为 NULL。

对于子进程退出状态的监测,使用宏函数进行监测,具体参考 Man Page。

示例代码

wait阻塞推出监测

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

int main()
{

    pid_t id = fork();
    int status;
    pid_t ret;

    if(id == 0)
    {
        // 子进程中返回,处理子进程逻辑
        printf("Child is running for 5s! The pid is %d\n",getpid());
        // 延时5s,执行退出
        sleep(5);
        exit(1);
    }
    else if(id > 0)
    {
        // 父进程中返回
        printf("This is parent, the parent id is %d\n",getppid());
    }

    while(1)
    {
        // 阻塞函数,将子进程返回状态存到status中
        ret = wait(&status);
        if(ret == id)
            printf("Child exits, pid id %d\n",ret);
        else if(ret == -1)
        {
            printf("No child is exist\n");
            break;
        }
        else
            break;
    }

    // 退出状态监测status
    if (WIFEXITED(status))
        printf("Child exited with code %d\n", WEXITSTATUS(status));
    else if (WIFSIGNALED(status))
        printf("Child terminated abnormally, signal %d\n", WTERMSIG(status));

    return 0;
}

waitpid指定阻塞监听

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

int main(void)
{
    pid_t pid;
    pid = fork();
    if (pid < 0) {
        perror("fork failed");
        exit(1);
    }
    if (pid == 0) {
        int i;
        for (i = 3; i > 0; i--) {
            printf("This is the child\n");
            sleep(1);
        }
        exit(3);
    } else {
        int stat_val;
        // 回收指定id进程资源
        waitpid(pid, &stat_val, 0);
            if (WIFEXITED(stat_val))
                printf("Child exited with code %d\n", WEXITSTATUS(stat_val));
            else if (WIFSIGNALED(stat_val))
                printf("Child terminated abnormally, signal %d\n", WTERMSIG(stat_val));
    }
    return 0;
}

#### 进程相关函数

获取进程id

#include <sys/types.h> 
#include <unistd.h>
pid_t getpid(void); //返回调用进程的PID号
pid_t getppid(void); //返回调用进程父进程的PID号

获取用户id

#include <unistd.h> 
#include <sys/types.h>
uid_t getuid(void); //返回实际用户ID 
uid_t geteuid(void); //返回有效用户ID

获取组id

#include <unistd.h> 
#include <sys/types.h>
gid_t getgid(void); //返回实际用户组ID 
gid_t getegid(void); //返回有效用户组ID
特殊权限位

s: 这里的特殊权限位s代表的是动态权限设置位(setuid),方便普通用户可以以root用户的角色运行只有root帐号才能运行的程序或命令。

whereis passwd
/usr/bin/passwd
ls -la /usr/bin/passwd
-rwsr-xr-x  1   root    root    ...

在设置s权限时文件属主、属组必须先设置相应的x权限,否则s权限并不能正真生效(当我们ls -l时看到rwS,大写S说明s权限未生效)

执行    sudo chmod * 04755  
原权限  -rwxr-xr-x 
新权限  -rwsr-xr-x 

t: 设置粘着位,一个文件可读写的用户并一定相让他有删除此文件的权限,如果文件设置了t权限则只用属主和root有删除文件的权限,通过chmod +t filename 来设置t权限。

i: 不可修改权限。例:chattr u+i filename 则filename文件就不可修改,无论任何人,如果需要修改需要先删除i权限,用chattr -i filename就可以了。查看文件是否设置了i权限用lsattr filename。

a: 只追加权限,对于日志系统很好用,这个权限让目标文件只能追加,不能删除,而且不能通过编辑器追加。可以使用chattr +a设置追加权限。

特殊权限详见

总结

本篇博文主要介绍了Linux中涉及进程的三大函数fork、exec以及wait,知晓了一般进程的创建,执行以及回收销毁登,为后续进程间通信提供基础。对于Linux中的进程限制,可以通过手动修改提高程序的并发行,但是此方法也不是万能的,后续还会介绍Linux下的多线程编程基础,涉及到多线程,又不得不提线程同步,东西那么多还是要一点一点的啃,此章节就此告一段落吧!

邢文鹏老师Linux教学资料

坚持原创技术分享,您的支持将鼓励我继续创作!