目录
2、TCPIP 协议栈的心跳机制
2.2、TCPIP 协议栈的心跳机制说明
2.3、修改 TCPIP 协议栈的默认心跳参数
3、libwebsockets 开源库中的心跳机制使用的就是 TCPIP 协议栈的心跳机制
5、使用非阻塞 socket 和 select 接口实现 connect 连接的超时控制
5.1、MSDN 上对 connect 和 select 接口的说明
5.2、使用非阻塞 socket 和 select 实现连接超时的控制
最近有个项目,客户的网络环境不太稳定,会时不时出现丢包和网络抖动的情况,导致我们软件客户端会时不时和服务器断链,导致正在进行中的视频会议会被中断。本文借此机会,结合具体的问题实例,详细讲解一下 TCP/IP 协议栈的 心跳机制 、 丢包重传机制 等内容,给大家提供一个借鉴和参考。
1、问题概述
虽然软件底层模块在网络恢复后能自动重连上服务器,但会议因为网络问题已经退出,需要重新加入会议。因为客户特殊的网络运行环境,会频繁出现网络抖动不稳定的情况,客户要求必须要实现 60 秒内网络恢复后能依然保持在会议中,保证会议流程不被中断。
客户坚持要实现这个特殊的功能点,项目已经接近尾声,目前处于客户试用阶段,不实现该功能,项目无法通过验收,客户不给钱。
前方同事将当前问题及项目进展情况向研发部门领导反馈,研发部紧急召开讨论会议,商讨 60 秒不掉会的实现方案。这里面涉及到两大类的网络连接,一类是传输控制信令的 TCP 连接 ,另一类是传输音视频码流的 UDP 连接 。UDP 连接的问题不大,主要是 TCP 连接的断链与重连问题,下面主要讨论 TCP 连接相关问题。
在出现网络不稳定掉会时,可能是系统 TCPIP 协议栈已经检测到网络异常,系统协议层已经将网络断开了;也可能软件应用层的心跳机制检测到网络故障,断开了与服务器的链接。
对于系统 TCPIP 协议栈自身检测出来的网络异常,则可能存在两种情况,一是 TCPIP 协议栈自身的心跳机制检测出来的;二是 TCP 连接的丢包重传机制检测出异常。
对于应用层的心跳检测机制,我们可以放大超时检测时间。 本文我们主要讨论一下 TCPIP 协议栈的 TCP 连接的心跳、丢包重传、连接超时等机制 。在检测到网络异常后,我们底层可以自动发起重连或者信令发送触发自动重连,业务模块将会议相关资源保存不释放,在网络恢复后可以继续保持在会议中,可以继续接收到会议中的音视频码流,可以继续进行会议中的一些操作!
2、TCPIP 协议栈的心跳机制
2.1、TCP 中的 ACK 机制
TCP 建链时的三次握手流程如下所示:
之所以说 TCP 连接是可靠的,首先是发送数据前要建立连接,再就是收到数据后都会给对方恢复一个 ACK 包,表明我收到你的数据包了。对于数据发送端,如果数据发出去后没有收到 ACK 包,则会触发丢包重传机制。
不管是建链时,还是建链后的数据收发时,都有 ACK 包,TCPIP 协议栈的心跳包也不例外。
2.2、TCPIP 协议栈的心跳机制说明
TCPIP 协议栈有个默认的 TCP 心跳机制 ,这个心跳机制是和 socket 套接字 (TCP 套接字)绑定的,可以对指定的套接字开启协议栈的心跳检测机制。 默认情况下,协议栈的心跳机制对 socket 套接字是关闭的 ,如果要使用需要人为开启的。
在 Windows 中,默认是每隔 2 个小时发一次心跳包,客户端程序将心跳包发给服务器后,接下来会有两种情况:
1) 网络正常时: 服务器收到心跳包,会立即回复 ACK 包,客户端收到 ACK 包后,再等 2 个小时发送下一个心跳包。其中,心跳包发送时间间隔时间 keepalivetime,Windows 系统中默认是 2 小时,可配置。如果在 2 个小时的时间间隔内,客户端和服务器有数据交互,客户端会收到服务器的 ACK 包,也算作心跳机制的心跳包,2 个小时的时间间隔会重新计时。
2) 网络异常时: 服务器收不到客户端发过去的心跳包,没法回复 ACK,Windows 系统中默认的是 1 秒超时,1 秒后会重发心跳包。如果还收不到心跳包的 ACK,则 1 秒后重发心跳包,如果始终收不到心跳包,则在发出 10 个心跳包就达到了系统的上限,就认为网络出故障了,协议栈就会直接将连接断开了。其中,发出心跳包收不到 ACK 的超时时间称为 keepaliveinterval,Windows 系统中默认是 1 秒,可配置;收不到心跳包对应的 ACK 包的重发次数 probe,Windows 系统是固定的,是固定的 10 次,不可配置的。
所以 TCPIP 协议栈的心跳机制也能检测出网络异常,不过在默认配置下可能需要很久才能检测出来,除非网络异常出现在正在发送心跳包后等待对端的回应时,这种情况下如果多次重发心跳包都收不到 ACK 回应,协议栈就会判断网络出故障,主动将连接关闭掉。
2.3、修改 TCPIP 协议栈的默认心跳参数
TCPIP 协议栈的默认心跳机制的开启, 不是给系统整个协议栈开启心跳监测 ,而是对某个 socket 套接字开启。
开启心跳机制后,还可以修改心跳的时间参数。从代码上看,先调用 setsockopt 给目标套接字开启心跳监测机制,再调用 WSAIoctl 去修改心跳检测的默认时间参数,相关代码如下所示:
SOCKET socket;
// ......(中间代码省略)
int optval = 1;
int nRet = setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, (const char *)&optval,
sizeof(optval));
if (nRet != 0)
return;
tcp_keepalive alive;
alive.onoff = TRUE;
alive.keepalivetime = 10*1000;
alive.keepaliveinterval = 2*1000;
DWORD dwBytesRet = 0;
nRet = WSAIoctl(socket, SIO_KEEPALIVE_VALS, &alive, sizeof(alive), NULL, 0,
&dwBytesRet, NULL, NULL);
if (nRet != 0)
return;
复制代码
上面的代码可以看到,先调用 setsockopt 函数,传入 SO_KEEPALIVE 参数,打开 TCP 连接的心跳开关,此时心跳参数使用系统默认的心跳参数值。紧接着,调用 WSAIoCtrl 函数,传入 SIO_KEEPALIVE_VALS 参数,同时将设置好时间值的心跳参数结构体传进去。
下面对心跳参数结构体 tcp_keepalive 做个详细的说明:(以 Windows 系统为例)
1) keepalivetime :默认 2 小时发送一次心跳保活包,比如发送第 1 个保活包之后,间隔 2 个小时后再发起下一个保活包。如果这期间有数据交互,也算是有效的保活包,这个时间段就不再发送保活包,发送下个保活包的时间间隔会从收发的最后一条数据的时刻开始重新从 0 计时。
2) keepaliveinterval :发送保活包后,没有收到对端的 ack 的超时时间默认为 1 秒。假设和对端的网络出问题了,给对端发送第 1 个保活包,1 秒内没有收到对端的 ack,则发第 2 个保活包,1 秒内没有收到对端的保活包,再发送下一个保活包,.....,直到发送第 10 个保活包后,1 秒钟还没收到 ack 回应,则达到发送 10 次保活包的探测次数上限,则认为网络出问题了。
3) probe 探测次数 :Windows 系统上的探测次数被固定为 10 次,不可修改。
MSDN 上对心跳机制检测出的网络异常的说明如下:
If a connection is dropped as the result of keep-alives the error code WSAENETRESET is returned to any calls in progress on the socket, and any subsequent calls will fail with WSAENOTCONN.
复制代码
因为保活次数达到上限导致连接被丢弃掉,所有正在调用中的套接字接口会返回 WSAENETRESET 错误码,后续的套接字 api 函数的调用都会返回 WSAENOTCONN。
3、libwebsockets 开源库中的心跳机制使用的就是 TCPIP 协议栈的心跳机制
我们的产品 之前在使用 websocket 时,就遇到没有设置心跳机制导致 TCP 长连接被网络设备无故释放的问题。
我们客户端程序在登录时,会去连接某业务的注册服务器,建立的是 websocket 长连接。这个长连接一直保持着,只有使用该业务模块的业务时才会使用到该连接,在该连接上进行数据交互。软件登录后,如果一直没有操作该业务模块的业务,这个长连接会一直处于闲置状态,即这个连接上没有数据交互。
结果在某次测试过程中出现了问题,排查下来发现, 这个长连接因为长时间没有数据交互,被中间的网络设备关闭了。 后来为了解决这个问题, 我们在初始化 websocket 库时设置心跳参数 ,这样上述 websocket 长连接在空闲的时候能跑一跑心跳包,这样就能确保该长连接不会因为长时间没有跑数据被无故关闭的问题了。
我们在调用 lws_create_context 接口创建 websockets 会话上下文时,该接口的结构体参数 lws_context_creation_info 中,有设置心跳参数的字段:
/**
* struct lws_context_creation_info - parameters to create context with
*
* This is also used to create vhosts.... if LWS_SERVER_OPTION_EXPLICIT_VHOSTS
* is not given, then for backwards compatibility one vhost is created at
* context-creation time using the info from this struct.
*
* If LWS_SERVER_OPTION_EXPLICIT_VHOSTS is given, then no vhosts are created
* at the same time as the context, they are expected to be created afterwards.
*
* @port: VHOST: Port to listen on... you can use CONTEXT_PORT_NO_LISTEN to
* suppress listening on any port, that's what you want if you are
* not running a websocket server at all but just using it as a
* client
* @iface: VHOST: NULL to bind the listen socket to all interfaces, or the
* interface name, eg, "eth2"
* If options specifies LWS_SERVER_OPTION_UNIX_SOCK, this member is
* the pathname of a UNIX domain socket. you can use the UNIX domain
* sockets in abstract namespace, by prepending an @ symbole to the
* socket name.
* @protocols: VHOST: Array of structures listing supported protocols and a protocol-
* specific callback for each one. The list is ended with an
* entry that has a NULL callback pointer.
* It's not const because we write the owning_server member
* @extensions: VHOST: NULL or array of lws_extension structs listing the
* extensions this context supports. If you configured with
* --without-extensions, you should give NULL here.
* @token_limits: CONTEXT: NULL or struct lws_token_limits pointer which is initialized
* with a token length limit for each possible WSI_TOKEN_***
* @ssl_cert_filepath: VHOST: If libwebsockets was compiled to use ssl, and you want
* to listen using SSL, set to the filepath to fetch the
* server cert from, otherwise NULL for unencrypted
* @ssl_private_key_filepath: VHOST: filepath to private key if wanting SSL mode;
* if this is set to NULL but sll_cert_filepath is set, the
* OPENSSL_CONTEXT_REQUIRES_PRIVATE_KEY callback is called
* to allow setting of the private key directly via openSSL
* library calls
* @ssl_ca_filepath: VHOST: CA certificate filepath or NULL
* @ssl_cipher_list: VHOST: List of valid ciphers to use (eg,
* "RC4-MD5:RC4-SHA:AES128-SHA:AES256-SHA:HIGH:!DSS:!aNULL"
* or you can leave it as NULL to get "DEFAULT"
* @http_proxy_address: VHOST: If non-NULL, attempts to proxy via the given address.
* If proxy auth is required, use format
* "username:password@server:port"
* @http_proxy_port: VHOST: If http_proxy_address was non-NULL, uses this port at
* the address
* @gid: CONTEXT: group id to change to after setting listen socket, or -1.
* @uid: CONTEXT: user id to change to after setting listen socket, or -1.
* @options: VHOST + CONTEXT: 0, or LWS_SERVER_OPTION_... bitfields
* @user: CONTEXT: optional user pointer that can be recovered via the context
* pointer using lws_context_user
* @ka_time: CONTEXT: 0 for no keepalive, otherwise apply this keepalive timeout to
* all libwebsocket sockets, client or server
* @ka_probes: CONTEXT: if ka_time was nonzero, after the timeout expires how many
* times to try to get a response from the peer before giving up
* and killing the connection
* @ka_interval: CONTEXT: if ka_time was nonzero, how long to wait before each ka_probes
* attempt
* @provided_client_ssl_ctx: CONTEXT: If non-null, swap out libwebsockets ssl
* implementation for the one provided by provided_ssl_ctx.
* Libwebsockets no longer is responsible for freeing the context
* if this option is selected.
* @max_http_header_data: CONTEXT: The max amount of header payload that can be handled
* in an http request (unrecognized header payload is dropped)
* @max_http_header_pool: CONTEXT: The max number of connections with http headers that
* can be processed simultaneously (the corresponding memory is
* allocated for the lifetime of the context). If the pool is
* busy new incoming connections must wait for accept until one
* becomes free.
* @count_threads: CONTEXT: how many contexts to create in an array, 0 = 1
* @fd_limit_per_thread: CONTEXT: nonzero means restrict each service thread to this
* many fds, 0 means the default which is divide the process fd
* limit by the number of threads.
* @timeout_secs: VHOST: various processes involving network roundtrips in the
* library are protected from hanging forever by timeouts. If
* nonzero, this member lets you set the timeout used in seconds.
* Otherwise a default timeout is used.
* @ecdh_curve: VHOST: if NULL, defaults to initializing server with "prime256v1"
* @vhost_name: VHOST: name of vhost, must match external DNS name used to
* access the site, like "warmcat.com" as it's used to match
* Host: header and / or SNI name for SSL.
* @plugin_dirs: CONTEXT: NULL, or NULL-terminated array of directories to
* scan for lws protocol plugins at context creation time
* @pvo: VHOST: pointer to optional linked list of per-vhost
* options made accessible to protocols
* @keepalive_timeout: VHOST: (default = 0 = 60s) seconds to allow remote
* client to hold on to an idle HTTP/1.1 connection
* @log_filepath: VHOST: filepath to append logs to... this is opened before
* any dropping of initial privileges
* @mounts: VHOST: optional linked list of mounts for this vhost
* @server_string: CONTEXT: string used in HTTP headers to identify server
* software, if NULL, "libwebsockets".
*/
struct lws_context_creation_info {
int port; /* VH */
const char *iface; /* VH */
const struct lws_protocols *protocols; /* VH */
const struct lws_extension *extensions; /* VH */
const struct lws_token_limits *token_limits; /* context */
const char *ssl_private_key_password; /* VH */
const char *ssl_cert_filepath; /* VH */
const char *ssl_private_key_filepath; /* VH */
const char *ssl_ca_filepath; /* VH */
const char *ssl_cipher_list; /* VH */
const char *http_proxy_address; /* VH */
unsigned int http_proxy_port; /* VH */
int gid; /* context */
int uid; /* context */
unsigned int options; /* VH + context */
void *user; /* context */
int ka_time; /* context */
int ka_probes; /* context */
int ka_interval; /* context */
#ifdef LWS_OPENSSL_SUPPORT
SSL_CTX *provided_client_ssl_ctx; /* context */
#else /* maintain structure layout either way */
void *provided_client_ssl_ctx;
#endif
short max_http_header_data; /* context */
short max_http_header_pool; /* context */
unsigned int count_threads; /* context */
unsigned int fd_limit_per_thread; /* context */
unsigned int timeout_secs; /* VH */
const char *ecdh_curve; /* VH */
const char *vhost_name; /* VH */
const char * const *plugin_dirs; /* context */
const struct lws_protocol_vhost_options *pvo; /* VH */
int keepalive_timeout; /* VH */
const char *log_filepath; /* VH */
const struct lws_http_mount *mounts; /* VH */
const char *server_string; /* context */
/* Add new things just above here ---^
* This is part of the ABI, don't needlessly break compatibility
*
* The below is to ensure later library versions with new
* members added above will see 0 (default) even if the app
* was not built against the newer headers.
*/
void *_unused[8];
};
复制代码
其中的 ka_time 、 ka_probes 和 ka_interval 三个字段就是心跳相关的设置参数。我们初始化 websockets 上下文的代码如下:
static lws_context* CreateContext()
{
lws_set_log_level( 0xFF, NULL );
lws_context* plcContext = NULL;
lws_context_creation_info tCreateinfo;
memset(&tCreateinfo, 0, sizeof tCreateinfo);
tCreateinfo.port = CONTEXT_PORT_NO_LISTEN;
tCreateinfo.protocols = protocols;
tCreateinfo.ka_time = LWS_TCP_KEEPALIVE_TIME;
tCreateinfo.ka_interval = LWS_TCP_KEEPALIVE_INTERVAL;
tCreateinfo.ka_probes = LWS_TCP_KEEPALIVE_PROBES;
tCreateinfo.options = LWS_SERVER_OPTION_DISABLE_IPV6;
plcContext = lws_create_context(&tCreateinfo);
return plcContext;
}
复制代码
通过查阅 libwebsockets 开源库代码得知,此处设置的心跳使用的就是 TCPIP 协议栈的心跳机制,如下所示:
LWS_VISIBLE int
lws_plat_set_socket_options(struct lws_vhost *vhost, lws_sockfd_type fd)
{
int optval = 1;
int optlen = sizeof(optval);
u_long optl = 1;
DWORD dwBytesRet;
struct tcp_keepalive alive;
int protonbr;
#ifndef _WIN32_WCE
struct protoent *tcp_proto;
#endif
if (vhost->ka_time) {
/* enable keepalive on this socket */
// 先调用setsockopt打开发送心跳包(设置)选项
optval = 1;
if (setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE,
(const char *)&optval, optlen) < 0)
return 1;
alive.onoff = TRUE;
alive.keepalivetime = vhost->ka_time*1000;
alive.keepaliveinterval = vhost->ka_interval*1000;
if (WSAIoctl(fd, SIO_KEEPALIVE_VALS, &alive, sizeof(alive),
NULL, 0, &dwBytesRet, NULL, NULL))
return 1;
}
/* Disable Nagle */
optval = 1;
#ifndef _WIN32_WCE
tcp_proto = getprotobyname("TCP");
if (!tcp_proto) {
lwsl_err("getprotobyname() failed with error %d\n", LWS_ERRNO);
return 1;
}
protonbr = tcp_proto->p_proto;
#else
protonbr = 6;
#endif
setsockopt(fd, protonbr, TCP_NODELAY, (const char *)&optval, optlen);
/* We are nonblocking... */
ioctlsocket(fd, FIONBIO, &optl);
return 0;
}
复制代码
4、TCPIP 丢包重传机制
如果网络出故障时,客户端与服务器之间正在进行 TCP 数据交互,客户端给服务器发送数据包后因为网络故障收不到服务器的 ACK 包,就会触发客户端的 TCP 丢包重传 ,丢包重传机制也能判断出网络出现异常。
对于 TCP 连接,客户端给服务器发送数据后没有收到服务器的 ACK 包,会触发丢包重传。每次重传的时间间隔会加倍,当重传次数达到系统上限(Windows 默认的上限是 5 次,Linux 默认的上限是 15 次)后,协议栈就认为网络出故障了,会直接将对应的连接关闭了。
所以当网络出现故障时有数据交互,协议栈会在数十秒内检测到网路出现异常,就会直接将连接直接关闭掉。丢包重传机制的详细描述如下所示:
对于丢包重传机制,可以通过给 PC 插拔网线来查看,可以使用 wireshark 抓包看一下。快速插拔网线时(先拔掉网线,等待几秒钟再将网线插上),给服务器发送的操作指令会因为丢包重传会收到数据的。
5、使用非阻塞 socket 和 select 接口实现 connect 连接的超时控制
5.1、MSDN 上对 connect 和 select 接口的说明
对于 tcp 套接字,我们需要调用套接字函数 connect 去建立 TCP 连接。我们先来看看微软 MSDN 上对套接字接口 connect 的描述:
On a blocking socket, the return value indicates success or failure of the connection attempt.
复制代码
对于阻塞式的 socket,通过 connect 的返回值就能确定有没有连接成功,返回 0 表示连接成功。
With a nonblocking socket, the connection attempt cannot be completed immediately. In this case, connect will return SOCKET_ERROR, and WSAGetLastError will return WSAEWOULDBLOCK. In this case, there are three possible scenarios:
Use the select function to determine the completion of the connection request by checking to see if the socket is writeable.
复制代码
对于非组赛式的 socket,connect 调用会立即返回,但连接操作还没有完成。connect 返回 SOCKET_ERROR,对于非阻塞式 socket,返回 SOCKET_ERROR 并不表示失败,需要调用 WSAGetLastError 获取 connect 函数执行后的 LastError 值,一般此时 WSAGetLastError 会返回 WSAEWOULDBLOCK:
表明连接正在进行中。可以使用 select 接口检测一下套接字是否可写(套接字是否在 writefds 集合中),如果可写,则表示连接成功。如果套接字在 exceptfds 集合中,则说明连接出现了异常,如下所示:
5.2、使用非阻塞 socket 和 select 实现连接超时的控制
对于阻塞式的 socket, 在 Windows 下,如果远端的 IP 和 Port 不可达,则会阻塞 75s 后返回 SOCKET_ERROR,表明连接失败。 所以当我们测试远端的 IP 和 Port 是否可以连接时,我们不使用阻塞式的 socket,而是使用 非阻塞式 socket ,然后调用 select ,通过 select 添加连接超时时间,实现 连接超时的控制 。
select 函数因为超时返回,会返回 0;如果发生错误,则返回 SOCKET_ERROR,所以判断时要判断 select 返回值,如果小于等于 0,则是连接失败,立即将套接字关闭掉。如果 select 返回值大于 0,则该返回值是已经准备就绪的 socket 个数,比如连接成功的 socket。我们判断套接字是否在可写集合 writefds 中,如果在该集合中,则表示连接成功。
根据 MSDN 上的相关描述,我们就能大概知道该如何实现 connect 的超时控制了,相关代码如下:
bool ConnectDevice( char* pszIP, int nPort )
{
// 创建TCP套接字
SOCKET connSock = socket(AF_INET, SOCK_STREAM, 0);
if (connSock == INVALID_SOCKET)
{
return false;
}
// 填充IP和端口
SOCKADDR_IN devAddr;
memset(&devAddr, 0, sizeof(SOCKADDR_IN));
devAddr.sin_family = AF_INET;
devAddr.sin_port = htons(nPort);
devAddr.sin_addr.s_addr = inet_addr(pszIP);
// 将套接字设置为非阻塞式的,为下面的select做准备
unsigned long ulnoblock = 1;
ioctlsocket(connSock, FIONBIO, &ulnoblock);
// 发起connnect,该接口立即返回
connect(connSock, (sockaddr*)&devAddr, sizeof(devAddr));
FD_SET writefds;
FD_ZERO(&writefds);
FD_SET(connSock, &writefds);
// 设置连接超时时间为1秒
timeval tv;
tv.tv_sec = 1; //超时1s
tv.tv_usec = 0;
// The select function returns the total number of socket handles that are ready and contained
// in the fd_set structures, zero if the time limit expired, or SOCKET_ERROR(-1) if an error occurred.
if (select(0, NULL, &writefds, NULL, &tv) <= 0)
{
closesocket(connSock);
return false; //超时未连接上就退出
}
ulnoblock = 0;
ioctlsocket(connSock, FIONBIO, &ulnoblock);
closesocket(connSock);
return true;
}
复制代码
评论