本文摘自《UNIX网络编程 卷1》。
fork和exec函数
fork函数是Unix/Linux中派生新进程的唯一方法。其定义如下:
#include <unistd.h> pid_t fork(void); // 返回:若成功则在子进程中返回0,在父进程中返回子进程ID,若出错则返回-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; 2 int listenfd, connfd; 3 listenfd = Socket(...); 4 /* fill in sockaddr_in{} with server‘s well-known port */ 5 Bind(listenfd, ...); 6 Listen(listenfd, LISTENQ); 7 for( ; ; ) 8 { 9 connfd = Accept(listenfd, ...); /* probably blocks */ 10 if((pid = Fork()) == 0) 11 { 12 Close(listenfd); /* child closes listening socket */ 13 doit(connfd); /* process the request */ 14 Close(connfd); /* done with this client */ 15 exit(0); /* child terminates */ 16 } 17 Close(connfd); /* parent closes connected socked */ 18 }
当一个连接建立时,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来处理下一个客户连接。