使用 tcp 时常见注意事项

本文中使用的代码参考如下:

tcp server:

#include <stdio.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <errno.h>

#define SERVER_IP     ("0.0.0.0")
#define SERVER_PORT   (12345)
#define MAX_LISTENQ   (32)

int main() {
    int ret = -1;
    int accept_fd = -1;
    int listen_fd = -1;

    struct sockaddr_in client_addr;
    struct sockaddr_in server_addr;
    socklen_t client = sizeof(struct sockaddr_in);

    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) {
        printf("create socket error: %s\n", strerror(errno));
        return -1;
    }

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = inet_addr(SERVER_IP); /**< 0.0.0.0 all local ip */
    server_addr.sin_port = htons(SERVER_PORT);

    if (bind(listen_fd,(struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind error: ");
        return -1;
    }

    if (listen(listen_fd, MAX_LISTENQ) < 0) {
        printf("listen error.\n");
        return -1;
    }

    accept_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client);
    if(accept_fd < 0) {
        printf("accept error.\n");
        return -1;
    }

    char buff[10] = {'\0'};
    for (int i = 0; i < 5; i++) {
        ret = recv(accept_fd, buff, 10, 0);
        printf("server recv len: %d, data: %s\n", ret, buff);
        sleep(1);
        send(accept_fd, buff, 10, 0);
    }

    printf("tcp server close\n");
    close(accept_fd);
    close(listen_fd);
    return 0;
}

 tcp client:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define SERVER_PORT   (12345)
#define SERVER_IP     "0.0.0.0"
#define MAX_BUFSIZE   (512)

int main(int argc,char *argv[]) {
    int sock_fd;
    sock_fd = socket(AF_INET, SOCK_STREAM, 0);
    if(sock_fd < 0) {
        printf("create socket error.\n");
        return -1;
    }

    struct sockaddr_in addr_serv;
    memset(&addr_serv, 0, sizeof(addr_serv));

    addr_serv.sin_family = AF_INET;
    addr_serv.sin_port =  htons(SERVER_PORT);
    addr_serv.sin_addr.s_addr = inet_addr(SERVER_IP);

    if(connect(sock_fd, (struct sockaddr *)&addr_serv,sizeof(struct sockaddr)) < 0){
        printf("connect error.\n");
        return -1;
    }

    char buff[10] = "hello tcp.";
    for (int i = 0; i < 5; i++) {
        send(sock_fd, buff, 10, 0);
        int ret = recv(sock_fd, buff, 10, 0);
        sleep(1);
        printf("client recv len: %d, data: %s\n", ret, buff);
    }

    close(sock_fd);
    return 0;
}

1 Address already in use

“Address already in use” 是一个错误,经常出现在服务端,比如当一个服务端退出之后立即又起来,这个时候服务端 bind 的时候很可能返回这个错误。

当 tcp 连接断开的时候,主动断开连接的一方,最后一个状态是 time wait,这个定时器的时间是 60s,所以当服务端退出的时候,time wait 状态还没有结束。在 linux 中,我们常常说进程是资源管理的单位,进程的资源包括内存,打开的文件,信号等,当进程退出的时候,系统会回收这个进程的所有资源。内存,打开的文件,信号在进程退出时会被系统回收,但是 tcp 中的 time wait 是一个特例,虽然这个时候已经不属于进程级的资源,属于内核的资源,但是从表面上看起来是进程退出时候,资源还没有完全释放。

如果是服务端主动发起断开连接,那么服务端的最后一个状态是 time wait,time wait 状态结束之前,服务端的端口号不能 bind()。

1.1 解决办法

如下列举了几种解决办法。本节不考虑这些解决办法是不是合理,具体怎么解决要考虑到实际的业务场景。

1.1.1 SO_REUSEPORT

服务端 bind() 之前,通过 setsockopet() 来设置 sock 的选项 SO_REUSEPORT,当上一个链接处于 time wait 时,新的服务端也可以绑定成功。

    int optval = 1;
    if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval)) == -1) {
        perror("Setsockopt failed");
        close(listen_fd);
        return -1;
    }

1.1.2 SO_REUSEADDR

服务端 bind() 之前,通过 setsockopet() 来设置 sock 的选项 SO_REUSEADDR,当上一个链接处于 time wait 时,新的服务端也可以绑定成功。

    int optval = 1;
    if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) == -1) {
        perror("Setsockopt failed");
        close(listen_fd);
        return -1;
    }

1.1.3 /proc/sys/net/ipv4/tcp_max_tw_buckets

这个配置是允许系统中可以存在的 time wait 状态套接字的个数的最大值。

