网络编程中容易踩的坑罗列,谨记!
1、TCP没考虑粘包分包
TCP是面向连接的可靠协议,TCP是流式协议,创建TCP套接字的类型为SOCK_STREAM
int sockfd = socket(AF_INET, SOCK_STREAM, 0);很多同学面试时对书上的话背诵如流,在实际TCP编程中却没有处理粘包和分包的代码,以为TCP也和UDP一样,客户端每send一次,服务端就会recv一次,在本机上测试可能也没有出现问题,一旦到了线上发生粘包和分包的情况就会导致逻辑出错甚至程序崩溃。
解决方案:
1、使用流式解析器保存当前状态,如http-parser就使用了流式解析;
2、使用缓存push接收到的数据,判断接收到完整的一帧数据再pop取出进行处理;
Tips:
在libhv中可通过hio_set_unpack设置拆包规则,支持固定包长、分隔符、头部长度字段三种常见的拆包方式,调用该接口设置拆包规则后,内部会根据拆包规则处理粘包与分包,保证回调上来的是完整的一包数据,大大节省了上层处理粘包与分包的成本。
2、UDP没考虑丢包
在一些追求低延时的场景,为了避免TCP三次握手,我们会考虑使用UDP协议,但是却忽略了系统对丢包的容忍度,没考虑到某个关键包丢失带来的影响,没有重传重组机制。
解决方案:
结合FEC、KCP、UDT、QUIC等手段增强可靠性;
Tips:
libhv计划陆续集成FEC、KCP、UDT、QUIC等开源实现,欢迎有志之士加入;
3、长连接没考虑应用层心跳
TCP连接不是指真的有一条物理的连接,而是通信双方靠状态来记录维持的,从客户端发起SYN请求开始,状态就开始有序转换了。如果不发包,我们也就无法感知对方是否掉线,虽然TCP协议本身有keepalive机制,但是默认的间隔时间特别久,也无法携带其它信息,所以发送应用层心跳是非常有必要的,能快速感知掉线以便做出通知和处理,也能及时关闭fd,释放相关资源,以节省开销。
解决方案:
使用定时器发送心跳包,多长时间或者多少次没有收到回应便断开连接;
Tips:
在libhv中可通过hio_set_heartbeat设置心跳;
4、大数据没考虑分片和流量控制
见过有人直接将几十M、上百M甚至几G的文件直接读到内存进行发送,试问你家内存TB级别的吗,经的起这么消耗,另外不做发送速率控制和流量控制,可能会导致网络拥塞。
解决方案:
循环从磁盘读取少量数据到内存再发送,并做好流量控制;
Tips:
libhv中大文件的发送示例可参考examples/httpd里的largeFileHandler;
5、客户端没考虑断线重连
网络哪没有个掉线的时候,如果没有断线重连机制,将会严重影响用户体验,试想你正在打游戏,突然掉线了,不给你自动重连,必须重新启动应用程序,是不是很影响心情。
Tips:
在libhv中可通过TcpClient::setReconnect设置重连延时策略;
6、外网没考虑加密通信
在外网环境不使用SSL/TLS加密通信,就犹如一个人在大街上裸奔,没有丝毫隐私可言,安全系数为0。
解决方案:
1、集成openssl、gnutls、mbedtls等SSL/TLS加密通信库;
2、在网关处使用SSL代理,如使用nginx做反向代理服务;
Tips:
在libhv中集成了openssl、gnutls、mbedtls等SSL/TLS加密通信库,打开WITH_OPENSSL、WITH_GNUTLS、WITH_MBEDTLS选项编译,通过hio_enable_ssl即可开启SSL/TLS加密通信。
7、没有处理SIGPIPE
当向已经收到RST的socket执行写操作时,内核就会向进程发送一个SIGPIPE信号,该信号的默认行为是终止进程。通常的做法是忽略该信号。
signal(SIGPIPE, SIG_IGN);
8、大小端字节序问题
计算机硬件有两种存储数据的方式:大端字节序和小端字节序。网络通信中我们一般使用大端字节序,如果我们不按照对应的字节序来编码解码,就会得到错误的值。
9、多线程发送乱序问题
TCP虽然保证重传重组,但是我们自己要保证发送数据的有序性,特别是多线程发送时,即使加锁我们也无法保证哪个线程先发送,除非每个发送的包都是独立完整的一包,不分先后顺序,否则就可能引发乱序问题。
解决方案:
通常不建议多线程发送,而是由一个线程来负责发送。
Tips:
libhv中的hio_write、hio_close是多线程安全的,这可以让网络IO事件循环线程里接收数据、拆包组包、反序列化后放入队列,消费者线程从队列里取出数据、处理后发送响应和关闭连接,变得更加简单。
10、串包问题
串包即将本将发送给A的数据发送给了B。通常发生在服务器用fd1接受A的请求,A掉线后,B再上线了,POSIX标准要求每次打开文件的时候必须是要当前最小可以的文件描述符,于是又将fd1分配给了B,如果你继续使用fd1给A发送数据就会发送到了B。
解决方案:
该问题发生的根本原因是使用fd作为了设备的标示,应该建立某种机制来确认socket句柄是否是你想发送的那一个,例如设备连接后通过登录验证携带uuid来唯一标示该设备。
11、server端业务进程响应心跳超时被监控进程kill,导致数据或者逻辑异常
我们的后台框架采用的是proxy,worker模型,proxy处理连接和会话,worker处理业务,proxy和worker之间通过共享内存队列进行通信,并有监控进程扫描proxy和worker的运行情况。管理进程会定时向worker发起心跳查询,防止业务进程挂起。业务worker的心跳默认是60s,如果任务处理超过60s没有回复心跳,该进程会被认为异常,被监控进程kill后重启。review代码,没有发现sleep或者耗时操作,初步判定是网络连接异常导致的超时,检查了客户端连接代码,果然是没有使用带超时的连接,导致超时被监控进程kill
分析到这里,应该是找到原因了,但是疑问来了:默认连接超时是多久,该怎样设置连接超时,如果是非阻塞的socket该怎么做?
先自行回顾下三次握手的过程。
connect函数的接口并没有设置超时时间,那么默认的超时机制是什么情况
int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);侯捷说源码面前,了无秘密,还是看协议栈源码。
先从系统调用开始,inet_stream_connect是connect调用的socket层实现
代码在内核文件 net/ipv4/af_inet.c
int __inet_stream_connect(struct socket *sock, struct sockaddr *uaddr,int addr_len, int flags){struct sock *sk = sock->sk;int err;long timeo;...timeo = sock_sndtimeo(sk, flags & O_NONBLOCK);...}
从代码可以看到,connect只是完成发送syn的过程,后续的两次握手由协议栈完成。如果是非阻塞方式,返回的错误码是EINPROGRESS,超时时间在连接前设置,设置连接超时和设置发送超时是一样的 。
timeo = sock_sndtimeo(sk, flags & O_NONBLOCK);设置超时的实现
static inline long sock_sndtimeo(const struct sock *sk, bool noblock){return noblock ? 0 : sk->sk_sndtimeo;}
如果没有设置发送超时,那么默认的机制是什么?继续看代码,传输层的具体实现是调用tcp_v4_connect(),中间过程忽略,最后调用构造syn并发送的接口是tcp_connect()
代码在文件 net/ipv4/tcp_output.c
从代码可以看到发送后会启用重传定时器,直到应答或者超时,每次重传的超时时间采用指数退避的方式。具体实现是这两个函数
tcp_write_timeout()retransmits_timed_out()
如果发送syn超时没有响应,重传次数sysctl_tcp_sys_retries,这个值是在tcp的系统参数设置,使用 sysctl 查看,默认设置为5
sysctl net.ipv4.tcp_syn_retriesnet.ipv4.tcp_syn_retries = 5
至此,tcp的connect的机制已经很清楚了,如果设置了超时,当syn_retries重传syn次数的累计时间大于超时,那么在超时后返回,否则在syn_retries重传累计时间后返回。
为了验证以上逻辑,使用telnet 和tcpdump进行验证
telnet 192.168.128.254 10086Trying 192.168.128.254...telnet: Unable to connect to remote host: Connection timed out
耗时:63.128s
tcpdump跟踪如下:
00:37:25.986061 IP 192.168.128.131.46640 > 192.168.128.254.10086: Flags [S], seq 3312153031, win 1460000:37:26.983700 IP 192.168.128.131.46640 > 192.168.128.254.10086: Flags [S], seq 3312153031, win 1460000:37:28.987752 IP 192.168.128.131.46640 > 192.168.128.254.10086: Flags [S], seq 3312153031, win 1460000:37:32.995936 IP 192.168.128.131.46640 > 192.168.128.254.10086: Flags [S], seq 3312153031, win 1460000:37:41.012194 IP 192.168.128.131.46640 > 192.168.128.254.10086: Flags [S], seq 3312153031, win 1460000:37:57.059795 IP 192.168.128.131.46640 > 192.168.128.254.10086: Flags [S], seq 3312153031, win 14600
如果目标ip不可达,在5次重试后返回,总耗时63.128s,从时间戳可以看到重传syn的时间是采用指数退避的方式,分别为 1,2,4,8,16,32
如果目标ip可达,只是没有对应监听端口,在一次重试后,对端机器直接发送了reset标志,连接结束,耗时只要1s多,tcpdump跟踪如下:
00:52:21.776637 IP 192.168.128.131.58497 > 192.168.128.1.10086: Flags [S], seq 2415778508, win 1460000:52:22.775693 IP 192.168.128.131.58497 > 192.168.128.1.10086: Flags [S], seq 2415778508, win 1460000:52:22.799371 IP 192.168.128.1.10086 > 192.168.128.131.58497: Flags [R.], seq 19086327, ack 2415778509, win 64240
telnet: Unable to connect to remote host: Connection refused
因此,要设置连接超时其实有两种方法,如果是非阻塞方式,按照Stevens的建议
1、设置socket为非阻塞
2、根据connect返回值检查连接是否建立
3、调用select
4、检查超时
5、检查socket状态是可读还是可写
如果是阻塞方式,根据之前的源码分析,只要在连接前设置socket的发送超时即可
int connect_with_timeout(){...struct timeval timeo = {1, 0};socklen_t len = sizeof(timeo);fd = socket(AF_INET, SOCK_STREAM, 0);setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeo, len);int ret=connect(fd, (struct sockaddr*)&addr, sizeof(addr))...}
12、在接入层调用后端多个逻辑服务时,某一个后端的服务异常,导致接入层不能处理新的请求
查看机器的log,发现某个后端服务异常,代码使用了短连接请求后端服务,并在失败时自动重试,创建socket时发生错误。查看机器的网络状态,发现有大量的TIME_WAIT状态。
统计机器的TIME_WAIT状态数量有几个命令,最简单的是 cat /proc/net/sockstat
sockets: used 2861TCP: inuse 603 orphan 0 tw 19 alloc 795 mem 339UDP: inuse 985 mem 557
如果要查看更详细的状态统计,可以使用netstat 或者ss 加 awk 来处理
netstat -ant |awk '{if(NR>1)++s[$NF]} END {for(k in s) print k,s[k]}'ss -ant |awk '{if(NR>1)++s[$1]} END {for(k in s) print k,s[k]}'
推荐使用ss命令,当socket数量很大的时候,ss会快很多。
因此,原因很清楚了,是短连接在后端服务异常时大量产生的TIME_WAIT状态导致创建文件描述符失败,不能处理请求。
这种情况通常的处理建议是打开tcp_tw_recycle 或者tcp_tw_reuse 选项,那么是否有效,还会不会有什么坑?
TIME_WAIT是在连接断开时产生,先看下连接断开的过程:

