多进程编程

15 篇文章 5 订阅
3 篇文章 0 订阅

一、服务器并发访问的问题

服务器按处理方式可以分为迭代服务器和并发服务器两类。平常用C写的简单Socket客户端服务器通信,服务器每次只能处理一个客户的请求,它实现简单但效率很低,通常这种服务器被称为迭代服务器。 然而在实际应用中,不可能让一个服务器长时间地为一个客户服务,而需要其具有同时处理 多个客户请求的能力,这种同时可以处理多个客户请求的服务器称为并发服务器,其效率很 高却实现复杂。在实际应用中,并发服务器应用的最广泛。linux有3种实现并发服务器的方式:多进程并发服务器,多线程并发服务器,IO复用,先来看多进程并发服务器的实现。
在这里插入图片描述

二、多进程编程

什么是一个进程?在操作系统原理使用这样的术语来描述的:正在运行的程序及其占用的资源(CPU、内存、系统资源等)叫做进程。站在程序员的角度来看,我们使用vim编辑生成的C文件叫做源码,源码给程序员来看的但机器不识别,这时我们需要使用编译器gcc编译生成CPU可识别的二进制可执行程序并保存在存储介质上,这时编译生成的可执行程序只能叫做程序而不能叫进程。而一旦我们通过命令(./a.out)开始运行时,那正在运行的这个程序及其占用的资源就叫做进程了。进程这个概念是针对系统而不是针对用户的,对用户来说,他面对的概念是程序。很显然,一个程序可以执行多次,这也意味着多个进程可以执行同一个程序。

进程空间内存布局

在深入理解Linux下多进程编程之前,我们首先要了解Linux下进程在运行时的内存布局。Linux 进程内存管理的对象都是虚拟内存,每个进程先天就有 0-4G 的各自互不干涉的虚拟内存空间,0—3G 是用户空间执行用户自己的代码, 高 1GB 的空间是内核空间执行 Linu x 系统调用,这里存放在整个内核的代码和所有的内核模块,用户所看到和接触的都是该虚拟地址,并不是实际的物理内存地址。 Linux下一个进程在内存里有三部分的数据,就是”代码段”、”堆栈段”和”数据段”。其实学过汇编语言的人一定知道,一般的CPU都有上述三种段寄存器,以方便操作系统的运行。这三个部分是构成一个完整的执行序列的必要的部分。”代码段”,顾名思义,就是存放了程序代码的数据,假如机器中有数个进程运行相同的一个程序,那么它们就可以使用相同的代码段。”堆栈段”存放的就是子程 序的返回地址、子程序的参数以及程序的局部变量和malloc()动态申请内存的地址。而数据段则存放程序的全局变量,静态变量及常量的内存空间。

下图是Linux下进程的内存布局:

在这里插入图片描述

**栈:**栈内存由编译器在程序编译阶段完成,进程的栈空间位于进程用户空间的顶部并且是向下增长,每个函数的每次调用都会在栈空间中开辟自己的栈空间,函数参数、局部变量、函数返回地址等都会按照先入者为栈顶的顺序压入函数栈中,函数返回后该函数的栈空间消失,所以函数中返回局部变量的地址都是非法的。
**堆:**堆内存是在程序执行过程中分配的,用于存放进程运行中被动态分配的的变量,大小并不固定,堆位于非初始化数据段和栈之间,并且使用过程中是向栈空间靠近的。当进程调用 malloc 等函数分配内存时,新分配的内存并不是该函数的栈帧中,而是被动态添加到堆上,此时堆就向高地址扩张;当利用 free 等函数释放内存时,被释放的内存从堆中被踢出,堆就会缩减。因为动态分配的内存并不在函数栈帧中,所以即使函数返回这段内存也是不会消失。
**非初始化数据段:**通常将此段称为 bss 段,用来存放未初始化的全局变量和 static 静态变量。并且在程序开始执行之前,就是在 main()之前,内核会将此段中的数据初始化为 0 或空指针。
**初始化数据段:**用来保已初始化的全局变量和 static 静态变量。文本段也称代码段,这是可执行文件中由 CPU 执行的机器指令部分。正文段常常是只读的,以防止程序由于意外而修改其自身的执行。

