一、服务器并发访问问题
服务器按处理方式可分为:迭代服务器和并发服务器两类。迭代服务器每次只能处理一个用户的请求,实现简单但是效率低;并发服务器可以同时处理多个用户的请求,虽然实现较为复杂但是效率很高,在实际应用中最为广泛。Linux中有3种实现并发服务器的方式:多进程并发服务器,多线程并发服务器,IO复用。接下来主要记录多进程并发服务器的实现方式。
二、多进程编程
进程:在操作系统原理中,正在运行的程序及其占用的资源(CPU、内存、系统资源等)叫做进程。比方说,我们在写完一个程序的源代码,并通过gcc编译完成生成CPU可识别的二进制可执行程序并存储到存储介质上这个过程不是进程,而叫做程序。当我们使用“./a.out”开始运行时这个程序和它占用的资源就叫做进程。
1.进程空间内存布局
Linux进程内存管理的对象都是虚拟内存,每个进程先天就有0——4G的各不相干的虚拟内存空间,0——3G空间是用户空间执行用户自己的代码,高1GB的空间是空间执行Linux系统调用,这里存放在整个内核代码和所有的内核模块,用户所看到的接触到的都是虚拟地址,并不是实际的物理内存地址。
Linux下一个进程在内存里有三部分数据,就是代码段、堆栈段和数据段。
Linux内存管理的基本的思想是只有在真正访问一个地址的时候才建立这个地址的物理映射,Linux的分配方式有以下三种:
(1)从静态存储区域分配。 就是数据段的内存分配,这段内存在程序编译阶段就已经分配好,在程序的整个运行期间都存在,例如全局变量 static变量。
(2)在栈上创建。在执行函数时,函数内部局部变量的存储单元可以在栈上创建,函数执行结束时这些存储单元会自动被释放。
(3)从堆上分配,也叫动态内存分配。程序在运行时用malloc()或者new申请任意多少内存,对应的程序要用free或delete释放相应的内存。
接下来是一段代码验证C程序编译以及运行时的进程内存布局:
#include<stdio.h>
#include<stdlib.h>
int g_var1;//未初始化的全局变量,存放在BSS区,值为0
int g_var2 = 20;//初始化的全局变量存在data区 值为20
int main(int argc,char **argv)//argv是命令行参数,存放在命令行参数区
{
static int s_var1;//未初始化的静态变量,存在bss区 值为0
static int s_var2 = 10;//初始化的静态变量 存在data区 值为10
char *str = "Hello";//str是初始化的局部变量,存放在栈中,它的值是“Hello”这个字符串常量 存放在DATA中的RODATA区中的地址
char *ptr;//未初始化的局部变量 存放在栈中,野指针
ptr = malloc(100);//malloc()会从堆中分配100个字节的内存空间,并将该内存空间的首地址返回给ptr存放
printf("[cmd args] : argv address : %p\n",argv);
printf("\n");
printf("[Stack] : str address : %p\n",&str);
printf("[Stack] : ptr address : %p\n",&ptr);
printf("\n");
printf("[ Heap ] : malloc address : %p\n",ptr);
printf("\n");
printf("[ bss ] : s_var1 address : %p value : %d\n",&s_var1,g_var1);
printf("[ bss ] : g_var1 address : %p\n value : %d\n",&g_var1,g_var1);
printf("\n");
printf("[ data ] : g_var2 address : %p value : %d\n",&g_var2,g_var2);
printf("[ data ] : s_var2 address :%p value : %d\n",&s_var2,s_var2);
printf("\n");
printf("[ rodata ] : \"%s\" address : %p\n",str,str);
printf("\n");
printf("[Text] : main() address : %p\n",main);
return 0;
}
运行结果如下:
2.fork()函数调用[pid_t fork(void)]
Linux下有两个基本的系统调用可以用于创建子进程:fork()和vfork()。在我们编程的时候,一个函数调用只有一个返回(return),但是由于fork()是创建一个新的进程,会有两次返回,一次返回给父进程,返回值是子进程的PID,第二次返回给子进程,其返回值为0。所以我们在用fork()创建子进程时,需要通过其返回值判断当前的代码是父进程还是紫禁城在运行,如果返回值小于0,则说明fork()函数调用出错。fork()函数出错的原因主要有以下两个:
(1)系统中已经有太多的进程;
(2)改实际用户ID的进程总数超过了系统限制;
每个子进程有且只有一个父进程,并且每个子进程可以通过getpid()来获得自己的进程号,也可以通过getppid()来获得自己的父进程号。一个父进程可以创建多个子进程,但是对于父进程来说并没有一个函数可以获得他的子进程的进程号,所以父进程在通过fork()创建子进程的时候需要通过返回值来告诉父进程子进程的进程号,这也是fork()函数有两个返回值的重要原因。
下面以一个简单的程序来演示子进程的创建过程
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<errno.h>
int main(int argc,char **argv)
{
pid_t pid;
printf("Parent process PID[%d]start running...\n",getpid());
pid = fork();
/*出错*/
if(pid < 0)
{
printf("fork() create child process failure : %s\n",strerror(errno));
return -1;
}
/*子进程运行*/
else if(pid == 0)
{
printf("Child process PID[%d] start running,my parent PID is [%d]\n",getpid(),getppid());
return 0;
}
/*父进程运行*/
else
{
printf("Parent process PID[%d] continue running.and chid process PID is [%d]\n",getpid(),pid);
return 0;
}
return 0;
}
运行结果如下:
在上述代码中我们需要注意的是:在编程时,任何位置的exit()函数调用都会导致本程序退出,main()函数中的return()调用也会导致程序退出,但是其他任何函数中的return()都只是这个函数返回而不是退出。
fork()系统调用会创建一个新的子进程, 这个子进程是父进程的一个副本,子进程被创建成功后,系统会将父进程的文本段、数据段、堆栈都复制一份给子进程,但是子进程具有自己的独立空间,子进程对这些内存的修改不会影响父进程。父子进程执行的顺序没有先后之分,哪个先执行要看系统自身的调度策略。如果要父/子进程先执行,则需要通过进程间通信机制来实现。
接下来的C程序将深入演示父进程创建子进程的过程:
1 #include<stdio.h>
2 #include<errno.h>
3 #include<unistd.h>
4 #include<string.h>
5 int g_var = 6;
6 char g_buf[] = "A string write to stdout.\n";
7 int main(int argc,char **argv)
8 {
9 int var = 88;
10 pid_t pid;
/*将g_buf[]中存的字符串直接打印到标准输出上*/
11 if( write(STDOUT_FILENO,g_buf,sizeof(g_buf)-1) < 0)
12 {
13 printf("Write string to stdout error:%s\n",strerror(errno));
14 return -1;
15 }
16 printf("Befor fork\n");
/*开始创建子进程*/
17 if((pid = fork()) < 0)
18 {
19 printf("fork() error:%s\n",strerror(errno));
return -2;
20 }
21 else if(0 == pid)
22 {
23 printf("Child process PID[%d] running...\n",getpid());
24 g_var ++;
var ++;
25 }
26 else
27 {
28 printf("Parent process PID[%d] waiting...\n",getpid());
29 sleep(1);
30 }
31 printf("PID = %ld,g_var = %d,var = %d\n",(long)getpid(),g_var,var);
32 return 0;
33 }
运行结果如下:
代码解析:
(1)因为不知道是父进程还是子进程先运行,所以在29行调用sleep(),让父进程延迟1s,子进程先执行;但是sleep()这种机制并不可靠,还是要使用进程间通信机制来实现父子进程之间的同步问题;
(2)31行的printf()被执行了两次,是因为fork()之后子进程会复制父进程的代码段,这样31行的代码也被复制给子进程。且子进程运行到25行之后 ,并没有调用exit()或者return()函数退出,所以程序会执行到32行的子程序退出;同理父进程也执行到32行退出,所以printf()被执行了两次;
(3)子进程在24行改变了这两个变量的值,这个改变只影响子进程空间的值,并不会影响父进程的内存空间,所以子进程里的g_var和var分别变成了7和89,而父进程里的g_var和var都没变;
接下来将上述代码输出重定向到tmp.log中运行结果如下:
代码解析
(1)第二次运行时,我们将标注输出重定向到了tmp.log文件中,我们发现g_buf[]中存储的字符串两次运行都只打印了一次,但是“Befor fork”打印了两次,原因如下:
<1>write()函数是系统调用不带缓冲,不管是否有重定向,字符串都会立马输出 ;
<2>printf()库函数在标准输出中默认是行缓冲,而当标准输出重定向到文件中之后变成了全缓冲;这样16行的printf()在第一次没有重定向的时候在遇到换行符的时候就立马输出到标准输出上;但是第二次因为有重定向,这时打印的内容并不会立马被打印到标准输出上,而是暂时被存储到缓冲区,这样当子进程创建完成后,子进程的缓冲区中也有“Befor fork”,并且不会被打印,直到父子进程都调用最后的return()退出程序时,进程会自动flush缓冲区里的数据 这时才会被打印出来,所以“Befor fork”才会被打印两次。
3.子进程继承父进程的东西
引自【https://blog.csdn.net/m0_68994303/article/details/134442248】
(1)文件描述符(File Descriptors):子进程会继承父进程打开的文件描述符,包括标准输入、标准输出、标准错误输出等。
(2)环境变量(Environment Variables):子进程会继承父进程设置的环境变量。
(3)工作目录(Working Directory):子进程会继承父进程的当前工作目录。
(4)信号处理方式(Signal Handlers):子进程会继承父进程设置的信号处理方式。
(5)用户 ID 和组 ID(User ID and Group ID):子进程会继承父进程的用户 ID 和组 ID。
(6)内存映射(Memory Maps):子进程会继承父进程的内存映射。
(7)限制和资源使用情况(Limits and Resource Usage):子进程会继承父进程的资源限制(如CPU、内存等)和资源使用情况。
(8)进程间通信(Interprocess Communication):子进程会继承父进程打开的管道、共享内存、消息队列等进程间通信的资源。
需要注意的是,子进程会继承这些资源的副本,而不是直接共享。子进程和父进程之间可以独立地修改这些资源的副本,互不影响。这样可以保证子进程的执行环境与父进程的执行环境相互独立。
————————————————
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/m0_68994303/article/details/134442248
4.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 arg[ ] );
int execvp(const char *file,char *const argv[ ]);
... ...
在上面的函数名中,l表示以列表(list)的形式传递要执行程序的命令行参数,而v表示以数组(vector)的形式传递要执行程序的命令行参数,而v表示给该命令传递环境变量(environment)。在上述函数调用中excel()参数相对简单,接下来以一个C程序为例演示使用方法:
ifconfig eth0输出:
接下来是通过C语言创建一个子进程来运行“ifconfig eth0”程序
#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>
#include <sys/types.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.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))
{
/*
* 包含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地址部分*/
while(!isblank(*ptr))
ptr++;
//第一个空白符的地址也就是IP地址终止的字符位置
ip_end = ptr;
/*使用memcpy()函数将IP地址copy到存放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;
}
运行结果:
5.vfork()系统调用[pid_t vfork(void)]
由上述可知,在fork()之后经常带有exec()来执行另外一个程序,而exec()会抛弃父进程的文本段、数据段、堆栈等来加载另外一个程序,所以现在的很多fork()实现并不执行一个父进程数据段、堆和栈的完全的副本copy;作为替代,使用了写时复制技术:这些数据将由父子进程共享,内核将他们的权限改为只读,如果父进程和子进程想要修改这些区域的时候,内核再为修改区的那块内存做一个副本。
vfork()是另一个用来创建进程的函数,与fork()不同的是,vfork()并不会将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exit()或exec(),也就不会引用这段内存空间;vfork()会保证子进程先运行,在它调用了exec()或exit()后父进程才可能被调度运行。
6.wait()与waitpid()
我们在一个程序中,想执行另外一个命令可以用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函数来实现(基于管道,后面会详细讲解管道的原理、用法):
#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>
int get_ipaddr(char *interface,char *ipaddr,int ipaddr_size);
int main(int argc,char **argv)
{
char ipaddr[16];
char *interface = "eth0";
memset(ipaddr,0,sizeof(ipaddr));
if( get_ipaddr(interface,ipaddr,sizeof(ipaddr)) < 0)
{
printf("Error : get IP address failure : %s\n",strerror(errno));
return -1;
}
printf("get network interface %s IP address [%s]\n",interface,ipaddr);
return 0;
}
int get_ipaddr(char *interface,char *ipaddr,int ipaddr_size)
{
char buf[1024];
char *ptr;
char *ip_start;
char *ip_end;
FILE *fp;
int len;
int rv;
if(!interface || !ipaddr || ipaddr_size < 16)
{
printf("Invalid input arguments\n");
return -1;
}
memset(buf,0,sizeof(buf));
snprintf(buf,sizeof(buf),"ifconfig %s",interface);
if( NULL == (fp = popen(buf,"r")))
{
printf("popen() to excute command \"%s\" failure : %s\n",buf,strerror(errno));
return -2;
}
rv = -3;/*Set default return value to -3 means parser failure*/
while(fgets(buf,sizeof(buf),fp))
{
if(strstr(buf,"netmask"))
{
ptr = strstr(buf,"inet");
if(!ptr)
{
break;
}
ptr += strlen("inet");
while(isblank(*ptr))
ptr++;
ip_start = ptr;
while(!isblank(*ptr))
ptr++;
ip_end = ptr;
memset(ipaddr,0,sizeof(ipaddr));
len = ip_end - ip_start;
len = len>ipaddr_size ? ipaddr_size : len;
memcpy(ipaddr,ip_start,len);
rv = 0;/*Parser IP address OK and set rv to 0*/
}
}
return rv;
}
运行结果如下: