目录
发送或者接受数据过程中对端可能发生的情况汇总
本端 TCP 发送数据时对端进程已经崩溃
本端 TCP 发送数据时对端主机已经崩溃
本端 TCP 发送数据时对端主机已经关机
某个连接长时间没有数据流动
TCP 发送数据不全
TCP 数据发送不全实例
为什么会出现数据发送不全的现象?
如何解决(如何正确关闭连接)?
SIGPIPE 信号
什么场景下会产生 SIGPIPE 信号?
如何处理 SIGPIPE 信号?
Nagle 算法, TCP_NODELAY
SO_RESUSEADDR
为什么要设计 2MSL 状态?
为什么处于 2MSL 状态时该插口对定义的连接不能被再用?
示例
解决办法
发送或者接受数据过程中对端可能发生的情况汇总
《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
选项, 否则本端无法得知对端主机已经崩溃.
某个连接长时间没有数据流动
这一种情况对应表格中的第三, 四行.
如果没有设置 SO_KEEPALIVE 选项, 那么如果对端只是进程崩溃, 那么本端还是可以通过 select 检测到的, 但是如果对端主机崩溃或者变得不可达, 那么本端没有办法得知, 这个连接也得不到正常的关闭.
如果设置了该选项.
这个选项是用来检测对端是否主机崩溃或者变得不可达(比如网线断开), 而不是检测对端进程是否崩溃, 如果是进程崩溃的话会发送一个 FIN, 本端可以用 select 检测到. 但是如果对端长时间没有数据流动, 我们除了设置这个选项, 没有办法得知对端是不是主机崩溃或者变得不可达.
设置该选项后, 如果 2 小时内该套接字任一方向上都没有数据交换, TCP 就自动给对端发送一个探测分节, 可能出现三种情况:
对端响应 ACK. 表示一切正常, 应用进程不会得到任何通知.
对端响应 RST, 表示对端已崩溃且以重新启动, 该套接字的待处理错误被置为 ECONNRESET, 套接字被关闭.
对端没有任何响应, 那么隔一段时间再次发送探测分节, 如果还是没有响应, 套接字错误被置为 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 数据.
参考资料:
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);
- }
来源: https://www.cnblogs.com/gatsby123/p/11099239.html