Linux 内存管理的基本思想就是只有在真正访问一个地址的时候才建立这个地址的物理映射,Linux C/C++语言的分配方式共有3 种方式。
(1)从静态存储区域分配。就是数据段的内存分配,这段内存在程序编译阶段就已经分配好,在程序的整个运行期间都存在,例如全局变量,static 变量。
(2)在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是系统栈中分配的内存容量有限,比如大额数组就会把栈空间撑爆导致段错误。
(3)从堆上分配,亦称动态内存分配。程序在运行的时候用 malloc 或 new 申请任意多少的内存,程序员自己负责在何时用free 或 delete 释放内存。此 区域内存分配称之为动态内存分配。动态内存的生存期由我们决定,使用非常灵活,但问题也最多,比如指向某个内存块的指针取值发生了变化又没有其他指针指向 这块内存,这块内存就无法访问,发生内存泄露。

fork()系统调用

Linux内核在启动的最后阶段会创建init进程来执行程序/sbin/init,该进程是系统运行的第一个进程,进程号为 1,称为Linux 系统的初始化进程,该进程会创建其他子进程来启动不同写系统服务,而每个服务又可能创建不同的子进程来执行不同的程序。所以init进程是所有其他进程的“祖先”,并且它是由Linux内核创建并以root的权限运行,并不能被杀死。Linux 中维护着一个数据结构叫做 进程表,保存当前加载在内存中的所有进程的有关信息,其中包括进程的 PID(Process ID)、进程的状态、命令字符串等,操作系统通过进程的 PID 对它们进行管理,这些 PID 是进程表的索引。

Linux下有两个基本的系统调用可以用于创建子进程:fork()和vfork()。fork在英文中是"分叉"的意思。为什么取这个名字呢?因为一个进程在运行中,如果使用了fork,就产生了另一个进程,于是进程就”分叉”了,所以这个名字取得很形象。在我们编程的过程中,一个函数调用只有一次返回(return),但由于fork()系统调用会创建一个新的进程,这时它会有两次返回。一次返回是给父进程,其返回值是子进程的PID(Process ID),第二次返回是给子进程,其返回值为0。所以我们在调用fork()后,需要通过其返回值来判断当前的代码是在父进程还是子进程运行,如果返回值是0说明现在是子进程在运行,如果返回值>0说明是父进程在运行,而如果返回值<0的话,说明fork()系统调用出错。 fork 函数调用失败的原因主要有两个:

  1. 系统中已经有太多的进程;
  2. 该实际用户 ID 的进程总数超过了系统限制

每个子进程只有一个父进程,并且每个进程都可以通过getpid()获取自己的进程PID,也可以通过getppid()获取父进程的PID,这样在fork()时返回0给子进程是可取的。一个进程可以创建多个子进程,这样对于父进程而言,他并没有一个API函数可以获取其子进程的进程ID,所以父进程在通过fork()创建子进程的时候,必须通过返回值的形式告诉父进程其创建的子进程PID。这也是fork()系统调用两次返回值设计的原因。

下面我们以一个简单的程序例子来讲解一下进程的创建过程。
pi@raspberrypi:~/huyuanzhuo/apue/ch3_fork$ vim fork.c

1 #include <stdio.h>
 2 #include <unistd.h>
 3 #include <string.h>
 4 #include <errno.h>
 5
 6 int main(int argc, char **argv)
 7 {
 8         pid_t pid;
 9
10         printf("Parent process PID[%d] start running...\n", getpid() );
11
12         pid = fork();
13         if(pid < 0)
14         {
15            printf("fork() create child process failure: %s\n", strerror(errno));
16            return -1;
17         }
18         else if( pid == 0 )
19          {
20            printf("Child process PID[%d] start running, my parent PID is [%d]\n", getpid(),getppid());
21            return 0;
22          }
23         else // if( pid > 0 )
24         {
25          printf("Parent process PID[%d] continue running, and child process PID is [%d]\n",
getpid(), pid);
26          return 0;
27         }
28 }

pi@raspberrypi:~/huyaunzhuo/apue/ch3_fork$ gcc fork.c -o fork
pi@raspberrypi:~/huyaunzhuo/apue/ch3_fork$ ./fork

Parent process PID[26765] start running...
Parent process PID[26765] continue running, and child process PID is [26766]
Child process PID[26766] start running, my parent PID is [26765]

