发送或者接受数据过程中对端可能发生的情况汇总

《UNP》p159总结了如下的情况:

情形 对端进程崩溃 对端主机崩溃 对端主机不可达
本端TCP正主动发送数据 对端TCP发送一个FIN,这通过使用select判断可读条件立即能检测出来,如果本端TCP发送另一个分节,对端TCP就以RST响应。如果本端TCP在收到RST后应用进程仍试图写套接字,我们的套接字实现就给该进程发送一个SIGPIPE信号 本端TCP将超时,且套接字的待处理错误被置为ETIMEDOUT 本端TCP将超时,且套接字的待处理错误被置为EHOSTUNREACH
本端TCP正主动接收数据 对端TCP发送一个FIN,我们将把它作为一个EOF读入 我们将停止接收数据 我们将停止接收数据
连接空闲,保持存活选项已设置 对端TCP发送一个FIN,这通过select判断可读条件能立即检测出来 在无数据交换2小时后,发送9个保持存活探测分节,然后套接字的待处理错误被置为ETIMEDOUT 在无数据交换2小时后,发送9个保持存活探测分节,然后套接字的待处理错误被置为HOSTUNREACH
连接空闲,保持存活选项未设置 对端TCP发送一个FIN,这通过select判断可读条件能立即检测出来

本端TCP发送数据时对端进程已经崩溃

服务端接收客户端的数据并丢弃:

int acceptOrDie(uint16_t port)
{
int listenfd = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
assert(listenfd >= 0); int yes = 1;
if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)))
{
perror("setsockopt");
exit(1);
} struct sockaddr_in addr;
bzero(&addr, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = INADDR_ANY;
if (::bind(listenfd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr)))
{
perror("bind");
exit(1);
} if (::listen(listenfd, 5))
{
perror("listen");
exit(1);
} struct sockaddr_in peer_addr;
bzero(&peer_addr, sizeof(peer_addr));
socklen_t addrlen = 0;
int sockfd = ::accept(listenfd, reinterpret_cast<struct sockaddr*>(&peer_addr), &addrlen);
if (sockfd < 0)
{
perror("accept");
exit(1);
}
::close(listenfd);
return sockfd;
} void discard(int sockfd)
{
char buf[65536];
while (true)
{
int nr = ::read(sockfd, buf, sizeof buf);
if (nr <= 0)
break;
}
} int main(int argc, char* argv[]) {
if (argc < 2) {
cout << "usage:./server port\n";
exit(0);
} int sockfd = acceptOrDie(atoi(argv[1])); //创建socket, bind, listen
discard(sockfd); //读取并丢弃所有客户端发送的数据 return 0;
}

客户端从命令行接受字符串并发送给服务端:

struct sockaddr_in resolveOrDie(const char* host, uint16_t port)
{
struct hostent* he = ::gethostbyname(host);
if (!he)
{
perror("gethostbyname");
exit(1);
}
assert(he->h_addrtype == AF_INET && he->h_length == sizeof(uint32_t));
struct sockaddr_in addr;
bzero(&addr, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr = *reinterpret_cast<struct in_addr*>(he->h_addr);
return addr;
} int main(int argc, char* argv[]) {
if (argc < 3) {
cout << "usage:./cli host port\n";
exit(0);
}
struct sockaddr_in addr = resolveOrDie(argv[1], atoi(argv[2])); int sockfd = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
assert(sockfd >= 0);
int ret = ::connect(sockfd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr));
if (ret)
{
perror("connect");
exit(1);
} char sendline[1024];
while (fgets(sendline, sizeof sendline, stdin) != NULL) { //从命令行读数据
write_n(sockfd, sendline, strlen(sendline)); //发送给服务端
}
return 0;
}

先启动tcpdump观察数据包的流动,然后分别启动服务端和客户端。

下面是三次握手的数据包:

