linux有3种实现并发服务器的方式:多进程并发服务器,多线程并发服务器,IO复用,先来看多进程并发服务器的实现。
什么是一个进程?
在操作系统原理使用这样的术语来描述的:正在运行的程序及其占用的资源(CPU、内存、系统资源等)叫做进程。站在程序员的角度来看,我们使用vim编辑生成的C文件叫做源码,源码给程序员来看的但机器不识别,这时我们需要使用编译器gcc编译生成CPU可识别的二进制可执行程序并保存在存储介质上,这时编译生成的可执行程序只能叫做程序而不能叫进程。而一旦我们通过命令(./a.out)开始运行时,那正在运行的这个程序及其占用的资源就叫做进程了。
进程这个概念是针对系统而不是针对用户的,对用户来说,他面对的概念是程序。很显然,一个程序可以执行多次,这也意味着多个进程可以执行同一个程序。
进程空间内存布局
在深入理解Linux下多进程编程之前,我们首先要了解Linux下进程在运行时的内存布局。
系统空间:每个进程运行独立4GB虚拟内存空间
Linux 进程内存管理的对象都是虚拟内存,每个进程先天就有 0-4G 的各自互不干涉的虚拟内存空间,0—3G 是用户空间执行用户自己的代码, 高 1GB 的空间是内核空间执行 Linu x 系统调用,这里存放在整个内核的代码和所有的内核模块,用户所看到和接触的都是该虚拟地址,并不是实际的物理内存地址。命令行传递的参数: int main(int argc, char **argv)栈区: 局部变量(自动分配,{}内有效,离开{}自动)堆区: malloc分配的内存(自己管理,用完free,否则泄漏)堆区和栈区里的数据都是随机值栈 。 栈内存由编译器在程序编译阶段完成,进程的栈空间位于进程用户空间的顶部并且是向下增长,每个函数的每次调用都会在栈空间中开辟自己的栈空间,函数参数、局部变量、函数返回地址等都会按照先入者为栈顶的顺序压入函数栈中,函数返回后该函数的栈空间消失,所以函数中返回局部变量的地址都是非法的。堆 。 堆内存是在程序执行过程中分配的,用于存放进程运行中被动态分配的的变量,大小并不固定,堆位于非初始化数据段和栈之间,并且使用过程中是向栈空间靠近的。当进程调用 malloc 等函数分配内存时,新分配的内存并不是该函数的栈帧中,而是被动态添加到堆上,此时堆就向高地址扩张;当利用 free 等函数释放内存时,被释放的内存从堆中被踢出,堆就会缩减。因为动态分配的内存并不在函数栈帧中,所以即使函数返回这段内存也是不会消失。数据段:.bss: 未初始化或初始化为0的全局变量存放在BSS区,其值为0;.data: 初始化为非0的的全局变量或static变量.rodata: const, #define,char *ptr="string"等定义的数据常量”代码段”,顾名思义,就是存放了程序代码的数据,假如机器中有数个进程运行相同的一个程序,那么它们就可以使用相 同的代码段。
Linux 内存管理的基本思想就是只有在真正访问一个地址的时候才建立这个地址的物理映射,Linux C/C++语言的分配方式共有3 种方式。
(1)从静态存储区域分配。就是数据段的内存分配,这段内存在程序编译阶段就已经分配好,在程序的整个运行期间都存在,例如全局变量,static 变量。
(2)在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是系统栈中分配的内存容量有限,比如大额数组就会把栈空间撑爆导致段错误。
(3)从堆上分配,亦称动态内存分配。程序在运行的时候用 malloc 或 new 申请任意多少的内存,程序员自己负责在何时用free 或 delete 释放内存。此 区域内存分配称之为动态内存分配。动态内存的生存期由我们决定,使用非常灵活,但问题也最多,比如指向某个内存块的指针取值发生了变化又没有其他指针指向 这块内存,这块内存就无法访问,发生内存泄露。
了解了进程和Linux内存分配,下面我们来看看多进程的工作流程图及相关函数:
工作流程图:
相关函数:
fork()系统调用
Linux内核在启动的最后阶段会创建init进程来执行程序/sbin/init,该进程是系统运行的第一个进程,进程号为 1,称为Linux 系统的初始化进程,该进程会创建其他子进程来启动不同写系统服务,而每个服务又可能创建不同的子进程来执行不同的程序。所以init进程是所有其他进程的“祖先”,并且它是由Linux内核创建并以root的权限运行,并不能被杀死。Linux 中维护着一个数据结构叫做 进程表,保存当前加载在内存中的所有进程的有关信息,其中包括进程的 PID(Process ID)、进程的状态、命令字符串等,操作系统通过进程的 PID 对它们进行管理,这些 PID 是进程表的索引。
Linux下有两个基本的系统调用可以用于创建子进程:fork()和vfork()。
在我们编程的过程中,一个函数调用只有一次返回(return),但由于fork()系统调用会创建一个新的进程,这时它会有两次返回。一次返回是给父进程,其返回值是子进程的PID(Process ID),第二次返回是给子进程,其返回值为0。所以我们在调用fork()后,需要通过其返回值来判断当前的代码是在父进程还是子进程运行,如果返回值是0说明现在是子进程在运行,如果返回值>0说明是父进程在运行,而如果返回值<0的话,说明fork()系统调用出错。fork 函数调用失败的原因主要有两个:1. 系统中已经有太多的进 程;2. 该实际用户 ID 的进程总数超过了系统限制。fork()系统调用会创建一个新的子进程,这个子进程是父进程的一个副本。这也意味着, 系统在创建新的子进程成功后,会将父进程的文本段、数据段、堆栈都复制一份给子进程,但子进程有自己独立的空间,子进程对这些内存的修改并不会影响父进程空间的相应内存 。这时 系统中出现两个基本完全相同的进程(父、子进程),这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的进程调度策略。 如果需要 确保让父进程或子进程先执行 ,则需要程序员 在代码中通过进程间通信的机制来自己实现 。
getpid:获取自己进程的ID;
getppid:获取父进程的进程ID;
一个进程可以创建多个子进程,一个子进程仅有一个父进程,这样对于父进程而言,他并没有一个API函数可以获取其子进程的进程ID,所以父进程在通过fork()创建子进程的时候,必须通过返回值的形式告诉父进程其创建的子进程PID。
下面通过一个程序来了解以下fork:
13 #include <stdio.h>
14 #include <errno.h>
15 #include <unistd.h>
16 #include <string.h>
17
18 int g_var = 6;
19 char g_buf[]="A string write to stdout.\n";
20 int main (int argc, char **argv)
21 {
22 int var = 88;
23 pid_t pid;
24
25 if( write(STDOUT_FILENO, g_buf, sizeof(g_buf)-1) < 0)
26 {
27 printf("Write string to stdout error: %s\n", strerror(errno));
28 return -1;
29 }
30
31 printf("Befor fork\n");
32
33 if( (pid=fork()) < 0)
34 {
35 printf("fork() error: %s\n", strerror(errno));
36 return -2;
37 }
38 else if( 0 == pid)
39 {
40 printf("Child process PID[%d] running...\n", getpid());
41 g_var ++;
42 var ++;
43 }
44 else
45 {
46 printf("Parent process PID[%d] waiting...\n", getpid());
47 sleep(1);
48 }
49
50 printf("PID=%ld, g_var=%d, var=%d\n", (long) getpid(), g_var, var);
51 return 0;
52 }
在上面的编译运行过程我们可以看到,父进程在代码第33行创建了子进程后,系统会将父进程的文本段、数据段、堆栈都拷贝一份给子进程,这样子进程也就继承了父进程数据段中的的全局变量g_var和局部变量var的值。1. 因为进程创建之后究竟是父进程还是子进程先运行没有规定,所以父进程在第47行调用了sleep(1)的目的是希望让子进程先运行,但这个机制是不能100%确定能让子进程先执行,如果系统负载较大时1秒的时间内操作系统可能还没调度到子进程运行,所以sleep()这个机制并不可靠,这时候我们需要使用到今后学习的进程间通信机制来实现这种父子进程之间的同步问题;2. 程序中50行的printf()被执行了两次,这是因为fork()之后,子进程会复制父进程的代码段,这样50行的代码也被复制给子进程了。而子进程在运行到第42行后并没有调用return()或exit()函数让进程退出,所以程序会继续执行到50行至51行调用return 0退出子进程;同理父进程也是执行50行至51行才让父进程退出,所以38行的printf()分别被父子进程执行了两次。3. 子进程在第41行和42行改变了这两个变量的值,这个改变只影响子进程的空间的值,并不会影响父进程的内存空间,所以子进程里g_var和var分别变成了7和89,而父进程的g_var和var都没改变;
在编程时,任何位置的exit()函数调用都会导致本进程(程序)退出,main()函数中的return()调用也会导致进程退出,而其他任何函数中的return()都只是这个函数返回而不会导致进程退出。
第二次运行时,我们将标注输出重定向到了tmp.log文件中,这时我们发现13行处的"A string write to stdout."在两次执行的过程中都只打印了一次,而31行处的"Befor fork" 却在重定向执行时打印了两次。这是因为:1. write()系统调用是不带缓冲的,不管是否有重定向,25行的输出会立刻输出到标准输出里;2. printf()库函数在标准输出是终端时默认是行缓冲,而当标准输出重定向到文件中后该函数是全缓冲的;这样31行printf()在第一次没有重定向执行时碰到换行符(\n)时就立刻输出到标准输出里了,而第二次因为有重定向,这时的打印内容并不会输出到标准输出而是存放在缓冲区中。在第33行调用fork()创建子进程时,系统会将缓冲区的内容也复制给了子进程,这样父子进程的printf()缓冲区里都有"Befor fork"的内容。父子进程在运行至50行的时候printf()缓冲区里的内容还没有达到缓冲区的大小,而并不会打印,直到父子进程都执行到51行调用return 0时才会导致进程退出。而进程在退出的时候会自动Flush缓冲区里的数据,这时候才会将缓冲区的内容输出到标准输出tmp.log文件中。所以这种情况下"Befor fork"会被打印两次
子进程继承父进程哪些东西
从上面的例子中我们可以知道,知道子进程从父进程那里继承什么或未继承什么将有助于我们今后的编程。下面这个名单会因为 不同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[]);
vfork()系统调用
在fork()之后常会紧跟着调用exec来执行另外一个程序,而exec会抛弃父进程的文本段、数据
段和堆栈等并加载另外一个程序,所以现在的很多fork()实现并不执行一个父进程数据段、堆和栈的完全副本拷贝。作为替代,使用了写时复制(CopyOnWrite)技术: 这些数据区域由父子进程共享,内核将他们的访问权限改成只读,
如果父进程和子进程中的任何一个试图修改这些区域的时候,内核再为修改区域的那块内存制作一个副本
。
vfork()是另外一个可以用来创建进程的函数,他与fork()的用法相同,也用于创建一个新进程。 但
vfork()并不将父进程的地址空间完全复制到子进程中
,因为子进程会立即调用exec或exit(),于是也就不会引用该地址空间了。不过子进程再调用exec()或exit()之前,
他将在父进程的空间中运行
,但如果
子进程想尝试修改数据域
(数据段、堆、栈)
都会带来未知的结
果,因为他会影响了父进程空间的数据可能会导致父进程的执行异常。此外,
vfork()会保证子进程先运行,在他调用了exec或exit()之后父进程才可能被调度运行
。如果子进程依赖于父进程的进一步动作,则会导致死锁。
wait()与waitpid()
当一个进程正常或异常退出时,内核就会向其父进程发送SIGCHLD信号。因为子进程退出是一个异步事件,所以这种信号也是内核向父进程发送的一个异步通知。父进程可以选择忽略该信号,或者提供一个该信号发生时即将被执行的函数,父进程可以调用wait()或waitpid()可以用来查看子进程退出的状态。
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
system()函数
如果我们在程序中,想执行另外一个Linux命令时,可以调用fork()然后再exec执行相应的命令即可,但这样相对比较麻烦。 Linux系统提供了一个system()库函数,该库函数可以快速创建一个进程来执行相应的命令。
int system(const char *command);
popen()函数
可以执行一条命令,并返回一个基于管道 (pipe)的文件流。
相关的函数的介绍到此结束,下面我
使用多进程编程模型改写服务器的多进程实现(具体的流程图就是上边提到的工作流程图):
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <ctype.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);//创建一个新的socket
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);
rv=bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));//bind绑定端口和ip
if(rv < 0)
{
printf("Socket[%d] bind on port[%d] failure: %s\n", sockfd, port, strerror(errno));
return -2;
}
listen(sockfd, 13);//监听这个socket,等待客户的连接请求
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);//accpet()接受来自客户端的连接请求
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();//连接到客户端后fork创建一个进程,来处理客户端与服务器端的通信交流
if( pid < 0 )
{
printf("fork() create child process failure: %s\n", strerror(errno));
close(clifd);
continue;
}
else if( pid > 0 )
{
close(clifd);
continue;
}
else if ( 0 == pid )
{
char buf[1024];
int i;
printf("Child process start to commuicate with socket client...\n");
close(sockfd);
/*子进程会继承父进程处于listen状态的socket 文件描述符(sockfd),也会继承父进程accept()返回的客户端socket 文件描述符(clifd),但子进程只处理与客户端的通信,这时他会将父进程的listen的文件描述符sockfd关闭;同样父进程只处理监听的事件,所以会将clifd关闭。*/
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);
}
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);
}
}
}
}
close(sockfd);
return 0;
}
该服务器能实现多个客户端的连接并处理这些客户端传过来的数据,但是Linux下每种资源都有相关的软硬限制,譬如单个用户最多能创建的子进程个数有限制,同样一个进程最多能打开的文件描述符 也有相应的限制值,这些限制会限制服务器能够提供并发访问的客户端的数量,因此多进程的服务器不能给无限多个客户端给个连接。