fork()系统调用会创建一个新的子进程,这个子进程是父进程的一个副本。这也意味着,系统在创建新的子进程成功后,会将父进程的文本段、数据段、堆栈都复制一份给子进程,但子进程有自己独立的空间,子进程对这些内存的修改并不会影响父进程空间的相应内存。这时系统中出现两个基本完全相同的进程(父、子进程),这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的进程调度策略。如果需要确保让父进程或子进程先执行,则需要程序员在代码中通过进程间通信的机制来自己实现。

我们知道main()函数里的return()会调用exit()函数,而在任何函数的任何位置如果调用 exit()将会导致进程退出。而子进程在21行调用了return 0,所以这时候子进程在执行完20行的打印后就退出了。同理父进程在程序第26行也退出了。这里我们需要注意的是: 在编程时,任何位置的exit()函数调用都会导致本进程(程序)退出,main()函数中的return()调用也会导致进程退出,而其他任何函数中的return()都只是这个函数返回而不会导致进程退出。

在这里插入图片描述
接下来我们再写一个fork()创建子进程的例程,来深入了解父进程创建子进程的过程。
pi@raspberrypi:~/huayuanzhuo/apue/ch3_fork$ vim fork_var.c

 1 #include <stdio.h>
 2 #include <errno.h>
 3 #include <unistd.h>
 4 #include <string.h>
 5
 6 int g_var = 6;
 7 char g_buf[]="A string write to stdout.\n";
 8 int main (int argc, char **argv)
 9 {
10        int var = 88;
11        pid_t pid;
12
13        if( write(STDOUT_FILENO, g_buf, sizeof(g_buf)-1) < 0)
14        {
15              printf("Write string to stdout error: %s\n", strerror(errno));
16              return -1;
17        }
18
19         printf("Befor fork\n");
20
21        if( (pid=fork()) < 0)
22        {
23             printf("fork() error: %s\n", strerror(errno));
24             return -2;
25        }
26        else if( 0 == pid)
27        {
28              printf("Child process PID[%d] running...\n", getpid());
29              g_var ++;
30              var ++;
31        }
32        else
33        {
34              printf("Parent process PID[%d] waiting...\n", getpid());
35              sleep(1);
36         }
37
38         printf("PID=%ld, g_var=%d, var=%d\n", (long) getpid(), g_var, var);
39         return 0;
40 }

运行后我们可以观察到:

A string write to stdout.
Befor fork
Parent process PID[27642] waiting...
Child process PID[27643] running...
PID=27643, g_var=7, var=89
PID=27642, g_var=6, var=88

在上面的编译运行过程我们可以看到,父进程在代码第21行创建了子进程后,系统会将父进程的文本段、数据段、堆栈都拷贝一份给子进程,这样子进程也就继承了父进程数据段中的的全局变量g_var和局部变量var的值。

  1. 因为进程创建之后究竟是父进程还是子进程先运行没有规定,所以父进程在第35行调用了sleep(1)的目的是希望让子进程先运行,但这个机制是不能100%确定能让子进程先执行,如果系统负载较大时1秒的时间内操作系统可能还没调度到子进程运行,所以sleep()这个机制并不可靠,这时候我们需要使用到今后学习的进程间通信机制来实现这种父子进程之间的同步问题;
  2. 程序中38行的printf()被执行了两次,这是因为fork()之后,子进程会复制父进程的代码段,这样38行的代码也被复制给子进程了。而子进程在运行到第30行后并没有调用return()或exit()函数让进程退出,所以程序会继续执行到38行至39行调用return 0退出子进程;同理父进程也是执行38行至39行才让父进程退出,所以38行的printf()分别被父子进程执行了两
    次。
  3. 子进程在第29行和30行改变了这两个变量的值,这个改变只影响子进程的空间的值,并不会影响父进程的内存空间,所以子进程里g_var和var分别变成了7和89,而父进程的g_var和var都没改变。

子进程继承父进程哪些东西

从上面的例子中我们可以知道,知道子进程从父进程那里继承什么或未继承什么将有助于我们今后的编程。下面这个名单会因为不同Unix的实现而发生变化,所以或许准确性有了水份。请注意子进程得到的是 这些东西的 拷贝,不是它们本身。
由子进程自父进程继承到:
进程的资格(真实(real)/有效(effective)/已保存(saved) 用户号(UIDs)和组号(GIDs))
环境(environment)变量
堆栈
内存

