网络socket编程实现并发服务器——多进程编程

多进程编程

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

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

二、多进程相关简介

1、什么是进程?

       很多人认为可执行的程序就是进程,其实这个说法并不到位!进程这个概念针对的是操作系统,而不是针对用户。进程在操作系统原理是这样描述的:正在运行的程序及其占用的资源(CPU、内存、系统资源等)叫做进程。

2、进程空间内存布局

       在深入了解多进程编程之前,我们首先要了解Linux下进程在运行时的内存布局。Linux 进程内存管理的对象都是虚拟内存,每个进程会有 0-4G 的虚拟内存空间,0-3G 是用户空间,用来执行用户自己的代码, 而高 1GB 的空间则是内核空间执行 Linux 系统调用,这里存放在整个内核的代码和所有的内核模块。
        Linux下一个进程在内存里有三部分的数据,即”代码段”、”堆栈段”和”数据段”。学过汇编语言的同学应该知道,CPU一般都有上述三种段寄存器,这三个部分数据构成了一个完整执行序列的必要部分。
① 代码段:存放程序代码的数据
② 堆栈段:存放子程序的返回地址、子程序的参数以及程序的局部变量和malloc()动态申请内存的地址
③ 数据段:存放程序的全局变量,静态变量及常量

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

3、系统调用fork()

       Linux下有两个基本的系统调用可以用于创建子进程:fork()和vfork()。英文fork是"分叉"的意思。可以这样理解:一个进程在运行中,如果调用了fork(),就产生了另一个进程,于是进程就”分叉”了!

fork()的函数原型及返回值:

#include <unistd.h>

pid_t fork(void);
RETURN VALUE
       On success, the PID of the child process is returned in the parent, and 0 is returned in
       the  child.   On failure, -1 is returned in the parent, no child process is created, and
       errno is set appropriately.
       /*fork()系统调用会创建一个新的进程,这时它会有两次返回。
       一次返回是给父进程,其返回值是子进程的PID(Process ID),
       第二次返回是给子进程,其返回值为0。*/

       系统调用fork()会创建一个新的子进程,这个子进程是父进程的一个副本。这也意味着,系统在创建新的子进程成功后,会将父进程的文本段、数据段、堆栈都复制一份给子进程,但子进程有自己独立的空间,子进程对这些内存的修改并不会影响父进程空间的相应内存。这就好比你父母之前买了一套房子。等到你结婚了,又买了一套一模一样的房子给你,然后你对这套房子怎么装修都不会影响到你父母的房子!

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

       因此在调用fork()后,需要通过其返回值来判断当前的代码是父进程还是子进程在运行,如果返回值是0说明现在是子进程在运行,如果返回值>0说明是父进程在运行,而如果返回值<0的话,说明fork()系统调用出错。出错原因大致有以下两点:
       ① 系统中已经有太多的进程
       ② 该实际用户 ID 的进程总数超过了系统限制

       关于进程的退出,我们知道在main()函数里使用return,就会调用exit()函数,从而导致进程退出。对于其他函数,在其任何位置调用exit()也会导致进程退出。因此,倘若子进程使用了return,那么子进程就会退出;同理,父进程使用了return,也会退出。这里我们需要注意的是:在编程时,在程序的任何位置调用exit()函数都会导致本进程退出;在main()函数中使用return,会导致进程退出;但在其他函数中使用return都只是令这个函数返回,而不会导致进程退出。

下面是一个简单的程序例子来描述父进程创建子进程的过程
示例代码如下:

#include <stdio.h>
#include <errno.h>
#include <unistd.h>
#include <string.h>

int g_var = 6;
char g_buf[] = "A string write to stdout.\n";

