网络通信程序中最经典的模型就是客户端/服务器(C/S)模型。该模型中的服务器程序通常处于长时间运行的状态,当客户端程序主动对服务器程序发出网络请求时,服务器程序才做出具体的回应。也就是说,服务器大多数处于等待状态,只有收到客户端的网络请求才被唤醒,当处理完客户端的请求时候,又将处于等待状态。
上述同时只能处理一个客户端请求的服务器被称为迭代服务器(iterative server),这样的C/S模型只能应对那些简单的应用。大多数情况下我们希望服务器可以同时服务多个客户端程序,即服务器程序既能处理客户端发送来的请求同时又能接收其他客户端发送的请求。这样的服务器称为并发服务器(concurrent server)。
Linux中实现并发服务器的方法很简单:每当客户端对服务器有网络请求时,服务器程序就fork一个子服务器进程来处理这个客户的请求,而主服务器程序仍处于等待其他客户端网络请求的状态。基于这个原理,并发服务器的基本模型如下:
if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) { my_error("socket", errno, __LINE__); } if (bind(sockfd, (struct sockaddr *)&my_addr, sizeof(struct sockaddr_in)) == -1) { my_error("bind", errno, __LINE__); } if (listen(sockfd, BACKLOG) == -1) { my_error("listen", errno, __LINE__); } while (1) { sin_size = sizeof(struct sockaddr_in); if ((client_fd = accept(sockfd, (struct sockaddr *)&remote_addr, &sin_size)) == -1) { my_error("accept", errno, __LINE__); continue; } if ((pid = fork()) == 0) { close(sockfd); process_client_request(client_fd); close(client_fd); exit(0); } else if (pid > 0) close(client_fd); else my_error("fork", errno, __LINE__); }
每当accept()接收到一个TCP连接时,主服务器进程就fork一个子服务器进程。子服务器进程调用相应的函数,通过client_fd(连接套接字)对客户端发来的网络请求进程处理;由于客户端的请求已被子服务进程处理,那么主服务器进程就什么也不做,通过sockfd(监听套接字)继续循环等待新的网络请求。
这里我们需要特别强调的是,在父子进程中需要关闭相应的套接字描述符。从上述代码中可以看到,子服务进程在处理客户端的网络请求之前关闭了监听套接字,主服务进程则关闭了连接套接字,子服务进程在处理完客户端请求后关闭了连接套接字。最后一种关闭套接字的情形比较合乎常理,这里我们重点关注前两种关闭套接字的情况。
我们的问题是:子服务进程关闭了监听套接字,服务器是否还能监听其他连接请求;父服务进程关闭了连接套接字,服务器是否还能够处理客户端请求。
如果理解了文件的的引用计数,这个问题就会迎刃而解。每个文件都有一个引用计数,该引用计数表示当前系统内的所有进程打开该文件描述符的个数。套接字是一种特殊的文件,当然也有引用计数。
在并发服务器这个例子当中,在accept函数之前,sockfd的引用计数为1;在fork函数执行之前,sockfd和client_fd的引用计数分别为1;当fork执行后,由于子进程复制了父进程的资源,所以子进程也拥有这两个套接字描述符,则此时sockfd和client_fd的引用计数都为2。只有当子进程处理完客户请求时,client_fd的引用计数才由于close函数而变为0。由于父服务器进程的主要任务是监听客户请求,则它关闭了连接套接字client_fd;而子进程的主要任务是处理客户请求,它不必监听其他客户请求,因此子进程关闭了sockfd。由上可知,父子进程关闭相应的套接字并不会影响其所负责的通信功能。
我们可以通过下面的两幅示意图更进一步了解fork函数执行前后的客户端和服务器之间的状态。
父子进程关闭相应套接字后客户端和服务器端的状态如下:
这就是监听套接字和连接套接字两者的最终状态,子进程处理客户请求,父进程监听其他客户请求。这样的并发服务器看似完美,但是会产生许多僵死进程,这是为什么?下文将会分析。