Linux的多任务编程-进程

Linux的多任务编程-进程

进程的概念

进程是指一个具有独立功能的程序在某个数据集合上的一次动态执行过程,它是系统进行资源分配和调度的基本单元.一次任务的运行可以并发激活多个进程,这些进程相互合作来完成该任务的一个最终目标.

进程的特性:并发性,动态性,交互性,独立性,异步性. 

进程的种类:交互式进程,批处理进程,实时进程.

进程和程序是有本质区别的:程序是静态的一段代码,是一些保存在非易失性存储器的指令的有序集合,没有任何执行的概念;而进程是一个动态的概念,它是程序执行的过程,包括了动态创建,调度和消亡的整个过程,它是程序执行和资源管理的最小单位.

进程状态:运行状态,可中断的阻塞状态,不可中断的阻塞状态,可终止的阻塞状态,暂停状态,跟踪状态,僵尸状态,僵尸撤销状态.

进程状态转换关系:


进程是构成Linux系统应用的一块基石,它代表了一个Linux系统上的绝大部分活动,不管你是系统程序员,应用程序员,还是系统管理员,弄明白Linux的进程管理将使你"一切尽在掌握".
一个正在运行的程序(或者叫进程),是由程序代码,数据,变量(占用着系统内存),打开的文件(文件描述符)和一个环境组成.通常,Linux系统会让进程共享代码和系统库,所以在任何时刻内存里都只有代码的一份拷贝.例如,不管有多少进程在调用printf()函数,内存里只需要有一份它的代码就够了.
每个进程都会分配到一个独一无二的数字编号,我们称之为"进程标识码"(Process identifier,PID),它这是一个正整数,取值范围从2到32768.当一个进程被启动的时候,它会分配到一个未使用的编号数字做为自己的PID.虽然该编号是唯一的,但是当一个进程终止后,其PID就可以再次使用了.根据系统具体实现的不同,大多数的系统则会将所有可有的PID轮过一圈后,再考虑使用之前释放出的PID.

Linux内核通过惟一的进程标识符PID来标识每个进程.PID存放在进程描述符的pid字段中.在Linux中获得当前进程的进程号(PID)和父进程号(PPID)的系统调用函数分别为getpid()和getppid().

进程的数据结构

表示进程的数据结构是struct task_struct.task_struct结构是进程实体的核心,Linux内核通过对该结构的相关操作来控制进程,task_struct结构是一个进程存在的唯一标志,也就是通常说的进程控制块(PCB,Process Control Block).
Linux将所有task_struct结构的指针存储在task数组中,数组的大小就是系统能容纳的进程数目,默认为512.

进程的内存结构


进程的创建

与其他的操作系统有所不同,为了实现创建进程的开销尽可能低,在Linux中"创建一个新的进程"与"在一个进程中运行一个给定的操作"是有所区别的.不过这样的区别在概念上并不十分重要,而是通过这样的观点设计出的Linux内核具有了很好的多进程性能,这样的设计思想是值得我们去学习的.一个现有的进程可以调用fork()函数创建一个新的进程.

fork()函数用于从已存在的进程中创建一个新进程.新进程称为子进程,而原进程称为父进程.使用fork()函数得到的子进程是父进程的一个复制品,它从父进程处继承了整个进程的地址空间,包括进程上下文、代码段、进程堆栈、内存信息、打开的文件描述符、信号控制设定、进程优先级、进程组号、当前工作目录、根目录、资源限制和控制终端等,而子进程所独有的只有它的进程号、资源使用和计时器等.fork函数的原型和返回值如下:


注:unistd.h 是 C 和 C++ 程序设计语言中提供对 POSIX 操作系统 API 的访问功能的头文件的名称.该头文件由 POSIX.1 标准(单一UNIX规范的基础)提出,故所有遵循该标准的操作系统和编译器均应提供该头文件(如 Unix 的所有官方版本,包括 Mac OS X,Linux 等).对于类 Unix 系统,unistd.h 中所定义的接口通常都是大量针对系统调用的封装,如 fork,pipe 以及各种 I/O 原语(read,write,close 等等).