int main (int argc, char **argv)
{
		int 		var = 88;
 		pid_t 		pid;

 		if (write(STDOUT_FILENO, g_buf, sizeof(g_buf)-1) < 0)
	    {
 			printf("Write string to stdout error: %s\n", strerror(errno));
			return -1;
 		}

 		printf("Befor fork\n");

 		if ((pid = fork()) < 0)
 		{
			printf("fork() error: %s\n", strerror(errno));
			return -2;
 		}
		else if ( 0 == pid)
 		{
 			printf("Child process PID[%d] running...\n", getpid());
 			g_var++;
			var++;
		}
 		else
 		{
 			printf("Parent process PID[%d] waiting...\n", getpid());
 			sleep(1);
 		}

		printf("PID=%ld, g_var=%d, var=%d\n", (long) getpid(), g_var, var);
 		return 0;
}

GCC编译:

gcc fork_var.c -o fork_var
./fork_var

运行结果:

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

程序分析:

  1. 由于子进程创建后究竟是父进程还是子进程先运行并没有规定,所以父进程在第35行调用了sleep(1)的目的是希望让子进程先运行,但这个机制并不能100%保证让子进程先执行;
  2. 程序中38行的printf()被执行了两次,这是因为fork()之后,子进程会复制父进程的代码段,这样38行的代码也被复制给子进程了。并且子进程在运行到第30行后并没有使用return或exit()函数让进程退出,所以程序会继续执行到38行至39行使用return 退出子进程;同理,父进程也是执行38行至39行才退出,因此38行的printf()分别被父子进程各执行了一次。
  3. 子进程在第29行和30行改变了这两个变量的值,这个改变只影响子进程的空间的值,并不会影响父进程的内存空间,所以子进程里g_var和var分别变成了7和89,而父进程的g_var和var都没改变;
4、子进程继承父进程哪些东西

       我们可以从上面的例子中发现,当知道子进程从父进程那里继承了什么或未继承什么时,会有助于今后的多进程编程。下面这个列表会因为不同Unix的实现而发生变化,所以不能保证完全准确。另外,子进程得到的是这些东西的拷贝,而不是它们本身。

由子进程自父进程继承到:

  • 进程的资格(真实(real)/有效(effective)/已保存(saved)
  • 用户号(UIDs)和组号(GIDs))
  • 环境(environment)变量
  • 堆栈
  • 内存
  • 打开文件的描述符(注意对应的文件的位置由父子进程共享, 这会引起含糊情况)
  • 执行时关闭(close-on-exec) 标志
  • 信号(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))
  • 在tms结构中的系统时间(tms结构可由times函数获得, 它保存四个数据用于记录进程使用*处理器 (CPU:Central Processing Unit)的时间,包括:用户时间,系统时间, 用户各子进程合计时间,系统各子进程合计时间)
  • 资源使用(resource utilizations)设定为0
  • 阻塞信号集初始化为空集
  • 不继承由timer_create函数创建的计时器
  • 不继承异步输入和输出
  • 不继承父进程设置的锁
5、exec*()执行另外一个程序

       在上面的例子中,我们所创建的子进程是让其继续执行父进程的文本段。但实际上,创建子进程的目的更多的是想让子进程去执行另外一个程序。这时我们会在fork()之后,紧接着调用exec*()系列函数便可让子进程去执行另外一个程序。

exec*()系列函数原型为:

int execl(const char *path, const char *arg, ...)
int execv(const char *path, char *const argv[])
int execle(const char *path, const char *arg, ..., char *const envp[])
int execve(const char *path, char *const argv[], char *const envp[])
int execlp(const char *file, const char *arg, ...)
int execvp(const char *file, char *const argv[])

exec*()系列函数关系:

  • l 表示以列表(list)的形式传递要执行程序的命令行参数,
  • v 表示以数组(vector)的形式传递要执行程序的命令行参数,
  • e 表示给该命令传递环境变量(environment),
  • p 表示可执行文件查找方式为文件名。

execl()函数参数说明:

  • 第一个参数 path:表示所要执行程序的路径
  • 第二个参数 arg:表示命令及其相关选项。命令、选项、参数都用双引号(" ")扩起来,并以NULL结束

       在这么多的函数调中,这里选择一个实现(execl()函数的参数相对简单,所以使用它要多些),接下来以一个程序实例来演示它的使用。
       该程序功能为通过C程序代码获取主机IP地址,首先通过fork()创建一个子进程,然后调用execl()来执行ifconfig程序,并将标准输出重定向到文件,之后父进程从该文件中读文件内容并作相应的字符串解析,最终获取IP地址!

