今天复习了一波多进程,对前面的 Socket 编程代码进行了一些改进,服务器端程序采用多进程与客户端进行通信,实现多任务处理,支持多客户端同时访问服务器。
- 服务器按处理方式可以分为迭代服务器和并发服务器两类。
- 平常用C写的简单Socket客户端服务器通信,服务器每次只能处理一个客户的请求,它实现简单但效率很低,通常这种服务器被称为迭代服务器。
- 然而在实际应用中,不可能让一个服务器长时间地为一个客户服务,而需要其具有同时处理多个客户请求的能力,这种同时可以处理多个客户请求的服务器称为并发服务器,其效率很高却实现复杂。
- 在实际应用中,并发服务器应用的最广泛。Linux有3种实现并发服务器的方式:多进程并发服务器,多线程并发服务器,IO复用,先来看多进程并发服务器的实现。
在操作系统原理中这样说道:正在运行的程序及其占用的资源(CPU、内存、系统资源等)叫做进程。站在程序员的角度来看,我们使用vim编辑生成的C文件叫做源码,源码给程序员来看的但机器不识别,这时我们需要使用编译器gcc编译生成CPU可识别的二进制可执行程序并保存在存储介质上,这时编译生成的可执行程序只能叫做程序而不能叫进程。而一旦我们通过命令(./a.out)开始运行时,那正在运行的这个程序及其占用的资源就叫做进程了。
进程这个概念是针对系统而不是针对用户的,对用户来说,他面对的概念是程序。很显然,一个程序可以执行多次,这也意味着多个进程可以执行同一个程序。
【以下均是以32位机为例】
Linux 进程内存管理的对象都是虚拟内存,每个内存先天就有 0~4GB( 2^32 bits = 4GB )的互不干涉的虚拟内存空间,03GB为用户空间执行用户自己的代码,34GB为内核空间执行 Linux 系统调用的,这里存放整个内核的代码和所有的内核模块,用户所看到和接触的都是该虚拟地址(由MMU内存管理单元虚拟出来的地址),并不是实际的物理内存地址。
而对于子进程而言,其实是将父进程的内存空间完完全全地拷贝了一份,作为自己独立的内存空间来进行使用。
【打个比方:就像是在百度中打开了百度一样】
Linux内核在启动的最后阶段会创建init进程来执行程序/sbin/init,该进程是系统运行的第一个进程,进程号为 1,称为Linux 系统的初始化进程,该进程会创建其他子进程来启动不同写系统服务,而每个服务又可能创建不同的子进程来执行不同的程序。所以init进程是所有其他进程的“祖先”,并且它是由Linux内核创建并以root的权限运行,并不能被杀死。Linux 中维护着一个数据结构叫做 进程表,保存当前加载在内存中的所有进程的有关信息,其中包括进程的 PID(Process ID)、进程的状态、命令字符串等,操作系统通过进程的 PID 对它们进行管理,这些 PID 是进程表的索引。
pid_t fork(void);
fork() 是一个系统调用函数,它会创建一个新的子进程并返回两次,
所以我们在调用fork()后,需要通过其返回值来判断当前的代码是在父进程还是子进程运行,如果返回值是 0 说明现在是子进程在运行,如果返回值 >0 说明是父进程在运行,而如果返回值 <0 的话,说明 fork() 系统调用出错。fork 函数调用失败的原因主要有两个:
fork() 新建的这个子进程是父进程的一个副本。这也就意味着,系统在创建新的子进程成功后,会将父进程的文本段、数据段、堆栈段都复制一份给子进程,由于子进程工作在自己独立的内存空间中,所以子进程对自己内存空间进行修改并不会影响到父进程对应的内存空间。
这时系统中会出现两个完全相同的进程(父、子进程),这两个进程在执行的时候没有固定的先后顺序,哪个进程先执行要看系统的进程调度策略,如果要确保某一个进程先执行,那么需要在程序中通过进程间通信的机制来实现。
在上面的描述中,我们创建的子进程是用来执行父进程的文本段,但在更多的情况下,我们会创建子进程去执行另外的程序,这时候我们就会在fork后面调用 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[]);
... ...
其中 execl() 函数会用得相对多一些,比如说我想让子进程去执行 “ ifconfig eth0 ” 这个命令,就
execl("/sbin/ifconfig", "ifconfig", "eth0", NULL);
execl() 会导致子进程彻底丢掉父进程的文本段、数据段和堆栈段,并加载 /sbin/ifconfig 这个程序的文本段、数据,重新建立进程内存空间
- 参数1:要执行程序的路径
- 剩余的参数是命令及其相关的选项和参数,每个命令、选项和参数都用双引号("")括起来,并在结尾以NULL结束。
注意:因为程序默认标准输出是屏幕,并且C程序没法读取标准输出的内容,所以我们需要将标准输出重定向到一个文件中,这样才能获取到该指令的返回结果
pid_t vfork(void);
vfork() 函数是另一个可以用来创建子进程的函数,它与 fork() 函数的用法相同,但是 vfork() 函数并不是将父进程的内存空间完全复制到子进程中,因为如果在创建子进程后,子进程立即调用 exec 或 exit(),那么就不会去引用这段内存空间了。
vfork() 使用了写时复制(CopyOnWrite)技术,这些数据区域由父子进程共享,内核将他们的访问权限改成只读,在子进程修改数据域【数据段、堆区、栈区】前,该子进程是在父进程的内存空间中运行的,但当子进程尝试修改数据域的时候,内核只拷贝修改区域的那块内存空间。
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
当一个进程正常或异常退出时,内核就会向其父进程发送SIGCHLD信号。因为子进程退出是一个异步事件,所以这种信号也是内核向父进程发送的一个异步通知。父进程可以选择忽略该信号,或者提供一个该信号发生时即将被执行的函数,父进程可以调用wait()或waitpid()可以用来查看子进程退出的状态。
system() 函数:
int system(const char *command);
如果我们在程序中,想执行另外一个Linux命令时,可以调用fork()然后再exec执行相应的命令即可,但这样相对比较麻烦。Linux系统提供了一个system()库函数,该库函数可以快速创建一个进程来执行相应的命令。比如:
system("ping -c 4 -I eth0 4.2.2.2");
popen() 函数:
FILE *popen(const char *command, const char *type);
该函数可以返回一个基于管道(pipe)的文件流,这样我们可以从该文件流中一行样解析了。
服务器端程序如下:
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define MSG_STR "Hello, Simply!"
void sig_handler(int signum);
int main(int argc, char **argv)
{
int listen_fd = -1;
int client_fd = -1;
struct sockaddr_in servaddr;
struct sockaddr_in cliaddr;
socklen_t cliaddr_len = sizeof(struct sockaddr);
int server_port;
int backlog = 10;
int on = 1;
pid_t pid;
//用来确认程序执行的格式是否正确,不正确则退出并提醒用户
if (argc < 2)
{
printf("Program usage: %s [Port]\n", argv[0]);
return -1;
}
//将端口参数赋给参数变量
//由于命令行传参进来是字符串类型,所以需要atoi转换为整型
server_port = atoi(argv[1]);
/*
* socket(),创建一个新的sockfd
* 指定协议族为IPv4
* socket类型为SOCK_STREAM(TCP)
*/
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0)
{
printf("create socket failure: %s\n", strerror(errno));
return -2;
}
printf("create socket[%d] success\n", listen_fd);
//避免上次结束程序时,端口未被及时释放的问题
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
/*
* bind(),将服务器的协议地址绑定到listen_fd
*/
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(server_port);
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listen_fd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0)
{
printf("socket[%d] bind port[%d] failure: %s\n", listen_fd, server_port, strerror(errno));
close(listen_fd);
return -3;
}
printf("socket[%d] bind port[%d] success\n", listen_fd, server_port);
/*
* listen()
* 监听listen_fd的端口,并设置最大排队连接个数
*/
listen(listen_fd, backlog);
printf("Start listening port[%d]\n", server_port);
signal(SIGCHLD, sig_handler);
/*
* accept()
* 等待并接受来自客户端的连接请求
* 如果没有客户端连接服务器的话该程序将一直阻塞着不会返回,直到有一个客户端连过来为止
* 返回一个client_fd与客户通信
*/
while (1)
{
printf("\nStart waitting and accept new client to connect...\n");
client_fd = accept(listen_fd, (struct sockaddr *)&cliaddr, &cliaddr_len);
if (client_fd < 0)
{
printf("\naccept new client failure: %s\n", strerror(errno));
continue;
}
printf("\naccept new client [%s:%d] with fd[%d] success\n", inet_ntoa(cliaddr.sin_addr), ntohs(cliaddr.sin_port), client_fd);
//创建一个子进程
pid = fork();
if (pid < 0)
{
printf("fork() create child progress failure: %s\n", strerror(errno));
close(client_fd);
continue;
}
else if (pid > 0) //父进程代码,此时pid为子进程的PID
{
printf("This is Parent progress[%d], Child progress[%d] close client_fd[%d]\n", getpid(), pid, client_fd);
//父进程关闭client_fd
close(client_fd);
continue;
}
else if (pid == 0) //子进程代码
{
int rv = -1;
char buf[1024];
printf("This is Child progress[%d], Parent porgress[%d]\n", getpid(), getppid());
//子进程关闭listen_fd
close(listen_fd);
printf("child[%d] progress close listen_fd\n", getpid());
while (1)
{
//清空buf内容,避免因为随机值出现乱码
memset(buf, 0, sizeof(buf));
//从客户端中读取数据
rv = read(client_fd, buf, sizeof(buf));
if (rv < 0)
{
printf("socket[%d] child[%d] read date from client failure: %s\n", client_fd, getpid(), strerror(errno));
close(client_fd);
//这里不能用continue/break,会导致与客户端断开连接后,进程无法关闭,下面同理
exit(0);
}
else if (rv == 0)
{
printf("socket[%d] child[%d] get Disconnected\n", client_fd, getpid());
close(client_fd);
exit(0);
}
else if (rv > 0)
{
printf("socket[%d] child[%d] read %d Byte data from client: %s\n", client_fd, getpid(), rv, buf);
}
//发送消息给客户端
rv = write(client_fd, MSG_STR, strlen(MSG_STR));
if (rv < 0)
{
printf("socket[%d] child[%d] write date to client failure: %s\n", client_fd, getpid(), strerror(errno));
close(client_fd);
exit(0);
}
printf("socket[%d] child[%d] write %d Byte data to client: %s\n", client_fd, getpid(), rv, MSG_STR);
printf("\n");
}
} //child progress
}
/*
* close()
* 关闭服务器监听的listen_fd
*/
printf("Close socket[%d]\n", listen_fd);
close(listen_fd);
return 0;
}
void sig_handler(int signum)
{
pid_t pid;
int stat;
while (1)
{
pid = waitpid(-1, NULL, WNOHANG);
if (pid <= 0)
break;
}
}
由于客户端程序是一直不间断收发,所以在这里使用的是 TCP Test Tool 工具来进行测试
打开了两个TCP Test Tool,相当于两个不同的客户端同时连接,可以看到由于服务器端在将客户端交给子进程后,就将client_fd[4]给关闭了,所以在后面的新客户端连接的时候,他们的client_fd永远都是4,但是进程号不一样了。并且有清晰的父子进程关系说明。
消息接收正常
在客户端断开连接后,子进程的资源也被系统释放了【处理了僵死进程】。
以上是对Linux 多进程的一些理解,如有写的不好的地方,还请各位大佬不吝赐教