15:33:21.184993 IP 221.218.38.144.53186 > 172.19.0.16.1234: Flags [S], seq 1654237964, win 64240, options [mss 1412,nop,wscale 8,nop,nop,sackOK], length 0
15:33:21.185027 IP 172.19.0.16.1234 > 221.218.38.144.53186: Flags [S.], seq 3710209371, ack 1654237965, win 29200, options [mss 1460,nop,nop,sackOK,nop,wscale 7], length 0
15:33:21.230698 IP 221.218.38.144.53186 > 172.19.0.16.1234: Flags [.], ack 1, win 259, length 0

然后终止服务端进程,观察数据包的情况。服务端进程终止后,会向客户端发送一个FIN分节,客户端内核回应一个ACK。此时客户端阻塞在fgets,感受不到这个FIN分节。

15:33:49.310810 IP 172.19.0.16.1234 > 221.218.38.144.53186: Flags [F.], seq 1, ack 8, win 229, length 0
15:33:49.356453 IP 221.218.38.144.53186 > 172.19.0.16.1234: Flags [.], ack 2, win 259, length 0

如果这时客户端继续发送数据,因为服务端进程已经不在了,所以服务端内核响应一个RST分节。

15:34:31.198332 IP 221.218.38.144.53186 > 172.19.0.16.1234: Flags [P.], seq 8:16, ack 2, win 259, length 8
15:34:31.198360 IP 172.19.0.16.1234 > 221.218.38.144.53186: Flags [R], seq 3710209373, win 0, length 0

如果客户端在收到RST分节后,继续发送数据,将会收到SIGPIPE信号,如果使用默认的处理方式,客户端进程将会崩溃。

如果我们在客户端代码中忽略SIGPIPE信号,那么客户端不会崩溃。

signal(SIGPIPE, SIG_IGN); // 忽略 SIGPIPE 信号

本端TCP发送数据时对端主机已经崩溃

这种情况本端TCP会超时,且套接字待处理错误会被置为ETIMEDOUT。

本端TCP发送数据时对端主机已经关机

服务端主机关机和崩溃不同,关机时会关闭进程打开的描述符,所以会发送FIN分节,客户端如果处理得当,就能检测到。但是如果是对端主机崩溃,除非设置了SO_KEEPALIVE

选项,否则本端无法得知对端主机已经崩溃。

某个连接长时间没有数据流动

这一种情况对应表格中的第三、四行。

  1. 如果没有设置SO_KEEPALIVE选项,那么如果对端只是进程崩溃,那么本端还是可以通过select检测到的,但是如果对端主机崩溃或者变得不可达,那么本端没有办法得知,这个连接也得不到正常的关闭。
  2. 如果设置了该选项。

    这个选项是用来检测对端是否主机崩溃或者变得不可达(比如网线断开),而不是检测对端进程是否崩溃,如果是进程崩溃的话会发送一个FIN,本端可以用select检测到。但是如果对端长时间没有数据流动,我们除了设置这个选项,没有办法得知对端是不是主机崩溃或者变得不可达。

    设置该选项后,如果2小时内该套接字任一方向上都没有数据交换,TCP就自动给对端发送一个探测分节,可能出现三种情况:

    1. 对端响应ACK。表示一切正常,应用进程不会得到任何通知。
    2. 对端响应RST,表示对端已崩溃且以重新启动,该套接字的待处理错误被置为ECONNRESET,套接字被关闭。
    3. 对端没有任何响应,那么隔一段时间再次发送探测分节,如果还是没有响应,套接字错误被置为ETIMEOUT,套接字被关闭。

TCP发送数据不全

TCP本身是可靠,但是如果使用不当会给人造成TCP不可靠的错觉。

TCP数据发送不全实例

假设服务端接收连接后调用后打开一个本地文件,然后将文件内容通过socket发送给客户端。