fork()函数的使用很简单,下面通过一个简单的例子来进一步学习.

int main()
{
    pid_t pid;
    char *message;
    int n = 6;/* 全局变量 */

    printf("fork program starting\n");
    pid = fork();
    switch(pid) 
    {
    case -1:
        perror("fork failed");
        exit(1);
    case 0:
        message = "This is the child";
        n ++;
        break;
    default:
        message = "This is the parent";
        n --;
        break;
    }
    printf("%s: pid = %d, n=%d\n",message,getpid(),n); 

    exit(0);
}
一般来说,在fork()之后是父进程还是子进程先执行是不确定的.这取决于内核所使用的调度算法.如果要求父子进程之间同步,则要使用某种形式的进程间同步进步.在这个例子中,可以看到父进程优先于子进程执行,但不能保证在所有的环境中都是这样的顺序.
子进程创建后就具有了自己的地址空间,因此在子进程中对变量的做的操作(n++),没有对父进程造成影响,这两个变量具有同样的名字,但在内存中位置是不同的,从输出的结果可以看到,父进程的n值最后为5,而子进程的n最后为7.

文件描述符共享

调用fork()的时候,子进程从父进程继承的属性都被设置为这些属性在父进程中的相同值.但是,之后两个进程按照各自的方式运行,多数情况下相互独立.例如,如果子进程改变了目录,则父进程的目录不受影响.
但是,已经打开的文件则是该规则的一个例外.已打开的文件描述符是共享的,一个进程对一个共享文件描述符的动作也会影响到该文件对于其他进程的状态.考虑下面的情况:一个进程通过fork()创建了一个子进程,父子进程都想标准输出写操作,如果父进程的标准输出已经重定向,那么子进程写到标准输出时,它将更新与父进程共享该文件的偏移量.请看下面的例子:
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>

int main()
{
    pid_t pid;
    char *message ;
    int n = 6;/* 全局变量 */
    /* 输出重定向 */
    message = “Message From STDOUT\n”;
    if (write(STDOUT_FILENO, message ,sizeof(message)-1) != sizeof(message)-1)
        perror(“write error”)

    printf("fork program starting\n");
    pid = fork();
    switch(pid) 
    {
    case -1:
        perror("fork failed");
        exit(1);
    case 0:
        message = "This is the child";
        n ++;
        break;
    default:
        message = "This is the parent";
        n --;
        sleep(2);
        break;
    }
    printf("%s: pid = %d, n=%d\n",message,getpid(),n); 

    exit(0);
}
而如果重定向该程序的输出:

在这个例子中,当父进程等待子进程时,子进程写到标准输出;而在子进程终止后,父进程也写到标准输出上,并且知道其输出会添加在进程所写数据之后.所以将标准输出重定向到一个文件后,等到了两条"fork program starting"信息.如果父子进程不共享同一个文件偏移量,这种形式的交互就很难实现.特别是当多个文件描述符指向同一个打开的文件,只有当所有的这些文件描述符都关闭后,该文件才会关闭. 有两种常见的模式处理文件描述符共享:
  • 如果父进程只需等待子进程完成,自己不做任何文件操作,那么父进程无需对其描述符做任何处理.当子进程终止后,它曾进行读,写操作的任意共享描述的文件偏移量已执行了相应的更新;
  • 如果父,子进程各自执行不同的任务,在fork()之后,关闭它父,子进程各自们不使用的文件描述符,这样就不会干扰对方使用的文件描述符.这种方法在网络服务程序中经常使用.我们后面在讲解网络编程时会进行重点分析.

vfork函数