打开文件的描述符(注意对应的文件的位置由父子进程共享, 这会引起含糊情况)执行时关闭(close-on-exec) 标志 (译者注:close-on-exec标志可通过fnctl()对文件描 述符设置,POSIX.1要求所有目录流都必须在exec函数调用时关闭。更详细说明, 参见《APUE》 W. R. Stevens, 1993, 尤晋元等译(以下简称《高级编程》), 3.13节和8.9节)
信号(signal)控制设定
**nice值 (**译者注:nice值由nice函数设定,该值表示进程的优先级, 数值越小,优先级越高)
进程调度类别(scheduler class) (译者注:进程调度类别指进程在系统中被调度时所属的类别,不同类别有不同优先级,
根据进程调度类别和nice值,进程调度程序可计算出每个进程的全局优先级(Global process prority),优先级高的进程优
先执行)
进程组号
对话期ID(Session ID) (译者注:译文取自《高级编程》,指:进程所属的对话期 (session)ID, 一个对话期包括一个或多
个进程组, 更详细说明参见《APUE》 9.5节)
当前工作目录
根目录 (根目录不一定是“/”,它可由chroot函数改变)
文件方式创建屏蔽字(file mode creation mask (umask))
资源限制
控制终端

子进程所独有:
进程号
不同的父进程号(译者注: 即子进程的父进程号与父进程的父进程号不同, 父进程号可由getppid函数得到)
自己的文件描述符和目录流的拷贝(译者注: 目录流由opendir函数创建,因其为顺序读取,顾称“目录流”)
子进程不继承父进程的进程,正文(text), 数据和其它锁定内存(memory locks) (译者注:锁定内存指被锁定的虚拟内存页,锁定后, 不允许内核将其在必要时换出(page out), 详细说明参见《The GNU C Library Reference Manual》 2.2
版, 1999, 3.4.2节)在tms结构中的系统时间(译者注:tms结构可由times函数获得, 它保存四个数据用于记录进程使用中央处理器 (CPU:Central Processing Unit)的时间,包括:用户时间,系统时间, 用户各子进程合计时间,系统各子进程合计时间)
资源使用(resource utilizations)设定为0
阻塞信号集初始化为空集(译者注:原文此处不明确, 译文根据fork函数手册页稍做修改)
不继承由timer_create函数创建的计时器
不继承异步输入和输出
父进程设置的锁(因为如果是排他锁,被继承的话就矛盾了)

exec*()执行另外一个程序

在上面的例子中,我们创建了一个子进程都是让子进程继续执行父进程的文本段,但更多的情况下是让该进程去执行另外一个程序。这时我们会在fork()之后紧接着调用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[]);

在上面的函数名中,l 表示以列表(list)的形式传递要执行程序的命令行参数,而v表示以数组(vector)的形式传递要执行程序的命令行参数,而v表示给该命令传递环境变(environment)。在这么多的函数调用中,我们选择一个实现即可,因为xecl()函数的参数相对简单些所以使用它要多些,接下来以一个程序实例来演示它的使用。首先看一下ifconfig eth0命令的输出:

eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
 inet 192.168.2.17 netmask 255.255.255.0 broadcast 192.168.2.255
 inet6 fe80::ba27:ebff:feb4:c096 prefixlen 64 scopeid 0x20<link>
 ether b8:27:eb:b4:c0:96 txqueuelen 1000 (Ethernet)
 RX packets 535546 bytes 129557958 (123.5 MiB)
 RX errors 0 dropped 0 overruns 0 frame 0
 TX packets 257794 bytes 45121061 (43.0 MiB)
 TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

我们知道,在Linux下可以使用命令ifconfig eth0来获取网卡的IP地址,但如果我们想在C程序代码里获取IP地址又该如何实现呢?其实ifconfig命令本身是一个程序,这样我们可以在程序里创建一个子进程来执行这个程序即可。另外一个问题是该命令执行的结果会打印到标注输出(默认是屏幕)上,那我们C程不可能像人眼一样在屏幕上获取IP地址。对于这个问题我们可以在子进程里将标准输出重定向到文件里,这样命令的打印信息会输出到该文件中。之后父进程就可以从该文件中读出相应的内容并作相应的字符串解析,就可以获取到IP地址了。

下面是该功能程序的实现源码和注释:

