TCP使用注意事项总结
目录
发送或者接受数据过程中对端可能发生的情况汇总
《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
";
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
";
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
", argv[0]);
return 0;
}
int sockfd = acceptOrDie(atoi(argv[2]));
printf("accept client
");
FILE* fp = fopen(argv[1], "rb");
if (!fp) {
return 0;
}
printf("sleeping 10 seconds
");
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
", 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
", argv[0]);
return 0;
}
int sockfd = acceptOrDie(atoi(argv[2]));
printf("accept client
");
FILE* fp = fopen(argv[1], "rb");
if (!fp) {
return 0;
}
printf("sleeping 10 seconds
");
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
", 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数据。
参考资料:
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);
}