vfork()函数的调用和返回值与fork()相同,但两者的实现不同.
  • 它与fork的不同之处在于它并不将父进程的地址空间完全复制到子进程中,vfork出来的子进程是在父进程的空间中运行的,如果这是子进程修改了某个变量,这会影响到父进程.
  • vfork存在的目的就是为了在创建子进程后调用exec去执行一个新的程序,由于没有了复制动作,创建和执行新程序的销量得到了优化和提高.
  • vfork和fork的另一个区别是:vfork保证子进程有限运行,在它调用exec或者exit后父进程才可能调度运行.而fork的父子进程运行顺序是不定的.
对于某些没有虚拟存储器的操作系统,如uClinux,它与Linux的区别在于前者没有fork()调用,只有vfork().因此在这类操作系统上创建新进程时需要特别的注意下列事项:
  • 调用vfork()后,父进程被挂起直到子进程调用exec(),或者子进程退出才能继续;
  • 进程在vfork()返回后直接运行在父进程的栈空间,并使用父进程的内存和数据.

exec()函数族

fork()函数是用于创建一个子进程,该子进程几乎拷贝了父进程的全部内容,但是,这个新创建的进程如何执行呢?这个exec函数族就提供了一个在进程中启动另一个程序执行的方法.因此,当用fork()函数创建子进程后,子进程往往要调用一个exec()函数以执行另一个程序.exec()函数实际上是一个函数族,共有6个不同的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, …, char *const envp[]);
int execve (const char *pathname, char *const argv[], char *const envp[]);
int execlp (const char *filename, const char *argv0,…);
int execvp (const char *filename, char *const argv[]);
该函数出错时返回-1.
下面通过一个例子来说明exec()函数的用法:
#include <unistd.h>
#include <stdio.h>

int main()
{
    printf("Start exec() \n");
    execlp("ps", "ps", "-ax", 0);
    printf("Done.\n");
    exit(0);
}

exec函数族使用区别:
查找方式

  • 前四个函数的查找方式都是完整的文件目录路径,而最后两个函数(以p结尾的函数)可以只给出文件名,系统就会自动从环境变量“$PATH”所指出的路径中进行查找.
参数传递方式
  • 两种方式:逐个列举,将所有参数整体构造指针数组传递
  • 以函数名的第五位字母来区分的,字母为"l"(list)的表示逐个列举的方式.其语法为char *arg;字母为"v"(vertor)的表示将所有参数整体构造指针数组传递,其语法为*const argv[].
环境变量
  • exec函数族可以默认系统的环境变量,也可以传入指定的环境变量.这里,以"e"(Enviromen)结尾的两个函数execle、execve就可以在envp[]中指定当前进程所使用的环境变量.
exec函数执行失败,常见原因:
  • 找不到文件或路径,此时errno被设置为ENOENT;
  • 数组argv和envp忘记用NULL结束,此时errno被设置为EFAULT;
  • 没有对应可执行文件的运行权限,此时errno被设置为EACCES.
在很多linux系统中,6个函数中只有execve是内核的系统调用,另外5个只是库函数,它们最终都要调用该系统调用,这6个函数的关系如下图所示:

在这种结构中,execlp和execvp函数使用系统PATH环境变量.函数execve函数的功能最丰富,但需要的参数也最多,所以应该根据实际需要选择合适的函数.下面我们通过一个而简单的例子来说明exec函数的用法.
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
    printf("Start exec() \n");
    execlp("ps", "-ax", (char *)0);
    printf("Done.\n");
    exit(0);
}
函数的运行结果如下:

注意:
  • 如果将execlp("ps", "-ax", (char *)0);写成execlp("ps", "-ax", 0);,在编译的时候会出现"函数调用中缺少哨兵 [-Wformat]"的警告!
  • "Done."的输出始终没有出现,是因为ps完全替换了调用它的进程,所有原进程执行到execlp函数后就不可能再继续执行原来的指令了.