pi@raspberrypi:~/huyuanzhuo/apue/ch3_fork$ vim fork_exec.c

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <ctype.h>
// 标准输出重定向的文件, /tmp路径是在Linux系统在内存里做的一个文件系统,放在这里不用写硬盘程序运行会快些。
#define TMP_FILE "/tmp/.ifconfig.log"
int main(int argc, char **argv)
{
           pid_t pid;
           int fd;
           char buf[1024];
           int rv;
           char *ptr;
           FILE *fp;
           char *ip_start;
           char *ip_end;
           char ipaddr[16];
           // 父进程打开这个文件,子进程将会继承父进程打开的这个文件描述符,这样父子进程都可以通过各自的文件描述符访问同一个文件了
           if( (fd=open(TMP_FILE, O_RDWR|O_CREAT|O_TRUNC, 0644)) < 0 )
           {
              printf("Redirect standard output to file failure: %s\n", strerror(errno));
               return -1;
            }

            // 父进程开始创建进程
            pid = fork();
            if( pid < 0)
            {
                 printf("fork() create child process failure: %s\n", strerror(errno));
                 return -1;
            }
            else if( pid == 0 ) //子进程开始运行
            {
                 printf("Child process start excute ifconfig program\n");
                
                 // 子进程会继承父进程打开的文件描述符,此时子进程重定向标准输出到父进程所打开的文件里
                 dup2(fd, STDOUT_FILENO);

          /*
          下面这句execl(...)函数是让子进程开始执行带参数的ifconfig命令:ifconfig eth0 
          execl()会导致子进程彻底丢掉父进程的文本段、数据段,并加载/sbin/ifconfig这个程序的文本段、数据段,并重新建立进程内存空间。
          execl()函数的第一个参数是所要执行程序的路径,ifconfig命令(程序)的路径是/sbin/ifconfig;
          接下来的参数是命令及其相关选项、参数,每个命令、选项、参数都用双引号("")扩起来,并以NULL结束。
         */

         /*
          ifconfig eth0命令在执行时会将命令的执行的结果输出到标准输出上,而这时子进程已经重定向标准输出到文件中去了,所以ifconfig
          命令的打印结果会输出到文件中去,这样父进程就会从该文件里读到子进程执行该命令的结果;
         */
         execl("/sbin/ifconfig", "ifconfig", "eth0", NULL);


         /* execl()函数并不会返回,因为他去执行另外一个程序了。如果execl()返回了,说明该系统调用出错了。 */
         printf("Child process excute another program, will not return here. Return here means execl() error\n");
         return -1;
         }
          else
          {
                 // 父进程等待3s,让子进程先执行
                 sleep(3);    
           }
           // 子进程因为调用了execl(), 它会丢掉父进程的文本段,所以子进程不会执行到这里了。只有父进程会继续执行这后面的代码
           memset(buf, 0, sizeof(buf));
           // 父进程这时候读是读不到内容的,这时因为子进程往文件里写内容时已经将文件偏移量修改到文件尾了
           rv=read(fd, buf, sizeof(buf));
           printf("Read %d bytes data dierectly read after child process write\n", rv);
           
           // 父进程如果要读则需要将文件偏移量设置到文件头才能读到内容
           memset(buf, 0, sizeof(buf));
           lseek(fd, 0, SEEK_SET);
           rv=read(fd, buf, sizeof(buf));
           printf("Read %d bytes data after lseek:\n %s", rv, buf);
           
           // 如果使用read()读的话,一下子就读 N 多个字节进buffer,但有时我们希望一行一行地读取文件的内容,这时可以使用fdopen()函数将文件描述符fd转成文件流fp
            fp = fdopen(fd, "r");
            
            fseek(fp, 0, SEEK_SET); // 重新设置文件偏移量到文件头
            while( fgets(buf, sizeof(buf), fp) ) // fgets()从文件里一下子读一行,如果读到文件尾则返回NULL
            {
               /*
               包含IP地址的那一行包含有netmask关键字,如果在该行中找到该关键字就可以从这里面解析出IP地址了。
                inet 192.168.2.17 netmask 255.255.255.0 broadcast 192.168.2.255
                inet6 fe80::ba27:ebff:fee1:95c3 prefixlen 64 scopeid 0x20<link>
               */
               if( strstr(buf, "netmask") )
               {
                       // 查找"inet"关键字,inet关键字后面跟的就是IP地址;
                       ptr=strstr(buf, "inet");
                       if( !ptr )
                       {
                           break;
                       }
                        ptr += strlen("inet");
                        
                        // inet 关键字后面是空白符,我们不确定是空格还是TAB,所以这里使用isblank()函数判断如果字符还是空白符就往后跳过;
                        while( isblank(*ptr) )
                        ptr++;
                       
                        // 跳过空白符后跟着的就是IP地址的起始字符;
                        ip_start = ptr;
                       
                        // IP地址后面又是跟着空白字符,跳过所有的非空白字符,即IP地址部分:xxx.xxx.xxx.xxx 
                        while( !isblank(*ptr) )
                        ptr++;
                        
                        // 第一个空白字符的地址也就是IP地址终止的字符位置
                        ip_end = ptr;
                        
                        // 使用memcpy()函数将IP地址拷贝到存放IP地址的buffer中,其中ip_end-ip_start就是IP地址的长度,ip_start就是IP地址的起始位置;
                        memset(ipaddr, 0, sizeof(ipaddr));
                        memcpy(ipaddr, ip_start, ip_end-ip_start);
                        break;
                 }
            }
            printf("Parser and get IP address: %s\n", ipaddr);
          
            fclose(fp);
            unlink(TMP_FILE);
            return 0;
  }     
           

