一、概述
下面的简单的例子是执行如下步骤的一个回射服务器:
1)客户从标准输入读入一行文本,并写给服务器
2)服务器从网络输入读入这行文本,并回射给客户
3)客户从网络输入读入这行回射文本,并显示在标准输出上
全双工TCI连接,fgets和fputs使用的是标准I/O程序库,writen和readline都是read和write。
我们还要考虑许多边界条件:客户和服务器启动时发生什么?客户正常终止时发送什么?服务器进程在客户之前终止,客户会发生什么?服务器崩溃,客户会发生什么?
二、TCP回射服务器程序: main函数
#include "unp.h"
int
main(int argc, char **argv)
{
int listenfd, connfd;
pid_t childpid;
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);//通配地址
servaddr.sin_port = htons(SERV_PORT);//9877
Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));
Listen(listenfd, LISTENQ);//把该套接字转化为监听套接字
for ( ; ; ) {
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);//阻塞,等待客户
if ( (childpid = Fork()) == 0) { /* child process */
Close(listenfd); /* close listening socket */
str_echo(connfd); /* process the request */
exit(0);
}
Close(connfd); /* parent closes connected socket */
}
}
三、TCP回射服务器程序:str_echo函数
#include "unp.h"
void
str_echo(int sockfd)
{
ssize_t n;
char buf[MAXLINE];
again:
while ( (n = read(sockfd, buf, MAXLINE)) > 0)
Writen(sockfd, buf, n);
if (n < 0 && errno == EINTR)//没有读到数据,而被意外中断
goto again;
else if (n < 0)
err_sys("str_echo: read error");
}
四、TCP回射客户程序:main函数
#include "unp.h"
int
main(int argc, char **argv)
{
int sockfd;
struct sockaddr_in servaddr;
if (argc != 2)
err_quit("usage: tcpcli <IPaddress>");
sockfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);//从unp.h获得众所周知端口号
Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);//从命令行参数获得服务器IP地址
Connect(sockfd, (SA *) &servaddr, sizeof(servaddr));
str_cli(stdin, sockfd); /* do it all */
exit(0);
}
五、TCP回射客户程序:str_cli函数
#include "unp.h"
void
str_cli(FILE *fp, int sockfd)
{
char sendline[MAXLINE], recvline[MAXLINE];
while (Fgets(sendline, MAXLINE, fp) != NULL) {//Fgets检查是否错误,遇到文件结束符返回空
Writen(sockfd, sendline, strlen(sendline));
if (Readline(sockfd, recvline, MAXLINE) == 0)
err_quit("str_cli: server terminated prematurely");
Fputs(recvline, stdout);
}
}
六、正常启动
我们在linux主机上后台启动服务器:
可以看到监听套接字。
接下来我们启动客户,并且用netstat检查套接字状态:
可以看到第一个是服务器父进程的套接字,第三个是服务器子进程的套接字,第二个是客户进程的套接字。
接下来查看端口号和进程号的关系:
查看进程对应的伪终端号,并且查看这些进程的状态和关系。
pst1为服务子进程的伪终端号。我们可以看到父进程在第一个进程是第二个进程的父进程。STAT那一列表明进程在等待某些资源而睡眠。第二行WCHAN列代表进程阻塞与accept或者connect,最后一行WCHAN列代表阻塞于I/O(套接字输入输出)。
接下来我们查看下客户进程的情况:
OK,只有一个阻塞与IO(终端IO)的进程。
七、正常终止
我们在客户输入EOF终止符。
然后我们立即(实际上,会有10+s的时间)执行netstat命令:
我们可以看到客户端进入了TIME-WAIT状态,而监听服务器仍在监听。
过一会,继续查看状态:
可以看到客户端已经关闭。
还有另外一部分内容:在服务器子进程终止时,给父进程发送一个SIGCHLD信号,但是我们没有在代码中捕获该信号,而该信号的默认行为是被忽略,既然子进程未加处理,子进程于是进入僵死状态。我们可以使用ps命令来验证这一点。
状态Z代表僵死进程,我们必须清理僵死进程。
八、POSIX信号处理
信号就是告知某个进程发生了某个事件的通知,有时也成为软中断。信号通常是异步发生的,也就是预先不知道信号发生的准确时刻。
信号可以:
由一个进程发送给另外一个进程(或者自身)。
由内核发送给某个进程。
比如SIGCHLD信号就是在内核在任何一个进程终止时发送给它父进程的一个信号。
1)我们提供一个函数,只要有特定信号发生它就被调用。这样的函数称为信号处理函数,这种行为称为捕获信号。有两种信号不能被捕获:SIGKILL和SIGSTOP。信号处理函数有信号值这个单一的整数参数来调用,且没有返回值:
void handler(int signo);
2)我们可以把某个信号设置定位SIG_IGN来忽略它。SIGKILL和SIGSTOP这两个信号不能被忽略。
3)我们可以把某个信号设定为SIG_DFL来启动它的默认处置。
信号处理:
1)一旦安装了信号处理函数,它便一直安装着。
2)在一个信号处理函数运行期间,正被递交的信号是阻塞的,安装处理函数时,sa_mask信号集中指定任何额外信号也被阻塞。
3)如果一个信号在被阻塞期间产生了一次或者多次,那么该信号被解阻塞后只递交一次,也就是Unix信号是不排队的。
九、处理SIGCHLD信号
我们在上面的服务程序上做一下处理:
建立一个俘获SIGCHLD信号的信号处理函数,在函数体内我们调用wait。
服务端增加函数调用:
#include "unp.h"
int
main(int argc, char **argv)
{
int listenfd, connfd;
pid_t childpid;
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
void sig_chld(int);
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));
Listen(listenfd, LISTENQ);
Signal(SIGCHLD, sig_chld);//加上这个函数调用
for ( ; ; ) {
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);
if ( (childpid = Fork()) == 0) { /* child process */
Close(listenfd); /* close listening socket */
str_echo(connfd); /* process the request */
exit(0);
}
Close(connfd); /* parent closes connected socket */
}
}
运行结果:
可以看到我们这里服务main函数并没有终止执行。
课本上是这样说的:当阻塞于某个慢系统调用的一个进程捕获某个信号且相应的信号处理函数返回时,该系统调用可能返回一个EINTR错误。
而我们这里的Aceept是包裹函数,里面有对失败的处理:
这段代码所做的事情就是自己重启被中断的系统调用。
然而事实是:我的电脑就算直接调用accept也是没有重启(这应该和系统有关了)
read,write,select,open之类的函数是可以重启的,但是connect函数不可以重启,我们必须调用select来等待连接完成(16章阻塞)
10、函数wait 和 waitpid
在上面的例子中,采用了wait来处理已终止的子进程:
两个函数均返回两个值:已终止子进程的进程ID号,以及通过staloc返回的子进程终止状态(一个整数)。
如果调用wait函数的进程没有已终止的子进程,不过有一个或多个子进程仍在进行,那么wait将阻塞到现有子进程第一个终止为止。
waitpid函数:pid参数-1表示等待第一个终止的子进程。options允许我们指定附加选项,最常用的选项是WNOHANG,它告诉内核没有已终止的子进程时不要阻塞。
接下来我们来看wait和waitpid函数的区别:
我们把客户程序改成如下所示:
#include "unp.h"
int
main(int argc, char **argv)
{
int i, sockfd[5];
struct sockaddr_in servaddr;
if (argc != 2)
err_quit("usage: tcpcli <IPaddress>");
for (i = 0; i < 5; i++) {
sockfd[i] = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
Connect(sockfd[i], (SA *) &servaddr, sizeof(servaddr));
}
str_cli(stdin, sockfd[0]); /* do it all */
exit(0);
}
也就是:客户建立5个与服务器的连接,随后在调用str_cli函数时只使用第一个连接。建立多个连接的目的是从并发服务器中派生多个子进程:
当客户终止时,所有打开的描述符由内核自动关闭,且所有5的连接基本上同一时刻终止,这就引发了5个FIN,每个连接一个,它们反过来使服务器的5个子进程基本在同一时刻终止。这又导致差不多同一时刻有5个SIGCHLD信号递交给父进程:
我们先运行一下:
我们一共运行了4次,我们预期每次运行的时候,5个子进程都终止了,然而发现,每次终止的个数是不确定的。
课本上是这样描述的:
解决办法是用waitpid函数而不是wait,我们在一个循环内调用waitpid:
以获取所有已终止子进程的状态。这个必须制定WNOHANG选项,它告知waitpid在尚未终止的子进程在运行时不要阻塞。接下来我们继续运行:
三次运行5个子进程都终止了。
总结3点:
1、当fork子进程时,必须捕获SIGCHLD信号。(P103)
2、当捕获信号时,必须处理被中断的系统调用。(P107)
3、SIGCHLD的信号处理函数必须被正确编写,应使用waitpid函数以免留下僵死进程。
十一、服务器进程终止
我们终止服务器子进程直接给出运行结果:
十二、sigpipe信号
要是客户部例会read函数返回的错误,反而写入更多的数据到服务器上,那么会发生什么呢?
适用于此的规则是:当一个进程向某个已收到RST的套接字执行写操作时,内核向该进程发送一个SIGPIPE信号。该信号的默认行为是终止进程,因此进程必须捕获它以免不情愿地被终止。
十三、服务器主机崩溃
在不同的主机上运行服务器和客户,键入一行确认正常工作。
1)当服务器主机崩溃时,已有的网络连接上不发出任何东西,这里我们假设的是主机崩溃,而不是由操作员执行命令关机
2)我们在客户上键入一行文本,write写入到内核,再由客户TCP作为一个数据分节送出。客户随后阻塞与read调用,等待回射的应答。
3)如果我们用tcpdump观察网络就会发现,客户TCP储蓄重传数据分节,试图从服务器上接受一个ACK。重传了很久很久,当客户TCP最终放弃是(假设这段时间内主机没有重新启动),给客户进程返回一个错误。既然客户阻塞在read调用上,该调用将返回一个错误。假设服务器主机已崩溃,从而对客户的数据分节根本没有响应,那么返回的错误是ETIMEDOUT。然而如果某个中间路由器判定服务器主机已经不可达,从而响应一个”destination unreachable“的ICMP消息。
如果我们不主动向它发生数据也想检测出服务器主机的崩溃,那么需要采用另外一个技术,也就是我们在7.5节讨论的SO_KEEPALIVE套接字选项。
十四、服务器主机崩溃后重启
我们模拟一种简单的方法就是:先建立连接,再从网络上断开服务器主机,将它关机后再重新启动,最后把它重新连接到网络中。如果在服务器主机崩溃时客户部主动给服务器发送数据,那么客户客户讲不会知道服务器已经崩溃。所发生的步骤如下:
1)启动服务器和客户,并在客户键入一行文本以确认连接已经连接。
2)服务器主机崩溃并重启
3)在客户上键入一行文本,它将作为一个TCP数据分节发送到服务器主机。
4)当服务器主机崩溃后重启时,它的TCP丢失了崩溃前的所有连接信息,因此服务器TCP对于所收到的来自客户的数据分节响应以一个RST。
5)当客户TCP收到该RST时,客户正阻塞于read调用,道指该调用返回ECONNRESET错误。
如果对客户而言检测服务器主机崩溃与否很重要,即使客户部主动发送数据也要能检测出来,就需要采用其他某种技术(注入SO_KEEPALIVE套接字选项或某些客户/服务器心搏函数)
十五、服务器主机关机
UNIX系统关机时,init进程通常先给所有进程发送SIGTERM信号(该信号可被捕获),等待一段固定的时间(往往在5到20秒时间),然后给所有仍在运行的进程发送SIGKILL信号(该信号不能被捕获)。这么做留给所有运行的进程一小段时间来清除和终止。如果我们不捕获SIGTERM信号并终止,我们的服务器将由SIGKILL信号终止。当服务器子进程终止时,它的所有打开着的描述符都被关闭,随后发生的步骤与5.12节中讨论过的一样。正如那一节所述,我们必须在客户中使用select和poll函数,使得服务器进程的终止一经发生,客户就能检测到。
十六、数据格式
我们必须关心在客户和服务器之间进行交换的数据的格式。
1)在客户与服务器之间传递文本串
修改我们的程序,从客户读入一行文本,不过新的服务器期望该文本行包含由空格分开的两个整数,服务器将返回这两个整数的和。我们的客户和服务器程序的main函数仍保持不变,str_cli函数也保持不变,所有修改都在str_echo函数。
这样的情况,不论客户和服务器主机的字节序如何,这个新的客户和服务器程序对懂工作得很好。
2)在客户与服务器传递二进制结构
当这样的客户和服务器程序运行在字节序不一样的或者所支持长整数的大小不一致的两个主机上时,工作将失常。