本文迁移并整理自个人之前在博客园的博客。
本文是并发服务器系列学习笔记的第 1 篇。
参考资料:《UNIX网络编程 卷1》
fork 及 exec 函数
fork函数是Unix/Linux中派生新进程的唯一方法。其定义如下:
1 |
|
fork 函数调用一次,返回两次。它在调用进程(称为父进程)中返回一次,返回值是新派生进程(称为子进程)的进程ID号;在子进程中返回一次,返回值为 0。因此,返回值本身告知当前进程是子进程还是父进程。
fork 在子进程返回 0 而不是父进程的进程 ID 的原因在于:任何子进程只有一个父进程,而且子进程总是可以通过调用 getppid 函数取得父进程的进程 ID。相反,父进程可以有很多子进程,而且无法获取各个子进程的进程 ID。如果父进程想要跟踪所有子进程的进程 ID,那么它必须记录每次调用 fork 的返回值。
父进程中调用 fork 之前打开的所有描述符在 fork 返回之后由子进程分享。
我们将看到网络服务器利用了这个特性:父进程调用 accept 之后调用 fork。所接受的已连接套接字随后就在父进程与子进程共享。通常情况下,子进程接着读写这个已连接套接字,父进程则关闭这个已连接套接字。
fork 有两个典型用法:
1)一个进程创建一个自身的副本,这样每个副本都可以在另一个副本执行其他任务的同时处理各自的某个操作。这是网络服务器的典型用法。
2)一个进程想要执行另一个程序。既然创建新进程的唯一办法是调用 fork,该进程于是首先调用 fork 创建一个自身的副本,然后其中一个副本(通常为子进程)调用 exec 把自身替换成新的程序。这是诸如 shell 之类程序的典型用法。
存放在硬盘上的可执行程序文件能够被 Unix 执行的唯一方法是:由一个现有进程调用六个 exec 函数中的某一个。(这六个函数中哪一个被调用并不重要,我们往往把它们统称为 exec 函数。)exec 把当前进程映像替换成新的程序文件,而且该新进程通常从 main 函数开始执行。进程 ID 并不改变。我们称调用 exec 的进程为调用进程(calling process),称新执行的程序为新程序(new program)。
这六个 exec 函数之间的区别在于:
a)待执行的程序文件是由文件名(filename)还是由路径名(pathname)指定;
b)新程序的参数是一一列出还是由一个指针数组来引用;
c)把调用进程的环境传递给新程序还是给新程序指定新的环境。
并发服务器
我们之前(第4章前,如图4-11)接触的服务器是一个迭代服务器(iterative server)。对于像时间获取这样的简单服务器来说,这就够了。然而当服务一个客户请求可能花费较长时间时,我们并不希望整个服务器被单个客户长期占用,而是希望同时服务多个客户。Unix/Linux中编写并发服务器程序最简单的办法就是fork一个子进程来服务每个客户。下边程序给出了一个典型的并发服务器程序的轮廓:
1 | pid_t pid; |
当一个连接建立时,accept返回,服务器接着调用fork,然后由子进程服务客户(通过已连接套接字connfd),父进程则等待另一个连接(通过监听套接字listenfd)。既然新的客户由子进程提供服务,父进程就关闭已连接套接字。
在上边程序中,我们假设由函数doit执行服务客户所需的所有操作。当该函数返回时,我们在该子进程显式地关闭已连接套接字。这一点并非必需,因为下一个语句就是调用exit,而进程终止处理的部分工作就是关闭所有由内核打开的描述符。是否显式调用close只和个人编程风格有关。
对一个TCP套接字调用close会导致发送一个FIN,随后是正常的TCP连接终止序列。为什么上边程序中父进程对connfd调用close没有终止它与客户的连接呢?为了便于理解,我们必须知道每个文件或套接字都有一个引用计数。引用计数在文件表项中维护(APUE第58-59页),它是当前打开着的引用该文件或套接字的描述符的个数
。在上边程序中,socket返回后与listenfd关联的文件表项的引用计数值为1。accept返回后与connfd关联的文件表项的引用计数值也为1。然而fork返回后,这两个描述符就在父进程与子进程间共享(也就是被复制),因此与这两个套接字相关联的文件表项各自的访问计数值均为2。这么一来,当父进程关闭connfd时,它只是把相应的引用计数值从2减为1。这套接字真正的清理和资源释放要等到其引用计数为0时才发生。这也会在子进程关闭connfd时发生。
我们还可以用图示直观的表现出来。
首先,图4-14给出了在服务器阻塞于accept调用且来自客户的连接请求到达时客户和服务器的状态:
从accept返回后,我们立即就有图4-15所示状态。连接被内核接受,新的套接字connfd被创建。这是一个已连接套接字,可由此跨连接读写数据:
并发服务器的下一步是调用fork,图4-16给出了从fork返回后的状态:
注意,此时listenfd和connfd这两个描述符都在父进程和子进程之间共享(被复制)。
再下一步是由父进程关闭已连接套接字,由子进程关闭监听套接字,如图4-17所示:
这是这两个套接字所期望的最终状态。子进程处理与客户的连接,父进程则可以在监听套接字上再次调用accept来处理下一个客户连接。
示例
示例 1
服务端程序
1 |
|
其中str_echo函数用于处理客户端消息,其定义如下:
1 | void |
客户端程序
1 |
|
其中函数str_cli用于从标准输入读入一行文本,写到服务器上,读回服务器对该行的回射,并把回射行写到标准输出上。其定义为:
1 | void |
测试效果
接下来,我们在机器上看一下实际效果:
1)首先开启服务端
上图表示服务端进程tcpserv01正处在inet_csk_accept状态(从accept队列中取出sock然后返回)。
2)开启客户端
这时我们再次查看进程,发现服务端父进程已经创建了一个子进程,且父进程仍处于监听状态,而由子进程处理与客户端的数据交互,如下图:
而当关闭客户端时会使得服务端子进程退出,最后成为僵尸进程,如下图:
当有大量的客户端断开与服务端的连接,就会造成大量的僵尸进程,浪费系统资源,如下图:
因此,示例1的服务端程序存在 造成大量僵尸进程从而大量浪费系统资源 的潜在威胁,所以我们需要改进一下该服务端程序。
关于僵尸进程解决方法请参考博文孤儿进程与僵尸进程。
示例 2:改进示例 1
在这里我们只需要改进服务端程序就可以了(详细解释请参考《UNIX网络编程 卷1》5.10节),改进后的程序如下:
1 |
|
其中Signal函数定义如下:
1 | /* include signal */ |