下面是运行的结果:

Child process start excute ifconfig program
Read 0 bytes data dierectly read after child process write
Read 496 bytes data after lseek:
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
 inet 192.168.2.17 netmask 255.255.255.0 broadcast 192.168.2.255
 inet6 fe80::ba27:ebff:feb4:c096 prefixlen 64 scopeid 0x20<link>
 ether b8:27:eb:b4:c0:96 txqueuelen 1000 (Ethernet)
 RX packets 532532 bytes 129032905 (123.0 MiB)
 RX errors 0 dropped 0 overruns 0 frame 0
 TX packets 257545 bytes 45078393 (42.9 MiB)
 TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
Parser and get IP address: 192.168.2.17

vfork()系统调用

在上面的例子中我们可以看到,在fork()之后常会紧跟着调用exec来执行另外一个程序,而exec会抛弃父进程的文本段、数据段和堆栈等并加载另外一个程序,所以现在的很多fork()实现并不执行一个父进程数据段、堆和栈的完全副本拷贝。作为替代,使用了写时复制(CopyOnWrite)技术: 这些数据区域由父子进程共享,内核将他们的访问权限改成只读,如果父进程和子进程中的任何一个试图修改这些区域的时候,内核再为修改区域的那块内存制作一个副本。
vfork()是另外一个可以用来创建进程的函数,他与fork()的用法相同,也用于创建一个新进程。 但vfork()并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec或exit(),于是也就不会引用该地址空间了。不过子进程再调用exec()或exit()之前,他将在父进程的空间中运行,但如果子进程想尝试修改数据域(数据段、堆、栈)都会带来未知的结果,因为他会影响了父进程空间的数据可能会导致父进程的执行异常。此外,vfork()会保证子进程先运行,在他调用了exec或exit()之后父进程才可能被调度运行。如果子进程依赖于父进程的进一步动作,则会导致死锁。

vfork()的函数原型和 fork原型一样:

#include <unistd.h>
#include <sys/types.h>
pid_t fork(void);
pid_t vfork(void);

wait()与waitpid()

当一个进程正常或异常退出时,内核就会向其父进程发送SIGCHLD信号。因为子进程退出是一个异步事件,所以这种信号也是内核向父进程发送的一个异步通知。父进程可以选择忽略该信号,或者提供一个该信号发生时即将被执行的函数,父进程可以调用wait()或waitpid()可以用来查看子进程退出的状态。

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

