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\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);
  }
点赞