首页 > 编程学习 > 【网络篇】第八篇——多进程版的TCP网络程序

前言

多进程版的TCP网络程序

捕捉SIGCHLD信号

让孙子进程提供服务


前言

之前我们已经利用socket编程实现了一个单进程的TCP网络程序(tcp详解),但上一章遗留了一个问题,当我们再开启一个终端去连接服务端,可以发现的是第二个客户端不能和服务器正常通信了,除非我们第一个客户端退出之后,第二个客户端才能和服务器正常通信。可以注意到大部分的socket接口都是阻塞型的。实际上除非特别指定,几乎所有的IO接口(包括socket接口)都是阻塞型的就像我们之前实现的代码,在accept接受了一个请求之后就会一直在while循环里面尝试去read,而没有继续去调用accpet,导致不能接受到新的请求。

可以肯定的是这样的设计是不合理的,所以我们将之前的代码进行改进。

多进程版的TCP网络程序

可以将当前的单执行流服务器改为多进程版的服务器.

当服务端调用accept函数获取到新连接后不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后让子进程为父进程获取到的连接提供服务。

由于父子进程是两个不同的执行流,当父进程调用fork创建出子进程后,父进程就可以继续从监听套接字获取新连接,而不用关心获取上来的连接是否服务完毕。

子进程继承父进程的文件描述符表

需要注意的是,文件描述符表是隶属于一个进程的, 子进程创建后会继承父进程的文件描述符表。比如父进程打开了一个文件,该文件对应的文件描述符是3,此时父进程创建的子进程的3号文件描述符也会指向这个打开的文件,而如果子进程再创建一个子进程,那么子进程创建的子进程的3号文件描述符也同样会指向这个打开的文件。

但当父进程创建子进程后,父子进程之间会保持独立性,此时父进程文件描述符表的变化不会影响子进程。最典型的代表就是匿名管道,父子进程在使用匿名管道进行通信时,父进程先调用pipe函数得到两个文件描述符,一个是管道读端的文件描述符,一个是管道写端的文件描述符,此时父进程创建出来的子进程就会继承这两个文件描述符,之后父子进程一个关闭管道的读端,另一个关闭管道的写端,这时父子进程文件描述符表的变化是不会相互影响的,此后父子进程就可以通过这个管道进行单向通信了。
对于套接字文件也是一样的,父进程创建的子进程也会继承父进程的套接字文件,此时子进程就能够对特定的套接字文件进行读写操作,进而完成对对应客户端的服务。

等待子进程问题

当父进程创建出子进程后,父进程是需要等待子进程退出的,否则子进程会变成僵尸进程,进而造成内存泄露,因此服务端创建子进程后需要调用wait或waitpid函数对子进程进行等待。

 阻塞式等待与非阻塞式等待:

  • 如果服务端采用阻塞的方式等待子进程,那么服务端还是需要等待服务完当前客户端,才能继续获取下一个连接请求,此时服务端仍然是以一种串行的方式为客户端提供服务。
  • 如果服务端采用非阻塞的方式等待子进程,虽然在子进程为客户端提供服务期间服务端可以继续获取新连接,但此时服务端就需要将所有子进程的PID保存下来,并且需要不断花费时间检测子进程是否退出。

总之,服务端要等待子进程退出,无论采用阻塞式等待还是非阻塞式等待,都不尽人意。此时我们可以考虑让服务端不等待子进程退出。

不等待子进程退出的方式

 让父进程不等待子进程退出,常见的方式有两种:

  • 捕捉SIGCHLD信号,将其处理动作设置为忽略。
  • 让父进程创建子进程,子进程再创建孙子进程,最后让孙子进程为客户端提供服务。

捕捉SIGCHLD信号

这里忘记的可以看我这边博客:信号

实际当子进程退出时会给父进程发送SIGCHLD信号了如果父进程将SIGCHLD信号进行捕捉,并将该信号的处理动作设置为忽略,此时父进程就只需要专心处理自己的工作,不必关心子进程了。

 该方式实现起来非常简单,也是比较推荐的一种做法。(客户端和服务端其他地方不变)

class TcpServer
{
public:
	void Start()
	{
		signal(SIGCHLD, SIG_IGN); //忽略SIGCHLD信号
		for (;;){
			//获取连接
			struct sockaddr_in peer;
			memset(&peer, '\0', sizeof(peer));
			socklen_t len = sizeof(peer);
			int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
			if (sock < 0){
				std::cerr << "accept error, continue next" << std::endl;
				continue;
			}
			std::string client_ip = inet_ntoa(peer.sin_addr);
			int client_port = ntohs(peer.sin_port);
			std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;
			
			pid_t id = fork();
			if (id == 0){ //child
				//处理请求
				Service(sock, client_ip, client_port);
				exit(0); //子进程提供完服务退出
			}
		}
	}
private:
	int _listen_sock; //监听套接字
	int _port; //端口号
};

 代码测试

 重新编译程序运行服务端后,可以通过以下监控脚本对服务进程进行监控。

while :; do ps axj | head -1 && ps axj | grep tcp_server | grep -v grep;echo "######################";sleep 1;done

此时可以看到,一开始没有客户端连接该服务器,此时服务进程只有一个,该服务进程就是不断获取新连接的进程,而获取到新连接后也是由该进程创建子进程为对应客户端提供服务的。