在一个子进程终止前,wait使其调用者阻塞,而waitpid有一选项可使调用者不用阻塞。 waitpid并不等待在其调用的之后的第一个终止进程,他有若干个选项,可以控制他所等待的进程。 如果一个已经终止、但其父进程尚未对其调用wait进行善后处理(获取终止子进程的有关信息如CPU时间片、释放它锁占用的资源如文件描述符等)的进程被称僵死进程(zombie),ps命令将僵死进程的状态打印为Z。如果子进程已经终止,并且是一个僵死进程,则wait立即返回该子进程的状态。所以,我们在编写多进程程序时,最好调用wait()或waitpid()来解决僵尸进程的问题。
此外,如果父进程在子进程退出之前退出了,这时候子进程就变成了孤儿进程。当然每一个进程都应该有一个独一无二的父进程,init进程就是这样的一个“慈父”,Linux内核中所有的子进程在变成孤儿进程之后都会被init进程“领养”,这也意味着孤儿进程的父进程最终会变成init进程。

system()与popen()函数

如果我们在程序中,想执行另外一个Linux命令时,可以调用fork()然后再exec执行相应的命令即可,但这样相对比较麻烦。Linux系统提供了一个system()库函数,该库函数可以快速创建一个进程来执行相应的命令。

int system(const char *command);

譬如我们想执行ping命令,则可以使用下面的程序片段:

system("ping -c 4 -I eth0 4.2.2.2");

/*如果这里的 eth0、4.2.2.2 等是一个变量参数,我们则可以使用snprintf()格式化生成该命令:*/

char cmd_buf[256];
int count = 4;
char *interface="eth0";
char *dst_ip = "4.2.2.2";

snprintf(cmd_buf, sizeof(buf), "ping -c %d -I %s %s", count, interface, dst_ip);
system(cmd_buf);

对于之前我们使用fork()+execl()函数来执行ifconfig命令,并将该命令执行的结果写入到文件后再来读取的实现,这个过程相比较麻烦,另外涉及到了创建文件和读取文件的过程。其实也有另外一个函数popen()可以执行一条命令,并返回一个基于管道(pipe)的文件流,这样我们可以从该文件流中一行样解析了。

三、多进程改写服务器程序

在了解Linux下多进程编程之后,我们就可以使用多进程编程模型改写服务器的多进程实现,其流程图和程序代码如下:
在这里插入图片描述
vim socket_server_fork.c

#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <getopt.h>
#include <ctype.h>

void print_usage(char *progname)
{
           printf("%s usage: \n", progname);
           printf("-p(--port): sepcify server listen port.\n");
           printf("-h(--Help): print this help information.\n");
           return;
}

int main(int argc, char **argv)
{
      int sockfd = -1;
      int rv = -1;
      struct sockaddr_in servaddr;
      struct sockaddr_in cliaddr;
      socklen_t len;
      int port = 0;
      int clifd;
      int ch;
      int on = 1;
      pid_t pid;
      
      struct option opts[] = {
          {"port", required_argument, NULL, 'p'},
          {"help", no_argument, NULL, 'h'},
          {NULL, 0, NULL, 0}
      };
      
      while( (ch=getopt_long(argc, argv, "p:h", opts, NULL)) != -1 )
      {
               switch(ch)
               {
                    case 'p':
                          port=atoi(optarg);
                          break;
                    
                    case 'h':
                          print_usage(argv[0]);
                          return 0;
                }
       }
       
       if( !port )
       {
           print_usage(argv[0]);
           return 0;
       }
       
      sockfd = socket(AF_INET, SOCK_STREAM, 0);
      if(sockfd < 0 )
      {
          printf("Create socket failure:%s\n",strerror(errno));
          return -1;
      }
      printf("Create socket successfully: %d\n", sockfd);
 
      setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
 
      memset(&serv_addr,0,sizeof(serv_addr));
      serv_addr.sin_family = AF_INET;
      serv_addr.sin_port  = htons(port);
      serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
  
      rv = bind(sockfd,(struct sockaddr *)&serv_addr,sizeof(serv_addr));
      if(rv < 0)
      {
         printf("socket[%d] bind to the port[%d] failure:%s\n",sockfd, port,strerror(errno));
         return -2;
      }
  
      listen(sockfd,13);
      printf("Start to listen on port [%d]\n", port);
      while(1)
      {
          printf("Start to wait and accept new client...\n");
           clifd = accept(sockfd, (struct sockaddr *)&cli_addr, &cliaddr_len);
          if(clifd < 0)
          {
             printf("Accept client failure:%s\n",strerror(errno));
             continue;
          }
          printf("Accept new client[%s:%d] successfully\n", inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port));
          
          pid = fork();
          if( pid < 0 )
          {
             printf("fork() create child process failure: %s\n", strerror(errno));
             close(clifd);
             continue;
          }
          else if( pid > 0 )
          {
              /* Parent process close client fd and goes to accept new socket client again */
              close(clifd);
              continue;
          }
          else if ( 0 == pid )
          {
             char buf[1024];
             int i;
             
             printf("Child process start to commuicate with socket client...\n");
             close(sockfd); /* Child process close the listen socket fd */
             while(1)
             {
               memset(buf, 0, sizeof(buf));
               rv=read(clifd, buf, sizeof(buf));
               if( rv < 0 )
               {
                    printf("Read data from client sockfd[%d] failure: %s\n", clifd,
                    strerror(errno));
                    close(clifd);
                    exit(0);
               }
               else if( rv == 0) 
               {
                  printf("Socket[%d] get disconnected\n", clifd);
                  close(clifd);
                  exit(0);
               }
               else if( rv > 0 )
               {
                  printf("Read %d bytes data from Server: %s\n", rv, buf);
               }
               /* convert letter from lowercase to uppercase */
               for(i=0; i<rv; i++)
               {
                   buf[i]=toupper(buf[i]);
               }
               rv=write(clifd, buf, rv);
               if(rv < 0)
               {
                      printf("Write to client by sockfd[%d] failure: %s\n", clifd,strerror(errno));
                      close(clifd);
                      exit(0);
               }
         } /* Child process loop */
      } /* Child process start*/
    }
  close(sockfd);
  return 0;
}
         

