This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
<audio id="audio" title="10 | TIME_WAIT隐藏在细节下的魔鬼" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0c/49/0cd463a790c13c3414b50d77ebcfa249.mp3"></audio>
你好我是盛延敏这是网络编程实战的第10讲欢迎回来。
在前面的基础篇里我们对网络编程涉及到的基础知识进行了梳理主要内容包括C/S编程模型、TCP协议、UDP协议和本地套接字等内容。在提高篇里我将结合我的经验引导你对TCP和UDP进行更深入的理解。
学习完提高篇之后我希望你对如何提高TCP及UDP程序的健壮性有一个全面清晰的认识从而为深入理解性能篇打下良好的基础。
在前面的基础篇里我们了解了TCP四次挥手在四次挥手的过程中发起连接断开的一方会有一段时间处于TIME_WAIT的状态你知道TIME_WAIT是用来做什么的么在面试和实战中TIME_WAIT相关的问题始终是绕不过去的一道难题。下面就请跟随我一起找出隐藏在细节下的魔鬼吧。
## TIME_WAIT发生的场景
让我们先从一例线上故障说起。在一次升级线上应用服务之后我们发现该服务的可用性变得时好时坏一段时间可以对外提供服务一段时间突然又不可以大家都百思不得其解。运维同学登录到服务所在的主机上使用netstat命令查看后才发现主机上有成千上万处于TIME_WAIT状态的连接。
经过层层剖析后我们发现罪魁祸首就是TIME_WAIT。为什么呢我们这个应用服务需要通过发起TCP连接对外提供服务。每个连接会占用一个本地端口当在高并发的情况下TIME_WAIT状态的连接过多多到把本机可用的端口耗尽应用服务对外表现的症状就是不能正常工作了。当过了一段时间之后处于TIME_WAIT的连接被系统回收并关闭后释放出本地端口可供使用应用服务对外表现为可以正常工作。这样周而复始便会出现了一会儿不可以过一两分钟又可以正常工作的现象。
那么为什么会产生这么多的TIME_WAIT连接呢
这要从TCP的四次挥手说起。
<img src="https://static001.geekbang.org/resource/image/f3/e1/f34823ce42a49e4eadaf642a75d14de1.png" alt=""><br>
TCP连接终止时主机1先发送FIN报文主机2进入CLOSE_WAIT状态并发送一个ACK应答同时主机2通过read调用获得EOF并将此结果通知应用程序进行主动关闭操作发送FIN报文。主机1在接收到FIN报文后发送ACK应答此时主机1进入TIME_WAIT状态。
主机1在TIME_WAIT停留持续时间是固定的是最长分节生命期MSLmaximum segment lifetime的两倍一般称之为2MSL。和大多数BSD派生的系统一样Linux系统里有一个硬编码的字段名称为`TCP_TIMEWAIT_LEN`其值为60秒。也就是说**Linux系统停留在TIME_WAIT的时间为固定的60秒。**
```
#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME- WAIT state, about 60 seconds */
```
过了这个时间之后主机1就进入CLOSED状态。为什么是这个时间呢你可以先想一想稍后我会给出解答。
你一定要记住一点,**只有发起连接终止的一方会进入TIME_WAIT状态**。这一点面试的时候经常会被问到。
## TIME_WAIT的作用
你可能会问为什么不直接进入CLOSED状态而要停留在TIME_WAIT这个状态
这要从两个方面来说。
首先这样做是为了确保最后的ACK能让被动关闭方接收从而帮助其正常关闭。
TCP在设计的时候做了充分的容错性设计比如TCP假设报文会出错需要重传。在这里如果图中主机1的ACK报文没有传输成功那么主机2就会重新发送FIN报文。
如果主机1没有维护TIME_WAIT状态而直接进入CLOSED状态它就失去了当前状态的上下文只能回复一个RST操作从而导致被动关闭方出现错误。
现在主机1知道自己处于TIME_WAIT的状态就可以在接收到FIN报文之后重新发出一个ACK报文使得主机2可以进入正常的CLOSED状态。
第二个理由和连接“化身”和报文迷走有关系,为了让旧连接的重复分节在网络中自然消失。
我们知道在网络中经常会发生报文经过一段时间才能到达目的地的情况产生的原因是多种多样的如路由器重启链路突然出现故障等。如果迷走报文到达时发现TCP连接四元组源IP源端口目的IP目的端口所代表的连接不复存在那么很简单这个报文自然丢弃。
我们考虑这样一个场景在原连接中断后又重新创建了一个原连接的“化身”说是化身其实是因为这个连接和原先的连接四元组完全相同如果迷失报文经过一段时间也到达那么这个报文会被误认为是连接“化身”的一个TCP分节这样就会对TCP通信产生影响。
<img src="https://static001.geekbang.org/resource/image/94/5f/945c60ae06d282dcc22ad3b868f1175f.png" alt=""><br>
所以TCP就设计出了这么一个机制经过2MSL这个时间足以让两个方向上的分组都被丢弃使得原来连接的分组在网络中都自然消失再出现的分组一定都是新化身所产生的。
划重点2MSL的时间是**从主机1接收到FIN后发送ACK开始计时的**如果在TIME_WAIT时间内因为主机1的ACK没有传输到主机2主机1又接收到了主机2重发的FIN报文那么2MSL时间将重新计时。道理很简单因为2MSL的时间目的是为了让旧连接的所有报文都能自然消亡现在主机1重新发送了ACK报文自然需要重新计时以便防止这个ACK报文对新可能的连接化身造成干扰。
## TIME_WAIT的危害
过多的TIME_WAIT的主要危害有两种。
第一是内存资源占用,这个目前看来不是太严重,基本可以忽略。
第二是对端口资源的占用一个TCP连接至少消耗一个本地端口。要知道端口资源也是有限的一般可以开启的端口为3276861000 ,也可以通过`net.ipv4.ip_local_port_range`指定如果TIME_WAIT状态过多会导致无法创建新连接。这个也是我们在一开始讲到的那个例子。
## 如何优化TIME_WAIT
在高并发的情况下如果我们想对TIME_WAIT做一些优化来解决我们一开始提到的例子该如何办呢
### net.ipv4.tcp_max_tw_buckets
一个暴力的方法是通过sysctl命令将系统值调小。这个值默认为18000当系统中处于TIME_WAIT的连接一旦超过这个值时系统就会将所有的TIME_WAIT连接状态重置并且只打印出警告信息。这个方法过于暴力而且治标不治本带来的问题远比解决的问题多不推荐使用。
### 调低TCP_TIMEWAIT_LEN重新编译系统
这个方法是一个不错的方法,缺点是需要“一点”内核方面的知识,能够重新编译内核。我想这个不是大多数人能接受的方式。
### SO_LINGER的设置
英文单词“linger”的意思为停留我们可以通过设置套接字选项来设置调用close或者shutdown关闭连接时的行为。
```
int setsockopt(int sockfd, int level, int optname, const void *optval,
        socklen_t optlen);
```
```
struct linger {
 int  l_onoff;    /* 0=off, nonzero=on */
 int  l_linger;    /* linger time, POSIX specifies units as seconds */
}
```
设置linger参数有几种可能
- 如果`l_onoff`为0那么关闭本选项。`l_linger`的值被忽略这对应了默认行为close或shutdown立即返回。如果在套接字发送缓冲区中有数据残留系统会将试着把这些数据发送出去。
- 如果`l_onoff`为非0`l_linger`值也为0那么调用close后会立该发送一个RST标志给对端该TCP连接将跳过四次挥手也就跳过了TIME_WAIT状态直接关闭。这种关闭的方式称为“强行关闭”。 在这种情况下,排队数据不会被发送,被动关闭方也不知道对端已经彻底断开。只有当被动关闭方正阻塞在`recv()`调用上时接受到RST时会立刻得到一个“connet reset by peer”的异常。
```
struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0;
setsockopt(s,SOL_SOCKET,SO_LINGER, &amp;so_linger,sizeof(so_linger));
```
- 如果`l_onoff`为非0`l_linger`的值也非0那么调用close后调用close的线程就将阻塞直到数据被发送出去或者设置的`l_linger`计时时间到。
第二种可能为跨越TIME_WAIT状态提供了一个可能不过是一个非常危险的行为不值得提倡。
### net.ipv4.tcp_tw_reuse更安全的设置
那么Linux有没有提供更安全的选择呢
当然有。这就是`net.ipv4.tcp_tw_reuse`选项。
Linux系统对于`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.
```
这段话的大意是从协议角度理解如果是安全可控的可以复用处于TIME_WAIT的套接字为新的连接所用。
那么什么是协议角度理解的安全可控呢?主要有两点:
1. 只适用于连接发起方C/S模型中的客户端
1. 对应的TIME_WAIT状态的连接创建时间超过1秒才可以被复用。
使用这个选项还有一个前提需要打开对TCP时间戳的支持`net.ipv4.tcp_timestamps=1`默认即为1
要知道TCP协议也在与时俱进RFC 1323中实现了TCP拓展规范以便保证TCP的高可用并引入了新的TCP选项两个4字节的时间戳字段用于记录TCP发送方的当前时间戳和从对端接收到的最新时间戳。由于引入了时间戳我们在前面提到的2MSL问题就不复存在了因为重复的数据包会因为时间戳过期被自然丢弃。
## 总结
在今天的内容里我讲了TCP的四次挥手重点对TIME_WAIT的产生、作用以及优化进行了讲解你需要记住以下三点
- TIME_WAIT的引入是为了让TCP报文得以自然消失同时为了让被动关闭方能够正常关闭
- 不要试图使用`SO_LINGER`设置套接字选项跳过TIME_WAIT
- 现代Linux系统引入了更安全可控的方案可以帮助我们尽可能地复用TIME_WAIT状态的连接。
## 思考题
最后按照惯例,我留两道思考题,供你消化今天的内容。
1. 最大分组MSL是TCP分组在网络中存活的最长时间你知道这个最长时间是如何达成的换句话说是怎么样的机制可以保证在MSL达到之后报文就自然消亡了呢
1. RFC 1323引入了TCP时间戳那么这需要在发送方和接收方之间定义一个统一的时钟吗
欢迎你在评论区写下你的思考如果通过这篇文章你理解了TIME_WAIT欢迎你把这篇文章分享给你的朋友或者同事一起交流学习一下。

View File

