下面的简单的例子是执行如下步骤的一个回射服务器:
1)客户从标准输入读入一行文本,并写给服务器
2)服务器从网络输入读入这行文本,并回射给客户
3)客户从网络输入读入这行回射文本,并显示在标准输出上
在客户和服务器之间画了两个箭头,不过他们实际上构成一个全双工的TCP链接。fgets和fputs这两个函数来自标注I/O函数库,writen和readline这两个函数是在3.9有讲解,这里使用的都是基本的函数read和write.
还需要考虑很多边界条件:客户和服务器启动时发送什么?客户正常终止时发生什么?若服务器进程在客户之前终止,则客户会发生什么?若服务器主机崩溃,则客户发生什么?
这里给了函数简单的main的实现
#define MAXLINE 1024 void str_echo(int); int main(int argc,char *argv[]) { struct sockaddr_in serveraddr,cliaddr; char recvline[MAXLINE+1]; int nread; int listenfd,connfd,connlen; int pid_t; connlen=1; memset(&cliaddr,0,sizeof(cliaddr)); bzero(&serveraddr,sizeof(struct sockaddr_in)); serveraddr.sin_port=htons(1222); serveraddr.sin_family=AF_INET; if(argc>=2){ if(!inet_aton(argv[1],&serveraddr.sin_addr)) printf("inet_aton error!\r\n"); return -1; } serveraddr.sin_addr.s_addr=htonl(INADDR_ANY); if((listenfd=socket(AF_INET,SOCK_STREAM,0))<=0) { printf("socket error!\r\n"); return 0; } bind(listenfd,(struct sockaddr*)&serveraddr,sizeof(serveraddr)); listen(listenfd,5); for(;;) { connfd=accept(listenfd,(struct sockaddr*)&cliaddr,&connlen); if((pid_t=fork())==0) { close(listenfd); str_echo(connfd); exit(0); } close(connfd); } return 0; } //注意在函数的子进程体的内容,在子进程中close了监听套接字,在子进程外关闭了链接套接字,这样的过程是为何还能运行,描述符的计数问题
void str_echo(int connfd) { char recvline[MAXLINE+1]; int nread; memset(recvline,0,sizeof(recvline)); nread=read(connfd,recvline,sizeof(recvline)); if(nread<=0) { printf("read error!\r\n"); exit(0); } nread=write(connfd,recvline,strlen(recvline)); if(nread<=0) { printf("write error!\r\n"); exit(0); } }
#define MAXLINE 1024 void str_cli(FILE *,int); int main(int argc,char *argv[]) { struct sockaddr_in cliaddr; int nread,connfd; char recvline[MAXLINE+1]; memset(recvline,0,sizeof(recvline)); connfd=socket(AF_INET,SOCK_STREAM,0); if(connfd<=0) { printf("socket error!\r\n"); return 0; } bzero(&cliaddr,sizeof(struct sockaddr_in)); cliaddr.sin_family=AF_INET; cliaddr.sin_port=htons(1222); cliaddr.sin_addr.s_addr=inet_addr("192.168.5.163"); if(connect(connfd,(struct sockaddr*)&cliaddr,sizeof(cliaddr))) { printf("connect error!\r\n"); } str_cli(stdin,connfd); exit(0); }
void str_cli(FILE *fp,int connfd) { int nread; char recvline[MAXLINE+1]; memset(recvline,0,sizeof(recvline)); fgets(recvline,sizeof(recvline),fp); write(connfd,recvline,strlen(recvline)); memset(recvline,0,sizeof(recvline)); read(connfd,recvline,sizeof(recvline)); fputs(recvline,stdout); }
其实这四个函数体并没有什么技术含量,都是一个函简单的过程,但是这已经是最简单的客户/服务器交互例子
在主机linux上后台启动服务器,服务器启动后,它调用socket、bind、listen、accept.并阻塞与accept调用。(因为还没有启动客户)在启动客户之前,我们运行netstat程序来检查服务器监听套接字的状态。
netstat -a可以发现我们自己的端口处于LISTEN状态,它的统配地址,本地端口为自己设置的端口,接下来运行客户端程序,同时指定服务器主机IP地址为127.0。0.1.客户调用socket和connect,后者引起TCP的三路握手过程。当三路握手完成后,客户中的connect和服务器中的accept均返回,连接于是建立。接着发送的步骤如下:
1) 客户调用str_cli函数,该函数将阻塞与fgets调用,因为我们还未曾键入一行文本。
2) 当服务器中的accept返回时,服务器调用fork,再由子进程调用str_echo。该函数调用readline,readline调用read,而read在等待客户送入一行文本期间阻塞。
3)另一方面,服务器父进程再次调用accept并阻塞,等待下一个客户连接。
至此,我们有三个都在睡眠的进程:客户进程,服务器父进程和服务器子进程。
此时再次使用上面通一个命令:
可以看到有三个和自己设置的端口号相关的进程,一个处于监听状态的父进程,一个处于ESTABLISHEDZ状态的父进程,一个处于ESTABLISHED状态的子进程。
使用ps命令可以看出有桑格子进程的STAT都是S,说明都是在sleep
在程序运行过程中,我们在客户端输入什么就会打印什么,在键入两行后,键入终端EOF字符(Ctr+D)以终止客户。
此时如果立即执行netstat命令,我们过滤自己的端口,可以看到当前连接的客户端进入了TIME_WAIT状态,而监听服务器仍然在等待另一个客户连接。
我们可以得出正常终止客户和服务器的步骤:
1)当我们键入EOF字符时,fgets返回一个空指针,于是str_cli函数返回
2(当str_cli返回到客户的main函数时,main通过调用exit终止
3)进程终止处理的部分工作是关闭所有打开的描述符,因此客户打开的套接字由内核关闭。这道指客户TCP发送一个FIN给服务器,服务器TCP则以ACK响应,这就是TCP连接终止序列的前半部分。至此,服务器套接字处于CLOSE_WAIT状态,客户套接字则处于FIN_WATI_2状态。
4)当服务器TCP接受FIN时,服务器子进程阻塞与read调用,于是read返回0.这道指ste-echo函数返回服务器子进程的main函数。
5)服务器子进程通过调用exit来终止
6)服务器子进程中打开的所有描述符随之关闭。由子进程关闭已连接套接字会引发TCP连接终止序列的最后两个分节:一个服务器到客户的FIN和一个客户到服务器的ACK。至此,连接完全终止,客户套接字进入TIME_WAIT状态。
7)进程终止处理的另一个部分内容是:在服务器子进程终止时,给父进程发送一个SIGCHLD信号。这一点在本例中国发生了,但是没有在代码中捕获该信号,而该信号的默认欣慰是被忽略。父进程没处理,子进程于是处于僵死状态。可以用ps命令来验证着一点。
信号就是告知某个进程发生了某个事件的通知,有时也称为软件中断。信号通常是异步发生的,也就是说进程预先不知道信号的准备发生时刻。
信号可以:
1)由一个进程发送给另一进程(或自身)
2)由内核发送给某个进程
比如SIGCHLD信号就是由内核在任何一个进程终止时发给他的父进程的一个信号。
每个信号都有一个与之关联的处置,可以通过调用sigaction函数来设定一个信号处置,并有三种选择
1) 可以提供一个函数,只要有特定信号发生它就被调用。这样的函数称为信号处理函数,这种行为称为捕获信号。其中SIGKILL和SIGSTOP信号不能捕获。信号处理函数由信号值这个单一的整数来电泳,且没有返回值,其函数原型为:
void handler(int sinno);
对于大多数信号来说,调用sigaction函数并指定信号发生时所调用的函数就是捕获信号所需做的全部工作
2) 可以把信号设置为SIG_IGN来忽略爱听。SIGKILL和SIGSTOP不能被忽略。
3)我们可以把某个信号处置设定为SIG_DFL来启用它的默认处置。
sigaction函数有点复杂,因为该函数的参数之一是我们必须分配或指向函数的指针,简单的方法就是调用signal函数,第一个参数是信号名,第二个参数或是指向函数的指针,或是常值SIG_IGN或SIG_DFL。然而signal是早于POSIX出现的历史悠久的函数。调用它时,不同的实现提供不同的信号语义以达成后向兼容,而POSIX则明确规定了调用sigaction时的信号语义。这里我们只要会使用信号处理函数signal即可
信号处理总结为以下几点:
1)一旦安装了信号处理函数,它便一直安装着
2)在一个信号处理函数运行期间,正被递交的信号时阻塞的。而且,安装处理函数时在传递给sigaction函数的sa_mask信号集中指定任何额外信号也被阻塞。
3)如果一个信号在被阻塞期间产生了一次或多次,那么该信号被解阻塞之后通常只递交一次,也就是说unix信号默认是不排队的
设置僵死状态的目的是维护子进程的信息,以便父进程在以后某个时候获取。这些信息包括子进程的进程ID、终止状态以及资源利用信息。如果一个进程终止,而该进程有子进程处于僵死状态,那么他的所有僵死子进程的父进程ID将被重置为1(INIT进程)。集成这些子进程的init进程将清理他们(也就是说Init进程将wait它们,从而去除他们的僵死状态)。
僵死进程占用内核空间,可能导致我们耗尽资源。无论何时我们fork子进程都得wait它们,以防它们变成僵死进程。为此我饿们建立一个俘获SIGCHILD信号的信号处理函数,在函数体中我们调用wait.
启动服务器程序,然后启动客户程序,在客户程序键入EOF字符
具体的步骤如下
1) 我们键入EOF字符来终止客户。客户TCP发送一个FIN个服务器,服务器响应以一个ACK。
2) 收到客户的FIN道指服务器TCP递送一个EOF给子进程阻塞中的reanline,从而子进程终止
3) 当SIGCHLD信号递交时,父进程阻塞与accept调用。Sig_chld函数(信号处理函数)执行,其wait调用取得子进程的PID和终止状态,随后是printf调用,最后返回。
4) 既然该信号是在父进程阻塞与慢系统调用(accept)时由父进程捕获的,内核就会使accept返回一个EINTR错误(被中断的系统调用)。而父进程不处理该错误,于是中止。
这个例子说明,在编写捕获信号的网络程序时,我们必须认清被中断的系统调用且处理他们。
我们用属于慢系统调用描述过accept函数,该属于也适用于那些可能永远阻塞的系统调用,永远阻塞的系统调用有可能永远无法返回,多数网络支持函数都属于这一类。如果没有客户连接到服务器上,那么服务器的accept调用就没有返回的保证。类似地,如果客户从未发送过一行要求服务器回射的文本,那么服务器的read调用将永不返回
适用于慢系统调用的基本规则是:当阻塞于某个慢系统调用的一个进程捕获某个信号且响应信号处理函数返回时,该系统调用可能返回一个EINTR错误。有些内核自动重启某些被中断的系统调用。
我们调用wait来处理已终止的子进程
#include <sys/wait.h>
Pif_t wait(int *statloc);
Pid_t waitpid(pid_t pid,int *statloc,int options);
在本书中的例子中介绍,当多个客户连接服务器时,同时中断所有客户和服务器的链接,看有什么结果,只有一个子进程可以被正确处理,其余四个都是僵死进程。
但是这种情况可以使用waitpid函数来解决,只不过是参数的问题。
除了被终端系统调用的例子,另有一种情形能够道指accept返回一个非致命的错误,在这种情况下,只需要再次调用accept。
这里,三路握手完成从而建立连接之后,客户TCP却发送了一个RST(复位)。在服务器端看来,就在该连接已由TCP排队,等着服务器进程调用accept的时候RST到达。稍后,服务器进程调用accept.。模拟这种情况也非常简单:启动服务器,让她调用socket bind listen,然后在调用accept之前睡眠仪段时间。在服务器进程睡眠时,启动客户,让它调用socket和connect,一旦connect返回,就设置SO_LINGER套接字选项以产生这个RST,然后终止
这种情况一般被忽略,直接再次调用accept即可,在16.6节我们将再次回到这些中止的链接,查看在与select函数和正常阻塞模式下的监听套接字组合时他们是如何成为问题的。
在服务器启动以后,客户也正常启动,然后在客户端输入一行,在这个时候杀死服务器子进程,看有什么效果。
1))在同一主机上启动服务器和客户,并在客户上键入一行文本,以验证一切正常。正常情况下文本由服务器子进程回射给客户。
2)找到服务器子进程的进程ID,并执行kill命令杀死它,作为进程终止处理的部分工作,子进程中所有打开着的描述符都被关闭。这就导致向客户发送一个FIN,而客户TCP则响应以一个ACK。这就是TCP连接终止工作的前半部分。
3)SIGCHLD信号被发送给服务器父进程,并得到正确处理。
4)客户上没有发送任何特殊之事。客户TCP接受来自服务器TCP的FIN并响应以一个ACK,然而问题是客户进程阻塞在fgets上,等到从终端接受一行文本。
5)此时,在另外一个窗口上运行netstat命令,以观察套接字的状态。可以发现有两个相关的进程(子进程被kill),其中一个进程处于LISTEN状态,一个处于FIN_WAIT2状态,一个处于CLOSE_WAIT状态,可以看到TCP连接终止序列的前半部分已经完成。
6)我们可以在客户上再键入一行文本,以下是从第一步的动作
当我们键入”another line“字符串时,str_cli调用write,客户tcp接着把数据发送给服务器、TCP允许这么做,因为客户TCP接受到FIN只是表示服务器进程已关闭了链接的服务器端,从而不再往其中发送任何数据而已、FIN的接受并没有告知客户TCP服务器进程已经终止(在这里确实终止了)在6.6节讨论TCP的半关闭时我们再讨论这一点。
7)当服务器TCP接受到来自客户的数据时,既然先前打开那个套接字的进程已经终止,于是响应一个RST。通过使用tcpdump来观察分组,RST确实发送了。然而客户进程看不到这个RST,因为它在调用write后立即调用read,并且由于又接受到了客户的FIN,所调用的read立即返回0,我们的客户此时并为期望收到EOF。
8)当客户终止时,它所打开的描述符都被关闭。
本例子的问题在于,当FIN到达套,而是应该阻塞在其中任何一个套接字时,客户正阻塞在fgets调用上。客户实际上再应对两个描述符------套接字和用户输入,它不能单纯阻塞在这两个源中某个特定源的输入上(正如目前编写的str-cli函数所为),而是应该阻塞在其中任何一个源的输入上,事实上这正是select和poll这两个函数的目的之一,在重写的stl_cli函数之后,一旦杀死服务器子进程,客户就会立即被告知已收到FIN。
要是客户部例会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时,客户正阻塞于readd调用,道指该调用返回ECONNREESET错误。
如果对客户而言检测服务器主机崩溃与否很重要,即使客户部主动发送数据也要能检测出来,就需要曹勇其他某种技术(注入SO_KEEPALIVE套接字选项或某些客户/服务器心博函数)
UNIX系统关机时,init进程通常先给所有进程发送SIGTERM信号(该信号可被捕获),等待一段固定的时间(往往在5到20秒时间),然后给所有仍在运行的进程发送SIGKILL信号(该信号不能被捕获)。这么做留给所有运行的进程一小段时间来清除和终止。如果我们不捕获SIGTERM信号并终止,我们的服务器激昂由SIGKILL信号终止。当服务器子进程终止时,它的所有打开着的描述符都被关闭,随后发生的步骤与5.12节中讨论过的一样。正如那一节所述,我们必须在客户中使用select和poll函数,使得服务器进程的终止一经发生,客户就能检测到。
我们必须关心在客户和服务器之间进行交换的数据的格式。
1)在客户与服务器之间传递文本串
修改我们的程序,从客户读入一行文本,不过新的服务器期望该文本行包含由空格分开的两个整数,服务器将返回这两个整数的和。我们的客户和服务器程序的main函数仍保持不变,str_cli函数也保持不变,所有修改都在str_echo函数。
void str_echo(nit sockfd) { long arg1,arg2; ssize_t n; char line[MAXLINE]; for(;;){ if((n=read(sockfd,line,MAXLINE))==0) return ; if(sscanf(line,"%ld%ld",&arg1,arg2)==2) snprintf(line,sizeof(line),"%ld\n",arg1+arg2); else snprintf(line,sizeof(line),"input error\n"); n=strlen(line); write(sockfd,line,n); } }这样的情况,不论客户和服务器主机的字节序如何,这个新的客户和服务器程序对懂工作得焊好。
2)在客户与服务器传递二进制结构
当这样的客户和服务器程序运行在字节序不一样的或者所支持长整数的大小不一致的两个主机上时,工作将市场。
ps:
UNIX信号时不排队的。
当服务器进程终止时,客户进程没被告知。我们看到客户的TCP确实被告知了,但是客户进程由于正阻塞与等待用户输入而为接受到该通知。