在该程序中,父进程accept()接收到新的连接后,就调用fork()系统调用来创建子进程来处理与客户端的通信。因为子进程会继承父进程处于listen状态的socket 文件描述符(sockfd),也会继承父进程accept()返回的客户端socket 文件描述符(clifd),但子进程只处理与客户端的通信,这时他会将父进程的listen的文件描述符sockfd关闭;同样父进程只处理监听的事件,所以会将clifd关闭。
此时父子进程同时运行完成不同的任务,子进程只负责跟已经建立的客户端通信,而父进程只用来监听到来的socket客户端连接。所以当有新的客户端到来时,父进程就有机会来处理新的客户连接请求了,同时每来一个客户端都会创建一个子进程为其服务。
子进程使用while(1)循环让自己一直执行,他负责将客户端发过来的小写字母改成大写字母后回传回去。只有当读写socket出错或客户端断开时才退出,在退出之前都调用close()关闭相应的套接字,因为是在main()函数中,所以我们可以使用return()或exit()退出进程,但不能使用break跳出。break 关键字主要用于循环语句(如 for、while、do-while)中,用于跳出当前循环,而不是用于退出整个进程。break 语句只能在循环体内使用,用于终止当前的循环迭代,然后继续执行循环后面的语句。break 不会影响到 main() 函数的返回值,也不会导致整个进程退出。

下面是程序编译执行运行的结果:

gcc socket_server_fork.c -o socket_server
./socket_server -p 7888

下图是我们使用windows下的TCP socket测试工具连接测试服务的过程,我们可以发现现在的服务器可以同时处理多个客户端
的连接请求和通信,并在客户端断开时子进程退出。

在这里插入图片描述

四、系统限制

在上面我们使用多进程可以实现多个客户端的并发,那是不是一个服务器就可以给无限多个客户端提供服务呢?其实不然!在Linux下每种资源都有相关的软硬限制,譬如单个用户最多能创建的子进程个数有限制,同样一个进程最多能打开的文件描述符也有相应的限制值,这些限制会限制服务器能够提供并发访问的客户端的数量。 在Linux系统下,我们可以使用下面两个函数来获取或设置这些限制:

#include <sys/resource.h>
int getrlimit(int resource, struct rlimit *rlim);
int setrlimit(int resource, const struct rlimit *rlim);

rlim:描述资源软硬限制的结构体

struct rlimit {
rlim_t rlim_cur;
rlim_t rlim_max;
};

一个服务器程序抛开硬件(CPU、内存、带宽)限制以外,还会受到Linux系统的资源限制。所以,如果我们想要增加Linux服务器并发访问的客户端数量,则需要在服务器程序里通过调用setrlimit()函数来修改这些限制。

Copyright © 2022 凌云物网智科实验室·郭工
Author: GuoWenxue Email: guowenxue@gmail.com QQ: 281143292

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值