@@ -0,0 +1,357 @@
<audio id="audio" title="11 | 优雅地关闭还是粗暴地关闭 ?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/de/c2/de42764fa62843355aeb21af76ce1bc2.mp3"></audio>
你好我是盛延敏这里是网络编程实战第11讲欢迎回来。
上一讲我们讲到了TCP的四次挥手其中发起连接关闭的一方会有一段时间处于TIME_WAIT状态。那么究竟如何来发起连接关闭呢这一讲我们就来讨论一下。
我们知道一个TCP连接需要经过三次握手进入数据传输阶段最后来到连接关闭阶段。在最后的连接关闭阶段我们需要重点关注的是“半连接”状态。
因为TCP是双向的这里说的方向指的是数据流的写入-读出的方向。
比如客户端到服务器端的方向指的是客户端通过套接字接口向服务器端发送TCP报文而服务器端到客户端方向则是另一个传输方向。在绝大数情况下TCP连接都是先关闭一个方向此时另外一个方向还是可以正常进行数据传输。
举个例子客户端主动发起连接的中断将自己到服务器端的数据流方向关闭此时客户端不再往服务器端写入数据服务器端读完客户端数据后就不会再有新的报文到达。但这并不意味着TCP连接已经完全关闭很有可能的是服务器端正在对客户端的最后报文进行处理比如去访问数据库存入一些数据或者是计算出某个客户端需要的值当完成这些操作之后服务器端把结果通过套接字写给客户端我们说这个套接字的状态此时是“半关闭”的。最后服务器端才有条不紊地关闭剩下的半个连接结束这一段TCP连接的使命。
当然,我这里描述的是服务器端“优雅”地关闭了连接。如果服务器端处理不好,就会导致最后的关闭过程是“粗暴”的,达不到我们上面描述的“优雅”关闭的目标,形成的后果,很可能是服务器端处理完的信息没办法正常传送给客户端,破坏了用户侧的使用场景。
接下来我们就来看看关闭连接时,都有哪些方式呢?
## close函数
首先我们来看最常见的close函数
```
int close(int sockfd)
```
这个函数很简单对已连接的套接字执行close操作就可以若成功则为0若出错则为-1。
这个函数会对套接字引用计数减一一旦发现套接字引用计数到0就会对套接字进行彻底释放并且会关闭**TCP两个方向的数据流**。
套接字引用计数是什么意思呢因为套接字可以被多个进程共享你可以理解为我们给每个套接字都设置了一个积分如果我们通过fork的方式产生子进程套接字就会积分+1 如果我们调用一次close函数套接字积分就会-1。这就是套接字引用计数的含义。
close函数具体是如何关闭两个方向的数据流呢
在输入方向,系统内核会将该套接字设置为不可读,任何读操作都会返回异常。
在输出方向系统内核尝试将发送缓冲区的数据发送给对端并最后向对端发送一个FIN报文接下来如果再对该套接字进行写操作会返回异常。
如果对端没有检测到套接字已关闭还继续发送报文就会收到一个RST报文告诉对端“Hi, 我已经关闭了,别再给我发数据了。”
我们会发现close函数并不能帮助我们关闭连接的一个方向那么如何在需要的时候关闭一个方向呢幸运的是设计TCP协议的人帮我们想好了解决方案这就是shutdown函数。
## shutdown函数
shutdown函数的原型是这样的
```
int shutdown(int sockfd, int howto)
```
对已连接的套接字执行shutdown操作若成功则为0若出错则为-1。
howto是这个函数的设置选项它的设置有三个主要选项
- SHUT_RD(0)关闭连接的“读”这个方向对该套接字进行读操作直接返回EOF。从数据角度来看套接字上接收缓冲区已有的数据将被丢弃如果再有新的数据流到达会对数据进行ACK然后悄悄地丢弃。也就是说对端还是会接收到ACK在这种情况下根本不知道数据已经被丢弃了。
- SHUT_WR(1)关闭连接的“写”这个方向这就是常被称为”半关闭“的连接。此时不管套接字引用计数的值是多少都会直接关闭连接的写方向。套接字上发送缓冲区已有的数据将被立即发送出去并发送一个FIN报文给对端。应用程序如果对该套接字进行写操作会报错。
- SHUT_RDWR(2)相当于SHUT_RD和SHUT_WR操作各一次关闭套接字的读和写两个方向。
讲到这里不知道你是不是有和我当初一样的困惑使用SHUT_RDWR来调用shutdown不是和close基本一样吗都是关闭连接的读和写两个方向。
其实,这两个还是有差别的。
第一个差别close会关闭连接并释放所有连接对应的资源而shutdown并不会释放掉套接字和所有的资源。
第二个差别close存在引用计数的概念并不一定导致该套接字不可用shutdown则不管引用计数直接使得该套接字不可用如果有别的进程企图使用该套接字将会受到影响。
第三个差别close的引用计数导致不一定会发出FIN结束报文而shutdown则总是会发出FIN结束报文这在我们打算关闭连接通知对端的时候是非常重要的。
## 体会close和shutdown的差别
下面我们通过构建一组客户端和服务器程序来进行close和shutdown的实验。
客户端程序,从标准输入不断接收用户输入,把输入的字符串通过套接字发送给服务器端,同时,将服务器端的应答显示到标准输出上。
如果用户输入了“close”则会调用close函数关闭连接休眠一段时间等待服务器端处理后退出如果用户输入了“shutdown”调用shutdown函数关闭连接的写方向注意我们不会直接退出而是会继续等待服务器端的应答直到服务器端完成自己的操作在另一个方向上完成关闭。
在这里我们会第一次接触到select多路复用这里不展开讲你只需要记住使用select使得我们可以同时完成对连接套接字和标准输入两个I/O对象的处理。
```
# include &quot;lib/common.h&quot;
# define MAXLINE 4096
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: graceclient &lt;IPaddress&gt;&quot;);
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &amp;server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
int connect_rt = connect(socket_fd, (struct sockaddr *) &amp;server_addr, server_len);
if (connect_rt &lt; 0) {
error(1, errno, &quot;connect failed &quot;);
}
char send_line[MAXLINE], recv_line[MAXLINE + 1];
int n;
fd_set readmask;
fd_set allreads;
FD_ZERO(&amp;allreads);
FD_SET(0, &amp;allreads);
FD_SET(socket_fd, &amp;allreads);
for (;;) {
readmask = allreads;
int rc = select(socket_fd + 1, &amp;readmask, NULL, NULL, NULL);
if (rc &lt;= 0)
error(1, errno, &quot;select failed&quot;);
if (FD_ISSET(socket_fd, &amp;readmask)) {
n = read(socket_fd, recv_line, MAXLINE);
if (n &lt; 0) {
error(1, errno, &quot;read error&quot;);
} else if (n == 0) {
error(1, 0, &quot;server terminated \n&quot;);
}
recv_line[n] = 0;
fputs(recv_line, stdout);
fputs(&quot;\n&quot;, stdout);
}
if (FD_ISSET(0, &amp;readmask)) {
if (fgets(send_line, MAXLINE, stdin) != NULL) {
if (strncmp(send_line, &quot;shutdown&quot;, 8) == 0) {
FD_CLR(0, &amp;allreads);
if (shutdown(socket_fd, 1)) {
error(1, errno, &quot;shutdown failed&quot;);
}
} else if (strncmp(send_line, &quot;close&quot;, 5) == 0) {
FD_CLR(0, &amp;allreads);
if (close(socket_fd)) {
error(1, errno, &quot;close failed&quot;);
}
sleep(6);
exit(0);
} else {
int i = strlen(send_line);
if (send_line[i - 1] == '\n') {
send_line[i - 1] = 0;
}
printf(&quot;now sending %s\n&quot;, send_line);
size_t rt = write(socket_fd, send_line, strlen(send_line));
if (rt &lt; 0) {
error(1, errno, &quot;write failed &quot;);
}
printf(&quot;send bytes: %zu \n&quot;, rt);
}
}
}
}
}
```
我对这个程序的细节展开解释一下:
第一部分是套接字的创建和select初始工作
- 9-10行创建了一个TCP套接字
- 12-16行设置了连接的目标服务器IPv4地址绑定到了指定的IP和端口
- 18-22行使用创建的套接字向目标IPv4地址发起连接请求
- 30-32行为使用select做准备初始化描述字集合这部分我会在后面详细解释这里就不再深入。
第二部分是程序的主体部分从33-80行 使用select多路复用观测在连接套接字和标准输入上的I/O事件其中
- 38-48行当连接套接字上有数据可读将数据读入到程序缓冲区中。40-41行如果有异常则报错退出42-43行如果读到服务器端发送的EOF则正常退出。
- 49-77行当标准输入上有数据可读读入后进行判断。如果输入的是“shutdown”则关闭标准输入的I/O事件感知并调用shutdown函数关闭写方向如果输入的是”close“则调用close函数关闭连接64-74行处理正常的输入将回车符截掉调用write函数通过套接字将数据发送给服务器端。
服务器端程序稍微简单一点,连接建立之后,打印出接收的字节,并重新格式化后,发送给客户端。
服务器端程序有一点需要注意那就是对SIGPIPE这个信号的处理。后面我会结合程序的结果展开说明。
```
#include &quot;lib/common.h&quot;
static int count;
static void sig_int(int signo) {
printf(&quot;\nreceived %d datagrams\n&quot;, count);
exit(0);
}
int main(int argc, char **argv) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;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 *) &amp;server_addr, sizeof(server_addr));
if (rt1 &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 &lt; 0) {
error(1, errno, &quot;listen failed &quot;);
}
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 *) &amp;client_addr, &amp;client_len)) &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
char message[MAXLINE];
count = 0;
for (;;) {
int n = read(connfd, message, MAXLINE);
if (n &lt; 0) {
error(1, errno, &quot;error read&quot;);
} else if (n == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
message[n] = 0;
printf(&quot;received %d bytes: %s\n&quot;, n, message);
count++;
char send_line[MAXLINE];
sprintf(send_line, &quot;Hi, %s&quot;, message);
sleep(5);
int write_nc = send(connfd, send_line, strlen(send_line), 0);
printf(&quot;send bytes: %zu \n&quot;, write_nc);
if (write_nc &lt; 0) {
error(1, errno, &quot;error write&quot;);
}
}
}
```
服务器端程序的细节也展开解释一下:
第一部分是套接字和连接创建过程:
- 11-12行创建了一个TCP套接字
- 14-18行设置了本地服务器IPv4地址绑定到了ANY地址和指定的端口
- 20-40行使用创建的套接字以此执行bind、listen和accept操作完成连接建立。
第二部分是程序的主体通过read函数获取客户端传送来的数据流并回送给客户端
- 51-52行显示收到的字符串在56行对原字符串进行重新格式化之后调用send函数将数据发送给客户端。注意在发送之前让服务器端程序休眠了5秒以模拟服务器端处理的时间。
我们启动服务器再启动客户端依次在标准输入上输入data1、data2和close观察一段时间后我们看到
```
$./graceclient 127.0.0.1
data1
now sending data1
send bytes:5
data2
now sending data2
send bytes:5
close
```
```
$./graceserver
received 5 bytes: data1
send bytes: 9
received 5 bytes: data2
send bytes: 9
client closed
```
客户端依次发送了data1和data2服务器端也正常接收到data1和data2。在客户端close掉整个连接之后服务器端接收到SIGPIPE信号直接退出。客户端并没有收到服务器端的应答数据。
我在下面放了一张图这张图详细解释了客户端和服务器端交互的时序图。因为客户端调用close函数关闭了整个连接当服务器端发送的“Hi, data1”分组到底时客户端给回送一个RST分组服务器端再次尝试发送“Hi, data2”第二个应答分组时系统内核通知SIGPIPE信号。这是因为在RST的套接字进行写操作会直接触发SIGPIPE信号。
这回知道你的程序莫名其妙终止的原因了吧。
<img src="https://static001.geekbang.org/resource/image/f2/9a/f283b804c7e33e25a900fedc8c36f09a.png" alt=""><br>
我们可以像这样注册一个信号处理函数对SIGPIPE信号进行处理避免程序莫名退出
```
static void sig_pipe(int signo) {
printf(&quot;\nreceived %d datagrams\n&quot;, count);
exit(0);
}
signal(SIGINT, sig_pipe);
```
接下来再次启动服务器再启动客户端依次在标准输入上输入data1、data2和shutdown函数观察一段时间后我们看到
```
$./graceclient 127.0.0.1
data1
now sending data1
send bytes:5
data2
now sending data2
send bytes:5
shutdown
Hi, data1
Hidata2
server terminated
```
```
$./graceserver
received 5 bytes: data1
send bytes: 9
received 5 bytes: data2
send bytes: 9
client closed
```
和前面的结果不同服务器端输出了data1、data2客户端也输出了“Hi,data1”和“Hi,data2”客户端和服务器端各自完成了自己的工作后正常退出。
我们再看下客户端和服务器端交互的时序图。因为客户端调用shutdown函数只是关闭连接的一个方向服务器端到客户端的这个方向还可以继续进行数据的发送和接收所以“Hi,data1”和“Hi,data2”都可以正常传送当服务器端读到EOF时立即向客户端发送了FIN报文客户端在read函数中感知了EOF也进行了正常退出。
## 总结
在这一讲中我们讲述了close函数关闭连接的方法使用close函数关闭连接有两个需要明确的地方。
- close函数只是把套接字引用计数减1未必会立即关闭连接
- close函数如果在套接字引用计数达到0时立即终止读和写两个方向的数据传送。
基于这两个确定在期望关闭连接其中一个方向时应该使用shutdown函数。
## 思考题
和往常一样,给你留两道思考题。
第一道题,你可以看到在今天的服务器端程序中,直接调用`exit(0)`完成了FIN报文的发送这是为什么呢为什么不调用close函数或shutdown函数呢
第二道题关于关于信号量处理,今天的程序中,使用的是`SIG_IGN`默认处理,你知道默认处理和自定义函数处理的区别吗?不妨查查资料,了解一下。
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起来交流。

View File