此时我们运行一个客户端,让该客户端连接服务器,此时服务进程就会调用fork函数创建出一个子进程,由该子进程为这个客户端提供服务。

如果再有一个客户端连接服务器,此时服务进程会再创建出一个子进程,让该子进程为这个客户端提供服务。

最重要的是,由于这两个客户端分别由两个不同的执行流提供服务,因此这两个客户端可以同时享受到服务,它们发送给服务端的数据都能够在服务端输出,并且服务端也会对它们的数据进行响应。

当客户端一个个退出后,在服务端对应为之提供服务的子进程也会相继退出,但无论如何服务端都至少会有一个服务进程,这个服务进程的任务就是不断获取新连接。

让孙子进程提供服务

我们也可以让服务端创建出来的子进程再次进行fork,让孙子进程为客户端提供服务, 此时我们就不用等待孙子进程退出了。

 命名说明:

  • 爷爷进程:在服务端调用accept函数获取客户端连接请求的进程。
  • 爸爸进程:由爷爷进程调用fork函数创建出来的进程。
  • 孙子进程:由爸爸进程调用fork函数创建出来的进程,该进程调用Service函数为客户端提供服务。

我们让爸爸进程创建完孙子进程后立刻退出,此时服务进程(爷爷进程)调用wait/waitpid函数等待爸爸进程就能立刻等待成功,此后服务进程就能继续调用accept函数获取其他客户端的连接请求。

不需要等待孙子进程退出

 而由于爸爸进程创建完孙子进程后就立刻退出了,因此实际为客户端提供服务的孙子进程就变成了孤儿进程,该进程就会被系统领养,当孙子进程为客户端提供完服务退出后系统会回收孙子进程,所以服务进程(爷爷进程)是不需要等待孙子进程退出的

关闭对应的文件描述符

 服务进程(爷爷进程)调用accpet函数获取到新连接后,会让孙子进程为该连接提供服务,此时服务进程已经将文件描述符继承给了爸爸进程,而爸爸进程又会调用fork函数创建出孙子进程,然后将文件描述符又继承给了孙子进程。

而父子进程创建后,他们各自的文件描述符表是独立的,不会互相影响。因此服务进程在调用fork函数后,服务进程就不需要再关心刚才从accept函数获取到的文件描述符了,此时服务进程就可以调用close函数将该文件描述符进行关闭。

同样的,对于爸爸进程和孙子进程来说,它们是不需要关心从服务进程(爷爷进程)继承下来的监听套接字的,因此爸爸进程可以将监听套接字关掉。

关闭文件描述符的必要性:

  • 对于服务进程来说,当它调用fork函数后就必须将从accept函数获取的文件描述符关掉。因为服务进程会不断调用accept函数获取新的文件描述符(服务套接字),如果服务进程不及时关掉不用的文件描述符,最终服务进程中可用的文件描述符就会越来越少。
  • 而对于爸爸进程和孙子进程来说,还是建议关闭从服务进程继承下来的监听套接字。实际就算它们不关闭监听套接字,最终也只会导致这一个文件描述符泄漏,但一般还是建议关上。因为孙子进程在提供服务时可能会对监听套接字进行某种误操作,此时就会对监听套接字当中的数据造成影响。
class TcpServer
{
public:
	void Start()
	{
		for (;;){
			//获取连接
			struct sockaddr_in peer;
			memset(&peer, '\0', sizeof(peer));
			socklen_t len = sizeof(peer);
			int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
			if (sock < 0){
				std::cerr << "accept error, continue next" << std::endl;
				continue;
			}
			std::string client_ip = inet_ntoa(peer.sin_addr);
			int client_port = ntohs(peer.sin_port);
			std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;
			
			pid_t id = fork();
			if (id == 0){ //child
				close(_listen_sock); //child关闭监听套接字
				if (fork() > 0){
					exit(0); //爸爸进程直接退出
				}
				//处理请求
				Service(sock, client_ip, client_port); //孙子进程提供服务
				exit(0); //孙子进程提供完服务退出
			}
			close(sock); //father关闭为连接提供服务的套接字
			waitpid(id, nullptr, 0); //等待爸爸进程(会立刻等待成功)
		}
	}
private:
	int _listen_sock; //监听套接字
	int _port; //端口号
};

服务器测试

重新编译程序运行客户端后,继续使用监控脚本对服务进程进行实时监控。

while :; do ps axj | head -1 && ps axj | grep tcp_server | grep -v grep;echo "######################";sleep 1;done

此时没有客户端连接服务器,因此也是只监控到了一个服务进程,该服务进程正在等待客户端的请求连接。

 此时我们运行一个客户端,让该客户端连接当前这个服务器,此时服务进程会创建出爸爸进程,爸爸进程再创建出孙子进程,之后爸爸进程就会立刻退出,而由孙子进程为客户端提供服务。因此这时我们只看到了两个服务进程,其中一个是一开始用于获取连接的服务进程,还有一个就是孙子进程,该进程为当前客户端提供服务,它的PPID为1,表明这是一个孤儿进程。

 当客户端全部退出后,对应为客户端提供服务的孤儿进程也会跟着退出,这时这些孤儿进程会被系统回收,而最终剩下那个获取连接的服务进程。

Copyright © 2010-2022 dgrt.cn 版权所有 |关于我们| 联系方式