赞
踩
四次挥手过程如下:
可以看到,每个方向都需要一个 FIN 和一个 ACK,因此通常被称为四次挥手。当然也可用 shutdown() 执行一端到另一端的半关闭。
TCP 连接终止时,主机1 先发送 FIN 报文,主机2 进入 CLOSE_WAIT 状态,并发送一个 ACK 应答,同时主机2 通过 read() 获得 EOF,并将此结果通知给应用程序做主动关闭(发送 FIN 报文)。主机1 在接收到 FIN 报文后发送 ACK 应答,此时主机1 进入 TIME_WAIT 状态。
主机1 在 TIME_WAIT 停留持续时间是 2MSL(MSL:最长分节生命期 maximum segment lifetime)。Linux 硬编码 TCP_TIMEWAIT_LEN 为固定的 60 秒(大多数 BSD 派生的系统与此相同)。过了这个时间之后,主机1 就进入 CLOSED 状态。即只有发起连接终止的一方会进入 TIME_WAIT 状态。
#define TCP_TIMEWAIT_LEN (60*HZ) // how long to wait to destroy TIME-WAIT state, about 60 seconds
当 socket 被关闭时,TCP 为其所在端发送一个 FIN 包。
无论是 client 还是 server 任何一端都可以发起主动关闭。当然大多数真实情况是client 执行主动关闭,你可能不会想到的是 HTTP/1.0 却是由服务器发起主动关闭的。
为什么不直接进入 CLOSED 状态,而要停留在 TIME_WAIT 这个状态?
2MSL 的时间是从 主机1 收到 FIN 后,发送 ACK 来开始计时的
重新计时
。道理很简单,因为 2MSL 的时间,目的是为了让旧连接的所有报文都能自然消亡,现在主机1 重新发送了 ACK 报文,自然需要重新计时,以便防止这个 ACK 报文对新可能的连接化身造成干扰。有如下危害:
net.ipv4.ip_local_port_range
指定,若 TIME_WAIT 状态过多,会导致无法创建新连接。在高并发的情况下,若想优化 TIME_WAIT 来解决我们一开始提到的例子,该如何办呢?
l_onoff
为 0,l_linger 的值将被忽略,这对应了默认行为,close() 或 shutdown() 立即返回。若在套接字发送缓冲区中有数据残留,系统会将试着把这些数据发送出去。l_onoff
为 非0, 而 l_linger
值为 0,那么调用 close() 后,会立刻发送一个 RST
标志给对端,该 TCP 连接将 跳过四次挥手,也就跳过了 TIME_WAIT 状态,直接关闭
。这种关闭的方式称为“强行关闭”。阻塞在 recv()
调用上时,接受到 RST 时,会立刻得到一个“connet reset by peer”的异常。l_onoff
为 非0, 且 l_linger
的值也非 0,那么调用 close() 后,调用 close() 的线程就将阻塞,直到数据被发送出去,或者设置的 l_linger 计时时间到。struct linger {
int l_onoff; // 0=off, nonzero=on
int l_linger; // linger time, POSIX specifies units as seconds
}
struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0;
setsockopt(s,SOL_SOCKET,SO_LINGER, &so_linger,sizeof(so_linger));
// int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
net.ipv4.tcp_tw_reuse
:是安全可控的,可以复用处于 TIME_WAIT 的套接字为新的连接所用:
net.ipv4.tcp_timestamps=1
(默认即为 1)。// net.ipv4.tcp_tw_reuse:
Allow to reuse TIME-WAIT sockets for new connections when it is safe from protocol
viewpoint. Default value is 0.
It should not be changed without advice/request of technical experts.
其实 TCP 协议也在与时俱进,RFC 1323 中实现了 TCP 拓展规范,以便保证 TCP 的高可用,并引入了新的 TCP 选项,两个 4 字节的时间戳字段,用于记录 TCP 发送方的当前时间戳和从对端接收到的最新时间戳。由于引入了时间戳,我们在前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃。
优雅关闭:client 主动发起连接的中断时,其将自己到 server 的数据流方向关闭,此时 client 不再往 server 写入数据,server 读完 client 数据后就不会再有新的报文到达。但这并不意味着 TCP 连接已完全关闭,很有可能的是,server 正在对 client 的最后报文进行处理,比如访问数据库存入一些数据;或者是计算出某个 client 需要的值;当完成这些操作之后, server 把结果通过套接字写给 client ,我们说这个套接字的状态此时是「半关闭」的。最后 server 才有条不紊地关闭剩下的半个连接,结束这一段 TCP 连接的使命。
非优雅关闭:当然这里描述的,是 server “优雅”地关闭了连接。但若 server 处理不好,就会导致最后的关闭过程是“粗暴”的,达不到我们上面描述的“优雅”关闭的目标,形成的后果很可能是 server 处理完的信息没办法正常传送给 client ,破坏了用户侧的使用场景。
int close(int sockfd)
close() 会对套接字引用计数减一,一旦发现套接字引用计数到 0,就会对套接字进行彻底释放,并会关闭 TCP 两个方向的数据流。(因套接字可被多个进程共享,当通过 fork() 产生子进程时套接字引用计数 +1,当 close() 时套接字引用计数 -1)。
close() 具体是如何关闭两个方向的数据流呢?
我们会发现,close() 并不能帮我们关闭连接的「一个」方向,那如何在需要时关闭一个方向呢?幸运的是设计 TCP 协议的人帮我们想好了 shutdown() 方案。
int shutdown(int sockfd, int howto)
howto 是这个函数的设置选项,其设置有三个选项:
用 SHUT_RDWR 来调用 shutdown() 和 close() 虽然都是关闭连接的读和写两个方向,但还是有区别的:
下面,我们通过构建一组 client 和 server 程序,来进行 close() 和 shutdown() 的实验。
client 端:从 stdin 不断接收用户输入,把输入的字符串通过 socket 发给 server,同时将 server 的应答显示到 stdout 上。client 端代码如下:
// https://github.com/datager/yolanda/blob/master/chap-11/graceclient.c
# include "lib/common.h"
# define MAXLINE 4096
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: graceclient <IPaddress>");
}
// 创建一个 TCP 套接字
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
// 设置了连接的目标服务器 IPv4 地址,绑定到了指定的 IP 和 port
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
// 使用创建的套接字,向目标 IPv4 地址发起连接请求
socklen_t server_len = sizeof(server_addr);
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
if (connect_rt < 0) {
error(1, errno, "connect failed ");
}
char send_line[MAXLINE], recv_line[MAXLINE + 1];
int n;
fd_set readmask;
fd_set allreads;
// 为使用 select 做准备,初始化描述字集合
FD_ZERO(&allreads);
FD_SET(0, &allreads);
FD_SET(socket_fd, &allreads);
// 程序主体部分:用 select 多路复用, 观测在 socket_fd 和 stdin 上的 I/O 事件
for (;;) {
readmask = allreads;
int rc = select(socket_fd + 1, &readmask, NULL, NULL, NULL);
if (rc <= 0)
error(1, errno, "select failed");
if (FD_ISSET(socket_fd, &readmask)) { // 当 socket_fd 上有数据可读,将数据读入到程序缓冲区中
n = read(socket_fd, recv_line, MAXLINE);
if (n < 0) {
error(1, errno, "read error"); // 若有异常则报错退出
} else if (n == 0) {
error(1, 0, "server terminated \n"); // 若读到 server 发送的 EOF 则正常退出
}
recv_line[n] = 0;
fputs(recv_line, stdout);
fputs("\n", stdout);
}
if (FD_ISSET(0, &readmask)) { // 当 stdin 上有数据可读,读入后进行判断
if (fgets(send_line, MAXLINE, stdin) != NULL) {
if (strncmp(send_line, "shutdown", 8) == 0) { // 若输入的是“shutdown”,则关闭 stdin 的 I/O 事件感知,并调用 shutdown() 关闭写方向
FD_CLR(0, &allreads);
if (shutdown(socket_fd, 1)) {
error(1, errno, "shutdown failed");
}
} else if (strncmp(send_line, "close", 5) == 0) { // 若输入的是”close“,则调用 close() 关闭连接
FD_CLR(0, &allreads);
if (close(socket_fd)) {
error(1, errno, "close failed");
}
sleep(6);
exit(0);
} else { // 处理正常的输入,将回车符截掉,调 write() 通过 socket_fd 将数据发送给 server
int i = strlen(send_line);
if (send_line[i - 1] == '\n') {
send_line[i - 1] = 0;
}
printf("now sending %s\n", send_line);
size_t rt = write(socket_fd, send_line, strlen(send_line));
if (rt < 0) {
error(1, errno, "write failed ");
}
printf("send bytes: %zu \n", rt);
}
}
}
}
}
server 端,连接建立之后,打印出接收的字节,并重新格式化后发送给 client。代码如下:
// https://github.com/datager/yolanda/blob/master/chap-11/graceserver.c
#include "lib/common.h"
static int count;
static void sig_int(int signo) {
printf("\nreceived %d datagrams\n", count);
exit(0); // exit后,操作系统内核协议栈会接管后续的处理: 即发送 FIN 报文
}
int main(int argc, char **argv) {
// 创建一个 TCP 套接字
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
// 设置了本地服务器 IPv4 地址,绑定到了 ANY 地址和指定的端口
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(SERV_PORT);
// 使用创建的套接字,以此执行 bind、listen 和 accept 操作,完成连接建立
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if (rt1 < 0) {
error(1, errno, "bind failed ");
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 < 0) {
error(1, errno, "listen failed ");
}
signal(SIGINT, sig_int);
signal(SIGPIPE, SIG_IGN);
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
error(1, errno, "bind failed ");
}
char message[MAXLINE];
count = 0;
// 程序的主体: 通过 read() 获取 client 传送来的数据流,并回送给 client
for (;;) {
int n = read(connfd, message, MAXLINE);
if (n < 0) {
error(1, errno, "error read");
} else if (n == 0) {
error(1, 0, "client closed \n");
}
message[n] = 0;
printf("received %d bytes: %s\n", n, message); // 显示收到的字符串
count++;
char send_line[MAXLINE];
sprintf(send_line, "Hi, %s", message); // 对原字符串进行重新格式化
sleep(5); // 发送之前,让 server 程序休眠了 5 秒,以模拟 server 处理的时间
int write_nc = send(connfd, send_line, strlen(send_line), 0); // 调用 send() 将数据发送给 client
printf("send bytes: %zu \n", write_nc);
if (write_nc < 0) {
error(1, errno, "error write");
}
}
}
效果如下:我们启动 server ,再启动 client,依次在 stdin 上输入 “data1”、“data2” 和 “close”,观察一段时间后可看到:client 依次发送了 data1 和 data2,server 也正常接收到 data1 和 data2。在 client 端 close() 掉整个连接之后,server 接收到 SIGPIPE 信号并直接退出。client 并没有收到 server 的应答数据。
下图详细解释了 client 和 server 交互的时序图:
static void sig_pipe(int signo) {
printf("\nreceived %d datagrams\n", count);
exit(0);
}
signal(SIGINT, sig_pipe);
接下来,再次启动 server,再启动 client,依次在标准输入上输入 “data1”、“data2” 和 “shutdown”,观察一段时间后可看到:
我们再看下 client 和 server 交互的时序图(见本文 7.3.1 节)。
close() 关闭连接有如下两个特点,因此「当期望关闭连接其中一个方向时应该用 shutdown()」。
之前我们讲到了如何使用 close() 和 shutdown() 来关闭连接,多数情况下我们会优选 shutdown() 来关闭连接的一个方向,待对端处理完之后,再关闭另外一个方向。
在很多情况下,有探活的需求:即「连接的一端要一直感知连接的状态」,若连接无效了,应用程序可能需要报错或重新发起连接等。
TCP 有叫做 Keep-Alive 的探活机制:其定义一个时间段,若在此时间段内无任何连接相关的活动,TCP 保活机制会开始作用,其每隔一段时间发一个探测报文(包含的数据非常少),若连续几个探测报文都无响应,则认为当前的 TCP 连接已死亡,系统内核则将错误信息通知给上层应用程序。
有三个控制变量,均可用 sysctl 修改:
TCP 保活有以下几种情况:
TCP 保活机制默认是关闭的,当我们打开时,既可分别在连接的两个方向上开启,也可只在一个方向上开启。
若使用 TCP 自身的 keep-Alive 机制,Linux 至少需经 2 小时 11 分 15 秒才可以发现一个“死亡”连接(其实是 2 小时,加上 75 秒乘以 9 的总和)。这在很多时延敏感的系统是不可接受的。(为什么 TCP 不提供一个频率很好的保活机制呢?我的理解是早期的网络带宽非常有限,若提供一个频率很高的保活机制,对有限的带宽是一个比较严重的浪费。)
我们可在「应用程序」中模拟 TCP Keep-Alive 机制来在应用层探活。即可设计一个 PING-PONG 的机制:即需要保活的一方(如 client)在探活时间达到后发起 PING,若 server 有回应则重置探活时间,否则对探测次数进行计数,当到达探活次数阈值后则认为连接已无效。
这里有两个关键点:第一个是需用定时器(可通过 I/O 复用机制实现);第二个是需设计一个 PING-PONG 协议。下面我们尝试来完成这样的一个设计。
首先定义消息对象结构体,其前 4 个字节标识了消息类型,其含 MSG_PING、MSG_PONG、MSG_TYPE 1 和 MSG_TYPE 2 四种消息类型。
// https://github.com/datager/yolanda/blob/master/chap-12/message_objecte.h
typedef struct {
u_int32_t type;
char data[1024];
} messageObject;
#define MSG_PING 1
#define MSG_PONG 2
#define MSG_TYPE1 11
#define MSG_TYPE2 21
client 端完全模拟 TCP Keep-Alive 的机制:当探活时间达到后,探活次数增加 1,并向 server 发 PING;此后以预设的保活时间间隔持续向 server 发 PING。若能收到 server 的应答则结束探活并将探活时间重置为 0。(这里用 select I/O 复用函数自带的定时器),代码如下:
// https://github.com/datager/yolanda/blob/master/chap-12/pingclient.c
#include "lib/common.h"
#include "message_objecte.h"
#define MAXLINE 4096
#define KEEP_ALIVE_TIME 10
#define KEEP_ALIVE_INTERVAL 3
#define KEEP_ALIVE_PROBETIMES 3
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: tcpclient <IPaddress>");
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
if (connect_rt < 0) {
error(1, errno, "connect failed ");
}
char recv_line[MAXLINE + 1];
int n;
fd_set readmask;
fd_set allreads;
struct timeval tv;
int heartbeats = 0;
tv.tv_sec = KEEP_ALIVE_TIME; // 设置了超时时间为 KEEP_ALIVE_TIME,这相当于保活时间
tv.tv_usec = 0;
messageObject messageObject;
FD_ZERO(&allreads); // 初始化 select() 的套接字
FD_SET(socket_fd, &allreads);
for (;;) {
readmask = allreads;
int rc = select(socket_fd + 1, &readmask, NULL, NULL, &tv); // 调 select() 感知 I/O 事件。这里的 I/O 事件,除了套接字上的读操作之外,还有在 39-40 行设置的超时事件。当 KEEP_ALIVE_TIME 这段时间到达之后,select() 会返回 0,于是进入 53-63 行的处理
if (rc < 0) {
error(1, errno, "select failed");
}
if (rc == 0) { // client 已在 KEEP_ALIVE_TIME 这段时间内没有收到任何对当前连接的反馈,于是发起 PING 消息,尝试问server :”喂,你还活着吗?“这里我们通过传送一个类型为 MSG_PING 的消息对象来完成 PING 操作,之后我们会看到 server 如何响应这个 PING 操作
if (++heartbeats > KEEP_ALIVE_PROBETIMES) {
error(1, 0, "connection dead\n");
}
printf("sending heartbeat #%d\n", heartbeats);
messageObject.type = htonl(MSG_PING);
rc = send(socket_fd, (char *) &messageObject, sizeof(messageObject), 0);
if (rc < 0) {
error(1, errno, "send failure");
}
tv.tv_sec = KEEP_ALIVE_INTERVAL;
continue;
}
if (FD_ISSET(socket_fd, &readmask)) { // client 接收到 server 响应后的处理。实际工作中这里需对报文进行解析后处理的:即只有是 PONG 类型的响应才认为是 PING 探活的结果(这里为了简单这里就没有再进行报文格式的转换和分析,认为只要收到 server 的报文则连接就是正常的),则会对探活计数器和探活时间都置零,等待下一次探活时间的来临。
n = read(socket_fd, recv_line, MAXLINE);
if (n < 0) {
error(1, errno, "read error");
} else if (n == 0) {
error(1, 0, "server terminated \n");
}
printf("received heartbeat, make heartbeats to 0 \n");
heartbeats = 0;
tv.tv_sec = KEEP_ALIVE_TIME;
}
}
}
server 端设计如下:其接受一个参数(此参数设置的比较大,可模拟连接无响应的情况)。
// https://github.com/datager/yolanda/blob/master/chap-12/pingserver.c
#include "lib/common.h"
#include "message_objecte.h"
static int count;
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: tcpsever <sleepingtime>");
}
int sleepingTime = atoi(argv[1]);
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(SERV_PORT);
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if (rt1 < 0) {
error(1, errno, "bind failed ");
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 < 0) {
error(1, errno, "listen failed ");
}
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
error(1, errno, "bind failed ");
}
messageObject message;
count = 0;
for (;;) {
int n = read(connfd, (char *) &message, sizeof(messageObject));
if (n < 0) {
error(1, errno, "error read");
} else if (n == 0) {
error(1, 0, "client closed \n");
}
printf("received %d bytes\n", n);
count++;
switch (ntohl(message.type)) {
case MSG_TYPE1 :
printf("process MSG_TYPE1 \n");
break;
case MSG_TYPE2 :
printf("process MSG_TYPE2 \n");
break;
case MSG_PING: { // 处理 MSG_PING 类型的消息。通过休眠来模拟响应是否及时,然后调用 send() 发一个 PONG 报文向 client 表示”还活着“的意思
messageObject pong_message;
pong_message.type = MSG_PONG;
sleep(sleepingTime);
ssize_t rc = send(connfd, (char *) &pong_message, sizeof(pong_message), 0);
if (rc < 0)
error(1, errno, "send failure");
break;
}
default :
error(1, 0, "unknown message type (%d)\n", ntohl(message.type));
}
}
}
基于上面的程序设计做两个不同的实验:
第一次实验,server 休眠时间为 60 秒。我们看到,client 在发送了三次心跳检测报文 PING 报文后,判断出连接无效,直接退出了。之所以造成这样的结果,是因为在这段时间内没有接收到来自 server 的任何 PONG 报文。当然实际工作的程序可能需要不一样的处理,比如重新发起连接。
第二次实验,让 server 休眠时间为 5 秒。我们看到,由于这一次 server 在心跳检测过程中及时地进行了响应,client 一直都会认为连接是正常的。(如下图:当 client 收到 PONG 后则重置变量 heartbeats 为 0,使得再发送的 PING 仍从 sending heartbeat #1
开始)
本文将通俗易懂的解释发送窗口、接收窗口、拥塞窗口的含义。
应用程序用 write() 或 send() 发送数据流,用这些接口并不意味着数据被真正发送到网络上,其实这些数据只是从应用程序中被拷贝到了系统内核的套接字缓冲区中等待协议栈的处理。至于这些数据是什么时候被发送出去对应用程序来说是无法预知的。对这件事情真正负责的是运行于操作系统内核的 TCP 协议栈实现模块。
可以把理想中的 TCP 协议可以想象成一队运输货物的货车,运送的货物就是 TCP 数据包,这些货车将数据包从发送端运送到接收端,就这样不断周而复始。
我们仔细想一下,货物达到接收端之后,是需要卸货处理、登记入库的,接收端限于自己的处理能力和仓库规模,是不可能让这队货车以不可控的速度发货的。接收端肯定会和发送端不断地进行信息同步,比如接收端通知发送端:“后面那 20 车你给我等等,等我这里腾出地方你再继续发货。”
其实这就是发送窗口和接收窗口的本质,我管这个叫做“TCP 的生产者 - 消费者”模型:发送窗口和接收窗口是 TCP 连接的双方,一个作为生产者,一个作为消费者,为了达到一致协同的生产消费速率的算法。
说白了,作为 TCP 发送端,即生产者,不能忽略 TCP 的接收端,也就是消费者的实际状况,不管不顾地把数据包都传送过来。若都传送过来,消费者来不及消费,必然会丢弃;而丢弃反过使得生产者又重传,发送更多的数据包,最后导致网络崩溃。
理解了“TCP 的生产者-消费者”模型,再反过来看发送窗口和接收窗口的设计目的和方式,就会恍然大悟了。
TCP 的生产者 - 消费者模型,只是在考虑单个连接的数据传递,但 TCP 数据包需经过网卡、交换机、核心路由器等一系列的网络设备,网络设备本身的能力也是有限的,当多个连接的数据包同时在网络上传送时,势必会发生带宽争抢、数据丢失等,这样 TCP 就必须考虑多个连接共享在有限的带宽上,兼顾效率和公平性的控制,这就是拥塞控制的本质。
我们可以把网络设备形成的网络信息高速公路和生活中实际的高速公路做个对比。
在 TCP 协议中,拥塞控制是通过拥塞窗口来完成的,拥塞窗口的大小会随着网络状况实时调整。
发送窗口和拥塞窗口的区别:
何一时刻,TCP 发送缓冲区的数据是否能真正发送出去,至少取决于两个因素,一个是当前的发送窗口大小,另一个是拥塞窗口大小,而 TCP 协议中总是取两者中最小值作为判断依据。比如当前发送的字节为 100,发送窗口的大小是 200,拥塞窗口的大小是 80,那么取 200 和 80 中的最小值 80,那么当前发送的字节数显然是大于拥塞窗口的则不能发出去。
注意我在前面的表述中,提到了在任何一个时刻里,TCP 发送缓冲区的数据是否能真正发送出去,用了“至少两个因素”这个说法,细心的你有没有想过这个问题,除了之前引入的发送窗口、拥塞窗口之外,还有什么其他因素吗?
我们考虑以下几个有趣的场景:
TCP 之所以复杂,就是因为 TCP 需要考虑的因素较多。像以上这几个场景,都是 TCP 需要考虑的情况,一句话概况就是如何有效地利用网络带宽。
Nagle 算法 + 延时 ACK 是很奇怪的组合。举一个例子来体会一下。
比如,client 分两次将一个请求发送出去,由于请求的第一部分的报文未被确认,Nagle 算法开始起作用;同时延时 ACK 在server 起作用,假设延时时间为 200ms,则 server 等待 200ms 后对请求的第一部分进行确认;接下来 client 收到了确认后,Nagle 算法解除请求第二部分的阻止,让第二部分得以发送出去,server 收到后处理应答并同时将第二部分的 ACK 捎带发送出去。
从上图可知,Nagle 算法 + 延时确认的组合增大了处理时延,实际上两个优化彼此在阻止对方。
因此有些情况 Nagle 算法并不适用(如时延敏感的应用)
幸运的是,我们可以通过对套接字的修改来关闭 Nagle 算法。
int on = 1;
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&on, sizeof(on));
值得注意的是,除非我们对此有十足的把握,否则不要轻易改变默认的 TCP Nagle 算法。因为在现代操作系统中,针对 Nagle 算法和延时 ACK 的优化已非常成熟了,有可能在禁用 Nagle 算法之后,性能问题反而更加严重。
其实前面的例子里,若我们能将一个请求一次性发送过去,而不是分开两部分独立发送,结果会好很多。所以,在写数据之前,将数据合并到缓冲区,批量发送出去,这是一个比较好的做法。不过,有时候数据会存储在两个不同的缓存中,对此,我们可以使用如下的方法来进行数据的读写操作,从而避免 Nagle 算法引发的副作用。
ssize_t writev(int filedes, const struct iovec *iov, int iovcnt)
ssize_t readv(int filedes, const struct iovec *iov, int iovcnt);
这两个函数的第二个参数都是指向某个 iovec 结构数组的一个指针,其中 iovec 结构定义如下:
struct iovec {
void *iov_base; /* starting address of buffer */
size_t iov_len; /* size of buffer */
};
下面的程序展示了集中写的方式:
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: tcpclient <IPaddress>");
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
if (connect_rt < 0) {
error(1, errno, "connect failed ");
}
char buf[128];
struct iovec iov[2];
char *send_one = "hello,"; // 24-33 行,使用了 iovec 数组,分别写入了两个不同的字符串,一个是“hello,”,另一个通过标准输入读入。
iov[0].iov_base = send_one; // 数组第一项
iov[0].iov_len = strlen(send_one);
iov[1].iov_base = buf; // 数组第二项
while (fgets(buf, sizeof(buf), stdin) != NULL) {
iov[1].iov_len = strlen(buf);
int n = htonl(iov[1].iov_len);
if (writev(socket_fd, iov, 2) < 0) // 用 writev() 合并发送 iov数组的两项
error(1, errno, "writev failure");
}
exit(0);
}
先启动 server,再启动上文的 client, 并在 client 依次输入“world”和“network”,随后 server 收到了 iovec 组成的新的字符串。原理其实就是调用 writev() 时,会自动把几个数组的输入合并成一个有序的字节流,然后发送给对端。
总结:
server 重启时,总是碰到 “Address in use” 的报错使 server 不能很快地重启。那么这个问题是如何产生的?我们又该如何避免呢?为了引入讨论,我们从之前讲过的一个 TCP server 开始说起:
// https://github.com/datager/yolanda/blob/master/chap-15/addressused.c
static int count;
static void sig_int(int signo) {
printf("\nreceived %d datagrams\n", count);
exit(0);
}
int main(int argc, char **argv) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(SERV_PORT);
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if (rt1 < 0) {
error(1, errno, "bind failed ");
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 < 0) {
error(1, errno, "listen failed ");
}
signal(SIGPIPE, SIG_IGN);
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
error(1, errno, "bind failed ");
}
char message[MAXLINE];
count = 0;
for (;;) {
int n = read(connfd, message, MAXLINE);
if (n < 0) {
error(1, errno, "error read");
} else if (n == 0) {
error(1, 0, "client closed \n");
}
message[n] = 0;
printf("received %d bytes: %s\n", n, message);
count++;
}
}
这个 server 绑定到一个本地 port,用的是通配地址 ANY,当连接建立后从该连接读取输入的字符流。启动 server 后,再用 telnet 向此 server 输入字符。和期望相同,server 打印出 client 的输入,在 Telnet client 端关闭连接之后,server 接收到 EOF 也顺利地关闭了连接。server 也可以很快重启,等待新的连接到来。
root@server:/home/yolanda/build/bin# ./addressused
received 3 bytes: a
received 3 bytes: b
received 3 bytes: c
client closed
root@server:/home/yolanda/build/bin# netstat -nultp | grep addressused
tcp 0 0 0.0.0.0:43211 0.0.0.0:* LISTEN 16053/addressused
root@client:/home/yolanda/build/bin# telnet -h
telnet: invalid option -- 'h'
Usage: telnet [-4] [-6] [-8] [-E] [-L] [-a] [-d] [-e char] [-l user]
[-n tracefile] [ -b addr ] [-r] [host-name [port]]
root@client:/home/yolanda/build/bin# telnet 127.0.0.1 43211
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
a
b
c
telnet> q # 用 ctrl+]然后再输入 q 来退出 telnet
Connection closed.
接下来,我们改变一下连接的关闭顺序。和前面的过程一样,先启动服务器,再使用 Telnet 作为client 登录到服务器,在屏幕上输入一些字符。注意接下来的不同,我不会在 Telnet 端关闭连接,而是直接使用 Ctrl+C 的方式在server 关闭连接。
root@k8s-master-163:/home/yolanda/build/bin# ./addressused
received 3 bytes: a
received 3 bytes: b
received 3 bytes: c
^C
root@client:/home/yolanda/build/bin# telnet 127.0.0.1 43211
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
a
b
c
Connection closed by foreign host.
我们看到,连接已被关闭,telnet client也感知连接关闭并退出了。接下来,我们尝试重启server 程序。你会发现,此时server 重启失败,报错信息为:bind failed: Address already in use。
root@k8s-master-163:/home/yolanda/build/bin# ./addressused
received 3 bytes: a
received 3 bytes: b
received 3 bytes: c
^C
root@k8s-master-163:/home/yolanda/build/bin# ./addressused
bind failed : Address already in use (98)
这个错误是由于 TIME_WAIT 造成的:当连接的一方主动关闭连接,在接收到对端的 FIN 报文之后,主动关闭连接的一方会在 TIME_WAIT 这个状态里停留一段时间,这个时间大约为 2MSL。
若此时用 netstat 去查看 server 所在主机的 TIME_WAIT 的状态连接,会发现有一个 server 生成的 TCP 连接,当前正处于 TIME_WAIT 状态。这里 9527 是本地监听端口,36650 是 telnet client 端口。当然 Telnet client 端口每次也会不尽相同。
# 启动 server:
root@server:/home/yolanda/build/bin# ./addressused
# 启动 server 后,网络状态如下。都是 LISTEN 的:
root@server:~# netstat -alepn | grep 43211
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State User Inode PID/Program name
tcp 0 0 0.0.0.0:43211 0.0.0.0:* LISTEN 0 1177104853 39821/addressused
# 启动 telnet 作为 client,连接 server:
root@server:~# netstat -alepn | grep 43211
tcp 0 0 0.0.0.0:43211 0.0.0.0:* LISTEN 0 1177104853 39821/addressused
tcp 0 0 127.0.0.1:43211 127.0.0.1:42510 ESTABLISHED 0 1177104854 39821/addressused
tcp 0 0 127.0.0.1:42510 127.0.0.1:43211 ESTABLISHED 0 1177100853 40842/telnet
# 关闭 server 后,再启动会报错 Address in used 错误,同时网络状态有 TIME_WAIT
root@server:/home/yolanda/build/bin# ./addressused
bind failed : Address already in use (98)
root@server:~# netstat -alepn | grep 43211
tcp 0 0 127.0.0.1:43211 127.0.0.1:42510 TIME_WAIT 0 0 -
server 发起的关闭连接操作,引起了一个已有的 TCP 连接处于 TME_WAIT 状态,正是这个 TIME_WAIT 的连接,使得服务器重启时,继续绑定在 127.0.0.1 地址和 9527 端口上的操作,返回了 Address already in use 的错误。
我们知道,一个 TCP 连接是通过四元组(源地址、源端口、目的地址、目的端口)来唯一确定的,若每次 telnet client 使用的本地端口都不同,就不会和已有的四元组冲突,也就不会有 TIME_WAIT 的新旧连接化身冲突的问题。
即使在很小的概率下,telnet client 用了相同的端口,从而造成新连接和旧连接的四元组相同,在现代 Linux 操作系统下也不会有什么大的问题,原因是现代 Linux 操作系统对此进行了一些优化。
在这样的优化之下,一个 TIME_WAIT 的 TCP 连接可以忽略掉旧连接,重新被新的连接所使用。
这就是重用套接字选项,通过给套接字配置可重用属性,告诉操作系统内核,这样的 TCP 连接完全可以复用 TIME_WAIT 状态的连接。代码如下:
int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
SO_REUSEADDR 套接字选项,允许启动绑定在一个端口,即使之前存在一个和该端口一样的连接。前面的例子已表明,在默认情况下,server 历经创建 socket、bind 和 listen 重启时,若试图绑定到一个现有连接上的端口,bind 操作会失败,但是若我们在创建 socket 和 bind 之间,使用上面的代码片段设置 SO_REUSEADDR 套接字选项,情况就会不同。
下面对原来的 server 代码升级,升级的部分主要在 11-12 行,在 bind() 监听套接字之前调 setsockopt() 设置重用套接字选项:
// https://github.com/datager/yolanda/blob/master/chap-15/addressused02.c
int main(int argc, char **argv) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(SERV_PORT);
int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); // SO_REUSEADDR
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if (rt1 < 0) {
error(1, errno, "bind failed ");
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 < 0) {
error(1, errno, "listen failed ");
}
signal(SIGPIPE, SIG_IGN);
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
error(1, errno, "bind failed ");
}
char message[MAXLINE];
count = 0;
for (;;) {
int n = read(connfd, message, MAXLINE);
if (n < 0) {
error(1, errno, "error read");
} else if (n == 0) {
error(1, 0, "client closed \n");
}
message[n] = 0;
printf("received %d bytes: %s\n", n, message);
count++;
}
}
重新编译过后,重复上面那个实验步骤,先启动 server,再用 telnet client 登录到服务器,在屏幕上输入一些字符,使用 Ctrl+C 的方式在 server 关闭连接。马上尝试重启 server,此时发现 server 正常启动,没再出现 Address already in use 的错误。说明修改已生效。
SO_REUSEADDR 套接字选项还有一个作用,那就是本机 server 若有多个地址,可以在不同地址上使用相同的 port 提供服务。
比如,一台 server 有 192.168.1.101 和 10.10.2.102 两个地址,我们可以在这台机器上启动三个不同的 HTTP 服务
这样配置后效果如下:
我们必须给这三个 server 都设置 SO_REUSEADDR 套接字选项,否则第二个和第三个服务调用 bind() 绑定到 80 端口时会出错。
最佳实践: server 都应该设置 SO_REUSEADDR 套接字选项,以便其在极短时间内复用同一个 port 启动。
有些人可能觉得这不是安全的。但其实单独重用一个套接字不会有任何问题:
注意:tcp_tw_reuse 的内核配置选项,和 SO_REUSEADDR 套接字选择,这两个东西一点关系也没有。
前文我们讲的都是单个 client - server的例子,可能会给你造成一种错觉,好像 TCP 是一种应答形式的数据传输过程,比如发送端一次发送 network 和 program 这样的报文,在前面的例子中,我们看到的结果基本是这样的:
这其实是一个假象,之所以会这样,是因为网络条件比较好,而且发送的数据也比较少。为了让大家理解 TCP 数据是流式的这个特性,我们分别从发送端和接收端来阐述。
第一种情况,一次性将 network 和 program 在一个 TCP 分组中发送出去,像这样:
...xxxnetworkprogramxxx...
第二种情况,program 的部分随 network 在一个 TCP 分组中发送出去,像这样:
TCP 分组 1:
...xxxxxnetworkpro
TCP 分组 2:
gramxxxxxxxxxx...
第三种情况,network 的一部分随 TCP 分组被发送出去,另一部分和 program 一起随另一个 TCP 分组发送出去,像这样。
TCP 分组 1:
...xxxxxxxxxxxnet
TCP 分组 2:
workprogramxxx...
实际上类似的组合可以枚举出无数种。不管是哪一种,核心的问题是我们不知道 network 和 program 这两个报文是如何进行 TCP 分组传输的。换言之发数据时不应假设 “数据流和 TCP 分组是一种映射关系”。就好像在前面我们似乎觉得 network 这个报文一定对应一个 TCP 分组,这是完全不正确的。
若我们再来看 client,数据流的特征更明显。接收端缓冲区保留了没有被取走的数据,随着应用程序不断从接收端缓冲区读出数据,接收端缓冲区就可以容纳更多新的数据。若我们使用 recv() 从接收端缓冲区读取数据,发送端缓冲区的数据是以字节流的方式存在的,无论发送端如何构造 TCP 分组,接收端最终受到的字节流总是像下面这样:
xxxxxxxxxxxxxxxxxnetworkprogramxxxxxxxxxxxx
关于接收端字节流,有两点需要注意:
计算机最终保存和传输,用的都是 0101 这样的二进制数据,字节流在网络上的传输,也是通过二进制来完成的。
从二进制到字节是通过编码完成的,比如著名的 ASCII 编码,通过一个字节 8 个比特对常用的西方字母进行了编码。
这里有一个有趣的问题,若需要传输数字,比如 0x0201,对应的二进制为 00000010000000001,那么两个字节的数据到底是先传 0x01,还是相反?
在计算机发展的历史上,对于如何存储这个数据没有形成标准。比如这里讲到的问题,不同的系统就会有两种存法
但是在网络传输中,必须保证双方都用同一种标准来表达,这就好比我们打电话时说的是同一种语言,否则双方不能顺畅地沟通。网络协议用的是大端字节序,我个人觉得大端字节序比较符合人类的思维习惯,你可想象手写一个多位数字,从开始往小位写,自然会先写大位,比如写 12, 1234 这个样子。
POSIX 标准提供了如下的转换函数来保证网络字节序的一致:
uint16_t htons (uint16_t hostshort)
uint16_t ntohs (uint16_t netshort)
uint32_t htonl (uint32_t hostlong)
uint32_t ntohl (uint32_t netlong)
这里函数中的 n 代表的就是 network,h 代表的是 host,s 表示的是 short,l 表示的是 long,分别表示 16 位和 32 位的整数。这些函数可帮我们在主机(host)和网络(network)的格式间灵活转换。用这些函数时并不需要关心主机到底是什么样的字节顺序,只要使用函数给定值进行网络字节序和主机字节序的转换就可以了。
你可以想象,若碰巧我们的系统本身是大端字节序,和网络字节序一样,那么使用上述所有的函数进行转换的时候,结果都仅仅是一个空实现,直接返回。比如这样:
# if __BYTE_ORDER == __BIG_ENDIAN
/* The host byte order is the same as network byte order,
so these functions are all just identity. */
# define ntohl(x) (x)
# define ntohs(x) (x)
# define htonl(x) (x)
# define htons(x) (x)
报文既然是以字节流的形式呈现给应用程序的,那么应用程序如何解读字节流呢?这就要说到报文格式和解析了。报文格式实际上定义了字节的组织形式,发送端和接收端都按照统一的报文格式进行数据传输和解析,这样就可以保证彼此能够完成交流。
报文格式最重要的是如何确定报文的边界。常见的报文格式有两种方法:
这个报文的格式很简单,首先 4 个字节大小的消息长度,其目的是将真正发送的字节流的大小显式通过报文告知接收端,接下来是 4 个字节大小的消息类型,而真正需要发送的数据则紧随其后。
client 如下:
// https://github.com/datager/yolanda/blob/master/chap-16/streamclient.c
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: tcpclient <IPaddress>");
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
if (connect_rt < 0) {
error(1, errno, "connect failed ");
}
// 图示的报文格式转化为结构体
struct {
u_int32_t message_length;
u_int32_t message_type;
char buf[128];
} message;
int n;
while (fgets(message.buf, sizeof(message.buf), stdin) != NULL) {
n = strlen(message.buf);
message.message_length = htonl(n);
message.message_type = 1;
// 实际发送的字节流大小为消息长度 4 字节,加上消息类型 4 字节,以及标准输入的字符串大小
if (send(socket_fd, (char *) &message, sizeof(message.message_length) + sizeof(message.message_type) + n, 0) < 0)
error(1, errno, "send failure");
}
exit(0);
}
server 如下:
// https://github.com/datager/yolanda/blob/master/chap-16/streamserver.c
static int count;
static void sig_int(int signo) {
printf("\nreceived %d datagrams\n", count);
exit(0);
}
int main(int argc, char **argv) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(SERV_PORT);
int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if (rt1 < 0) {
error(1, errno, "bind failed ");
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 < 0) {
error(1, errno, "listen failed ");
}
signal(SIGPIPE, SIG_IGN);
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
error(1, errno, "bind failed ");
}
char buf[128];
count = 0;
while (1) {
int n = read_message(connfd, buf, sizeof(buf));
if (n < 0) {
error(1, errno, "error read message");
} else if (n == 0) {
error(1, 0, "client closed \n");
}
buf[n] = 0;
printf("received %d bytes: %s\n", n, buf);
count++;
}
exit(0);
}
其中解析报文的read_message() 和 readn() 函数:
// 第 6 行通过调用 readn 函数获取 4 个字节的消息长度数据,紧接着,第 11 行通过调用 readn 函数获取 4 个字节的消息类型数据。第 15 行判断消息的长度是不是太大,若大到本地缓冲区不能容纳,则直接返回错误;第 19 行调用 readn 一次性读取已知长度的消息体。
size_t read_message(int fd, char *buffer, size_t length) {
u_int32_t msg_length;
u_int32_t msg_type;
int rc;
rc = readn(fd, (char *) &msg_length, sizeof(u_int32_t));
if (rc != sizeof(u_int32_t))
return rc < 0 ? -1 : 0;
msg_length = ntohl(msg_length);
rc = readn(fd, (char *) &msg_type, sizeof(msg_type));
if (rc != sizeof(u_int32_t))
return rc < 0 ? -1 : 0;
if (msg_length > length) {
return -1;
}
rc = readn(fd, buffer, msg_length);
if (rc != msg_length)
return rc < 0 ? -1 : 0;
return rc;
}
// 读取报文预设大小的字节,readn 调用会一直循环,尝试读取预设大小的字节,若接收缓冲区数据空,readn 函数会阻塞在那里,直到有数据到达。
// readn 函数中使用 count 来表示还需要读取的字符数,若 count 一直大于 0,说明还没有满足预设的字符大小,循环就会继续。第 9 行通过 read 函数来服务最多 count 个字符。11-17 行针对返回值进行出错判断,其中返回值为 0 的情形是 EOF,表示对方连接终止。19-20 行要读取的字符数减去这次读到的字符数,同时移动缓冲区指针,这样做的目的是为了确认字符数是否已读取完毕。
size_t readn(int fd, void *buffer, size_t length) {
size_t count;
ssize_t nread;
char *ptr;
ptr = buffer;
count = length;
while (count > 0) {
nread = read(fd, ptr, count);
if (nread < 0) {
if (errno == EINTR)
continue;
else
return (-1);
} else if (nread == 0)
break; /* EOF */
count -= nread;
ptr += nread;
}
return (length - count); /* return >= 0 */
}
实验如下:我们依次启动作为报文解析的服务器一端,以及作为报文发送的client 。我们看到,每次client 发送的报文都可以被server 解析出来,在标准输出上的结果验证了这一点。
HTTP 是一个非常好的例子。HTTP 通过设置回车符、换行符做为 HTTP 报文协议的边界。
因 windows 和 mac linux 的换行不一样。\r 或\r\n,所以 HTTP 报文要同时处理 \r 或\r\n。
下面的 read_line 函数就是在尝试读取一行数据,也就是读到回车符 \r,或者读到回车换行符 \r\n 为止。这个函数每次尝试读取一个字节。
int read_line(int fd, char *buf, int size) {
int i = 0;
char c = '\0';
int n;
while ((i < size - 1) && (c != '\n')) {
n = recv(fd, &c, 1, 0);
if (n > 0) {
if (c == '\r') { // 第 9 行若读到了回车符 \r
n = recv(fd, &c, 1, MSG_PEEK);
if ((n > 0) && (c == '\n')) // 接下来在 11 行的“观察”下看有没有换行符 \n
recv(fd, &c, 1, 0); // 若有就在第 12 行读取这个换行符 \n
else
c = '\n';
}
buf[i] = c; // 若没有读到回车符 \r,就在第 16-17 行将字符放到缓冲区,并移动指针。
i++;
} else
c = '\n';
}
buf[i] = '\0';
return (i);
}
你可能会认为,TCP 是一种可靠的协议,这种可靠体现在端到端的通信上。这似乎给我们带来了一种错觉,从发送端来看,应用程序通过调用 send() 发送的数据流总能可靠地到达接收端;而从接收端来看,总是可以把对端发送的数据流完整无损地传递给应用程序来处理。事实上若对 TCP 传输环节进行详细的分析,就会沮丧地发现上述论断是不正确的。
你有没有发现,TCP 协议实现并没有提供给上层应用程序过多的异常处理细节,即 TCP 协议反映链路异常的能力偏弱,这其实是有原因的。要知道 TCP 诞生之初,是为美国防部服务的,考虑到军事作战的实际需要 TCP 不希望暴露更多的异常细节,而是能够以无人值守、自我恢复的方式运作。
TCP 连接建立之后,能感知 TCP 链路的方式是有限的:
接下来,我们就看下如何通过读写操作来感知异常情况,以及对应的处理方式。实际情景有的异常情况主要有如下两大类,这两大类情况又可以根据应用程序的场景细分:
很多原因都会造成网络中断,在这种情况下,TCP 程序并不能及时感知到异常信息。除非网络中的其他设备,如路由器发出一条 ICMP 报文,说明目的网络或主机不可达,此时 read() 或 write() 就会返回 Unreachable 的错误。
当系统突然崩溃,如断电时,网络连接来不及发出任何东西。这里和通过系统调用杀死应用程序非常不同的是,没有任何 FIN 包被发送出来。
这种情况和网络中断造成的结果非常类似,在无 ICMP 报文的情况下,TCP 程序只能通过 read() 和 write() 得到网络连接异常的信息,超时错误是一个常见的结果。
不过还有一种情况需要考虑,那就是系统在崩溃之后又重启,当重传的 TCP 分组到达重启后的系统,由于系统中没有该 TCP 分组对应的连接数据,系统会返回一个 RST 重置分节,TCP 程序通过 read() 和 write() 可分别对 RST 进行错误处理。
对端若有 FIN 包发出,可能的场景是对端调用了 close() 或 shutdown() 显式地关闭了连接,也可能是对端应用程序崩溃而操作系统内核代为清理所发出的。从应用程序角度上看无法区分是哪种情形。
阻塞的 read() 在完成正常接收数据的读取之后,FIN 包会通过返回一个 EOF 来完成通知,此时 read() 返回值为 0。
为了展示这些特性,我分别编写了 server 和 client 如下:
// https://github.com/datager/yolanda/blob/master/chap-17/reliable_server01.c
// server : 是一个简单的应答程序,在收到数据流之后回显给client ,在此之前,休眠 5 秒,以便完成后面的实验验证。
int main(int argc, char **argv) {
int connfd;
char buf[1024];
connfd = tcp_server(SERV_PORT);
for (;;) {
int n = read(connfd, buf, 1024);
if (n < 0) {
error(1, errno, "error read");
} else if (n == 0) {
error(1, 0, "client closed \n");
}
sleep(5);
int write_nc = send(connfd, buf, n, 0);
printf("send bytes: %zu \n", write_nc);
if (write_nc < 0) {
error(1, errno, "error write");
}
}
exit(0);
}
// https://github.com/datager/yolanda/blob/master/chap-17/reliable_client01.c
// client : 从标准输入读入,将读入的字符串传输给server
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: reliable_client01 <IPaddress>");
}
int socket_fd = tcp_client(argv[1], SERV_PORT);
char buf[128];
int len;
int rc;
while (fgets(buf, sizeof(buf), stdin) != NULL) {
len = strlen(buf);
rc = send(socket_fd, buf, len, 0);
if (rc < 0)
error(1, errno, "write failed");
rc = read(socket_fd, buf, sizeof(buf));
if (rc < 0)
error(1, errno, "read failed");
else if (rc == 0)
error(1, 0, "peer connection closed\n");
else
fputs(buf, stdout);
}
exit(0);
}
我们依次启动 server 和 client,在 client 输入 “good” 字符后,迅速结束掉 server,这里需赶在 server 从睡眠中苏醒之前杀死 server。屏幕上打印出:peer connection closed。client 正常退出。
这说明 client 通过 read() ,感知到了 server 发的 FIN 包,于是正常退出了client 。
注意若我们的速度不够快,导致 server 从睡眠中苏醒,并成功将报文发送出来后,client 会正常显示,此时我们停留等待 stdin。若不继续通过 read 或 write() 对套接字进行读写,是无法感知 server 已关闭套接字这个事实的。
这一次,我们仍然依次启动 server 和client ,在client 输入 bad 字符之后,等待一段时间,直到client 正确显示了服务端的回应“bad”字符之后,再杀死服务器程序。client 再次输入 bad2,这时屏幕上打印出”peer connection closed“。
时序图如下:
在很多书籍和文章中,对这个程序的解读是,收到 FIN 包的 client 继续合法地向 server 发送数据,server 在无法定位该 TCP 连接信息的情况下发送了 RST 信息,client 收到 RST 并当 client 调 read() 时内核会将 RST 错误信息通知给应用程序。这是一个典型的 client 的 write() 造成异常,再通过 client 的 read() 来感知异常的样例。
# macos
$./reliable_client01 127.0.0.1
$bad
$bad
$bad2
$read failed: Connection reset by peer (54)
为了模拟这个过程,对 server 和 client 都做了如下修改:
// https://github.com/datager/yolanda/blob/master/chap-17/reliable_server02.c
// server 每次读取 1K 数据后休眠 1 秒,以模拟处理数据的过程。
int main(int argc, char **argv) {
int connfd;
char buf[1024];
int time = 0;
connfd = tcp_server(SERV_PORT);
while (1) {
int n = read(connfd, buf, 1024);
if (n < 0) {
error(1, errno, "error read");
} else if (n == 0) {
error(1, 0, "client closed \n");
}
time++;
fprintf(stdout, "1K read for %d \n", time);
usleep(1000); // 休眠一秒
}
exit(0);
}
// https://github.com/datager/yolanda/blob/master/chap-17/reliable_client02.c
// client 第 8 行注册了 SIGPIPE 的信号处理程序,第 14-22 行 client 一直循环发数据流。
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: reliable_client02 <IPaddress>");
}
int socket_fd = tcp_client(argv[1], SERV_PORT);
signal(SIGPIPE, SIG_IGN); // 注册信号处理程序
char *msg = "network programming";
ssize_t n_written;
int count = 10000000;
while (count > 0) { // 第 14-22 行 client 一直循环发数据流
n_written = send(socket_fd, msg, strlen(msg), 0);
fprintf(stdout, "send into buffer %ld \n", n_written);
if (n_written <= 0) {
error(1, errno, "send error");
return -1;
}
count--;
}
return 0;
}
若在 server 读数并处理据的过程中突然被 kill,我们会看到 client 很快也会退出,并在屏幕上打印出 “Connection reset by peer” 的提示。
这是因为server 被 kill 之后,操作系统内核会做一些清理的事情,为这个套接字发送一个 FIN 包,但是 client 在收到 FIN 包之后,没有 read() ,还是会继续往这个套接字写入数据。这是因为根据 TCP 协议,连接是双向的,收到对方的 FIN 包只意味着对方不会再发送任何消息。 在一个双方正常关闭的流程中,收到 FIN 包的一端将剩余数据发送给对面(通过一次或多次 write),然后关闭套接字。
当 client 的数据到达 server 时,server 操作系统内核发现这是一个指向关闭的套接字,该 server 会再次向 client 发送一个 RST 包,对于 client 而言若此时再执行 write() ,立即会返回一个 RST 错误信息。时序图如下:
在很多书籍和文章中,对这个实验的期望结果不是这样的。大部分的教程是这样说的:在第二次 write() 时,由于 server 无法查到对应的 TCP 连接信息,于是发了一个 RST 包给 client ,client 第二次操作时应用程序会收到一个 SIGPIPE 信号。若不捕捉这个信号则应用程序会在毫无征兆的情况下直接退出。
我在 Max OS 10.13.6 上尝试这个程序,得到的结果确实如此。你可以看到屏幕显示和时序图。
#send into buffer 19
#send into buffer -1
#send error: Broken pipe (32)
这说明,Linux4.4 的实现和类 BSD 的实现已非常不一样了。限于时间的关系,我没有仔细对比其他版本的 Linux,还不清楚是新的内核特性。但有一点是可以肯定的,「需给 SIGPIPE 注册处理函数」,通过 write() 感知 RST 的错误信息,这样可保证应用程序在 Linux 4.4 和 Mac OS 上都能正常处理异常。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。