@@ -0,0 +1,350 @@
<audio id="audio" title="12 | 连接无效使用Keep-Alive还是应用心跳来检测" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/58/60/586941b4a8a67086dc4151bf00541d60.mp3"></audio>
你好我是盛延敏这里是网络编程实战第12讲欢迎回来。
上一篇文章中我们讲到了如何使用close和shutdown来完成连接的关闭在大多数情况下我们会优选shutdown来完成对连接一个方向的关闭待对端处理完之后再完成另外一个方向的关闭。
在很多情况下,连接的一端需要一直感知连接的状态,如果连接无效了,应用程序可能需要报错,或者重新发起连接等。
在这一篇文章中,我将带你体验一下对连接状态的检测,并提供检测连接状态的最佳实践。
## 从一个例子开始
让我们用一个例子开始今天的话题。
我之前做过一个基于NATS消息系统的项目多个消息的提供者 pub和订阅者sub都连到NATS消息系统通过这个系统来完成消息的投递和订阅处理。
突然有一天线上报了一个故障一个流程不能正常处理。经排查发现消息正确地投递到了NATS服务端但是消息订阅者没有收到该消息也没能做出处理导致流程没能进行下去。
通过观察消息订阅者后发现消息订阅者到NATS服务端的连接虽然显示是“正常”的但实际上这个连接已经是无效的了。为什么呢这是因为NATS服务器崩溃过NATS服务器和消息订阅者之间的连接中断FIN包由于异常情况没能够正常到达消息订阅者这样造成的结果就是消息订阅者一直维护着一个“过时的”连接不会收到NATS服务器发送来的消息。
这个故障的根本原因在于作为NATS服务器的客户端消息订阅者没有及时对连接的有效性进行检测这样就造成了问题。
保持对连接有效性的检测,是我们在实战中必须要注意的一个点。
## TCP Keep-Alive选项
很多刚接触TCP编程的人会惊讶地发现在没有数据读写的“静默”的连接上是没有办法发现TCP连接是有效还是无效的。比如客户端突然崩溃服务器端可能在几天内都维护着一个无用的 TCP连接。前面提到的例子就是这样的一个场景。
那么有没有办法开启类似的“轮询”机制让TCP告诉我们连接是不是“活着”的呢
这就是TCP保持活跃机制所要解决的问题。实际上TCP有一个保持活跃的机制叫做Keep-Alive。
这个机制的原理是这样的:
定义一个时间段在这个时间段内如果没有任何连接相关的活动TCP保活机制会开始作用每隔一个时间间隔发送一个探测报文该探测报文包含的数据非常少如果连续几个探测报文都没有得到响应则认为当前的TCP连接已经死亡系统内核将错误信息通知给上层应用程序。
上述的可定义变量分别被称为保活时间、保活时间间隔和保活探测次数。在Linux系统中这些变量分别对应sysctl变量`net.ipv4.tcp_keepalive_time``net.ipv4.tcp_keepalive_intvl``net.ipv4.tcp_keepalve_probes`默认设置是7200秒2小时、75秒和9次探测。
如果开启了TCP保活需要考虑以下几种情况
第一种对端程序是正常工作的。当TCP保活的探测报文发送给对端, 对端会正常响应这样TCP保活时间会被重置等待下一个TCP保活时间的到来。
第二种对端程序崩溃并重启。当TCP保活的探测报文发送给对端后对端是可以响应的但由于没有该连接的有效信息会产生一个RST报文这样很快就会发现TCP连接已经被重置。
第三种是对端程序崩溃或对端由于其他原因导致报文不可达。当TCP保活的探测报文发送给对端后石沉大海没有响应连续几次达到保活探测次数后TCP会报告该TCP连接已经死亡。
TCP保活机制默认是关闭的当我们选择打开时可以分别在连接的两个方向上开启也可以单独在一个方向上开启。如果开启服务器端到客户端的检测就可以在客户端非正常断连的情况下清除在服务器端保留的“脏数据”而开启客户端到服务器端的检测就可以在服务器无响应的情况下重新发起连接。
为什么TCP不提供一个频率很好的保活机制呢我的理解是早期的网络带宽非常有限如果提供一个频率很高的保活机制对有限的带宽是一个比较严重的浪费。
## 应用层探活
如果使用TCP自身的keep-Alive机制在Linux系统中最少需要经过2小时11分15秒才可以发现一个“死亡”连接。这个时间是怎么计算出来的呢其实是通过2小时加上75秒乘以9的总和。实际上对很多对时延要求敏感的系统中这个时间间隔是不可接受的。
所以,必须在应用程序这一层来寻找更好的解决方案。
我们可以通过在应用程序中模拟TCP Keep-Alive机制来完成在应用层的连接探活。
我们可以设计一个PING-PONG的机制需要保活的一方比如客户端在保活时间达到后发起对连接的PING操作如果服务器端对PING操作有回应则重新设置保活时间否则对探测次数进行计数如果最终探测次数达到了保活探测次数预先设置的值之后则认为连接已经无效。
这里有两个比较关键的点:
第一个是需要使用定时器这可以通过使用I/O复用自身的机制来实现第二个是需要设计一个PING-PONG的协议。
下面我们尝试来完成这样的一个设计。
### 消息格式设计
我们的程序是客户端来发起保活为此定义了一个消息对象。你可以看到这个消息对象这个消息对象是一个结构体前4个字节标识了消息类型为了简单这里设计了`MSG_PING``MSG_PONG``MSG_TYPE 1``MSG_TYPE 2`四种消息类型。
```
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
```
### 客户端程序设计
客户端完全模拟TCP Keep-Alive的机制在保活时间达到后探活次数增加1同时向服务器端发送PING格式的消息此后以预设的保活时间间隔不断地向服务器端发送PING格式的消息。如果能收到服务器端的应答则结束保活将保活时间置为0。
这里我们使用select I/O复用函数自带的定时器select函数将在后面详细介绍。
```
#include &quot;lib/common.h&quot;
#include &quot;message_objecte.h&quot;
#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, &quot;usage: tcpclient &lt;IPaddress&gt;&quot;);
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &amp;server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
int connect_rt = connect(socket_fd, (struct sockaddr *) &amp;server_addr, server_len);
if (connect_rt &lt; 0) {
error(1, errno, &quot;connect failed &quot;);
}
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;
tv.tv_usec = 0;
messageObject messageObject;
FD_ZERO(&amp;allreads);
FD_SET(socket_fd, &amp;allreads);
for (;;) {
readmask = allreads;
int rc = select(socket_fd + 1, &amp;readmask, NULL, NULL, &amp;tv);
if (rc &lt; 0) {
error(1, errno, &quot;select failed&quot;);
}
if (rc == 0) {
if (++heartbeats &gt; KEEP_ALIVE_PROBETIMES) {
error(1, 0, &quot;connection dead\n&quot;);
}
printf(&quot;sending heartbeat #%d\n&quot;, heartbeats);
messageObject.type = htonl(MSG_PING);
rc = send(socket_fd, (char *) &amp;messageObject, sizeof(messageObject), 0);
if (rc &lt; 0) {
error(1, errno, &quot;send failure&quot;);
}
tv.tv_sec = KEEP_ALIVE_INTERVAL;
continue;
}
if (FD_ISSET(socket_fd, &amp;readmask)) {
n = read(socket_fd, recv_line, MAXLINE);
if (n &lt; 0) {
error(1, errno, &quot;read error&quot;);
} else if (n == 0) {
error(1, 0, &quot;server terminated \n&quot;);
}
printf(&quot;received heartbeat, make heartbeats to 0 \n&quot;);
heartbeats = 0;
tv.tv_sec = KEEP_ALIVE_TIME;
}
}
}
```
这个程序主要分成三大部分:
第一部分为套接字的创建和连接建立:
- 15-16行创建了TCP套接字
- 18-22行创建了IPv4目标地址其实就是服务器端地址注意这里使用的是传入参数作为服务器地址
- 24-28行向服务器端发起连接。
第二部分为select定时器准备
- 39-40行设置了超时时间为KEEP_ALIVE_TIME这相当于保活时间
- 44-45行初始化select函数的套接字。
最重要的为第三部分,这一部分需要处理心跳报文:
- 48行调用select函数感知I/O事件。这里的I/O事件除了套接字上的读操作之外还有在39-40行设置的超时事件。当KEEP_ALIVE_TIME这段时间到达之后select函数会返回0于是进入53-63行的处理
- 在53-63行客户端已经在KEEP_ALIVE_TIME这段时间内没有收到任何对当前连接的反馈于是发起PING消息尝试问服务器端“喂你还活着吗”这里我们通过传送一个类型为MSG_PING的消息对象来完成PING操作之后我们会看到服务器端程序如何响应这个PING操作
- 第65-74行是客户端在接收到服务器端程序之后的处理。为了简单这里就没有再进行报文格式的转换和分析。在实际的工作中这里其实是需要对报文进行解析后处理的只有是PONG类型的回应我们才认为是PING探活的结果。这里认为既然收到服务器端的报文那么连接就是正常的所以会对探活计数器和探活时间都置零等待下一次探活时间的来临。
### 服务器端程序设计
服务器端的程序接受一个参数这个参数设置的比较大可以模拟连接没有响应的情况。服务器端程序在接收到客户端发送来的各种消息后进行处理其中如果发现是PING类型的消息在休眠一段时间后回复一个PONG消息告诉客户端“嗯我还活着。”当然如果这个休眠时间很长的话那么客户端就无法快速知道服务器端是否存活这是我们模拟连接无响应的一个手段而已实际情况下应该是系统崩溃或者网络异常。
```
#include &quot;lib/common.h&quot;
#include &quot;message_objecte.h&quot;
static int count;
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: tcpsever &lt;sleepingtime&gt;&quot;);
}
int sleepingTime = atoi(argv[1]);
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;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 *) &amp;server_addr, sizeof(server_addr));
if (rt1 &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 &lt; 0) {
error(1, errno, &quot;listen failed &quot;);
}
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &amp;client_addr, &amp;client_len)) &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
messageObject message;
count = 0;
for (;;) {
int n = read(connfd, (char *) &amp;message, sizeof(messageObject));
if (n &lt; 0) {
error(1, errno, &quot;error read&quot;);
} else if (n == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
printf(&quot;received %d bytes\n&quot;, n);
count++;
switch (ntohl(message.type)) {
case MSG_TYPE1 :
printf(&quot;process MSG_TYPE1 \n&quot;);
break;
case MSG_TYPE2 :
printf(&quot;process MSG_TYPE2 \n&quot;);
break;
case MSG_PING: {
messageObject pong_message;
pong_message.type = MSG_PONG;
sleep(sleepingTime);
ssize_t rc = send(connfd, (char *) &amp;pong_message, sizeof(pong_message), 0);
if (rc &lt; 0)
error(1, errno, &quot;send failure&quot;);
break;
}
default :
error(1, 0, &quot;unknown message type (%d)\n&quot;, ntohl(message.type));
}
}
}
```
服务器端程序主要分为两个部分。
第一部分为监听过程的建立包括7-38行 第13-14行先创建一个本地TCP监听套接字16-20行绑定该套接字到本地端口和ANY地址上第27-38行分别调用listen和accept完成被动套接字转换和监听。
第二部分为43行到77行从建立的连接套接字上读取数据解析报文根据消息类型进行不同的处理。
- 55-57行为处理MSG_TYPE1的消息
- 59-61行为处理MSG_TYPE2的消息
- 重点是64-72行处理MSG_PING类型的消息。通过休眠来模拟响应是否及时然后调用send函数发送一个PONG报文向客户端表示“还活着”的意思
- 74行为异常处理因为消息格式不认识所以程序出错退出。
## 实验
基于上面的程序设计,让我们分别做两个不同的实验:
第一次实验服务器端休眠时间为60秒。
我们看到客户端在发送了三次心跳检测报文PING报文后判断出连接无效直接退出了。之所以造成这样的结果是因为在这段时间内没有接收到来自服务器端的任何PONG报文。当然实际工作的程序可能需要不一样的处理比如重新发起连接。
```
$./pingclient 127.0.0.1
sending heartbeat #1
sending heartbeat #2
sending heartbeat #3
connection dead
```
```
$./pingserver 60
received 1028 bytes
received 1028 bytes
```
第二次实验我们让服务器端休眠时间为5秒。
我们看到,由于这一次服务器端在心跳检测过程中,及时地进行了响应,客户端一直都会认为连接是正常的。
```
$./pingclient 127.0.0.1
sending heartbeat #1
sending heartbeat #2
received heartbeat, make heartbeats to 0
received heartbeat, make heartbeats to 0
sending heartbeat #1
sending heartbeat #2
received heartbeat, make heartbeats to 0
received heartbeat, make heartbeats to 0
```
```
$./pingserver 5
received 1028 bytes
received 1028 bytes
received 1028 bytes
received 1028 bytes
```
## 总结
通过今天的文章我们能看到虽然TCP没有提供系统的保活能力让应用程序可以方便地感知连接的存活但是我们可以在应用程序里灵活地建立这种机制。一般来说这种机制的建立依赖于系统定时器以及恰当的应用层报文协议。比如使用心跳包就是这样一种保持Keep Alive的机制。
## 思考题
和往常一样,我留两道思考题:
你可以看到今天的内容主要是针对TCP的探活那么你觉得这样的方法是否同样适用于UDP呢
第二道题是有人说额外的探活报文占用了有限的带宽对此你是怎么想的呢而且为什么需要多次探活才能决定一个TCP连接是否已经死亡呢
欢迎你在评论区写下你的思考,我会和你一起交流。也欢迎把这篇文章分享给你的朋友或者同事,与他们一起讨论一下这两个问题吧。

View File

@@ -0,0 +1,186 @@
<audio id="audio" title="13 | 小数据包应对之策理解TCP协议中的动态数据传输" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ea/9d/eaa3479a08d67268f51167ca5156fd9d.mp3"></audio>
你好我是盛延敏这里是网络编程实战第13讲欢迎回来。
在上一篇文章里我在应用程序中模拟了TCP Keep-Alive机制完成TCP心跳检测达到发现不活跃连接的目的。在这一讲里我们将从TCP角度看待数据流的发送和接收。
如果你学过计算机网络的话,那么对于发送窗口、接收窗口、拥塞窗口等名词肯定不会陌生,它们各自解决的是什么问题,又是如何解决的?在今天的文章里,我希望能从一个更加通俗易懂的角度进行剖析。
## 调用数据发送接口以后……
在前面的内容中我们已经熟悉如何通过套接字发送数据比如使用write或者send方法来进行数据流的发送。
我们已经知道,**调用这些接口并不意味着数据被真正发送到网络上,其实,这些数据只是从应用程序中被拷贝到了系统内核的套接字缓冲区中,或者说是发送缓冲区中**等待协议栈的处理。至于这些数据是什么时候被发送出去的对应用程序来说是无法预知的。对这件事情真正负责的是运行于操作系统内核的TCP协议栈实现模块。
## 流量控制和生产者-消费者模型
我们可以把理想中的TCP协议可以想象成一队运输货物的货车运送的货物就是TCP数据包这些货车将数据包从发送端运送到接收端就这样不断周而复始。
我们仔细想一下货物达到接收端之后是需要卸货处理、登记入库的接收端限于自己的处理能力和仓库规模是不可能让这队货车以不可控的速度发货的。接收端肯定会和发送端不断地进行信息同步比如接收端通知发送端“后面那20车你给我等等等我这里腾出地方你再继续发货。”
其实这就是发送窗口和接收窗口的本质我管这个叫做“TCP的生产者-消费者”模型。
发送窗口和接收窗口是TCP连接的双方一个作为生产者一个作为消费者为了达到一致协同的生产-消费速率、而产生的算法模型实现。
说白了作为TCP发送端也就是生产者不能忽略TCP的接收端也就是消费者的实际状况不管不顾地把数据包都传送过来。如果都传送过来消费者来不及消费必然会丢弃而丢弃反过来使得生产者又重传发送更多的数据包最后导致网络崩溃。
我想理解了“TCP的生产者-消费者”模型,再反过来看发送窗口和接收窗口的设计目的和方式,我们就会恍然大悟了。
## 拥塞控制和数据传输
TCP的生产者-消费者模型,只是在考虑单个连接的数据传递,但是, TCP数据包是需要经过网卡、交换机、核心路由器等一系列的网络设备的网络设备本身的能力也是有限的当多个连接的数据包同时在网络上传送时势必会发生带宽争抢、数据丢失等这样**TCP就必须考虑多个连接共享在有限的带宽上兼顾效率和公平性的控制**,这就是拥塞控制的本质。
举个形象一点的例子,有一个货车行驶在半夜三点的大路上,这样的场景是断然不需要拥塞控制的。
我们可以把网络设备形成的网络信息高速公路和生活中实际的高速公路做个对比。正是因为有多个TCP连接形成了高速公路上的多队运送货车高速公路上开始变得熙熙攘攘这个时候就需要拥塞控制的接入了。
在TCP协议中拥塞控制是通过拥塞窗口来完成的拥塞窗口的大小会随着网络状况实时调整。
拥塞控制常用的算法有“慢启动”它通过一定的规则慢慢地将网络发送数据的速率增加到一个阈值。超过这个阈值之后慢启动就结束了另一个叫做“拥塞避免”的算法登场。在这个阶段TCP会不断地探测网络状况并随之不断调整拥塞窗口的大小。
现在你可以发现在任何一个时刻TCP发送缓冲区的数据是否能真正发送出去**至少**取决于两个因素,一个是**当前的发送窗口大小**,另一个是**拥塞窗口大小**而TCP协议中总是取两者中最小值作为判断依据。比如当前发送的字节为100发送窗口的大小是200拥塞窗口的大小是80那么取200和80中的最小值就是80当前发送的字节数显然是大于拥塞窗口的结论就是不能发送出去。
这里千万要分清楚发送窗口和拥塞窗口的区别。
发送窗口反应了作为单TCP连接、点对点之间的流量控制模型它是需要和接收端一起共同协调来调整大小的而拥塞窗口则是反应了作为多个TCP连接共享带宽的拥塞控制模型它是发送端独立地根据网络状况来动态调整的。
## 一些有趣的场景
注意我在前面的表述中提到了在任何一个时刻里TCP发送缓冲区的数据是否能真正发送出去用了“至少两个因素”这个说法细心的你有没有想过这个问题除了之前引入的发送窗口、拥塞窗口之外还有什么其他因素吗
我们考虑以下几个有趣的场景:
第一个场景接收端处理得急不可待比如刚刚读入了100个字节就告诉发送端“喂我已经读走100个字节了你继续发”在这种情况下你觉得发送端应该怎么做呢
第二个场景是所谓的“交互式”场景比如我们使用telnet登录到一台服务器上或者使用SSH和远程的服务器交互这种情况下我们在屏幕上敲打了一个命令等待服务器返回结果这个过程需要不断和服务器端进行数据传输。这里最大的问题是每次传输的数据可能都非常小比如敲打的命令“pwd”仅仅三个字符。这意味着什么这就好比每次叫了一辆大货车只送了一个小水壶。在这种情况下你又觉得发送端该怎么做才合理呢
第三个场景是从接收端来说的。我们知道接收端需要对每个接收到的TCP分组进行确认也就是发送ACK报文但是ACK报文本身是不带数据的分段如果一直这样发送大量的ACK报文就会消耗大量的带宽。之所以会这样是因为TCP报文、IP报文固有的消息头是不可或缺的比如两端的地址、端口号、时间戳、序列号等信息 在这种情形下,你觉得合理的做法是什么?
TCP之所以复杂就是因为TCP需要考虑的因素较多。像以上这几个场景都是TCP需要考虑的情况一句话概况就是如何有效地利用网络带宽。
第一个场景也被叫做糊涂窗口综合症这个场景需要在接收端进行优化。也就是说接收端不能在接收缓冲区空出一个很小的部分之后就急吼吼地向发送端发送窗口更新通知而是需要在自己的缓冲区大到一个合理的值之后再向发送端发送窗口更新通知。这个合理的值由对应的RFC规范定义。
第二个场景需要在发送端进行优化。这个优化的算法叫做Nagle算法Nagle算法的本质其实就是限制大批量的小数据包同时发送为此它提出在任何一个时刻未被确认的小数据包不能超过一个。这里的小数据包指的是长度小于最大报文段长度MSS的TCP分组。这样发送端就可以把接下来连续的几个小数据包存储起来等待接收到前一个小数据包的ACK分组之后再将数据一次性发送出去。
第三个场景也是需要在接收端进行优化这个优化的算法叫做延时ACK。延时ACK在收到数据后并不马上回复而是累计需要发送的ACK报文等到有数据需要发送给对端时将累计的ACK**捎带一并发送出去**。当然延时ACK机制不能无限地延时下去否则发送端误认为数据包没有发送成功引起重传反而会占用额外的网络带宽。
## 禁用Nagle算法
有没有发现一个很奇怪的组合即Nagle算法和延时ACK的组合。
这个组合为什么奇怪呢?我举一个例子你来体会一下。
比如客户端分两次将一个请求发送出去由于请求的第一部分的报文未被确认Nagle算法开始起作用同时延时ACK在服务器端起作用假设延时时间为200ms服务器等待200ms后对请求的第一部分进行确认接下来客户端收到了确认后Nagle算法解除请求第二部分的阻止让第二部分得以发送出去服务器端在收到之后进行处理应答同时将第二部分的确认捎带发送出去。
<img src="https://static001.geekbang.org/resource/image/42/eb/42073ad07805783add96ee87aeee8aeb.png" alt=""><br>
你从这张图中可以看到Nagle算法和延时确认组合在一起增大了处理时延实际上两个优化彼此在阻止对方。
从上面的例子可以看到在有些情况下Nagle算法并不适用 比如对时延敏感的应用。
幸运的是我们可以通过对套接字的修改来关闭Nagle算法。
```
int on = 1;
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&amp;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, &quot;usage: tcpclient &lt;IPaddress&gt;&quot;);
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &amp;server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
int connect_rt = connect(socket_fd, (struct sockaddr *) &amp;server_addr, server_len);
if (connect_rt &lt; 0) {
error(1, errno, &quot;connect failed &quot;);
}
char buf[128];
struct iovec iov[2];
char *send_one = &quot;hello,&quot;;
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) &lt; 0)
error(1, errno, &quot;writev failure&quot;);
}
exit(0);
}
```
这个程序的前半部分创建套接字建立连接就不再赘述了。关键的是24-33行使用了iovec数组分别写入了两个不同的字符串一个是“hello,”,另一个通过标准输入读入。
在启动该程序之前我们需要启动服务器端程序在客户端依次输入“world”和“network”
```
world
network
```
接下来我们可以看到服务器端接收到了iovec组成的新的字符串。这里的原理其实就是在调用writev操作时会自动把几个数组的输入合并成一个有序的字节流然后发送给对端。
```
received 12 bytes: hello,world
received 14 bytes: hello,network
```
## 总结
今天的内容我重点讲述了TCP流量控制的生产者-消费者模型,你需要记住以下几点:
- 发送窗口用来控制发送和接收端的流量;阻塞窗口用来控制多条连接公平使用的有限带宽。
- 小数据包加剧了网络带宽的浪费为了解决这个问题引入了如Nagle算法、延时ACK等机制。
- 在程序设计层面不要多次频繁地发送小报文如果有可以使用writev批量发送。
## 思考题
和往常一样,留两道思考题:
针对最后呈现的writev函数你可以查一查Linux下一次性最多允许数组的大小是多少
另外TCP拥塞控制算法是一个非常重要的研究领域请你查阅下最新的有关这方面的研究看看有没有新的发现
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起来交流。