下面我们再来看一个复杂的例子,这个例子由两个例程组成.
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
    pid_t pid;
    const char *usr_envp[ ] = {"MYDEFINE=unknown","PATH=/tmp", (char *)0};

    printf ("Begin fork()\n");
    pid = fork();
    switch(pid) 
    {
    case -1:
        perror("fork failed");
        exit(1);
    case 0:
        if (execle("/tmp/child","myarg1","my arg2", (char *)0, usr_envp)<0)
              perror("execle failed");
        break;
    default:
        break;
    }

    if (waitpid (pid, NULL, 0) < 0)
        perror("waitpid failed");
    printf ("parent exiting\n");
    exit(0);
}
在该程序中的父进程中首先创建了一个新进程,然后在子进程中调用execle函数,并将命令行参数和环境变量字符都传给了新进程.子进程的功能就是打印出所有的命令行参数和所有的环境变量字符串.子进程的代码如下:
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main ( int argc , char *argv[ ] ,char *envp[])
{
    int i;
    char **ptr;
   
    printf ("child starting\n");
    
    for ( i = 0; i < argc; i++)
       printf ("argv[%d] : %s\n",i, argv[i]);
    
    for ( ptr = envp; *ptr != 0 ; ptr++)
       printf ("%s\n",*ptr);
    printf ("child exiting\n");
    exit(0);
}
下图是该程序的运行结果:

进程的终止

一个C语言的程序总是从main()函数开始执行的,main()函数的原型为:
int main (int argc, char *argv[ ])
其中,argc是命令行参数的数目,argv是指向参数的各个指针所构成的数组.当内核执行C程序时,即使用exec()函数执行一个程序,内核首先开启一个特殊的启动例程,该例程从内核取得命令行参数和环境变量值,然后调用main()函数.
而一个进程终止则存在正常终止和异常终止两种情况。
  • 1. 正常终止的三种方式
一个进程正常终止有三种方式:

  • 由main()函数返回;
  • 调用exit()函数;
  • 调用_exit()或_Exit()函数.
由main函数返回的程序,一般会在函数的结尾处通过return语句指明函数的返回值,如果不指定这个返回值,main函数通常会返回0.但这种特性与编译器有关,因此为了程序的的通用性,应该主动养成使用return语句的习惯.
下面是一个使用exit函数的例子.
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
    pid_t pid;
   
    char 	*message ;
    int  exit_code ;

    printf ("Begin fork()\n");

    pid = fork();
    switch(pid) 
    {
    case -1:
        perror("fork failed");
        exit(1);
    case 0:
        message = "This is the child";
        exit_code = 37; 
       break;
    default:
        message = "This is the parent";
       exit_code = 0; 
        break;
    }
    printf("%s: pid = %d\n",message,getpid()); 
    
    if(pid) {
        int stat_val;
        pid_t child_pid;
        child_pid = wait(&stat_val);

        printf("Child has finished: PID = %d\n", child_pid);
        if(WIFEXITED(stat_val))
            printf("Child exited with code %d\n", WEXITSTATUS(stat_val));
        else
            printf("Child terminated abnormally\n");
    }
    exit (exit_code);
}
可以看出,在主进程中得到了子进程的退出状态值37.获得该值的方法将在后面详细讲解.

  • 2. 异常终止的两种方式

  • 当进程接收到某些信号时;或是调用abort()函数,它产生SIGABRT信号.这是前一种的特例.

这便是进程异常终止的两种方式。一个进程正常退出后传递了一个退出状态给系统,如return语句和exit()等函数.退出值是一个8位值,通常为一个int型的值.通常退出状态0表示正常退出,任何非0的退出状态表示出现了某种错误.

exit()和_exit()
exit()和_exit()函数都是用来终止进程的.当程序执行到exit()或_exit()时,进程会无条件地停止剩下的所有操作,清除包括各种数据结构,并终止本进程的运行.


exit()和_exit()的区别

  • _exit()函数的作用是直接使进程停止运行,清除其使用的内存空间,并销毁其在内核中的各种数据结构;
  • exit()函数则在这些基础上作了一些包装,在执行退出之前加了若干道工序.
  • exit()函数与_exit()函数最大的区别就在于exit()函数在终止当前进程之前要检查该进程打开过哪些文件,把文件缓冲区中的内容写回文件,就是上图中的"清理I/O缓冲"一项.