int main(int argc, char* argv[]) {
if (argc < 3) {
printf("Usage:%s filename port\n", argv[0]);
return 0;
} int sockfd = acceptOrDie(atoi(argv[2]));
printf("accept client\n"); FILE* fp = fopen(argv[1], "rb");
if (!fp) {
return 0;
} printf("sleeping 10 seconds\n");
sleep(10); char buf[8192];
size_t nr = 0;
while ((nr = fread(buf, 1, sizeof buf, fp)) > 0) { //读文件
write_n(sockfd, buf, nr); //发送给客户端
} fclose(fp);
printf("finish sending file %s\n", argv[1]);
}

首先在在服务端启动该程序./send file_1M_size 1234。file_1M_size的1M大小的文件。

用nc作为客户端nc localhost 1234 | wc -c

连接建立后,服务端会sleep 10秒,然后拷贝文件,最终客户端输出:

1048576

这里没问题,确实发送了1M数据的文件。

如果我们在服务端sleep 10秒期间,在客户端输入了一些数据:

root@DESKTOP-2A432QS:/mnt/c/Users/12401/Desktop/network_programing/recipes-master/tpc# nc localhost 1234 | wc -c
abcdfef
976824

abcdfef是我们发送给服务端的,976824是收到的字节数。显然不够1M。

为什么会出现数据发送不全的现象?

建立连接后,客户端也向服务端发送了一些数据,这些数据到达服务端后,保存在服务端的内核缓冲区中。服务端读取文件后调用write发送出去,虽然write返回了,但这仅仅代表要发送的数据已经被放到了内核发送缓冲区,并不代表已经被客户端接收了。这时服务端while循环结束,直接退出了main函数,这会导致close连接,当接收缓冲区还有数据没有读取时调用close,将会向对端发送一个RST分节,该分节会导致发送缓冲区中待发送的数据被丢弃,而不是正常的TCP断开连接序列,从而导致客户端没有收到完整的文件。

问题的本质是:在没有确认对端进程已经收到了完整的数据,就close了socket。那么如何保证确保对端进程已经收到了完整的数据呢?

如何解决(如何正确关闭连接)?

一句话:read读到0之后才close。

发送完数据后,调用shutdown(第二个参数设置为SHUT_WR),后跟一个read调用,该read返回0,表示对端也关闭了连接(这意味着对端应用进程完整接收了我们发送的数据),然后才close。

发送方接收方程序结构如下:

发送方:1.send() , 2.发送完毕后调用shutdown(WR), 5.read()->0(此时发送方才算能确认接收方已经接收了全部数据), 6.close()。

接收方:3.read()->0(说明没有数据可读了), 4.如果没有数据可发调用close()。

序号表明了时间的顺序。

我们修改之前的服务端代码:

int main(int argc, char* argv[]) {
if (argc < 3) {
printf("Usage:%s filename port\n", argv[0]);
return 0;
} int sockfd = acceptOrDie(atoi(argv[2]));
printf("accept client\n"); FILE* fp = fopen(argv[1], "rb");
if (!fp) {
return 0;
} printf("sleeping 10 seconds\n");
sleep(10); char buf[8192];
size_t nr = 0;
while ((nr = fread(buf, 1, sizeof buf, fp)) > 0) {
write_n(sockfd, buf, nr);
} fclose(fp); shutdown(sockfd, SHUT_WR); //新增代码,发送FIN分节
while ((nr = read(sockfd, buf, sizeof buf)) > 0) { //新增代码,等客户端close
//do nothing
}
printf("finish sending file %s\n", argv[1]);
}

这次在while循环结束后,不是直接退出main,而是shutdown,然后循环read,等客户端先close,客户端close后,read会返回0,然后退出main函数。这样就能保证数据被完整发送了。

root@DESKTOP-2A432QS:/mnt/c/Users/12401/Desktop/network_programing/recipes-master/tpc# nc localhost 1234 | wc -c
abcdefg
1048576

这次就算客户端发送了数据,也能保证收到了完整的1M数据。

参考资料:

  1. why is my tcp not reliable

SIGPIPE信号