示例代码如下:

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#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)
{
	int 		fd;
	int 		rv;
	pid_t		pid;
	FILE 	   *fp;
	char 	   *ptr;
	char 	   *ip_start;
	char 	   *ip_end;
	char 		ipaddr[16];
	char		buf[1024];
	
	//父进程打开这个文件,子进程将会继承父进程打开的这个文件描述符,这样父子进程都可以通过各自的文件描述符访问同一个文件了
	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");
		
		//子进程会继承父进程打开的文件描述符,并将标准输出重定向到打开的文件中
		//将ifconfig eth0命令在执行后的结果输出到文件中
		dup2(fd, STDOUT_FILENO);
		
		/*
			execl()函数让子进程开始执行带参数的ifconfig命令: ifconfig eth0
			并且execl()会让子进程彻底丢掉父进程的文本段、数据段,
			并加载/sbin/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
	{
		sleep(3);		//父进程睡眠3s,保证子进程先执行
	}
	//子进程因为调用了execl(), 它会丢掉父进程的文本段,所以子进程不会执行到这里了。只有父进程会继续执行这后面的代码

	fp = fdopen(fd, "r");		 //调用fdopen()函数将文件描述符fd转成文件流fp
	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
6、系统调用vfork()

       vfork()是另外一个可以用来创建进程的函数,他与fork()的用法相同,也用于创建一个新进程。 但vfork()并不将父进程的地址空间完全复制给子进程。由于子进程会调用exec*()或exit(),因此就不会引用该地址空间了。不过子进程在调用exec()或exit()之前,他在父进程的空间中运行,此时如果子进程想尝试修改数据域(数据段、堆、栈)都会带来未知的结果,因为这会影响父进程空间的数据,从而可能导致父进程的执行出现异常。
       此外,vfork()会保证子进程先运行,在他调用了exec或exit()之后父进程才可能被调度运行。如果子进程依赖于父进程的进一步动作,则会导致死锁。

vfork()函数原型:

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

pid_t vfork(void);
7、wait()与waitpid()

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

wait()与waitpid()函数原型:

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

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

RETURN VALUE
       wait(): on success, returns the process ID of the terminated  child;  on  error,  -1  is
       returned.

       waitpid():  on  success, returns the process ID of the child whose state has changed; 
       if WNOHANG was specified and one or more child(ren) specified by pid exist, but have 
       not yet changed state, then 0 is returned.  On error, -1 is returned.

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

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

1、多进程并发服务器

                     网络socket编程实现并发服务器——多进程编程

2、服务器端

代码如下:

#include <stdio.h>
#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;
	int 					port = 0;
	int						clifd;
	int 					ch;
	int 					on = 1;
	struct sockaddr_in 		servaddr;
	struct sockaddr_in 		cliaddr;
	socklen_t				len;
	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]);
					break;
		}
	}
	
	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[%d] successfully!\n", sockfd);
	
	setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
	
	memset(&servaddr, 0, sizeof(servaddr));
	servaddr.sin_family=AF_INET;
	servaddr.sin_port = htons(port);
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY); /* listen all the IP address on this host */
	
	rv = bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
	if (rv < 0)
	{
		printf("Socket[%d] bind on 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 accept new client incoming...\n");
		
		clifd = accept(sockfd, (struct sockaddr *)&cliaddr, &len);
		if (clifd < 0)
		{
			printf("Accept new client failure: %s\n", strerror(errno));
			continue;
		}	
		printf("Accept new client[%s:%d] successfully\n", inet_ntoa(cliaddr.sin_addr),
				ntohs(cliaddr.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  //0 == pid
		{
			int  	i;
			char 	buf[1024];

			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 //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)循环让自己一直执行,并通过toupper()将客户端发过来的小写字母改成大写字母后再传回去。只有当读写socket出错或客户端主动断开时,子进程才退出,并在退出之前调用close()关闭相应的套接字。因为是在main()函数中,所以我们可以使用return或exit()退出进程,但注意不能使用break跳出。

       接下来在windows下使用TCP socket测试工具连接并测试服务器的执行情况,我们可以发现服务器可以同时处理多个客户端的连接请求和通信,并在客户端断开时子进程退出,从而实现了服务器并发访问。

四、系统限制

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

#include <sys/resource.h>

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

参数 resource说明:

  • RLIMIT_AS 进程的最大虚内存空间,字节为单位。
  • RLIMIT_CORE 内核转存文件的最大长度。
  • RLIMIT_CPU 最大允许的CPU使用时间,秒为单位。当进程达到软限制,内核将给其发送SIGXCPU信号,这一信号的默认行为是终止进程的执行。
  • RLIMIT_DATA 进程数据段的最大值。
  • RLIMIT_FSIZE 进程可建立的文件的最大长度。如果进程试图超出这一限制时,核心会给其发送SIGXFSZ信号,默认情况下将终止进程的执行。
  • RLIMIT_LOCKS 进程可建立的锁和租赁的最大值。
  • RLIMIT_MEMLOCK 进程可锁定在内存中的最大数据量,字节为单位。
  • RLIMIT_MSGQUEUE 进程可为POSIX消息队列分配的最大字节数。
  • RLIMIT_NICE 进程可通过setpriority() 或 nice()调用设置的最大完美值。
  • RLIMIT_NOFILE 指定比进程可打开的最大文件描述词大一的值,超出此值,将会产生EMFILE错误。
  • RLIMIT_NPROC 用户可拥有的最大进程数。
  • RLIMIT_RTPRIO 进程可通过sched_setscheduler 和 sched_setparam设置的最大实时优先级。
  • RLIMIT_SIGPENDING 用户可拥有的最大挂起信号数。
  • RLIMIT_STACK 最大的进程堆栈,以字节为单位。

参数rlim说明:描述资源软硬限制的结构体

struct rlimit 
{
		rlim_t rlim_cur;
		rlim_t rlim_max;
};

下面我们用一个例程介绍一下这两个函数的使用方法。
代码如下:

#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <sys/resource.h>

void print_limits(char* name, int resource)
{
	struct rlimit 		limit;
	
	if (getrlimit(resource, &limit) < 0)
	{
		printf("getrlimit for %s failure: %s\n", strerror(errno));
		return ;
	}
	printf("%-15s ",name);
	
	if	(limit.rlim_cur == RLIM_INFINITY)
	{
		printf("(infinite) ");
	}
	else
	{
		printf("%-15ld",limit.rlim_cur);
	}
	
	if (limit.rlim_max == RLIM_INFINITY)
	{
		printf("(infinite) ");
	}
	else
	{
		printf("%-15ld",limit.rlim_max);
	}
	
	printf("\n");
}

int main(void)
{
	struct rlimit limit = {0};
	
	print_limits("RLIMIT_NPROC", RLIMIT_NPROC);
	print_limits("RLIMIT_DATA", RLIMIT_DATA);
	print_limits("RLIMIT_STACK", RLIMIT_STACK);
	print_limits("RLIMIT_NOFILE", RLIMIT_NOFILE);
	printf("\nAfter set RLIMIT_NOFILE:\n");
	
	getrlimit(RLIMIT_NOFILE, &limit );
	limit.rlim_cur = limit.rlim_max;
	setrlimit(RLIMIT_NOFILE, &limit );
	print_limits("RLIMIT_NOFILE", RLIMIT_NOFILE);
	
	return 0;
}

程序运行结果如下:

RLIMIT_NPROC 7345 7345
RLIMIT_DATA (infinite) (infinite)
RLIMIT_STACK 8388608 (infinite)
RLIMIT_NOFILE 1024 1048576

After set RLIMIT_NOFILE:
RLIMIT_NOFILE 1048576 1048576

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