上面就是常说的连接断开四次挥手的过程,TIME_WAIT出现在主动断开连接方,那它存在的意义是什么呢?
stevens在unix网络编程里边讲到有两点:
1、保证TCP连接关闭的可靠性。如果最终发送的ACK丢失,被动关闭的一端会重传最终的FIN包,如果执行主动关闭的一端没有维护这个连接的状态信息,会发送RST包响应,导致连接不正常关闭。
2、允许老的重复分组在网络中消逝。假设在一个连接关闭后,发起建立连接的一端(客户端)立即重用原来的端口、IP地址和服务端建立新的连接。老的连接上的分组可能在新的连接建立后到达服务端,TCP必须防止来自某个连接的老的重复分组在连接终止后再现,从而被误解为同一个连接的化身。要实现这种功能,TCP不能给处于TIME_WAIT状态的连接启动新的连接。
TIME_WAIT的时长通常定义成2*MSL,MSL表示报文在网络上存在的最长时间,如果超过这个时间,报文将被丢弃。linux下TIME_WAIT被定义在tcp.h中,时间是60s,除非重新编译内核,否则不能修改。
```/* how long to wait to destroy TIME-WAIT state, about 60 seconds*/#define TCP_TIMEWAIT_LEN (60*HZ)```
如果每秒有1000个请求,在60秒内产生的TIME_WAIT就有60000个,要控制或者减少TIME_WAIT的数量,协议栈提供了tcp_tw_recycle、tcp_tw_reuse、tcp_max_tw_buckets这几个选项,下面逐一分析。
tcp_tw_recycle
linux协议栈实现的时候提供了一种快速回收TIME_WAIT状态的机制,不用等待2MSL的时间,只要等待一个重传的时间即可回收,在idc内部,这个时间极短,可能不到1ms。但是新建立的连接可能存在风险:
1、如果之前的FIN延迟到达,新连接会被reset
2、如果之前发出的包延时后到达对端,会造成干扰
tcp协议栈设计的时候是如何处理这些风险呢,代码如下:
int tcp_conn_request(struct request_sock_ops *rsk_ops,const struct tcp_request_sock_ops *af_ops,struct sock *sk, struct sk_buff *skb){...if (!want_cookie && !isn) {if (tcp_death_row.sysctl_tw_recycle) {bool strict;dst = af_ops->route_req(sk, &fl, req, &strict);if (dst && strict &&!tcp_peer_is_proven(req, dst, true,tmp_opt.saw_tstamp)) {NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_PAWSPASSIVEREJECTED);goto drop_and_release;}}}...}
在tcp_tw_recycle模式下,判断是无效连接的条件是:
1、来自对端的tcp syn请求携带时间戳
2、本机在MSL时间内接收过来自同一台ip机器的tcp数据
3、新连接的时间戳小于上次tcp数据的时间戳
以上条件满足时,连接请求会被拒绝,使用netstat -s |grep timestamp 有如下记录
……packets rejects in established connections because of timestamp因此,在启用了tcp_tw_recycle的情况下,TIME_WAIT时间内(60s),同一源ip主机syn请求中的timestamp必须是递增的,连接才能被接受。
这个看起来很完美,同一个主机的timestamp的一定是递增的,但是NAT环境就悲剧了,NAT下,多个主机映射到同一个或几个对外IP,NAT设备只修改源地址和端口,timestamp不做修改,不能保证来自NAT机器多个主机间连接请求的timestamp是递增的,时间戳小的请求都会被拒绝。
tcp_tw_reuse
TIME_WAIT的重用只满足一定的条件下,处于TIME_WAIT状态的socket连接可以被新请求的syn使用。条件如下:
1、新请求的sequence要大于TIME_WAIT连接的最后的sequence
2、如果启用了tcp的timestamp选项,syn请求的时间戳要大于TIME_WAIT连接最后接收数据的时间戳
这个选项没有太大的意义,满足这个条件的情形并不多,并不能减少TIME_WAIT的数量。
tcp_max_tw_buckets:这个选项其实没有什么可说的,就是设置系统允许的最大TIME_WAIT数量,如果超过这个量,就不再出现TIME_WAIT,直接close
struct inet_timewait_sock *inet_twsk_alloc(const struct sock *sk,struct inet_timewait_death_row *dr,const int state){struct inet_timewait_sock *tw;if (atomic_read(&dr->tw_count) >= dr->sysctl_max_tw_buckets)return NULL;...}
这种方式对TIME_WAIT数量控制简单粗暴,但是效果也比较明显。但是问题和tcp_tw_recycle类似,新连接也可能被对端重传的FIN reset。
总结:控制TIME_WAIT的选项都存在一些问题,最好慎用。最好的方式是维持正常的TIME_WAIT状态,通过连接池的方式复用连接,减少TIME_WAIT出现的数量。如果要使用tcp_tw_recycle,一定要确保没有NAT设备接入,如果是只有client场景的机器,可以使用tcp_tw_reuse或增大net.ipv4.ip_local_port_range范围来发起更多的连接。
13、使用spp实现简单的web服务器,压测时短连接功能正常,但是采用keeplive模式,大约有40ms的时延
抓包分析发现,server端连续发送了两个小于mss的包,第一个包发出后,经过大约40ms才确认,第二个包才发出。检查代码,回包时,先回了http的包头,再回复http的body,命中nagle算法和delayed ack的应用场景,后一个包延时发出,但是为什么短连接正常,长连接有问题?
wikipedia对nagle算法的描述:
https://en.wikipedia.org/wiki/Nagle%27s_algorithm
算法实现:
if there is new data to sendif the window size >= MSS and available data is >= MSSsend complete MSS segment nowelseif there is unconfirmed data still in the pipeenqueue data in the buffer until an acknowledge is receivedelsesend data immediatelyend ifend ifend if
xshell或telnet这样的应用,每次键盘输入发送包含一个字符的包,却要耗费40字节的包头(tcp头加ip头),为了改进这种情况,Nagle算法的做法是先把第一个小包发送出去,后面的小包都缓存起来,直到收到前一个数据段的ack,或者缓存数据长度已经达到mss大小才发送,代码文件在 net/ipv4/tcp_output.c
static bool tcp_write_xmit(struct sock *sk, unsigned int mss_now, int nonagle,int push_one, gfp_t gfp){...if (tso_segs == 1) {if (unlikely(!tcp_nagle_test(tp, skb, mss_now,(tcp_skb_is_last(sk, skb) ?nonagle : TCP_NAGLE_PUSH))))break;} else {if (!push_one && tcp_tso_should_defer(sk, skb, &is_cwnd_limited,max_segs))break;}...}
具体判断是否启用nagle算法的逻辑:
/* Return false, if packet can be sent now without violation Nagle's rules: * 1. It is full sized. (provided by caller in %partial bool)* 2. Or it contains FIN. (already checked by caller)* 3. Or TCP_CORK is not set, and TCP_NODELAY is set.* 4. Or TCP_CORK is not set, and all sent packets are ACKed.* With Minshall's modification: all sent small packets are ACKed.*/static bool tcp_nagle_check(bool partial, const struct tcp_sock *tp,int nonagle){return partial &&((nonagle & TCP_NAGLE_CORK) ||(!nonagle && tp->packets_out && tcp_minshall_check(tp)));}
根据上面代码,nagle算法生效的条件是:
1、当前发送的包小于mss
2、启用TCP_NAGLE_CORK 并禁用TCP_NODELAY,或者启用TCP_NODELAY,有需要发送的数据以及还未ack的数据包
明确nagle算法的条件后,问题来了,为什么上一个包的ack是经过了40ms才返回,正常情况应该就是一个rtt的时间,同一idc的rtt时延小于1ms,怎么会有40ms延时呢?
其实这个涉及到了tcp协议的另外一个机制:延迟确认delayed ack
tcp发送ack有两种方式:quick ack 和 delayed ack
quick ack:收到数据包后,立即发送ACK给对端。
delayed ack:收到数据包后,不会立即发送ACK,而是启动延时确认定时器,在此期间:
1. 本端有数据包要发送给对端。就在发送数据包的时候捎带上此ACK。
2. 本端没有数据包要发送,定时器超时后发送ACK给对端。
根据算法的描述可以看到:nagle算法和delayed ack都是为了减少小数据包在网路中传输的数量,优化网络性能。
delayed ack的具体实现在代码文件net/ipv4/tcp_input.c
检查是否需要发送ack:
static void __tcp_ack_snd_check(struct sock *sk, int ofo_possible){struct tcp_sock *tp = tcp_sk(sk);/* More than one full frame received... */if (((tp->rcv_nxt - tp->rcv_wup) > inet_csk(sk)->icsk_ack.rcv_mss &&/* ... and right edge of window advances far enough.* (tcp_recvmsg() will send ACK otherwise). Or...*/__tcp_select_window(sk) >= tp->rcv_wnd) ||/* We ACK each frame or... */tcp_in_quickack_mode(sk) ||/* We have out of order data. */(ofo_possible && skb_peek(&tp->out_of_order_queue))) {/* Then ack it now */tcp_send_ack(sk);} else {/* Else, send delayed ack. */tcp_send_delayed_ack(sk);}}
满足下列条件之一,需要立即发送ack,否则进入延迟确认模式:
1、收到大于mss的包且有能力接收数据
2、满足快速确认模式
3、有乱序的数据,需要对端重传
检查是否快速确认模式:
/* Send ACKs quickly, if "quick" count is not exhausted* and the session is not interactive.*/static inline int tcp_in_quickack_mode(const struct sock *sk){const struct inet_connection_sock *icsk = inet_csk(sk);return icsk->icsk_ack.quick && !icsk->icsk_ack.pingpong;}
快速确认模式的初始化:
static void tcp_incr_quickack(struct sock *sk){struct inet_connection_sock *icsk = inet_csk(sk);unsigned int quickacks = tcp_sk(sk)->rcv_wnd / (2 * icsk->icsk_ack.rcv_mss);if (quickacks == 0)quickacks = 2;if (quickacks > icsk->icsk_ack.quick)icsk->icsk_ack.quick = min(quickacks, TCP_MAX_QUICKACKS);}static void tcp_enter_quickack_mode(struct sock *sk){struct inet_connection_sock *icsk = inet_csk(sk);tcp_incr_quickack(sk);icsk->icsk_ack.pingpong = 0;icsk->icsk_ack.ato = TCP_ATO_MIN;}
socket有一个pingpong属性来表明当前会话是否交互模式,如果是,会使用延迟确认机制,这个值是动态计算的。
有数据要发送时,如果当前时间与最近接受数据包的时间间隔小于ato(40ms,min(rtt,200ms)),则进入pingpong模式。因此,一旦有数据交互后,很快就切换到pingpong模式。
综上,在长连接的模式下,会话很快进入pingpong模式,server端先回了一个http头的小包,client收到数据准备回复ack时进入延时确认机制,server端继续发送http body也是一个小包,nagle算法生效,需要等前一个包的ack到达或者发送的数据大于mss时,数据才会发送,等40ms后,前一个包的延时ack到达,http的body内容才发送出去。
至此,问题已经真相大白,但是有个疑问,为什么短连接的时候,server端也是连续写入了两个小包,为什么没有触发nagle算法和delayed ack 呢?玄机在这里:
/* There is something which you must keep in mind when you analyze the* behavior of the tp->ato delayed ack timeout interval. When a* connection starts up, we want to ack as quickly as possible. The* problem is that "good" TCP's do slow start at the beginning of data* transmission. The means that until we send the first few ACK's the* sender will sit on his end and only queue most of his data, because* he can only send snd_cwnd unacked packets at any given time. For* each ACK we send, he increments snd_cwnd and transmits more of his* queue. -DaveM*/static void tcp_event_data_recv(struct sock *sk, struct sk_buff *skb){...}
tcp_event_data_recv函数的注释解释的很清楚:
连接刚启动的时候,拥塞算法使用的是慢启动,必须尽快发送ack,发送方才可能尽快增大发送窗口,发送更多的数据,所以在首次收包的时候,启用了快速确认模式,pingpong模式的值为0,而短连接只有一次业务数据的收发,后边连接就关闭了,nagle算法和delayed ack并没有生效。
针对问题描述的情形,解决方案是把http头和http body合并后发送,就不会有问题了,因为接收方收到完整数据后会重新发起新的请求,这时候会把上一个包的ack附带发回,发送方就不用等40ms的超时了。
如果发送方确实存在有多个小包要分别发送,并使用长连接的情况,最好是禁用nagle算法,其实这也是主流的做法,nginx在keeplive模式下就禁用了nagle算法。