什么场景下会产生SIGPIPE信号?

如果一个 socket 在接收到了 RST packet之后,程序仍然向这个socket写入数据,那么就会产生SIGPIPE信号。

具体例子见“本端TCP发送数据时对端进程已经崩溃”这一节。

如何处理SIGPIPE信号?

signal(SIGPIPE, SIG_IGN); // 忽略 SIGPIPE 信号

直接忽略该信号,此时write()会返回-1,并且此时errno的值为EPIPE。

Nagle算法,TCP_NODELAY

Nagle算法的基本定义是任意时刻,最多只能有一个未被确认的小段。 所谓“小段”,指的是小于MSS尺寸的数据块,所谓“未被确认”,是指一个数据块发送出去后,没有收到对方发送的ACK确认该数据已收到。

通过TCP_NODELAY选项关闭Nagle算法,一般都需要。

SO_RESUSEADDR

TCP主动关闭的一端在发送最后一个ACK后,必须在TIME_WAIT状态等待2倍的MSL(报文最大生存时间)。

在连接处于2MSL状态期间,由该插口对(src_ip:src_port, dest_ip:dest_port)定义的连接不能被再次使用。对于服务端,如果服务器主动断开连接,那么在2MSL时间内,该服务器无法在相同的端口,再次启动。

可以使用SO_REUSEADDR选项,允许一个进程重新使用处于2MSL等待的端口。

为什么要设计2MSL状态?

这样可以防止最后一个ACK丢失,如果丢失了,在2倍的MSL时间内,对端会重发FIN,然后主动关闭的一端可以再次发送ACK,以确保连接正确关闭。

为什么处于2MSL状态时该插口对定义的连接不能被再用?

假设处于2MSL状态的插口对,能再次被使用,那么前一个连接迟到的报文对这个新的连接会有影响。

示例

以前文的sender为例,在服务端执行./sender file_1M_size 1234,然后客户端进行连接 nc localhost 1234 | wc -c,连接后,终止sender进程。

用netstat查看会发现这个连接处于TIME_WAIT状态,然后试图再在1234端口启动sender会发现:

bind: Address already in use

解决办法

开启套接字的SO_REUSEADDR选项。

  int yes = 1;
if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)))
{
perror("setsockopt");
exit(1);
}

最新文章

  1. 深入理解jsonp跨域请求原理
  2. OC编码问题输出中文
  3. highcharts 当Y轴全部没有数据的时候 数据标签显示最下面 而不是居中显示
  4. cocos2dx一个场景添加多个层
  5. 环形进度条带数字显示(canvas)
  6. java反射获取注解并拼接sql语句
  7. 简单模拟一下ab压力测试
  8. C++框架_之Qt的窗口部件系统的详解-上
  9. Flex Builder 4.6切换语言
  10. Django-CRM项目学习(一)-admin组件
  11. 学习WPF
  12. 洗礼灵魂,修炼python(28)--异常处理(2)—&gt;运用异常
  13. Vue学习一:{{}}html模板使用方法
  14. highly variable gene | 高变异基因的选择 | feature selection | 特征选择
  15. SpringMVC深度探险(四) —— SpringMVC核心配置文件详解
  16. setuid和setgid
  17. Sql Server约束的学习一(主键约束、外键约束、唯一约束)
  18. 制作Linux内核
  19. shell函数使用
  20. Idea安装findbugs插件,以及findbugs安装find security bugs插件

热门文章

  1. Hibernate综合问题
  2. 关于js的window.open()
  3. DOM渲染
  4. 详解第一个CUDA程序kernel.cu
  5. 新建py文件时取名千万要小心 不要和已有模块重名
  6. 用WPF窗体打造个性化界面的图片浏览器
  7. WPF4文字模糊不清晰、边框线条粗细不一致的解决方法
  8. 如何直接访问WEB-INF下列文件
  9. JScript运行批处理命令的做法
  10. abp框架(aspnetboilerplate)扩展系统表