一个进程正常退出后传递了一个退出状态给系统,如return语句和exit函数.退出值是一个8位值,通常为一个int型的值.通常退出状态0表示正常退出,任何非0的退出状态表示出了某种错误.

进程的退出状态

前面我们已经多次用到了wait()和waitpid(),这两个函数的原型是:



wait()函数是用于使父进程(也就是调用wait()的进程)阻塞,直到一个子进程结束或者该进程接到了一个指定的信号为止.如果该父进程没有子进程或者他的子进程已经结束,则wait()就会立即返回。
waitpid()的作用和wait()一样,但它并不一定要等待第一个终止的子进程,它还有若干选项,如可提供一个非阻塞版本的wait()功能,也能支持作业控制

下面有几个宏可判别结束情况:
WIFEXITED(status)如果子进程正常结束则为非0 值.
WEXITSTATUS(status)取得子进程exit()返回的结束代码,一般会先用WIFEXITED来判断是否正常结束才能使用此宏.

WIFSIGNALED(status)如果子进程是因为信号而结束则此宏值为真.
WTERMSIG(status) 取得子进程因信号而中止的信号代码,一般会先用WIFSIGNALED来判断后才使用此宏.

WIFSTOPPED(status) 如果子进程处于暂停执行情况则此宏值为真.一般只有使用WUNTRACED时才会有此情况.
WSTOPSIG(status) 取得引发子进程暂停的信号代码,一般会先用WIFSTOPPED来判断后才使用此宏.

waitpid函数可以提供wait函数所没有的三个特性:

  1. waitpid可等待一个特定的进程,而wait函数则返回任意终止子进程的状态;
  2. waitpid函数提供了一个wait的非阻塞版本(使用WNOHANG选项);
  3. waitpid支持作业控制(利用WUNTRACED和WCONTINUED选项).
下面是一个例子.
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
    pid_t pid, w;
    int status;  
    char 	*message ;
    printf ("Begin fork()\n");

    pid = fork();
    switch(pid) 
    {
    case -1:
        perror("fork failed");
        exit(EXIT_FAILURE);
    case 0:
        message = "This is the child";
        printf("%s: pid = %d\n",message,getpid());
        
	if (argc == 1)
            pause();                    
        _exit(atoi(argv[1]));
       break;
    default:
        message = "This is the parent";
        break;
    }
    printf("%s: pid = %d\n",message,getpid()); 

    do {
            w = waitpid(pid, &status, WUNTRACED | WCONTINUED);
            if (w == -1) 
            { 
                perror("waitpid"); 
                exit(EXIT_FAILURE); 
            }
            
	    if (WIFEXITED(status)) {
                printf("exited, status=%d\n", WEXITSTATUS(status));
            } 
            
	    else if (WIFSIGNALED(status)) {
                printf("killed by signal %d\n", WTERMSIG(status));
            } 
            
            else if (WIFSTOPPED(status)) {
                printf("stopped by signal %d\n", WSTOPSIG(status));
            } 
            
           else if (WIFCONTINUED(status)) {
                printf("continued\n");
            }
        } while (!WIFEXITED(status) && !WIFSIGNALED(status));
        exit(EXIT_SUCCESS);
}
我们首先看看带参数运行的结果:
子进程创建后就立即退出了,并返回参数值15,接下来看看不带参数执行的结果,为了方便向进程发送信号,将进程运行在后台.

Zombie进程

创建子进程是十分容易的,但你必须密切注意子进程的执行情况.当一个子进程结束运行的时候,它与其父进程之间的关联还会保持到父进程也正常地结束运行或者父进程调用了wait()才告终止.因此,进程表中代表子进程的数据项是不会立刻释放的,虽然不再活跃了,可子进程还停留在系统里,因为它的退出码还需要保存起来以备父进程中后续的wait()调用使用.它将成为一个Zombie 进程("僵尸进程").

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值