默认是 16384,如果我们把这个配置改成 0,那么就是不允许有 time wait  状态的套接字体,这样当然是可以避免 “Address already in use”  错误。

将配置改成 0:

 echo 0 > /proc/sys/net/ipv4/tcp_max_tw_buckets

1.1.2 /proc/sys/net/ipv4/tcp_tw_reuse

tcp_tw_reuse 的取值范围是 0,1,2。

只对主动发起连接的一方起作用,一般都是客户端主动发起连接,所以只对客户端的 time wait 状态的套接字起作用,对服务端不起作用。

在 Linux 中源码中搜索关键字 tcp_tw_reuse 关键字,这个配置只在函数 tcp_twsk_unique 中使用了。我们使用 ftrace 追踪这个函数的调用栈。

ftrace 命令:

echo 0 >tracing_on
echo function >current_tracer
echo tcp_twsk_unique > set_ftrace_filter

echo 1 >options/func_stack_trace
echo 1 > tracing_on
cat trace

调用栈如下,打印出了进程的名字和进程号,分别是 tcp_client 和 8815。从调用栈可以看出来,tcp_twsk_unique() 只在主动创建连接的时候才会调用。

      tcp_client-8815    [003] b..2. 15001.550349: tcp_twsk_unique <-__inet_check_established
      tcp_client-8815    [003] b..2. 15001.550356: <stack trace>
 => tcp_twsk_unique
 => __inet_check_established
 => __inet_hash_connect
 => inet_hash_connect
 => tcp_v4_connect
 => __inet_stream_connect
 => inet_stream_connect
 => __sys_connect_file
 => __sys_connect
 => __x64_sys_connect
 => do_syscall_64
 => entry_SYSCALL_64_after_hwframe

客户端的端口号是内核自动分配的,为了快速复现内核分配的端口号重复的情况,我们使用如下命令限制内核可选的端口号的范围是 60989 到 60999。这样让内核可以选择的端口范围非常小,我们多次快速创建连接,断开连接,就能构造出 time wait 占用的端口号被重复占用的情况。

echo 60989 60999 >/proc/sys/net/ipv4/ip_local_port_range

如果把 tcp_tw_reuse 配置为 1,那么处于 time wait 状态的套接字就可以复用。

如果 tcp_tw_reuse 配置为 0,那么处于 time wait 状态的套接字就不可以复用。如下图所示,当端口使用完毕之后,再次创建连接就会返回错误,错误是 "Cannot assign requested address"。

"Cannot assign requested address" 这个错误类型就是在函数 __inet_check_established()中返回的错误码 EADDRNOTAVAIL。

/* called with local bh disabled */
static int __inet_check_established(struct inet_timewait_death_row *death_row,
				    struct sock *sk, __u16 lport,
				    struct inet_timewait_sock **twp)
{
    ...

	sk_nulls_for_each(sk2, node, &head->chain) {
		if (sk2->sk_hash != hash)
			continue;

		if (likely(INET_MATCH(net, sk2, acookie, ports, dif, sdif))) {
			if (sk2->sk_state == TCP_TIME_WAIT) {
				tw = inet_twsk(sk2);
				if (twsk_unique(sk, sk2, twp))
					break;
			}
			goto not_unique; // 处于 time wait 状态的套接字不能复用
		}
	}
    ...

not_unique:
	spin_unlock(lock);
	return -EADDRNOTAVAIL;
}

tcp_tw_reuse 一开始是一个 bool 变量,只有两个取值 0 和 1。后来下边这个补丁合入之后,是 int 变量,增加了取值  2,如果 tcp_tw_reuse 取值 2 的话,是只支持 loopback 口对 time wait 的套接字进行复用。

net-tcp: extend tcp_tw_reuse sysctl to enable loopback only optimization - kernel/git/torvalds/linux.git - Linux kernel source tree

2 time wait

本节两个参考:

 两个参考:

(1)详述 TCP 的 TIME_WAIT 状态要维持 2MSL 的原因_tcpip time_wait 2msl-CSDN博客

(2)UNIX 网络编程 卷 1:套接字连网 API 2.7 节

2.1 time wait 状态存在的意义

time wait 是主动断开连接的一方的最后一个状态,持续时间是 60s。

time wait 存在的意义有两点:

(1)保证连接的两端正常关闭

time wait 是主动关闭连接的一方,收到对端的 FIN,并回应 ack 之后进入的状态。如果最后回应的 ack 在链路上丢了,那么对端会重传 FIN,本端收到 FIN之后还会再回应一个 ack,并且重置 time wait 定时器。

如果没有 time wait 2MSL 这么长时间的等待,而是 socket 直接关闭,那么如果最后一个 ack 丢了,对端重传 fin 的话,本端就会回应一个 RST 报文,这样就是一个错误,不是正常的关闭了。