View File

@@ -0,0 +1,336 @@
<audio id="audio" title="14丨UDP也可以是“已连接”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fe/20/fe209d5ee5eee6cab978d3f2887c3920.mp3"></audio>
你好我是盛延敏这里是网络编程实战的第14讲欢迎回来。
在前面的基础篇中我们已经接触到了UDP数据报协议相关的知识在我们的脑海里已经深深印上了“**UDP 等于无连接协议**”的特性。那么看到这一讲的题目你是不是觉得有点困惑没关系和我一起进入“已连接”的UDP的世界回头再看这个标题相信你就会恍然大悟。
## 从一个例子开始
我们先从一个客户端例子开始在这个例子中客户端在UDP套接字上调用connect函数之后将标准输入的字符串发送到服务器端并从服务器端接收处理后的报文。当然向服务器端发送和接收报文是通过调用函数sendto和recvfrom来完成的。
```
#include &quot;lib/common.h&quot;
# define MAXLINE 4096
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: udpclient1 &lt;IPaddress&gt;&quot;);
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &amp;server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
if (connect(socket_fd, (struct sockaddr *) &amp;server_addr, server_len)) {
error(1, errno, &quot;connect failed&quot;);
}
struct sockaddr *reply_addr;
reply_addr = malloc(server_len);
char send_line[MAXLINE], recv_line[MAXLINE + 1];
socklen_t len;
int n;
while (fgets(send_line, MAXLINE, stdin) != NULL) {
int i = strlen(send_line);
if (send_line[i - 1] == '\n') {
send_line[i - 1] = 0;
}
printf(&quot;now sending %s\n&quot;, send_line);
size_t rt = sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &amp;server_addr, server_len);
if (rt &lt; 0) {
error(1, errno, &quot;sendto failed&quot;);
}
printf(&quot;send bytes: %zu \n&quot;, rt);
len = 0;
recv_line[0] = 0;
n = recvfrom(socket_fd, recv_line, MAXLINE, 0, reply_addr, &amp;len);
if (n &lt; 0)
error(1, errno, &quot;recvfrom failed&quot;);
recv_line[n] = 0;
fputs(recv_line, stdout);
fputs(&quot;\n&quot;, stdout);
}
exit(0);
}
```
我对这个程序做一个简单的解释:
- 9-10行创建了一个UDP套接字
- 12-16行创建了一个IPv4地址绑定到指定端口和IP
- **20-22行调用connect将UDP套接字和IPv4地址进行了“绑定”这里connect函数的名称有点让人误解其实可能更好的选择是叫做setpeername**
- 31-55行是程序的主体读取标准输入字符串后调用sendto发送给对端之后调用recvfrom等待对端的响应并把对端响应信息打印到标准输出。
在没有开启服务端的情况下,我们运行一下这个程序:
```
$ ./udpconnectclient 127.0.0.1
g1
now sending g1
send bytes: 2
recvfrom failed: Connection refused (111)
```
看到这里你会不会觉得很奇怪不是说好UDP是“无连接”的协议吗不是说好UDP客户端只会阻塞在recvfrom这样的调用上吗怎么这里冒出一个“Connection refused”的错误呢
别着急,下面就跟着我的思路慢慢去解开这个谜团。
## UDP connect的作用
从前面的例子中你会发现我们可以对UDP套接字调用connect函数但是和TCP connect调用引起TCP三次握手建立TCP有效连接不同UDP connect函数的调用并不会引起和服务器目标端的网络交互也就是说并不会触发所谓的“握手”报文发送和应答。
那么对UDP套接字进行connect操作到底有什么意义呢
其实上面的例子已经给出了答案,这主要是为了让应用程序能够接收“异步错误”的信息。
如果我们回想一下第6篇不调用connect操作的客户端程序在服务器端不开启的情况下客户端程序是不会报错的程序只会阻塞在recvfrom上等待返回或者超时
在这里我们通过对UDP套接字进行connect操作将UDP套接字建立了“上下文”该套接字和服务器端的地址和端口产生了联系正是这种绑定关系给了操作系统内核必要的信息能够将操作系统内核收到的信息和对应的套接字进行关联。
我们可以展开讨论一下。
事实上当我们调用sendto或者send操作函数时应用程序报文被发送我们的应用程序返回操作系统内核接管了该报文之后操作系统开始尝试往对应的地址和端口发送因为对应的地址和端口不可达一个ICMP报文会返回给操作系统内核该ICMP报文含有目的地址和端口等信息。
如果我们不进行connect操作建立UDP套接字——目的地址+端口之间的映射关系操作系统内核就没有办法把ICMP不可达的信息和UDP套接字进行关联也就没有办法将ICMP信息通知给应用程序。
如果我们进行了connect操作帮助操作系统内核从容建立了UDP套接字——目的地址+端口之间的映射关系当收到一个ICMP不可达报文时操作系统内核可以从映射表中找出是哪个UDP套接字拥有该目的地址和端口别忘了套接字在操作系统内部是全局唯一的当我们在该套接字上再次调用recvfrom或recv方法时就可以收到操作系统内核返回的“Connection Refused”的信息。
## 收发函数
在对UDP进行connect之后关于收发函数的使用很多书籍是这样推荐的
- 使用send或write函数来发送如果使用sendto需要把相关的to地址信息置零
- 使用recv或read函数来接收如果使用recvfrom需要把对应的from地址信息置零。
其实不同的UNIX实现对此表现出来的行为不尽相同。
在我的Linux 4.4.0环境中使用sendto和recvfrom系统会自动忽略to和from信息。在我的macOS 10.13中确实需要遵守这样的规定使用sendto或recvfrom会得到一些奇怪的结果切回send和recv后正常。
考虑到兼容性,我们也推荐这些常规做法。所以在接下来的程序中,我会使用这样的做法来实现。
## 服务器端connect的例子
一般来说服务器端不会主动发起connect操作因为一旦如此服务器端就只能响应一个客户端了。不过有时候也不排除这样的情形一旦一个客户端和服务器端发送UDP报文之后该服务器端就要服务于这个唯一的客户端。
一个类似的服务器端程序如下:
```
#include &quot;lib/common.h&quot;
static int count;
static void recvfrom_int(int signo) {
printf(&quot;\nreceived %d datagrams\n&quot;, count);
exit(0);
}
int main(int argc, char **argv) {
int socket_fd;
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;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(socket_fd, (struct sockaddr *) &amp;server_addr, sizeof(server_addr));
socklen_t client_len;
char message[MAXLINE];
message[0] = 0;
count = 0;
signal(SIGINT, recvfrom_int);
struct sockaddr_in client_addr;
client_len = sizeof(client_addr);
int n = recvfrom(socket_fd, message, MAXLINE, 0, (struct sockaddr *) &amp;client_addr, &amp;client_len);
if (n &lt; 0) {
error(1, errno, &quot;recvfrom failed&quot;);
}
message[n] = 0;
printf(&quot;received %d bytes: %s\n&quot;, n, message);
if (connect(socket_fd, (struct sockaddr *) &amp;client_addr, client_len)) {
error(1, errno, &quot;connect failed&quot;);
}
while (strncmp(message, &quot;goodbye&quot;, 7) != 0) {
char send_line[MAXLINE];
sprintf(send_line, &quot;Hi, %s&quot;, message);
size_t rt = send(socket_fd, send_line, strlen(send_line), 0);
if (rt &lt; 0) {
error(1, errno, &quot;send failed &quot;);
}
printf(&quot;send bytes: %zu \n&quot;, rt);
size_t rc = recv(socket_fd, message, MAXLINE, 0);
if (rc &lt; 0) {
error(1, errno, &quot;recv failed&quot;);
}
count++;
}
exit(0);
}
```
我对这个程序做下解释:
- 11-12行创建UDP套接字
- 14-18行创建IPv4地址绑定到ANY和对应端口
- 20行绑定UDP套接字和IPv4地址
- 27行为该程序注册一个信号处理函数以响应Ctrl+C信号量操作
- 32-37行调用recvfrom等待客户端报文到达并将客户端信息保持到client_addr中
- **39-41行调用connect操作将UDP套接字和客户端client_addr进行绑定**
- 43-59行是程序的主体对接收的信息进行重新处理加上”Hi“前缀后发送给客户端并持续不断地从客户端接收报文该过程一直持续直到客户端发送“goodbye”报文为止。
注意这里所有收发函数都使用了send和recv。
接下来我们实现一个connect的客户端程序
```
#include &quot;lib/common.h&quot;
# define MAXLINE 4096
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: udpclient3 &lt;IPaddress&gt;&quot;);
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &amp;server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
if (connect(socket_fd, (struct sockaddr *) &amp;server_addr, server_len)) {
error(1, errno, &quot;connect failed&quot;);
}
char send_line[MAXLINE], recv_line[MAXLINE + 1];
int n;
while (fgets(send_line, MAXLINE, stdin) != NULL) {
int i = strlen(send_line);
if (send_line[i - 1] == '\n') {
send_line[i - 1] = 0;
}
printf(&quot;now sending %s\n&quot;, send_line);
size_t rt = send(socket_fd, send_line, strlen(send_line), 0);
if (rt &lt; 0) {
error(1, errno, &quot;send failed &quot;);
}
printf(&quot;send bytes: %zu \n&quot;, rt);
recv_line[0] = 0;
n = recv(socket_fd, recv_line, MAXLINE, 0);
if (n &lt; 0)
error(1, errno, &quot;recv failed&quot;);
recv_line[n] = 0;
fputs(recv_line, stdout);
fputs(&quot;\n&quot;, stdout);
}
exit(0);
}
```
我对这个客户端程序做一下解读:
- 9-10行创建了一个UDP套接字
- 12-16行创建了一个IPv4地址绑定到指定端口和IP
- **20-22行调用connect将UDP套接字和IPv4地址进行了“绑定”**
- 27-46行是程序的主体读取标准输入字符串后调用send发送给对端之后调用recv等待对端的响应并把对端响应信息打印到标准输出。
注意这里所有收发函数也都使用了send和recv。
接下来我们先启动服务器端程序然后依次开启两个客户端分别是客户端1、客户端2并且让客户端1先发送UDP报文。
服务器端:
```
$ ./udpconnectserver
received 2 bytes: g1
send bytes: 6
```
客户端1
```
./udpconnectclient2 127.0.0.1
g1
now sending g1
send bytes: 2
Hi, g1
```
客户端2
```
./udpconnectclient2 127.0.0.1
g2
now sending g2
send bytes: 2
recv failed: Connection refused (111)
```
我们看到客户端1先发送报文服务端随之通过connect和客户端1进行了“绑定”这样客户端2从操作系统内核得到了ICMP的错误该错误在recv函数中返回显示了“Connection refused”的错误信息。
## 性能考虑
一般来说客户端通过connect绑定服务端的地址和端口对UDP而言可以有一定程度的性能提升。
这是为什么呢?
因为如果不使用connect方式每次发送报文都会需要这样的过程
连接套接字→发送报文→断开套接字→连接套接字→发送报文→断开套接字 →………
而如果使用connect方式就会变成下面这样
连接套接字→发送报文→发送报文→……→最后断开套接字
我们知道连接套接字是需要一定开销的比如需要查找路由表信息。所以UDP客户端程序通过connect可以获得一定的性能提升。
## 总结
在今天的内容里我对UDP套接字调用connect方法进行了深入的分析。之所以对UDP使用connect绑定本地地址和端口是为了让我们的程序可以快速获取异步错误信息的通知同时也可以获得一定性能上的提升。
## 思考题
在本讲的最后,按照惯例,给你留两个思考题:
1. 可以对一个UDP 套接字进行多次connect操作吗? 你不妨动手试试,看看结果。
1. 如果想使用多播或广播我们应该怎么去使用connect呢
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,250 @@
<audio id="audio" title="15 | 怎么老是出现“地址已经被使用”?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cf/78/cf702851365c4c253565da3ee626ff78.mp3"></audio>
你好我是盛延敏这里是网络编程实战的第15讲欢迎回来。
上一讲我们讲到UDP也可以像TCP一样使用connect方法以快速获取异步错误的信息。在今天的内容里我们将讨论服务器端程序重启时地址被占用的原因和解决方法。
我们已经知道网络编程中服务器程序需要绑定本地地址和一个端口然后就监听在这个地址和端口上等待客户端连接的到来。在实战中你可能会经常碰到一个问题当服务器端程序重启之后总是碰到“Address in use”的报错信息服务器程序不能很快地重启。那么这个问题是如何产生的我们又该如何避免呢
今天我们就来讲一讲这个“地址已经被使用”的问题。
## 从例子开始
为了引入讨论我们从之前讲过的一个TCP服务器端程序开始说起
```
static int count;
static void sig_int(int signo) {
printf(&quot;\nreceived %d datagrams\n&quot;, count);
exit(0);
}
int main(int argc, char **argv) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;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 *) &amp;server_addr, sizeof(server_addr));
if (rt1 &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 &lt; 0) {
error(1, errno, &quot;listen failed &quot;);
}
signal(SIGPIPE, SIG_IGN);
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &amp;client_addr, &amp;client_len)) &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
char message[MAXLINE];
count = 0;
for (;;) {
int n = read(connfd, message, MAXLINE);
if (n &lt; 0) {
error(1, errno, &quot;error read&quot;);
} else if (n == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
message[n] = 0;
printf(&quot;received %d bytes: %s\n&quot;, n, message);
count++;
}
}
```
这个服务器端程序绑定到一个本地端口使用的是通配地址ANY当连接建立之后从该连接中读取输入的字符流。
启动服务器之后我们使用Telnet登录这个服务器并在屏幕上输入一些字符例如networkgood。
和我们期望的一样服务器端打印出Telnet客户端的输入。在Telnet端关闭连接之后服务器端接收到EOF也顺利地关闭了连接。服务器端也可以很快重启等待新的连接到来。
```
$./addressused
received 9 bytes: network
received 6 bytes: good
client closed
$./addressused
```
接下来我们改变一下连接的关闭顺序。和前面的过程一样先启动服务器再使用Telnet作为客户端登录到服务器在屏幕上输入一些字符。注意接下来的不同我不会在Telnet端关闭连接而是直接使用Ctrl+C的方式在服务器端关闭连接。
```
$telneet 127.0.0.1 9527
network
bad
Connection closed by foreign host.
```
我们看到连接已经被关闭Telnet客户端也感知连接关闭并退出了。接下来我们尝试重启服务器端程序。你会发现这个时候服务端程序重启失败报错信息为**bind failed: Address already in use**。
```
$./addressused
received 9 bytes: network
received 6 bytes: good
client closed
$./addressused
bind faied: Address already in use(98)
```
## 复习TIME_WAIT
那么,这个错误到底是怎么发生的呢?
还记得第10篇文章里提到的TIME_WAIT么当连接的一方主动关闭连接在接收到对端的FIN报文之后主动关闭连接的一方会在TIME_WAIT这个状态里停留一段时间这个时间大约为2MSL。如果你对此有点淡忘没有关系我在下面放了一张图希望会唤起你的记忆。
<img src="https://static001.geekbang.org/resource/image/94/5f/945c60ae06d282dcc22ad3b868f1175f.png" alt=""><br>
如果我们此时使用netstat去查看服务器程序所在主机的TIME_WAIT的状态连接你会发现有一个服务器程序生成的TCP连接当前正处于TIME_WAIT状态。这里9527是本地监听端口36650是telnet客户端端口。当然了Telnet客户端端口每次也会不尽相同。
<img src="https://static001.geekbang.org/resource/image/51/e1/5127adf94e564c13d6be86460d3317e1.png" alt=""><br>
通过服务器端发起的关闭连接操作引起了一个已有的TCP连接处于TME_WAIT状态正是这个TIME_WAIT的连接使得服务器重启时继续绑定在127.0.0.1地址和9527端口上的操作返回了**Address already in use**的错误。
## 重用套接字选项
我们知道一个TCP连接是通过四元组源地址、源端口、目的地址、目的端口来唯一确定的如果每次Telnet客户端使用的本地端口都不同就不会和已有的四元组冲突也就不会有TIME_WAIT的新旧连接化身冲突的问题。
事实上即使在很小的概率下客户端Telnet使用了相同的端口从而造成了新连接和旧连接的四元组相同在现代Linux操作系统下也不会有什么大的问题原因是现代Linux操作系统对此进行了一些优化。
第一种优化是新连接SYN告知的初始序列号一定比TIME_WAIT老连接的末序列号大这样通过序列号就可以区别出新老连接。
第二种优化是开启了tcp_timestamps使得新连接的时间戳比老连接的时间戳大这样通过时间戳也可以区别出新老连接。
在这样的优化之下一个TIME_WAIT的TCP连接可以忽略掉旧连接重新被新的连接所使用。
这就是重用套接字选项通过给套接字配置可重用属性告诉操作系统内核这样的TCP连接完全可以复用TIME_WAIT状态的连接。代码片段已经放在文章中了
```
int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &amp;on, sizeof(on));
```
SO_REUSEADDR套接字选项允许启动绑定在一个端口即使之前存在一个和该端口一样的连接。前面的例子已经表明在默认情况下服务器端历经创建socket、bind和listen重启时如果试图绑定到一个现有连接上的端口bind操作会失败但是如果我们在创建socket和bind之间使用上面的代码片段设置SO_REUSEADDR套接字选项情况就会不同。
下面我们对原来的服务器端代码进行升级升级的部分主要在11-12行在bind监听套接字之前调用setsockopt方法设置重用套接字选项
```
nt main(int argc, char **argv) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;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, &amp;on, sizeof(on));
int rt1 = bind(listenfd, (struct sockaddr *) &amp;server_addr, sizeof(server_addr));
if (rt1 &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 &lt; 0) {
error(1, errno, &quot;listen failed &quot;);
}
signal(SIGPIPE, SIG_IGN);
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &amp;client_addr, &amp;client_len)) &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
char message[MAXLINE];
count = 0;
for (;;) {
int n = read(connfd, message, MAXLINE);
if (n &lt; 0) {
error(1, errno, &quot;error read&quot;);
} else if (n == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
message[n] = 0;
printf(&quot;received %d bytes: %s\n&quot;, n, message);
count++;
}
}
```
重新编译过后重复上面那个例子先启动服务器再使用Telnet作为客户端登录到服务器在屏幕上输入一些字符使用Ctrl+C的方式在服务器端关闭连接。马上尝试重启服务器这个时候我们发现服务器正常启动没有出现**Address already in use**的错误。这说明我们的修改已经起作用。
```
$./addressused2
received 9 bytes: network
received 6 bytes: good
client closed
$./addressused2
```
SO_REUSEADDR套接字选项还有一个作用那就是本机服务器如果有多个地址可以在不同地址上使用相同的端口提供服务。
比如一台服务器有192.168.1.101和10.10.2.102连个地址我们可以在这台机器上启动三个不同的HTTP服务第一个以本地通配地址ANY和端口80启动第二个以192.168.101和端口80启动第三个以10.10.2.102和端口80启动。
这样目的地址为192.168.101目的端口为80的连接请求会被发往第二个服务目的地址为10.10.2.102目的端口为80的连接请求会被发往第三个服务目的端口为80的所有其他连接请求被发往第一个服务。
我们必须给这三个服务设置SO_REUSEADDR套接字选项否则第二个和第三个服务调用bind绑定到80端口时会出错。
## 最佳实践
这里的最佳实践可以总结成一句话: 服务器端程序都应该设置SO_REUSEADDR套接字选项以便服务端程序可以在极短时间内复用同一个端口启动。
有些人可能觉得这不是安全的。其实单独重用一个套接字不会有任何问题。我在前面已经讲过TCP连接是通过四元组唯一区分的只要客户端不使用相同的源端口连接服务器是没有问题的即使使用了相同的端口根据序列号或者时间戳也是可以区分出新旧连接的。
而且TCP的机制绝对不允许在相同的地址和端口上绑定不同的服务器即使我们设置SO_REUSEADDR套接字选项也不可能在ANY通配符地址下和端口9527上重复启动两个服务器实例。如果我们启动第二个服务器实例不出所料会得到**Address already in use**的报错即使当前还没有任何一条有效TCP连接产生。
比如下面就是第二次运行服务器端程序的报错信息:
```
$./addressused2
bind faied: Address already in use(98)
```
你可能还记得[第10讲](https://time.geekbang.org/column/article/125806)中我们提到过一个叫做tcp_tw_reuse的内核配置选项这里又提到了SO_REUSEADDR套接字选择你会不会觉得两个有点混淆呢
其实,这两个东西一点关系也没有。
- tcp_tw_reuse是内核选项主要用在连接的发起方。TIME_WAIT状态的连接创建时间超过1秒后新的连接才可以被复用注意这里是连接的发起方
- SO_REUSEADDR是用户态的选项SO_REUSEADDR选项用来告诉操作系统内核如果端口已被占用但是TCP连接状态位于TIME_WAIT 可以重用端口。如果端口忙而TCP处于其他状态重用端口时依旧得到“Address already in use”的错误信息。注意这里一般都是连接的服务方。
## 总结
今天我们分析了“Address already in use”产生的原因和解决方法。你只要记住一句话**在所有TCP服务器程序中调用bind之前请设置SO_REUSEADDR套接字选项**。这不会产生危害,相反,它会帮助我们在很快时间内重启服务端程序,而这一点恰恰是很多场景所需要的。
## 思考题
跟往常一样,给你布置两道思考题:
第一道之前我们看到的例子都是对TCP套接字设置SO_REUSEADDR套接字选项你知道吗我们也可以对UDP设置SO_REUSEADDR套接字选项。那么问题来了对UDP来说设置SO_REUSEADDR套接字选项有哪些场景和好处呢
第二道在服务器端程序中设置SO_REUSEADDR套接字选项时需要在bind函数之前对监听字进行设置想一想为什么不是对已连接的套接字进行设置呢
欢迎你在评论区写下你的思考,我会和你一起讨论交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,391 @@
<audio id="audio" title="16 | 如何理解TCP的“流”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d1/cb/d1471106b130f34c1fce97b4f1f312cb.mp3"></audio>
你好我是盛延敏这里是网络编程实战第16讲欢迎回来。
上一讲我们讲到了使用SO_REUSEADDR套接字选项可以让服务器满足快速重启的需求。在这一讲里我们回到数据的收发这个主题谈一谈如何理解TCP的数据流特性。
## TCP是一种流式协议
在前面的章节中,我们讲的都是单个客户端-服务器的例子可能会给你造成一种错觉好像TCP是一种应答形式的数据传输过程比如发送端一次发送network和program这样的报文在前面的例子中我们看到的结果基本是这样的
发送端network ----&gt; 接收端回应Hi, network
发送端program -----&gt; 接收端回应Hi, program
这其实是一个假象,之所以会这样,是因为网络条件比较好,而且发送的数据也比较少。
为了让大家理解TCP数据是流式的这个特性我们分别从发送端和接收端来阐述。
我们知道在发送端当我们调用send函数完成数据“发送”以后数据并没有被真正从网络上发送出去只是从应用程序拷贝到了操作系统内核协议栈中至于什么时候真正被发送取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件。也就是说我们不能假设每次send调用发送的数据都会作为一个整体完整地被发送出去。
如果我们考虑实际网络传输过程中的各种影响假设发送端陆续调用send函数先后发送network和program报文那么实际的发送很有可能是这个样子的。
第一种情况一次性将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分组这是完全不正确的。
如果我们再来看客户端,数据流的特征更明显。
我们知道接收端缓冲区保留了没有被取走的数据随着应用程序不断从接收端缓冲区读出数据接收端缓冲区就可以容纳更多新的数据。如果我们使用recv从接收端缓冲区读取数据发送端缓冲区的数据是以字节流的方式存在的无论发送端如何构造TCP分组接收端最终收到的字节流总是像下面这样
```
xxxxxxxxxxxxxxxxxnetworkprogramxxxxxxxxxxxx
```
关于接收端字节流,有两点需要注意:
第一这里netwrok和program的顺序肯定是会保持的也就是说先调用send函数发送的字节总在后调用send函数发送字节的前面这个是由TCP严格保证的
第二如果发送过程中有TCP分组丢失但是其后续分组陆续到达那么TCP协议栈会缓存后续分组直到前面丢失的分组到达最终形成可以被应用程序读取的数据流。
## 网络字节排序
我们知道计算机最终保存和传输用的都是0101这样的二进制数据字节流在网络上的传输也是通过二进制来完成的。
从二进制到字节是通过编码完成的比如著名的ASCII编码通过一个字节8个比特对常用的西方字母进行了编码。
这里有一个有趣的问题如果需要传输数字比如0x0201对应的二进制为0000001000000001那么两个字节的数据到底是先传0x01还是相反
<img src="https://static001.geekbang.org/resource/image/79/e6/79ada2f154205f5170cf8e69bf9f59e6.png" alt=""><br>
在计算机发展的历史上对于如何存储这个数据没有形成标准。比如这里讲到的问题不同的系统就会有两种存法一种是将0x02高字节存放在起始地址这个叫做**大端字节序**Big-Endian。另一种相反将0x01低字节存放在起始地址这个叫做**小端字节序**Little-Endian
但是在网络传输中必须保证双方都用同一种标准来表达这就好比我们打电话时说的是同一种语言否则双方不能顺畅地沟通。这个标准就涉及到了网络字节序的选择问题对于网络字节序必须二选一。我们可以看到网络协议使用的是大端字节序我个人觉得大端字节序比较符合人类的思维习惯你可以想象手写一个多位数字从开始往小位写自然会先写大位比如写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代表的就是networkh代表的是hosts表示的是shortl表示的是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)
```
## 报文读取和解析
应该看到,报文是以字节流的形式呈现给应用程序的,那么随之而来的一个问题就是,应用程序如何解读字节流呢?
这就要说到报文格式和解析了。报文格式实际上定义了字节的组织形式,发送端和接收端都按照统一的报文格式进行数据传输和解析,这样就可以保证彼此能够完成交流。
只有知道了报文格式,接收端才能针对性地进行报文读取和解析工作。
报文格式最重要的是如何确定报文的边界。常见的报文格式有两种方法,一种是发送端把要发送的报文长度预先通过报文告知给接收端;另一种是通过一些特殊的字符来进行边界的划分。
## 显式编码报文长度
### 报文格式
下面我们来看一个例子,这个例子是把要发送的报文长度预先通过报文告知接收端:
<img src="https://static001.geekbang.org/resource/image/33/15/33805892d57843a1f22830d8636e1315.png" alt=""><br>
由图可以看出这个报文的格式很简单首先4个字节大小的消息长度其目的是将真正发送的字节流的大小显式通过报文告知接收端接下来是4个字节大小的消息类型而真正需要发送的数据则紧随其后。
### 发送报文
发送端的程序如下:
```
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: tcpclient &lt;IPaddress&gt;&quot;);
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &amp;server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
int connect_rt = connect(socket_fd, (struct sockaddr *) &amp;server_addr, server_len);
if (connect_rt &lt; 0) {
error(1, errno, &quot;connect failed &quot;);
}
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;
if (send(socket_fd, (char *) &amp;message, sizeof(message.message_length) + sizeof(message.message_type) + n, 0) &lt;
0)
error(1, errno, &quot;send failure&quot;);
}
exit(0);
}
```
程序的1-20行是常规的创建套接字和地址建立连接的过程。我们重点往下看21-25行就是图示的报文格式转化为结构体29-37行从标准输入读入数据分别对消息长度、类型进行了初始化注意这里使用了htonl函数将字节大小转化为了网络字节顺序这一点很重要。最后我们看到23行实际发送的字节流大小为消息长度4字节加上消息类型4字节以及标准输入的字符串大小。
### 解析报文:程序
下面给出的是服务器端的程序,和客户端不一样的是,服务器端需要对报文进行解析。
```
static int count;
static void sig_int(int signo) {
printf(&quot;\nreceived %d datagrams\n&quot;, count);
exit(0);
}
int main(int argc, char **argv) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;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, &amp;on, sizeof(on));
int rt1 = bind(listenfd, (struct sockaddr *) &amp;server_addr, sizeof(server_addr));
if (rt1 &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 &lt; 0) {
error(1, errno, &quot;listen failed &quot;);
}
signal(SIGPIPE, SIG_IGN);
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &amp;client_addr, &amp;client_len)) &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
char buf[128];
count = 0;
while (1) {
int n = read_message(connfd, buf, sizeof(buf));
if (n &lt; 0) {
error(1, errno, &quot;error read message&quot;);
} else if (n == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
buf[n] = 0;
printf(&quot;received %d bytes: %s\n&quot;, n, buf);
count++;
}
exit(0);
}
```
这个程序1-41行创建套接字等待连接建立部分和前面基本一致。我们重点看42-55行的部分。45-55行循环处理字节流调用read_message函数进行报文解析工作并把报文的主体通过标准输出打印出来。
### 解析报文readn函数
在了解read_message工作原理之前我们先来看第5讲就引入的一个函数readn。这里一定要强调的是readn函数的语义**读取报文预设大小的字节**readn调用会一直循环尝试读取预设大小的字节如果接收缓冲区数据空readn函数会阻塞在那里直到有数据到达。
```
size_t readn(int fd, void *buffer, size_t length) {
size_t count;
ssize_t nread;
char *ptr;
ptr = buffer;
count = length;
while (count &gt; 0) {
nread = read(fd, ptr, count);
if (nread &lt; 0) {
if (errno == EINTR)
continue;
else
return (-1);
} else if (nread == 0)
break; /* EOF */
count -= nread;
ptr += nread;
}
return (length - count); /* return &gt;= 0 */
}
```
readn函数中使用count来表示还需要读取的字符数如果count一直大于0说明还没有满足预设的字符大小循环就会继续。第9行通过read函数来服务最多count个字符。11-17行针对返回值进行出错判断其中返回值为0的情形是EOF表示对方连接终止。19-20行要读取的字符数减去这次读到的字符数同时移动缓冲区指针这样做的目的是为了确认字符数是否已经读取完毕。
### 解析报文: read_message函数
有了readn函数作为基础我们再看一下read_message对报文的解析处理
```
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 *) &amp;msg_length, sizeof(u_int32_t));
if (rc != sizeof(u_int32_t))
return rc &lt; 0 ? -1 : 0;
msg_length = ntohl(msg_length);
rc = readn(fd, (char *) &amp;msg_type, sizeof(msg_type));
if (rc != sizeof(u_int32_t))
return rc &lt; 0 ? -1 : 0;
if (msg_length &gt; length) {
return -1;
}
rc = readn(fd, buffer, msg_length);
if (rc != msg_length)
return rc &lt; 0 ? -1 : 0;
return rc;
}
```
在这个函数中第6行通过调用readn函数获取4个字节的消息长度数据紧接着第11行通过调用readn函数获取4个字节的消息类型数据。第15行判断消息的长度是不是太大如果大到本地缓冲区不能容纳则直接返回错误第19行调用readn一次性读取已知长度的消息体。
### 实验
我们依次启动作为报文解析的服务器一端,以及作为报文发送的客户端。我们看到,每次客户端发送的报文都可以被服务器端解析出来,在标准输出上的结果验证了这一点。
```
$./streamserver
received 8 bytes: network
received 5 bytes: good
```
```
$./streamclient
network
good
```
## 特殊字符作为边界
前面我提到了两种报文格式另外一种报文格式就是通过设置特殊字符作为报文边界。HTTP是一个非常好的例子。
<img src="https://static001.geekbang.org/resource/image/6d/5a/6d91c7c2a0224f5d4bad32a0f488765a.png" alt=""><br>
HTTP通过设置回车符、换行符作为HTTP报文协议的边界。
下面的read_line函数就是在尝试读取一行数据也就是读到回车符`\r`,或者读到回车换行符`\r\n`为止。这个函数每次尝试读取一个字节第9行如果读到了回车符`\r`接下来在11行的“观察”下看有没有换行符如果有就在第12行读取这个换行符如果没有读到回车符就在第16-17行将字符放到缓冲区并移动指针。
```
int read_line(int fd, char *buf, int size) {
int i = 0;
char c = '\0';
int n;
while ((i &lt; size - 1) &amp;&amp; (c != '\n')) {
n = recv(fd, &amp;c, 1, 0);
if (n &gt; 0) {
if (c == '\r') {
n = recv(fd, &amp;c, 1, MSG_PEEK);
if ((n &gt; 0) &amp;&amp; (c == '\n'))
recv(fd, &amp;c, 1, 0);
else
c = '\n';
}
buf[i] = c;
i++;
} else
c = '\n';
}
buf[i] = '\0';
return (i);
}
```
## 总结
和我们预想的不太一样TCP数据流特性决定了字节流本身是没有边界的一般我们通过显式编码报文长度的方式以及选取特殊字符区分报文边界的方式来进行报文格式的设计。而对报文解析的工作就是要在知道报文格式的情况下有效地对报文信息进行还原。
## 思考题
和往常一样,这里给你留两道思考题,供你消化今天的内容。
第一道题关于HTTP的报文格式我们看到既要处理只有回车的情景也要处理同时有回车和换行的情景你知道造成这种情况的原因是什么吗
第二道题是我们这里讲到的报文格式和TCP分组的报文格式有什么区别和联系吗
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,与他们一起交流一下这两个问题吧。

View File

@@ -0,0 +1,275 @@
<audio id="audio" title="17 | TCP并不总是“可靠”的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ae/58/ae172aaf12d8df6378e52eee03aaa658.mp3"></audio>
你好我是盛延敏这里是网络编程实战第17讲欢迎回来。
在前面一讲中我们讲到如何理解TCP数据流的本质进而引出了报文格式和解析。在这一讲里我们讨论通过如何增强读写操作以处理各种“不可靠”的场景。
## TCP是可靠的
你可能会认为TCP是一种可靠的协议这种可靠体现在端到端的通信上。这似乎给我们带来了一种错觉从发送端来看应用程序通过调用send函数发送的数据流总能可靠地到达接收端而从接收端来看总是可以把对端发送的数据流完整无损地传递给应用程序来处理。
事实上如果我们对TCP传输环节进行详细的分析你就会沮丧地发现上述论断是不正确的。
前面我们已经了解发送端通过调用send函数之后数据流并没有马上通过网络传输出去而是存储在套接字的发送缓冲区中由网络协议栈决定何时发送、如何发送。当对应的数据发送给接收端接收端回应ACK存储在发送缓冲区的这部分数据就可以删除了但是发送端并无法获取对应数据流的ACK情况也就是说发送端没有办法判断对端的接收方是否已经接收发送的数据流如果需要知道这部分信息就必须在应用层自己添加处理逻辑例如显式的报文确认机制。
从接收端来说也没有办法保证ACK过的数据部分可以被应用程序处理因为数据需要接收端程序从接收缓冲区中拷贝可能出现的状况是已经ACK的数据保存在接收端缓冲区中接收端处理程序突然崩溃了这部分数据就没有办法被应用程序继续处理。
你有没有发现TCP协议实现并没有提供给上层应用程序过多的异常处理细节或者说TCP协议反映链路异常的能力偏弱这其实是有原因的。要知道TCP诞生之初就是为美国国防部服务的考虑到军事作战的实际需要TCP不希望暴露更多的异常细节而是能够以无人值守、自我恢复的方式运作。
TCP连接建立之后能感知TCP链路的方式是有限的一种是以read为核心的读操作另一种是以write为核心的写操作。接下来我们就看下如何通过读写操作来感知异常情况以及对应的处理方式。
## 故障模式总结
在实际情景中,我们会碰到各种异常的情况。在这里我把这几种异常情况归结为两大类:
<img src="https://static001.geekbang.org/resource/image/39/af/39b060fa90628db95fd33305dc6fc7af.png" alt=""><br>
第一类是对端无FIN包发送出来的情况第二类是对端有FIN包发送出来。而这两大类情况又可以根据应用程序的场景细分接下来我们详细讨论。
## 网络中断造成的对端无FIN包
很多原因都会造成网络中断在这种情况下TCP程序并不能及时感知到异常信息。除非网络中的其他设备如路由器发出一条ICMP报文说明目的网络或主机不可达这个时候通过read或write调用就会返回Unreachable的错误。
可惜大多数时候并不是如此在没有ICMP报文的情况下TCP程序并不能理解感应到连接异常。如果程序是阻塞在read调用上那么很不幸程序无法从异常中恢复。这显然是非常不合理的不过我们可以通过给read操作设置超时来解决在接下来的第18讲中我会讲到具体的方法。
如果程序先调用了write操作发送了一段数据流接下来阻塞在read调用上结果会非常不同。Linux系统的TCP协议栈会不断尝试将发送缓冲区的数据发送出去大概在重传12次、合计时间约为9分钟之后协议栈会标识该连接异常这时阻塞的read调用会返回一条TIMEOUT的错误信息。如果此时程序还执着地往这条连接写数据写操作会立即失败返回一个SIGPIPE信号给应用程序。
## 系统崩溃造成的对端无FIN包
当系统突然崩溃如断电时网络连接上来不及发出任何东西。这里和通过系统调用杀死应用程序非常不同的是没有任何FIN包被发送出来。
这种情况和网络中断造成的结果非常类似在没有ICMP报文的情况下TCP程序只能通过read和write调用得到网络连接异常的信息超时错误是一个常见的结果。
不过还有一种情况需要考虑那就是系统在崩溃之后又重启当重传的TCP分组到达重启后的系统由于系统中没有该TCP分组对应的连接数据系统会返回一个RST重置分节TCP程序通过read或write调用可以分别对RST进行错误处理。
如果是阻塞的read调用会立即返回一个错误错误信息为连接重置Connection Reset
如果是一次write操作也会立即失败应用程序会被返回一个SIGPIPE信号。
## 对端有FIN包发出
对端如果有FIN包发出可能的场景是对端调用了close或shutdown显式地关闭了连接也可能是对端应用程序崩溃操作系统内核代为清理所发出的。从应用程序角度上看无法区分是哪种情形。
阻塞的read操作在完成正常接收的数据读取之后FIN包会通过返回一个EOF来完成通知此时read调用返回值为0。这里强调一点收到FIN包之后read操作不会立即返回。你可以这样理解收到FIN包相当于往接收缓冲区里放置了一个EOF符号之前已经在接收缓冲区的有效数据不会受到影响。
为了展示这些特性,我分别编写了服务器端和客户端程序。
```
//服务端程序
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 &lt; 0) {
error(1, errno, &quot;error read&quot;);
} else if (n == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
sleep(5);
int write_nc = send(connfd, buf, n, 0);
printf(&quot;send bytes: %zu \n&quot;, write_nc);
if (write_nc &lt; 0) {
error(1, errno, &quot;error write&quot;);
}
}
exit(0);
}
```
服务端程序是一个简单的应答程序在收到数据流之后回显给客户端在此之前休眠5秒以便完成后面的实验验证。
客户端程序从标准输入读入,将读入的字符串传输给服务器端:
```
//客户端程序
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: reliable_client01 &lt;IPaddress&gt;&quot;);
}
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 &lt; 0)
error(1, errno, &quot;write failed&quot;);
rc = read(socket_fd, buf, sizeof(buf));
if (rc &lt; 0)
error(1, errno, &quot;read failed&quot;);
else if (rc == 0)
error(1, 0, &quot;peer connection closed\n&quot;);
else
fputs(buf, stdout);
}
exit(0);
}
```
### read直接感知FIN包
我们依次启动服务器端和客户端程序在客户端输入good字符之后迅速结束掉服务器端程序这里需要赶在服务器端从睡眠中苏醒之前杀死服务器程序。
屏幕上打印出peer connection closed。客户端程序正常退出。
```
$./reliable_client01 127.0.0.1
$ good
$ peer connection closed
```
这说明客户端程序通过read调用感知到了服务端发送的FIN包于是正常退出了客户端程序。
<img src="https://static001.geekbang.org/resource/image/b0/ec/b0922e1b1824f1e4735f2788eb3527ec.png" alt=""><br>
注意如果我们的速度不够快导致服务器端从睡眠中苏醒并成功将报文发送出来后客户端会正常显示此时我们停留等待标准输入。如果不继续通过read或write操作对套接字进行读写是无法感知服务器端已经关闭套接字这个事实的。
### 通过write产生RSTread调用感知RST
这一次我们仍然依次启动服务器端和客户端程序在客户端输入bad字符之后等待一段时间直到客户端正确显示了服务端的回应“bad”字符之后再杀死服务器程序。客户端再次输入bad2这时屏幕上打印出”peer connection closed“。
这是这个案例的屏幕输出和时序图。
```
$./reliable_client01 127.0.0.1
$bad
$bad
$bad2
$peer connection closed
```
<img src="https://static001.geekbang.org/resource/image/a9/f2/a95d3b87a9a93421774d7aeade8efbf2.png" alt=""><br>
在很多书籍和文章中对这个程序的解读是收到FIN包的客户端继续合法地向服务器端发送数据服务器端在无法定位该TCP连接信息的情况下发送了RST信息当程序调用read操作时内核会将RST错误信息通知给应用程序。这是一个典型的write操作造成异常再通过read操作来感知异常的样例。
不过我在Linux 4.4内核上实验这个程序多次的结果都是内核正常将EOF信息通知给应用程序而不是RST错误信息。
我又在Max OS 10.13.6上尝试这个程序read操作可以返回RST异常信息。输出和时序图也已经给出。
```
$./reliable_client01 127.0.0.1
$bad
$bad
$bad2
$read failed: Connection reset by peer (54)
```
### 向一个已关闭连接连续写最终导致SIGPIPE
为了模拟这个过程,我对服务器端程序和客户端程序都做了如下修改。
```
nt 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 &lt; 0) {
error(1, errno, &quot;error read&quot;);
} else if (n == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
time++;
fprintf(stdout, &quot;1K read for %d \n&quot;, time);
usleep(1000);
}
exit(0);
}
```
服务器端每次读取1K数据后休眠1秒以模拟处理数据的过程。
客户端程序在第8行注册了SIGPIPE的信号处理程序在第14-22行客户端程序一直循环发送数据流。
```
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: reliable_client02 &lt;IPaddress&gt;&quot;);
}
int socket_fd = tcp_client(argv[1], SERV_PORT);
signal(SIGPIPE, SIG_IGN);
char *msg = &quot;network programming&quot;;
ssize_t n_written;
int count = 10000000;
while (count &gt; 0) {
n_written = send(socket_fd, msg, strlen(msg), 0);
fprintf(stdout, &quot;send into buffer %ld \n&quot;, n_written);
if (n_written &lt;= 0) {
error(1, errno, &quot;send error&quot;);
return -1;
}
count--;
}
return 0;
}
```
如果在服务端读取数据并处理过程中,突然杀死服务器进程,我们会看到客**户端很快也会退出**并在屏幕上打印出“Connection reset by peer”的提示。
```
$./reliable_client02 127.0.0.1
$send into buffer 5917291
$send into buffer -1
$send: Connection reset by peer
```
这是因为服务端程序被杀死之后操作系统内核会做一些清理的事情为这个套接字发送一个FIN包但是客户端在收到FIN包之后没有read操作还是会继续往这个套接字写入数据。这是因为根据TCP协议连接是双向的收到对方的FIN包只意味着**对方不会再发送任何消息**。 在一个双方正常关闭的流程中收到FIN包的一端将剩余数据发送给对面通过一次或多次write然后关闭套接字。
当数据到达服务器端时操作系统内核发现这是一个指向关闭的套接字会再次向客户端发送一个RST包对于发送端而言如果此时再执行write操作立即会返回一个RST错误信息。
你可以看到针对这个全过程的一张描述图,你可以参考这张图好好理解一下这个过程。
<img src="https://static001.geekbang.org/resource/image/eb/42/ebf533a453573b85ff03a46103fc5b42.png" alt=""><br>
以上是在Linux 4.4内核上测试的结果。
在很多书籍和文章中对这个实验的期望结果不是这样的。大部分的教程是这样说的在第二次write操作时由于服务器端无法查询到对应的TCP连接信息于是发送了一个RST包给客户端客户端第二次操作时应用程序会收到一个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上都能正常处理异常。
## 总结
在这一讲中我们意识到TCP并不是那么“可靠”的。我把故障分为两大类一类是对端无FIN包需要通过巡检或超时来发现另一类是对端有FIN包发出需要通过增强read或write操作的异常处理帮助我们发现此类异常。
## 思考题
和往常一样,给大家布置两道思考题。
第一道你不妨在你的Linux系统中重新模拟一下今天文章里的实验看看运行结果是否和我的一样。欢迎你把内核版本和结果贴在评论里。
第二道题是,如果服务器主机正常关闭,已连接的程序会发生什么呢?
你不妨思考一下这两道题,欢迎你在评论区写下你的模拟结果和思考,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,335 @@
<audio id="audio" title="18 | 防人之心不可无:检查数据的有效性" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6d/c4/6d1debeb6162a55ea86c0e68df7c6dc4.mp3"></audio>
你好我是盛延敏这里是网络编程实战第18讲欢迎回来。
在前面一讲中,我们仔细分析了引起故障的原因,并且已经知道为了应对可能出现的各种故障,必须在程序中做好防御工作。
在这一讲里,我们继续前面的讨论,看一看为了增强程序的健壮性,我们还需要准备什么。
## 对端的异常状况
在前面的第11讲以及第17讲中我们已经初步接触过一些防范对端异常的方法比如通过read等调用时可以通过对EOF的判断随时防范对方程序崩溃。
```
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
if (nBytes == -1) {
error(1, errno, &quot;error read message&quot;);
} else if (nBytes == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
```
你可以看到这一个程序中的第4行当调用read函数返回0字节时实际上就是操作系统内核返回EOF的一种反映。如果是服务器端同时处理多个客户端连接一般这里会调用shutdown关闭连接的这一端。
上一讲也讲到了不是每种情况都可以通过读操作来感知异常比如服务器完全崩溃或者网络中断的情况下此时如果是阻塞套接字会一直阻塞在read等调用上没有办法感知套接字的异常。
其实有几种办法来解决这个问题。
第一个办法是给套接字的read操作设置超时如果超过了一段时间就认为连接已经不存在。具体的代码片段如下
```
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
setsockopt(connfd, SOL_SOCKET, SO_RCVTIMEO, (const char *) &amp;tv, sizeof tv);
while (1) {
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
if (nBytes == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
printf(&quot;read timeout\n&quot;);
onClientTimeout(connfd);
} else {
error(1, errno, &quot;error read message&quot;);
}
} else if (nBytes == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
...
}
```
这个代码片段在第4行调用setsockopt函数设置了套接字的读操作超时超时时间为在第1-3行设置的5秒当然在这里这个时间值是“拍脑袋”设置的比较科学的设置方法是通过一定的统计之后得到一个比较合理的值。关键之处在读操作返回异常的第9-11行根据出错信息是`EAGAIN`或者`EWOULDBLOCK`,判断出超时,转而调用`onClientTimeout`函数来进行处理。
这个处理方式虽然比较简单却很实用很多FTP服务器端就是这么设计的。连接这种FTP服务器之后如果FTP的客户端没有续传的功能在碰到网络故障或服务器崩溃时就会挂断。
第二个办法是第12讲中提到的办法添加对连接是否正常的检测。如果连接不正常需要从当前read阻塞中返回并处理。
还有一个办法前面第12讲也提到过那就是利用多路复用技术自带的超时能力来完成对套接字I/O的检查如果超过了预设的时间就进入异常处理。
```
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
FD_ZERO(&amp;allreads);
FD_SET(socket_fd, &amp;allreads);
for (;;) {
readmask = allreads;
int rc = select(socket_fd + 1, &amp;readmask, NULL, NULL, &amp;tv);
if (rc &lt; 0) {
error(1, errno, &quot;select failed&quot;);
}
if (rc == 0) {
printf(&quot;read timeout\n&quot;);
onClientTimeout(socket_fd);
}
...
}
```
这段代码使用了select多路复用技术来对套接字进行I/O事件的轮询程序的13行是到达超时后的处理逻辑调用`onClientTimeout`函数来进行超时后的处理。
## 缓冲区处理
一个设计良好的网络程序,应该可以在随机输入的情况下表现稳定。不仅是这样,随着互联网的发展,网络安全也愈发重要,我们编写的网络程序能不能在黑客的刻意攻击之下表现稳定,也是一个重要考量因素。
很多黑客程序会针对性地构建出一定格式的网络协议包导致网络程序产生诸如缓冲区溢出、指针异常的后果影响程序的服务能力严重的甚至可以夺取服务器端的控制权随心所欲地进行破坏活动比如著名的SQL注入就是通过针对性地构造出SQL语句完成对数据库敏感信息的窃取。
所以,在网络程序的编写过程中,我们需要时时刻刻提醒自己面对的是各种复杂异常的场景,甚至是别有用心的攻击者,保持“防人之心不可无”的警惕。
那么程序都有可能出现哪几种漏洞呢?
### 第一个例子
```
char Response[] = &quot;COMMAND OK&quot;;
char buffer[128];
while (1) {
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
if (nBytes == -1) {
error(1, errno, &quot;error read message&quot;);
} else if (nBytes == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
buffer[nBytes] = '\0';
if (strcmp(buffer, &quot;quit&quot;) == 0) {
printf(&quot;client quit\n&quot;);
send(socket, Response, sizeof(Response), 0);
}
printf(&quot;received %d bytes: %s\n&quot;, nBytes, buffer);
}
```
这段代码从连接套接字中获取字节流并且判断了出差和EOF情况如果对端发送来的字符是“quit”就回应“COMAAND OK”的字符流乍看上去一切正常。
但仔细看一下,这段代码很有可能会产生下面的结果。
```
char buffer[128];
buffer[128] = '\0';
```
通过recv读取的字符数为128时就会这样的结果。因为buffer的大小只有128字节最后的赋值环节产生了缓冲区溢出的问题。
所谓缓冲区溢出是指计算机程序中出现的一种内存违规操作。本质是计算机程序向缓冲区填充的数据超出了原本缓冲区设置的大小限制导致了数据覆盖了内存栈空间的其他合法数据。这种覆盖破坏了原来程序的完整性使用过游戏修改器的同学肯定知道如果不小心修改错游戏数据的内存空间很可能导致应用程序产生如“Access violation”的错误导致应用程序崩溃。
我们可以对这个程序稍加修改主要的想法是留下buffer里的一个字节以容纳后面的`'\0'`
```
int nBytes = recv(connfd, buffer, sizeof(buffer)-1, 0);
```
这个例子里面,还昭示了一个有趣的现象。你会发现我们发送过去的字符串,调用的是`sizeof`那也就意味着Response字符串中的`'\0'`是被发送出去的,而我们在接收字符时,则假设没有`'\0'`字符的存在。
为了统一我们可以改成如下的方式使用strlen的方式忽略最后一个`'\0'`字符。
```
send(socket, Response, strlen(Response), 0);
```
### 第二个例子
第16讲中提到了对变长报文解析的两种手段一个是使用特殊的边界符号例如HTTP使用的回车换行符另一个是将报文信息的长度编码进入消息。
在实战中,我们也需要对这部分报文长度保持警惕。
```
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 *) &amp;msg_length, sizeof(u_int32_t));
if (rc != sizeof(u_int32_t))
return rc &lt; 0 ? -1 : 0;
msg_length = ntohl(msg_length);
rc = readn(fd, (char *) &amp;msg_type, sizeof(msg_type));
if (rc != sizeof(u_int32_t))
return rc &lt; 0 ? -1 : 0;
if (msg_length &gt; length) {
return -1;
}
/* Retrieve the record itself */
rc = readn(fd, buffer, msg_length);
if (rc != msg_length)
return rc &lt; 0 ? -1 : 0;
return rc;
}
```
在进行报文解析时第15行对实际的报文长度`msg_length`和应用程序分配的缓冲区大小进行了比较,如果报文长度过大,导致缓冲区容纳不下,直接返回-1表示出错。千万不要小看这部分的判断试想如果没有这个判断对方程序发送出来的消息体可能构建出一个非常大的`msg_length`,而实际发送的报文本体长度却没有这么大,这样后面的读取操作就不会成功,如果应用程序实际缓冲区大小比`msg_length`小,也产生了缓冲区溢出的问题。
```
struct {
u_int32_t message_length;
u_int32_t message_type;
char data[128];
} message;
int n = 65535;
message.message_length = htonl(n);
message.message_type = 1;
char buf[128] = &quot;just for fun\0&quot;;
strncpy(message.data, buf, strlen(buf));
if (send(socket_fd, (char *) &amp;message,
sizeof(message.message_length) + sizeof(message.message_type) + strlen(message.data), 0) &lt; 0)
error(1, errno, &quot;send failure&quot;);
```
就是这样一段发送端“不小心”构造的一个程序消息的长度“不小心”被设置为65535长度实际发送的报文数据为“just for fun”。在去掉实际的报文长度`msg_length`和应用程序分配的缓冲区大小做比较之后服务器端一直阻塞在read调用上这是因为服务器端误认为需要接收65535大小的字节。
### 第三个例子
如果我们需要开发一个函数,这个函数假设报文的分界符是换行符(\n一个简单的想法是每次读取一个字符判断这个字符是不是换行符。
这里有一个这样的函数这个函数的最大问题是工作效率太低要知道每次调用recv函数都是一次系统调用需要从用户空间切换到内核空间上下文切换的开销对于高性能来说最好是能省则省。
```
size_t readline(int fd, char *buffer, size_t length) {
char *buf_first = buffer;
char c;
while (length &gt; 0 &amp;&amp; recv(fd, &amp;c, 1, 0) == 1) {
*buffer++ = c;
length--;
if (c == '\n') {
*buffer = '\0';
return buffer - buf_first;
}
}
return -1;
}
```
于是就有了第二个版本这个函数一次性读取最多512字节到临时缓冲区之后将临时缓冲区的字符一个一个拷贝到应用程序最终的缓冲区中这样的做法明显效率会高很多。
```
size_t readline(int fd, char *buffer, size_t length) {
char *buf_first = buffer;
static char *buffer_pointer;
int nleft = 0;
static char read_buffer[512];
char c;
while (length-- &gt; 0) {
if (nleft &lt;= 0) {
int nread = recv(fd, read_buffer, sizeof(read_buffer), 0);
if (nread &lt; 0) {
if (errno == EINTR) {
length++;
continue;
}
return -1;
}
if (nread == 0)
return 0;
buffer_pointer = read_buffer;
nleft = nread;
}
c = *buffer_pointer++;
*buffer++ = c;
nleft--;
if (c == '\n') {
*buffer = '\0';
return buffer - buf_first;
}
}
return -1;
}
```
这个程序的主循环在第8行通过对length变量的判断试图解决缓冲区长度溢出问题第9行是判断临时缓冲区的字符有没有被全部拷贝完如果被全部拷贝完就会再次尝试读取最多512字节第20-21行在读取字符成功之后重置了临时缓冲区读指针、临时缓冲区待读的字符个数第23-25行则是在拷贝临时缓冲区字符每次拷贝一个字符并移动临时缓冲区读指针对临时缓冲区待读的字符个数进行减1操作。在程序的26-28行判断是否读到换行符如果读到则将应用程序最终缓冲区截断返回最终读取的字符个数。
这个程序运行起来可能很久都没有问题,但是,它还是有一个微小的瑕疵,这个瑕疵很可能会造成线上故障。
为了讲清这个故障,我们假设这样调用, 输入的字符为`012345678\n`
```
//输入字符为: 012345678\n
char buf[10]
readline(fd, buf, 10)
```
当读到最后一个\n字符时length为1问题是在第26行和27行如果读到了换行符就会增加一个字符串截止符这显然越过了应用程序缓冲区的大小。
这是正确的程序这里最关键的是需要先对length进行处理再去判断length的大小是否可以容纳下字符。
```
size_t readline(int fd, char *buffer, size_t length) {
char *buf_first = buffer;
static char *buffer_pointer;
int nleft = 0;
static char read_buffer[512];
char c;
while (--length&gt; 0) {
if (nleft &lt;= 0) {
int nread = recv(fd, read_buffer, sizeof(read_buffer), 0);
if (nread &lt; 0) {
if (errno == EINTR) {
length++;
continue;
}
return -1;
}
if (nread == 0)
return 0;
buffer_pointer = read_buffer;
nleft = nread;
}
c = *buffer_pointer++;
*buffer++ = c;
nleft--;
if (c == '\n') {
*buffer = '\0';
return buffer - buf_first;
}
}
return -1;
}
```
## 总结
今天的内容到这里就结束了。让我们总结一下: 在网络编程中,是否做好了对各种异常边界的检测,将决定我们的程序在恶劣情况下的稳定性,所以,我们一定要时刻提醒自己做好应对各种复杂情况的准备,这里的异常情况包括缓冲区溢出、指针错误、连接超时检测等。
## 思考题
和往常一样,给你留两道思考题吧。
第一道,我们在读数据的时候,一般都需要给应用程序最终缓冲区分配大小,这个大小有什么讲究吗?
第二道你能分析一下我们文章中的例子所分配的缓冲是否可以换成动态分配吗比如调用malloc函数来分配缓冲区
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,115 @@
<audio id="audio" title="19丨提高篇答疑如何理解TCP四次挥手" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/59/c8/5945e61482c0d49f1bd93e1cae2584c8.mp3"></audio>
你好我是盛延敏这里是网络编程实战第19讲欢迎回来。
这一篇文章是提高篇的答疑部分,也是提高篇的最后一篇文章。非常感谢大家的积极评论与留言,让每一篇文章的留言区都成为学习互动的好地方。在今天的内容里,我将针对大家的问题做一次集中回答,希望能帮助你解决前面碰到的一些问题。
这部分我将采用Q&amp;A的形式来展开。
## 如何理解TCP四次挥手
TCP建立一个连接需3次握手而终止一个连接则需要四次挥手。四次挥手的整个过程是这样的
<img src="https://static001.geekbang.org/resource/image/b8/ea/b8911347d23251b6b0ca07c6ec03a1ea.png" alt=""><br>
首先一方应用程序调用close我们称该方为主动关闭方该端的TCP发送一个FIN包表示需要关闭连接。之后主动关闭方进入FIN_WAIT_1状态。
接着接收到这个FIN包的对端执行被动关闭。这个FIN由TCP协议栈处理我们知道TCP协议栈为FIN包插入一个文件结束符EOF到接收缓冲区中应用程序可以通过read调用来感知这个FIN包。一定要注意这个EOF会被放在**已排队等候的其他已接收的数据之后**这就意味着接收端应用程序需要处理这种异常情况因为EOF表示在该连接上再无额外数据到达。此时被动关闭方进入CLOSE_WAIT状态。
接下来被动关闭方将读到这个EOF于是应用程序也调用close关闭它的套接字这导致它的TCP也发送一个FIN包。这样被动关闭方将进入LAST_ACK状态。
最终主动关闭方接收到对方的FIN包并确认这个FIN包。主动关闭方进入TIME_WAIT状态而接收到ACK的被动关闭方则进入CLOSED状态。经过2MSL时间之后主动关闭方也进入CLOSED状态。
你可以看到每个方向都需要一个FIN和一个ACK因此通常被称为四次挥手。
当然这中间使用shutdown执行一端到另一端的半关闭也是可以的。
当套接字被关闭时TCP为其所在端发送一个FIN包。在大多数情况下这是由应用进程调用close而发生的值得注意的是一个进程无论是正常退出exit或者main函数返回还是非正常退出比如收到SIGKILL信号关闭就是我们常常干的kill -9所有该进程打开的描述符都会被系统关闭这也导致TCP描述符对应的连接上发出一个FIN包。
无论是客户端还是服务器任何一端都可以发起主动关闭。大多数真实情况是客户端执行主动关闭你可能不会想到的是HTTP/1.0却是由服务器发起主动关闭的。
## 最大分组 MSL是TCP 分组在网络中存活的最长时间吗?
MSL是任何IP数据报能够在因特网中存活的最长时间。其实它的实现不是靠计时器来完成的在每个数据报里都包含有一个被称为TTLtime to live的8位字段它的最大值为255。TTL可译为“生存时间”这个生存时间由源主机设置初始值它表示的是一个IP数据报可以经过的最大跳跃数每经过一个路由器就相当于经过了一跳它的值就减1当此值减为0时则所在的路由器会将其丢弃同时发送ICMP报文通知源主机。RFC793中规定MSL的时间为2分钟Linux实际设置为30秒。
## 关于listen函数中参数backlog的释义问题
我们该如何理解listen函数中的参数backlog如果backlog表示的是未完成连接队列的大小那么已完成连接的队列的大小有限制吗如果都是已经建立连接的状态那么并发取决于已完成连接的队列的大小吗
backlog的值含义从来就没有被严格定义过。原先Linux实现中backlog参数定义了该套接字对应的未完成连接队列的最大长度 pending connections)。如果一个连接到达时该队列已满客户端将会接收一个ECONNREFUSED的错误信息如果支持重传该请求可能会被忽略之后会进行一次重传。
从Linux 2.2开始backlog的参数内核有了新的语义它现在定义的是已完成连接队列的最大长度表示的是已建立的连接established connection正在等待被接收accept调用返回而不是原先的未完成队列的最大长度。现在未完成队列的最大长度值可以通过 /proc/sys/net/ipv4/tcp_max_syn_backlog完成修改默认值为128。
至于已完成连接队列如果声明的backlog参数比 /proc/sys/net/core/somaxconn的参数要大那么就会使用我们声明的那个值。实际上这个默认的值为128。注意在Linux 2.4.25之前这个值是不可以修改的一个固定值大小也是128。
设计良好的程序在128固定值的情况下也是可以支持成千上万的并发连接的这取决于I/O分发的效率以及多线程程序的设计。在后面的性能篇里我们的目标就是设计这样的程序。
## UDP连接和断开套接字的过程是怎样的
UDP连接套接字不是发起连接请求的过程而是记录目的地址和端口到套接字的映射关系。
断开套接字则相反,将删除原来记录的映射关系。
## 在UDP中不进行connect为什么客户端会收到信息
有人说如果按照我在文章中的说法UDP只有connect才建立socket和IP地址的映射那么如果不进行connect收到信息后内核又如何把数据交给对应的socket
这个问题非常有意思。我刚刚看到这个问题的时候,心里也在想,是啊,我是不是说错了?
其实呢这对应了两个不同的API场景。
第一个场景就是我这里讨论的connect场景在这个场景里我们讨论的是ICMP报文和socket之间的定位。我们知道ICMP报文发送的是一个不可达的信息不可达的信息是通过**目的地址和端口**来区分的如果没有connect操作**目的地址和端口**就没有办法和socket套接字进行对应所以即使收到了ICMP报文内核也没有办法通知到对应的应用程序告诉它连接地址不可达。
那么为什么在不connect的情况下我们的客户端又可以收到服务器回显的信息了
这就涉及到了第二个场景也就是报文发送的场景。注意服务器端程序先通过recvfrom函数调用获取了客户端的地址和端口信息这当然是可以的因为UDP报文里面包含了这部分信息。然后我们看到服务器端又通过调用sendto函数把客户端的地址和端口信息告诉了内核协议栈可以肯定的是之后发送的UDP报文就带上了**客户端的地址和端口信息**,通过客户端的地址和端口信息,可以找到对应的套接字和应用程序,完成数据的收发。
```
//服务器端程序先通过recvfrom函数调用获取了客户端的地址和端口信息
int n = recvfrom(socket_fd, message, MAXLINE, 0, (struct sockaddr *) &amp;client_addr, &amp;client_len);
message[n] = 0;
printf(&quot;received %d bytes: %s\n&quot;, n, message);
char send_line[MAXLINE];
sprintf(send_line, &quot;Hi, %s&quot;, message);
//服务器端程序调用send函数把客户端的地址和端口信息告诉了内核
sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &amp;client_addr, client_len);
```
从代码中可以看到这里的connect的作用是记录**客户端目的地址和端口–套接字**的关系,而之所以能正确收到从服务器端发送的报文,那是因为系统已经记录了**客户端源地址和端口–套接字**的映射关系。
## 我们是否可以对一个 UDP套接字进行多次connect的操作?
我们知道对于TCP套接字connect只能调用一次。但是对一个UDP套接字来说进行多次connect操作是被允许的这样主要有两个作用。
第一个作用是可以重新指定新的IP地址和端口号第二个作用是可以断开一个已连接的套接字。为了断开一个已连接的UDP套接字第二次调用connect时调用方需要把套接字地址结构的地址族成员设置为AF_UNSPEC。
## 第11讲中程序和时序图的解惑
在11讲中我们讲了关闭连接的几种方式有同学对这一篇文章中的程序和时序图存在疑惑并提出了下面几个问题
1. 代码运行结果是先显示hi data1之后才接收到标准输入的close为什么时序图中画的是先close才接收到hi data1
1. 当一方主动close之后另一方发送数据的时候收到RST。主动方缓冲区会把这个数据丢弃吗这样的话应用层应该读不到了吧
1. 代码中SIGPIPE的作用不是忽略吗为什么服务器端会退出
1. 主动调用socket的那方关闭了写端但是还没关闭读端这时候socket再读到数据是不是就是RST然后再SIGPIPE如果是这样的话为什么不一次性把读写全部关闭呢
我还是再仔细讲一下这个程序和时序图。
首先回答问题1。针对close这个例子时序图里画的close表示的是客户端发起的close调用。
关于问题2“Hi, data1”确实是不应该被接收到的这个数据报即使发送出去也会收到RST回执应用层是读不到的。
关于问题3中SIGPIPE的作用事实上默认的SIGPIPE忽略行为就是退出程序什么也不做当然实际程序还是要做一些清理工作的。
问题4的理解是错误的。第二个例子也显示了如果主动关闭的一方调用shutdown关闭没有关闭读这一端主动关闭的一方可以读到对端的数据注意这个时候主动关闭连接的一方是在使用read方法进行读操作而不是write写操作不会有RST的发生更不会有SIGPIPE的发生。
<img src="https://static001.geekbang.org/resource/image/f2/9a/f283b804c7e33e25a900fedc8c36f09a.png" alt="">
## 总结
以上就是提高篇中一些同学的疑问。我们常说,学问学问,有学才有问。我希望通过今天的答疑可以让你加深对文章的理解,为后面的模块做准备。
这篇文章之后,我们就将进入到专栏中最重要的部分,也就是性能篇和实战篇了,在性能篇和实战篇里,我们将会使用到之前学到的知识,逐渐打造一个高性能的网络程序框架,你,准备好了吗?
如果你觉得今天的答疑内容对你有所帮助,欢迎把它转发给你的朋友或者同事,一起交流一下。