UNP学习笔记2——从一个简单的ECHO程序分析TCP客户/服务器之间的通信
1 概述
编写一个简单的ECHO(回复)程序来分析TCP客户和服务器之间的通信流程,要求如下:
- 客户从标准输入读入一行文本,并发送给服务器
- 服务器从网络输入读取这个文本,并回复给客户
- 客户从网络输入读取这个回复,并显示在标准输出上
通过这样一个简单的例子来学习TCP协议的基本流程,同时探讨在实际过程中可能发生的意外情况,从而更深层次的理解其工作原理:
- 客户和服务器启动时发生了什么?
- 客户正常终止发生了什么?
- 若服务器进程在客户之前终止,则客户会发生什么?
- 若服务器主机崩溃,则客户会发生什么?
- ……
2 基本程序
TCP echo 服务器函数:echo_server.c
- #include <stdio.h>
- #include <stdlib.h>
- #include <sys/socket.h>
- #include <netinet/in.h>
- #include <arpa/inet.h>
- #include <errno.h>
- //str_echo函数从套接字上回射数据
- void str_echo(int sockfd)
- {
- ssize_t n;
- char buf[];
- again:
- while((n=read(sockfd, buf, )) > )
- {
- write(sockfd, buf, n);
- }
- if(n< && errno==EINTR)
- goto again;
- else if(n<)
- perror("read error");
- }
- int main(int argc, char **argv)
- {
- int listenfd, connfd; //监听描述符,连接描述符
- pid_t childpid; //子进程pid
- socklen_t clilen; //客户IP地址长度
- struct sockaddr_in server_addr, client_addr;
- /*socket函数*/
- listenfd=socket(AF_INET, SOCK_STREAM, );
- /*服务器地址*/
- memset(&server_addr, , sizeof(server_addr));
- server_addr.sin_family=AF_INET;
- server_addr.sin_addr.s_addr=htonl(INADDR_ANY);
- server_addr.sin_port=htons();
- /*bindt函数*/
- if(bind(listenfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < )
- perror("bind error");
- /*listent函数*/
- if(listen(listenfd, ) < )
- perror("listen error");
- while()
- {
- clilen=sizeof(client_addr);
- /*父进程调用accpet函数,阻塞直到客户connect*/
- if((connfd=accept(listenfd, (struct sockaddr*)&client_addr, &clilen)) < )
- perror("accept error");
- if((childpid=fork())==) //子进程
- {
- close(listenfd); //关闭监听描述符
- str_echo(connfd); //处理请求
- exit();
- }
- close(connfd); //父进程关闭连接描述符
- }
- return ;
- }
TCP echo 客户端函数:echo_client.c
- #include <stdio.h>
- #include <stdlib.h>
- #include <sys/socket.h>
- #include <netinet/in.h>
- #include <arpa/inet.h>
- int main(int argc, char **argv)
- {
- if(argc<) //检查输入参数
- perror("usage:echo_client <server addr>");
- int sockfd; //网络套接字
- struct sockaddr_in server_addr; //服务器地址
- /*socket函数*/
- sockfd=socket(AF_INET, SOCK_STREAM, );
- /*配置服务器地址*/
- memset(&server_addr, , sizeof(server_addr));
- server_addr.sin_family=AF_INET;
- server_addr.sin_port=htons();
- if((inet_pton(AF_INET, argv[], &server_addr.sin_addr)) < )
- perror("invaild IP address");
- /*connect函数*/
- if(connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < )
- perror("can't connect to server");
- /*ECHO处理函数*/
- char send[],recv[];
- /*从标准输入读取文本*/
- while(fgets(send, , stdin)!=NULL)
- {
- /*发送文本到服务器*/
- write(sockfd, send, strlen(send));
- /*接收从服务器返回*/
- if(read(sockfd, recv, )==)
- perror("server terminated");
- /*打印到标准输出*/
- fputs(recv, stdout);
- }
- return ;
- }
3 正常启动
首先在Linux上后台启动服务器程序
- # ./echo_server &
- []
服务器启动之后,它调用socket、bind、listen并阻塞于accept。检查服务器监听套接字的状态。
- # netstat -a | grep
- tcp *: *:* LISTEN
接着在同一主机上启动客户端程序
- ./echo_client localhost
服务器启动之后,它调用socket、connect引起TCP三路握手过程。当三路握手完成后,客户端中的connect和服务器中的accept均返回,于是连接建立。接着发生步骤如下:
- 客户调用echo_str函数,该函数将阻塞于fgets调用
- 服务器中accept函数返回,调用fork,再由子进程调用echo_str,该函数将阻塞于read调用
- 另一方面,服务器父进程再次调用accept并阻塞,等待下一个客户连接
至此,有3个正在睡眠(阻塞)的进程:客户进程、服务器父进程和服务器子进程。
- tcp *:12345 *:* LISTEN
- tcp localhost: localhost: ESTABLISHED
- tcp localhost: localhost: ESTABLISHED
4 正常终止
- #echo_server localhsot
- hello
- hello
- bye
- bye
- ^D
我们键入两行,都能得到回射,接着键入EOF字符(Ctrl+D),客户端进程将终止。如果此时立即执行netstat命令,将会看到如下结果
- # netstat -a | grep
- tcp *: *:* LISTEN
- tcp localhost: localhost: TIME_WAIT
当前连接的客户端进入了TIME_WAIT状态,而监听服务器仍在等待另外一个客户连接。因此总结正常的终止客户和服务器的步骤:
- 当我们键入EOF后,fgets返回一个空指针,于是str_cli返回,main函数返回,最终客户进程终止
- 进程终止处理需要关闭所有打开的描述符,因此客户向TCP服务器发送一个FIN,服务器响应ACK,这是TCP连接终止的前半部分。此时,服务器套接字处于CLOSE_WAIT状态,客户端套接字处于FIN_WAIT_2状态
- 当服务器接收FIN时,服务器进程阻塞于read调用,于是read返回0,main函数返回,最终服务器子进程终止
- 服务器子进程打开的描述符关闭,向客户发送一个FIN,客户返回一个ACK。此时客户套接字处于TIME_WAIT状态
- 进程终止处理的另一部分内容是:当服务器子进程终止时,给父进程发送一个SIGCHLD信号。但是我们没有在代码中处理该信号,该信号的默认行为是忽略。因为父进程未加处理,因此子进程处于僵死状态。
用ps命令验证:
- # ps
- PID TTY TIME CMD
- pts/ :: bash
- pts/ :: echo_server
- pts/ :: echo_server <defunct>
- pts/ :: ps
我们必须要处理僵死进程,它们占用内核空间,并且可能导致进程资源耗尽。
5 处理SIGCHLD信号
5.1 处理僵死进程
设置僵死状态目的是维护子进程信息(包括子进程ID、终止状态、CPU时间、内存使用量等等资源利用信息),以便父进程在以后的某个时间获取。如果一个父进程终止,有子进程处于僵死状态,那么这些僵死进程的父进程会被设置为1(init进程),init进程负责清理它们。
我们显然不愿意保留僵死进程。无论何时我们fork子进程都得wait它们,以防它们变成僵死进程。因此我们建立一个捕获SIGCHLD信号的信号处理函数,在函数体中调用wait,放在listen调用之后。
signal(SIGCHLD, sig_chld);
定义的sig_chld函数如下所示
- void sig_chld(int signo)
- {
- pid_t pid;
- int stat;
- pid=wait(&stat);
- printf("child %d terminated\n", pid); //通常不建议在信号处理函数中调用标准I/O函数
- return;
- }
修改后的程序运行结果
- #echo_server &
- []
- #echo_client localhost
- hello
- hello
- ^D
- child terminated
- accept error: Interrupted system call
当SIGCHLD信号提交时,父进程阻塞于accept调用。sig_chld函数执行,其wait调用取到子进程的PID和终止状态,随后是printf调用,最后返回。
既然该信号是父进程阻塞于慢系统调用(accept)时由父进程捕获的,内核就会使accept返回一个EINTER错误(被中断的系统调用),而父进程不处理该错误,于是终止。
因此这个例子说明,在编写捕获信号的网络程序时,必须意识到中断的系统调用并且正确处理它们。
5.2 处理被中断的系统调用
适用于慢系统调用的基本规则是:当阻塞于某个慢系统调用的一个进程捕获某个信号且相应的信号处理函数返回时,该系统调用可能会返回一个TINTR错误。有些内核会自动重启某些被中断的系统调用,有些则不会,因此我们必须对慢系统调用返回EINTR有所准备。
为了处理被中断的accept,我们修改如下
- if ((connfd = accept(listenfd, (struct sockaddr *)&client_addr, &clilen)) < )
- {
- if(errno == EINTR)
- continue;
- else
- perror("accept error");
- }
5.3 wait和waitpid
- #include <sys/wait.h>
piad_t wait(int *statloc);- pid_t waitpid(pid_t pid, int *statloc, int options);
函数wait和waitpid均返回两个值:已终止的子进程的PID号和通过statloc指针返回的子进程终止状态(一个整数)。
如果调用wait的进程没有已终止的子进程,但有子进程在运行,那么wait将阻塞到现有第一个子进程终止为止。而waitpid函数的pid参数指定想等待的进程ID,值-1表示等待第一个终止的子进程。其次,options参数允许指定附加选项。最常用的选项是WNOHANG,它告知内核在没有已终止子进程时不要阻塞。
我们现在修改函数使客户建立5个与服务器的连接,同时终止客户端主进程,这样会5个连接基本上在同一时刻终止,最终会导致在同一时刻有5个SIGCHLD信号递交给父进程。
正是这一种一个信号多个实例的递交造成了一些问题,运行新的程序结果:
- #echo_server &
- []
- #echo_client localhost
- hello
- hello
- ^D
- child terminated
结果显示,5个子进程只有一个调用printf输出,说明其他4个子进程仍然作为僵死进程存在着。
- # ps
- PID TTY TIME CMD
- pts/ :: bash
- pts/ :: echo_server
- pts/ :: echo_server <defunct>
- pts/ :: echo_server <defunct>
- pts/ :: echo_server <defunct>
- pts/ :: echo_server <defunct>
- pts/ :: ps
因此,建立一个信号处理函数并且调用wait并不足以防止出现僵死进程。问题在于:5个信号都在信号处理函数执行之前产生,而信号处理函数只执行一次,因为Unix信号一般都是排队的。更严重的是,该问题是不确定的,在服务器和客户在同一台主机上执行了1次,若不在一台主机上一般出现2次,3次……则依赖于FIN到达主机的时机。
正确的做法是调用waitpid而不是wait,下面给出了正确处理SIG_CHLD信号的sig_chld函数。
- void sig_chld(int signo)
- {
- pid_t pid;
- int stat;
- while ( (pid=waitpid(-, &stat, WNOHANG)) > )
- printf("child %d terminated\n", pid); //通常不建议在信号处理函数中调用标准I/O函数
- return;
- }
这个版本管用的原因在于:在一个循环内调用waitpid,以获取所有已终止的子进程的状态。必须指定WNOHANG选项,它告知waitpid在有尚未终止的子进程在运行时不要阻塞。我们不能再循环内调用wait,因为没有办法防止在有子进程在尚未终止时阻塞。
总结之前的结论,我们在网络编程时可能会遇到的3种情况:
- 当fork子进程时,必须捕获SIGCHLD信号
- 当捕获信号时,必须处理被中断的系统调用
- SIGCHLD的信号处理函数必须正确编写,应使用waitpid避免留下僵死进程。
6 accept返回之前终止
除了系统中断的例子,另外一种情形也会导致accept返回一个非致命的错误,在这种情况下,需要再次调用accept。
在三次握手从而连接建立之后,客户TCP却发送了一个RST(复位)。在服务端看来,就在该连接已在排队,等待服务器进程调用accept的时候到达。
大多数系统会返回一个错误给服务器进程作为accept的返回结果,不过错误本身取决于实现
7 服务器进程终止
现在我们启动客户/服务器对,然后杀死服务器子进程,这就导致服务器发送一个FIN,客户TCP于是回应一个ACK。
客户端没有发生任何事,然而客户端进程阻塞在fgets调用上,等待用户从标准输入输入内容,此时运行netstat命令,观察套接字的状态:
- # netstat -a | grep
- tcp *: *:* LISTEN
- tcp localhost: localhost: TIME_WAIT2
- tcp localhost: localhost: CLOSE_WAIT
我们还可以在客户端上再键入一行文本,str_cli调用write,客户TCP接着把数据发送给服务器。TCP允许这么做,因为客户接受到FIN只是表明服务器进程关闭了自己那边的连接,不再向客户发送数据了而已,但仍然可以接收数据。FIN的接收并没有告知客户TCP服务器进程已经终止(事实上是终止了)。
当服务器收到客户发来的数据时,因为连接已经终止了,就发送给客户一个RST。然而客户进程看不到这个RST,因为它在调用write之后立即调用read,并且由于之前收到的FIN,read立即返回0(表示EOF)。客户端进程并未预期收到EOF,于是调用出错信息“server terminated prematurely”(服务器过早终止)退出。
- #echo_client localhost
- hello
- hello
- another line
- str_cli: server terminated prematurely
本例子的问题在于:当FIN到达套接字时,客户正阻塞在fgets调用上。客户实际上在应对两个描述符——套接字和用户输入,而它不能单纯的阻塞在两者之一上,而是应该阻塞在任何一个源的输入上。因此,今后采用select和poll两个函数解决这个问题。
10 SIGPIPE信号
接着上一个问题,如果客户不理会read返回的错误,反而写入更多的数据到服务器上,那会发生什么呢?这种情况是可能发生的。例如,客户在读回任何数据之前向服务器执行了两次写操作,而RST是由第一次写操作引发的。
因此,适用于这种情况的规则是:当一个进程向一个已经收到RST的套接字执行写操作时,内核将向该进程发送一个SIGPIPE信号,该信号的默认行为是终止该进程,因此进程应该捕获它防止被意外终止。无论是捕获还是忽略该信号,写操作都会返回EPIPE错误。
11 服务器意外情况
(1)服务器崩溃
此时客户TCP持续重传数据分节,试图从服务器接收一个ACK。当经过一段相当长的时间之后,TCP放弃,给客户进程返回一个错误。如果服务器崩溃对客户的数据分节没有相应,则返回的错误是ETIMEOUT,如果某个中间路由器发现主机不可达,则响应一个destination unreachable的ICMP消息,所返回的错误是EHOSTUNREACH或ENETUNREACH。
(2) 服务器崩溃后重启
当服务器崩溃重启时,他的TCP丢失了崩溃前的所有连接信息,因此服务器TCP对于所收到的分节都响应一个RST。当客户收到该RST时,客户正在阻塞于read调用,导致该调用返回ECONNRESET错误。
(3) 服务器主机关机
Unix系统关机时,init进程通常给所有进程发送SIGTERM信号(该信号可被捕获),等待一段固定时间,然后给所有仍在运行的进程发送SIGKILL信号(该信号不能被捕获)。当所有子进程被终止时,将关闭所有打开的套接字。因此这种情形和服务器关闭的情况一样。
12 最后改进过的程序
echo_server.c
- #include <stdio.h>
- #include <stdlib.h>
- #include <sys/socket.h>
- #include <netinet/in.h>
- #include <arpa/inet.h>
- #include <errno.h>
- #include <sys/wait.h>
- #include <string.h>
- /*ECHO函数*/
- void str_echo(int sockfd)
- {
- ssize_t n;
- char buf[];
- again:
- while((n=read(sockfd, buf, )) > )
- {
- write(sockfd, buf, n);
- }
- if(n< && errno==EINTR)
- goto again;
- else if(n<)
- perror("read error");
- }
- // 信号处理函数
- void sig_chld(int signo)
- {
- pid_t pid;
- int stat;
- while((pid = waitpid(-, &stat, WNOHANG)>))
- printf("child %d terminated\n", pid);
- }
- int main(int argc, char **argv)
- {
- int listenfd, connfd;
- pid_t childpid;
- socklen_t clilen;
- struct sockaddr_in server_addr, client_addr;
- listenfd=socket(AF_INET, SOCK_STREAM, );
- memset(&server_addr, , sizeof(server_addr));
- server_addr.sin_family=AF_INET;
- server_addr.sin_addr.s_addr=htonl(INADDR_ANY);
- server_addr.sin_port=htons();
- if(bind(listenfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < )
- perror("bind error");
- if(listen(listenfd, ) < )
- perror("listen error");
- /*处理SIGCHLD信号*/
- signal(SIGCHLD, sig_chld);
- while()
- {
- clilen=sizeof(client_addr);
- /*处理被中断的accept调用*/
- if((connfd=accept(listenfd, (struct sockaddr*)&client_addr, &clilen)) < )
- {
- if(errno == EINTR)
- continue;
- else
- perror("accept error");
- }
- if((childpid=fork())==) //child process
- {
- close(listenfd); //close listening socket
- str_echo(connfd);
- exit();
- }
- close(connfd);
- }
- return ;
- }
echo_client.c
- #include <stdio.h>
- #include <stdlib.h>
- #include <unistd.h>
- #include <string.h>
- #include <sys/socket.h>
- #include <netinet/in.h>
- #include <arpa/inet.h>
- void str_client(FILE *fp, int sockfd)
- {
- char send[],recv[];
- /*从标准输入读取文本*/
- while(fgets(send, , fp)!=NULL)
- {
- /*发送文本到服务器*/
- write(sockfd, send, strlen(send));
- /*接收从服务器返回*/
- if(read(sockfd, recv, )==)
- perror("server terminated");
- /*打印到标准输出*/
- fputs(recv, stdout);
- }
- }
- int main(int argc, char **argv)
- {
- if(argc<) //检查输入参数
- perror("usage:echo_client <server addr>");
- int sockfd; //网络套接字
- struct sockaddr_in server_addr; //服务器地址
- /*socket函数*/
- sockfd=socket(AF_INET, SOCK_STREAM, );
- /*配置服务器地址*/
- memset(&server_addr, , sizeof(server_addr));
- server_addr.sin_family=AF_INET;
- server_addr.sin_port=htons();
- if((inet_pton(AF_INET, argv[], &server_addr.sin_addr)) < )
- perror("invaild IP address");
- /*connect函数*/
- if(connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < )
- perror("can't connect to server");
- /*ECHO处理函数*/
- str_client(stdin, sockfd);
- return ;
- }
UNP学习笔记2——从一个简单的ECHO程序分析TCP客户/服务器之间的通信的更多相关文章
- Linux系统学习笔记之 1 一个简单的shell程序
不看笔记,长时间不用自己都忘了,还是得经常看看笔记啊. 一个简单的shell程序 shell结构 1.#!指定执行脚本的shell 2.#注释行 3.命令和控制结构 创建shell程序的步骤 第一步: ...
- 第一讲 一个简单的Qt程序分析
本文概要:通过一个简单的Qt程序来介绍Qt程序编写的基本框架与一些Qt程序中常见的概念 #include <QApplication> #include <QPushButton&g ...
- 【opencv学习笔记五】一个简单程序:图像读取与显示
今天我们来学习一个最简单的程序,即从文件读取图像并且创建窗口显示该图像. 目录 [imread]图像读取 [namedWindow]创建window窗口 [imshow]图像显示 [imwrite]图 ...
- Django 学习笔记之六 建立一个简单的博客应用程序
最近在学习django时建立了一个简单的博客应用程序,现在把简单的步骤说一下.本人的用的版本是python 2.7.3和django 1.10.3,Windows10系统 1.首先通过命令建立项目和a ...
- [原创]java WEB学习笔记12:一个简单的serlet连接数据库实验
本博客为原创:综合 尚硅谷(http://www.atguigu.com)的系统教程(深表感谢)和 网络上的现有资源(博客,文档,图书等),资源的出处我会标明 本博客的目的:①总结自己的学习过程,相当 ...
- Ruby学习笔记2 : 一个简单的Ruby网站,搭建ruby环境
Ruby on Rails website 的基础是 请求-返回 循环. 首先是浏览器请求服务器, 第二步,Second, in our Rails application, the route ta ...
- 【Python学习笔记三】一个简单的python爬虫
这里写爬虫用的requests插件 1.一般那3.x版本的python安装后都带有相应的安装文件,目录在python安装目录的Scripts中,如下: 2.将scripts的目录配置到环境变量pa ...
- 编写Java程序,实现一个简单的echo程序(网络编程TCP实践练习)
首先启动服务端,客户端通过TCP的三次握手与服务端建立连接: 然后,客户端发送一段字符串,服务端收到字符串后,原封不动的发回给客户端. ECHO 程序是网络编程通信交互的一个经典案例,称为回应程序,即 ...
- DuiLib学习笔记2——写一个简单的程序
我们要独立出来自己创建一个项目,在我们自己的项目上加皮肤这才是初衷.我的新建项目名为:duilibTest 在duilib根目录下面有个 Duilib入门文档.doc 我们就按这个教程开始入门 首先新 ...
随机推荐
- RabbitMQ学习之集群消息可靠性测试
之前介绍过关于消息发送和接收的可靠性:RabbitMQ学习之消息可靠性及特性 下面主要介绍一下集群环境下,rabbitmq实例宕机的情况下,消息的可靠性.验证rabbitmq版本[3.4.1]. 集群 ...
- Python笔记7----Pandas中变长字典Series
1.Series概念 类似一维数组的对象,由数据和索引组成 2.Series创建 用Series()函数创建,0,1,2为series结构自带的索引. 可以自己指定索引值,用index,也可以直接用字 ...
- Javascript继承(原始写法,非es6 class)
知识点: Object.create的内部原理: Object.create = function (o) { var F = function () {}; F.prototype ...
- Laravel的路由功能
只能在当前方法内加载视图和URL跳转!
- PHP算法之判断是否是质数
质数的定义 质数又称素数.一个大于1的自然数,除了1和它自身外,不能整除其他自然数的数叫做质数:否则称为合数. 实现思路 循环所有可能的备选数字,然后和中间数以下且大于等于2的整数进行整除比较,如果能 ...
- Android学习总结(4)——Andorid Studio熟练使用
前言 该文以Android Studio2.1.1(Bundle)为例.JDK使用的是1.8版本,也建议大家使用1.8版本. 使用技巧无先后顺序. Android Studio 2.1.1 软件下载 ...
- svn查看工程版本库的url地址
打开cmd,cd到工程目录,使用svn的命令:svn info 完.
- knockout.validation.js 异步校验
self.forDealPwd.extend({ required:{ params:true, message:'请输入验证码!' }, minLength:{ params:4, message: ...
- BA-siemens-insight_ppcl_adapts函数用法
adapts函数是比pid调节性更好的自适应调节算法,比pid有更好的稳定性,具有震荡小.调节过程快.平稳等特点,函数的用法如下:
- 洛谷—— P3353 在你窗外闪耀的星星
https://www.luogu.org/problem/show?pid=3353 题目描述 飞逝的的时光不会模糊我对你的记忆.难以相信从我第一次见到你以来已经过去了3年.我仍然还生动地记得,3年 ...