(2)保证报文在链路上消失,不会被五元组相同的新连接误接收

time wait 持续 2MSL 的时间,可以保证链路上的报文都消失。如果不持续这么长时间的话,并且后边创建了一个新的连接,五元组与旧连接完全相同,那么可能会收到旧连接的报文。

time wait 为什么要持续 2MSL 的时间 ?

MSL 全称 maximum segment lifetime,是 tcp 分片在链路上存活的最长时间(具体解释可以参考 UNIX 网络编程 卷 1:套接字连网 API 2.7 节)。

那么为什么是 2MSL,而不是 1MSL 或者 3MSL 呢?

linux 中 MSL 是 30s,也就是 30000ms,我们假设最后一个 ack 在 29999ms 的时候被对端收到,但是对端在 29998ms 的时候没有收到 ack 而进行了重传,又发了一次 FIN,这个时候虽然被动关闭的一方收到 ack 之后可以进入关闭状态了,但是链路上还有一个 FIN,需要等这个 FIN 消失,才可以,所以需要再等待一个 MSL 的时间。

2.2 当机器上有大量处于 time wait 状态的套接字怎么处理

(1)大量处于 time wait 状态的套接字的危害

主要是消耗端口号,如果端口号资源紧张的话,新的连接就没有端口号可用。客户端可用的端口号的范围可以通过如下配置文件查看,当这个范围内的端口号都用完之后,比如都处于 time wait 状态,那么再创建新的连接就会失败。

/proc/sys/net/ipv4/ip_local_port_range

(2)如何应对大量 time wait 状态的套接字

有以下几种方式,有些方式在第 1 节已经介绍过。

① 修改 /proc/sys/net/ipv4/ip_local_port_range,扩大可用端口号的范围

② 服务端设置 SO_REUSEPORT 或者 SO_REUSEADDR

③ 将 /proc/sys/net/ipv4/tcp_max_tw_buckets 调小

④ 将 /proc/sys/net/ipv4/tcp_tw_reuse 设置为 1

⑤ 优化应用本身的实现来减少 time wait 状态套接字的个数,比如是不是因为短连接导致的 time wait 大量存在,可不可以把短连接改成长连接

3 SO_REUSEPORT 和 SO_REUSEADDR

SO_REUSEPORT:

允许多个 socket bind 相同的 ip:port。比如 tcp 的服务端,如果多个服务端都绑定相同的 ip:port,并且都在 bind 之前设置了 SO_REUSEPORT,这样多个服务端都能 bind 成功。

当服务端接收到新的连接到来时,内核会做负载均衡,如果有多个连接,会把连接分配给不同的服务端进行接收。

SO_REUSEPORT 名字很容易让人产生误解,总感觉 SO_REUSEPORT 是只针对 port 能复用,但是 ip 地址需要不一样才行,其实不是的,设置这个选项之后,可以绑定相同的 ip:port。

注:SO_REUSEPORT 生效有个前提,要求每个服务端都属于同一个用户,如果不在同一个用户,那么即使都设置了 SO_REUSEPORT,bind 也会失败。

SO_REUSEADDR:
作用是当占用端口处于 time wait 状态时,新的服务端可以复用这个处于 time wait 状态的端口。

4 以数据包的方式接收数据(MSG_WAITALL)

tcp 是面向连接的字节流,在接收侧经常使用 reav() 来接收数据,函数声明如下:

ssize_t recv(int sockfd, void *buf, size_t len, int flags);

第 3 个形参是 len,表示要接收的长度,对于 tcp 来说,如果 len 的值是 32,那么现在如果到来了 16 字节长的数据,recv() 也会返回,并且返回值是实际接收的数据长度。

如果在使用 tcp 的时候,我们想要保证接收的数据长度与 len 一致,那么可以在最后一个形参设置 MSG_WAITALL,这样就能等到接收 len 个字节之后再返回。

5 killed by SIGPIPE

如果一个 tcp 连接的对端已经关闭,本端还要发送数据,那么本端就会被信号 SIGPIPE 杀死。

在实际使用中,应用被 SIGPIPE 杀死,往往不打印信息,只有使用 gdb 来调试的时候才能看到应用收到了 SIGPIPE  信号。为了让应用的日志更加丰富或者当对端关闭之后,send() 操作不会导致应用直接退出,可以在 send() 的最后一个形参中设置 MSG_NOSIGNAL。设置 MSG_NOSIGNAL 之后,发送端不会直接被 SIGPIPE 杀死,而是会返回错误 EPIPE,错误信息是 “Broken pipe”。

  • 20
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值