文章目录
1. 问题
在做 NodeJS 开发的过程中,碰到一个棘手的问题:已建立好的 TCP 连接,在网络异常情况下(拔网线),客户端无法及时的响应(close 或 error),这会影响到业务应用的。
查了不少资料,从 Linux 网络模型到 Nodejs 手册再到内核,一步步走来,算是找到了解决问题的方法,这里总结一下。
2. 解决思路
2.1. Linux TCP 参数
2.1.1. TCP keepalive 机制
在网络上能找到大量的有关 TCP keepalive 机制,通过配置 SO_KEEPALIVE、TCP_KEEPIDLE、TCP_KEEPINTVL、TCP_KEEPCNT 四个内核变量的值,可以检测是否存在异常的网络情况。
使用 SO_KEEPALIVE 将会检测对方主机是否崩溃,避免服务器永远阻塞于 TCP 连接的输入。设置该选项后,如果 2 小时内在此套接口的任一方向都没有数据交换,TCP 就自动给对方发一个 keepalive probe。此时会出现以下三种情况:
a. 对方接收一切正常:以期望的 ACK 响应。2 小时后,TCP 将发出另一个 keepalive probe。
b. 对方已崩溃且已重新启动:以 RST 响应。套接口的待处理错误被置为 ECONNRESET,套接口本身则被关闭。
c. 对方无任何响应:源自 berkeley 的 TCP 发送另外 8 个探测分节,相隔 75 秒一个,试图得到一个响应。在发出第一个探测分节 11 分钟 15 秒后若仍无响应就放弃。套接口的待处理错误被置为 ETIMEOUT,套接口本身则被关闭。
在路径 /proc/sys/net/ipv4/下 可以看到 tcp_keepalive_intvl、tcp_keepalive_probes、tcp_keepalive_time 三个文件,分别对应:两次 KeepAlive 探测间的时间间隔(75)、判定断开前的 KeepAlive 探测次数(9)、发起 KeepAlive 探测的定时器时间(7200)。
2.1.2. TCP_USER_TIMEOUT
TCP_USER_TIMEOUT 选项是 TCP 层的 socket 选项,选项接受 unsigned int 类型的值。值为数据包被发送后未接收到 ACK 确认的最大时长,以毫秒为单位,例如设置为 10000 时,代表如果发送出去的数据包在十秒内未收到 ACK 确认,则下一次调用 send 或者 recv,则函数会返回-1,errno 设置为 ETIMEOUT,代表 connection timeout。
2.2. 使用 NodeJS 接口测试,只适用于没有数据发送的时候,但有数据发送则不行
在 NodeJS 中,有一个函数接口 socket.setKeepAlive,可以设置 SO_KEEPALIVE、TCP_KEEPIDLE、TCP_KEEPINTVL、TCP_KEEPCNT 四个内核变量
SO_KEEPALIVE=1
TCP_KEEPIDLE=initialDelay
TCP_KEEPCNT=10
TCP_KEEPINTVL=1
在示例代码中实验,未成功响应到网线拔出,示例代码如下:
//client.js
var net = require('net');
let handle;
const { setInterval } = require('timers');
var client = net.connect({ port: 8888, host: '192.168.1.1' }, function () {
client.setKeepAlive(true, 15000);
client.name = '客户机1';
let i = 0;
client.on('close', (error) => {
console.log('close:', error);
clearInterval(handle);
client.end();
});
client.on('connect', (error) => {
});
client.on('data', function (data) {
console.log('recv data:', data.toString());
});
client.on('drain', function (data) {
console.log('drain:', data.toString());
});
client.on('end', (error) => {
console.log('end:', error);
});
client.on('error', (error) => {
console.log('error:', new Date(Date.now()).toString());
});
client.on('lookup', (error) => {
console.log('lookup:', error);
});
client.on('ready', (error) => {
console.log('ready:', new Date(Date.now()).toString());
handle = setInterval(() => {
console.log('write data:', 'asdf');
client.write('asdf')
}, 1000);
});
client.setTimeout(3000);
client.on('timeout', () => {
console.log('socket timeout');
client.end();
});
});
这个只适用于没有数据发送的时候,但有数据发送则不行,继续查资料。
2.3. 从网上找相关资料,出现关键信息
在实验的过程中发现,如果发送方发送的数据包没有收到接收方回复的 ACK 数据包,则 TCP Keep-alive 机制就不会被启动,而 TCP 会启动超时重传机制,这样就使得 TCP Keep-alive 机制在未收到 ACK 包时失效。在查阅这个问题时找到了 stackoverflow 上面的资料:http://stackoverflow.com/questions/5907527/application-control-of-tcp-retransmission-on-linux
根据排名第一的回答表示,Linux Kernel 2.6.37 中增加了一个叫做 TCP_USER_TIMEOUT 的 socket 选项。答案大意是,TCP_USER_TIMEOUT 选项是 TCP 层的 socket 选项,选项接受 unsigned int 类型的值。值为数据包被发送后未接收到 ACK 确认的最大时长,以毫秒为单位,例如设置为 10000 时,代表如果发送出去的数据包在十秒内未收到 ACK 确认,则下一次调用 send 或者 recv,则函数会返回-1,errno 设置为 ETIMEOUT,代表 connection timeout。
但在 Nodejs 中没有设置此值的方法,想到可以使用 NodeJS C++插件开发实现
2.4. NodeJS C++插件
插件是用 C++编写的动态链接的共享对象。require()函数可以作为普通的 Node.js 模块加载插件。插件提供了 JavaScript 和 C/C++库之间的接口。
实现插件有三个选项:Node-api、nan,或直接使用内部 V8、libuv 和 Node.js 库。除非需要直接访问 node-api 没有公开的功能,否则请使用 node-api。有关 node-api 的更多信息,请参阅带有 node-api 的 C/C++插件。
当不使用 node-api 时,实现插件是很复杂的,涉及到几个组件和 api 的知识:
V8:C++库 Node.js 用来提供 JavaScript 实现。V8 提供了创建对象、调用函数等的机制。V8 的 API 主要记录在 v8.h 头文件中(deps/v8/inv8/在 Node.js 源树中),该文件也可以在线获得。
Libuv:实现 Node.js 事件循环的 C 库,它的工作线程和平台的所有异步行为。它还作为一个跨平台抽象库,为所有主要操作系统提供许多常见系统任务的简单的、类似 posix 的访问,例如与文件系统、套接字、计时器和系统事件的交互。Libuv 还提供了一个类似于 POSIX 线程的线程抽象,用于需要超越标准事件循环的更复杂的异步插件。Addon 作者应该通过通过 libuv 将工作卸载到非阻塞系统操作、工作线程或定制使用 libuv 线程,从而避免使用 I/O 或其他耗费时间的任务来阻塞事件循环。
内部 Node.js 库。Node.js 本身导出插件可以使用的 C++api,其中最重要的是节点::ObbectWrap 类。
Node.js 包括其他静态链接的库,包括 OpenSSL。这些其他库位于 Node.js 源代码树中的 deps/目录中。只有 libuv、OpenSSL、V8 和 zlib 符号会被 Node.js 有意地重新导出,并且插件可以在不同程度上使用。有关其他信息,请参见链接到 Node.js 中包含的库。
在 NPM 中有sockopt库,此库可以插件形式实现 getsockopt & setsockopt 接口 Node.js sockets.
2.5. 测试两种场景
两种场景:
- TCP 一直没有发数据,此时拔网线,触发 nodejs setKeepAlive 接口设置
- TCP 一直发数据,此时拔网络,触发 TCP_USER_TIMEOUT 超时
经测试,两种场景都满足,问题解决。
代码如下:
//client.js
var net = require('net');
const { getsockopt, setsockopt } = require('sockopt');
let handle;
const { setInterval } = require('timers');
var client = net.connect({ port: 8888, host: '60.205.214.26' }, function () {
client.setKeepAlive(true, 15000);
client.name = '客户机1';
let i = 0;
client.on('close', (error) => {
console.log('close:', error);
clearInterval(handle);
client.end();
});
client.on('connect', (error) => {});
client.on('data', function (data) {
console.log('recv data:', data.toString());
});
client.on('drain', function (data) {
console.log('drain:', data.toString());
});
client.on('end', (error) => {
console.log('end:', error);
});
client.on('error', (error) => {
console.log('error:', new Date(Date.now()).toString());
});
client.on('lookup', (error) => {
console.log('lookup:', error);
});
client.on('ready', (error) => {
console.log('ready:', new Date(Date.now()).toString());
const SOL_TCP = 6;
const TCP_USER_TIMEOUT = 18;
console.log('TCP_USER_TIMEOUT is now', getsockopt(client, SOL_TCP, TCP_USER_TIMEOUT));
setsockopt(client, SOL_TCP, TCP_USER_TIMEOUT, 5 * 1000);
console.log('TCP_USER_TIMEOUT is now', getsockopt(client, SOL_TCP, TCP_USER_TIMEOUT));
handle = setInterval(() => {
console.log('write data:', 'asdf');
client.write('asdf');
}, 1000);
});
client.setTimeout(3000);
client.on('timeout', () => {
console.log('socket timeout');
client.end();
});
});
3. 引申
尝试为 nodejs 贡献代码
Node.js 源码解析之 TCP
linux 定时器_通过 linux 源码分析 nodejs 的 keep-alive
unix: add uv_tcp_user_timeout fonction #900
epoll 使用详解(精髓)
硬核图解网络 IO 模型!
Linux IO 模式及 select、poll、epoll 详解
哪 5 种 IO 模型?什么是 select/poll/epoll?同步异步阻塞非阻塞有啥区别?全在这讲明白了!