3.4.1 针对 TCP 应该如何 Socket 编程?
-
图中的内容大概为:
- 服务端和客户端初始化 socket,得到文件描述符
- 服务端调用 bind,将 socket 绑定在指定的 IP 地址和端口
- 服务端调用 listen,进行监听
- 服务端调用 accept,等待客户端连接
- 客户端调用 connect,向服务端的地址和端口发起连接请求
- 服务端 accept 返回用于传输的 socket 的文件描述符
- 客户端调用 write 写入数据;服务端调用 read 读取数据
- 客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭
-
这里需要注意的是,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据
-
所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket
- 一个叫作监听 socket:负责建立连接(只能有一个)
- 一个叫作已完成连接 socket:用于传输数据(可能有多个)
-
成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样
-
accept与三次握手无关,它的作用是从全连接队列中取出一个已经完成三次握手的连接
-
调用accept时,如果当前全连接队列是空的,会阻塞,直到全连接队列不为空为止
伪文件是什么?
- 伪文件(pseudo file)是一种特殊的文件,它不对应于实际的物理存储,所以有时也被称为虚拟文件(virtual file),因为它们并不实际存在于磁盘上,而是通过内核提供的接口动态生成的
- 在 Linux 系统中,伪文件常用于与系统内核进行交互,获取系统信息或调整系统参数
读取伪文件会返回0吗?
-
取决于文件的内容和读取的方式
-
一般来说,读取伪文件时不会返回0。读取伪文件时返回的非零值,表示已读取的字节数,因为伪文件是在内存中动态生成的,并没有一个确定的文件大小。因此,如果读取伪文件时返回0,则通常表示读取失败或到达了文件末尾,而不是表示文件实际大小为0
-
但是,有些伪文件可能会返回0,这取决于具体的实现方式和应用场景
-
例如,在某些系统中,/proc/stat 文件的第一行可能会返回0,表示系统启动以来的CPU利用率。这是因为第一行的数据是累加值,而不是实时值,所以在系统启动后第一次读取该文件时会返回0
白哥讲的???
- 读取时会不会返回0???
- 读取普通文件时会遇到
- 读到末尾,返回0,文件结尾标志EOF
- 协议栈干的,收到fin后,会在socket的末尾放一个EOF
- 读普通文件read不会阻塞,普通文件没阻塞这个属性
- 读到末尾,返回0,文件结尾标志EOF
- 读设备或者管道套接字,一般不会
- 读取普通文件时会遇到
- 管道有没有写端???
- 就是有没有管道的文件描述符?没有的话会被系统自动释放掉
- 管道是内核空间的内存,不会轻易泄漏
- 就是有没有管道的文件描述符?没有的话会被系统自动释放掉
- 管道有没有数据???
- 以后有没有可能有数据?
- 没有数据,写端也没有,未来肯定没有数据,会直接返回
- 没有数据,有写端,未来可能有数据,等待
- 以后有没有可能有数据?
3.4.2.listen 时候参数 backlog 的意义?
- Linux内核中会维护两个队列:
- 半连接队列(SYN 队列):接收到一个 SYN 建立连接请求,处于 SYN_RCVD 状态
- 全连接队列(Accpet 队列):已完成 TCP 三次握手过程,处于 ESTABLISHED 状态
int listen (int socketfd, int backlog)
- 对上面的代码有:
- 参数一 socketfd 为 socketfd 文件描述符
- 参数二 backlog,这参数在历史版本有一定的变化,现在是指accept队列的大小
listen详细介绍参考文章
listen的定义
- 在Linux下listen()的定义是这样的
#include <sys/type.h>#include <sys/socket.h>int listen(int fd , int backlog)
- listen()一般用在bind()之后accept()之前,其中第一个参数是通过scoket()函数获得的套接字编号,第二个参数backlog是他所能监听的最大套接字数量
listen的第二个参数
-
listen的第二个参数backlog,backlog的含义众说纷纭,其中有以下说法:
- 1.SYN队列的大小
- 2.ACCEPT队列的大小
- 3.是SYN+ACCEPT的大小
-
下面是实践验证:
-
要验证的点有两个:
- 1.connect在三次握手后就会返回
- 2.backlog的具体含义
// 服务器代码#include <stdlib.h>#include <stdio.h>#include <string.h>#include <sys/types.h>#include <errno.h>#include <sys/socket.h>#include <netinet/in.h>#include <unistd.h>#include <arpa/inet.h>#include <pthread.h>#include <fcntl.h>
int main(){ int sock_fd; struct sockaddr_in seve, cli;
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
memset(&seve, 0, sizeof(struct sockaddr_in)); seve.sin_family = AF_INET; seve.sin_port = htons(4555); seve.sin_addr.s_addr = htonl(INADDR_ANY);
bind(sock_fd, (struct sockaddr *)&seve, sizeof(struct sockaddr_in));
if (listen(sock_fd, 5) < 0) perror("listen");
socklen_t len = sizeof(struct sockaddr_in);
sleep(30); printf("连接一个\n"); int newfd = accept(sock_fd, (struct sockaddr *)&cli, &len);
sleep(30); printf("连接一个\n"); newfd = accept(sock_fd, (struct sockaddr *)&cli, &len);
sleep(30); printf("连接一个\n"); newfd = accept(sock_fd, (struct sockaddr *)&cli, &len);
while (1) { }}
// 客户端代码#include <stdlib.h>#include <stdio.h>#include <string.h>#include <sys/types.h>#include <errno.h>#include <sys/socket.h>#include <netinet/in.h>#include <unistd.h>#include <arpa/inet.h>#include <pthread.h>#include <fcntl.h>
int main(int argc, char *argv[]){ int sock_fd, ret; int haha; struct sockaddr_in seve; sock_fd = socket(AF_INET, SOCK_STREAM, 0); seve.sin_family = AF_INET; seve.sin_port = htons(4555); inet_aton("127.0.0.1", &seve.sin_addr); // 所连接的局域网的IP if (connect(sock_fd, (struct sockaddr *)&seve, sizeof(struct sockaddr_in)) < 0) perror("connect"); printf("连接成功\n"); while (1) { }}
-
两个代码在root下执行
-
监控4555端口的情况(此数据是在一台电脑下开多个终端测试)
- 命令:
watch -n 1 -d 'netstat -natp | grep "4555"'
- 命令:
-
因为是一个电脑开多个终端所以每一次执行客户端代码都会显示两行
-
这是最开始将打开了多个终端去连接服务器,此时正在sleep,我们可以看到ACCEPT队列里有6个内容,而我们的backlog值为5;所以我们可以得出结论backlog的值是规定==ACCEPT队列==大小的
-
当ACCEPT队列满了时后来的客户端,就会被放到SYN队列中
-
在早期 Linux 内核 backlog 是 SYN 队列大小,也就是未完成的队列大小
-
在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成连接建立的队列长度,所以现在通常认为 backlog 是 accept 队列
-
但是上限值是内核参数 somaxconn 的大小,也就说 accpet 队列长度 = ==min(backlog, somaxconn)==
3.4.3.accept 发生在三次握手的哪一步?
- 我们先看看客户端连接服务端时,发送了什么?
-
客户端的协议栈向服务端发送了 SYN 包,并告诉服务端当前发送序列号 client_isn,客户端进入 SYN_SENT 状态
-
服务端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 client_isn+1,表示对 SYN 包 client_isn 的确认,同时服务端也发送一个 SYN 包,告诉客户端当前我的发送序列号为 server_isn,服务端进入 SYN_RCVD 状态
-
客户端协议栈收到 ACK 之后,使得应用程序从 connect 调用返回,表示客户端到服务端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务端的 SYN 包进行应答,应答数据为 server_isn+1
-
ACK 应答包到达服务端后,服务端的 TCP 连接进入 ESTABLISHED 状态,同时服务端协议栈使得 accept 阻塞调用返回,这个时候服务端到客户端的单向连接也建立成功。至此,客户端与服务端两个方向的连接都建立成功
-
从上面的描述过程,我们可以得知客户端 connect 成功返回是在第二次握手,服务端 accept 成功返回是在三次握手成功之后
3.4.4.客户端调用 close 了,连接断开的流程是什么?
- 我们看看客户端主动调用了 close,会发生什么?
- 客户端调用 close,表明客户端没有数据需要发送了,则此时会向服务端发送 FIN 报文,进入 FIN_WAIT_1 状态
- 服务端接收到了 FIN 报文,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,应用程序可以通过 read 调用来感知这个 FIN 包。这个 EOF 会被放在已排队等候的其他已接收的数据之后,这就意味着服务端需要处理这种异常情况,因为 EOF 表示在该连接上再无额外数据到达。此时,服务端进入 CLOSE_WAIT 状态
- 接着,当处理完数据后,自然就会读到 EOF,于是也调用 close 关闭它的套接字,这会使得服务端发出一个 FIN 包,之后处于 LAST_ACK 状态
- 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态
- 服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态
- 客户端经过 2MSL 时间之后,也进入 CLOSE 状态
3.4.5.没有 accept,能建立 TCP 连接吗?
- 答案:可以的
- accpet 系统调用并不参与 TCP 三次握手过程,它只是负责从 TCP 全连接队列取出一个已经建立连接的 socket,用户层通过 accpet 系统调用拿到了已经建立连接的 socket,就可以对该 socket 进行读写操作了
3.4.6 没有 listen,能建立 TCP 连接吗?
- 答案:可以的
- 客户端是可以自己连自己的形成连接(TCP自连接),也可以两个客户端同时向对方发出请求建立连接(TCP同时打开),这两个情况都有个共同点,就是没有服务端参与,也就是没有 listen,就能 TCP 建立连接
3.5 重传机制
- TCP 实现可靠传输的方式之一,是通过序列号与确认应答
- 在 TCP 中,当发送端的数据到达接收主机时,接收端主机会返回一个确认应答消息,表示已收到消息
-
但在错综复杂的网络,并不一定能如上图那么顺利能正常的数据传输,万一数据在传输过程中丢失了呢?
-
所以 TCP 针对数据包丢失的情况,会用重传机制解决
-
接下来说说常见的重传机制:
- 超时重传
- 快速重传
- SACK
- D-SACK
3.5.1.什么是超时重传?
- 重传机制的一个方式,就是在发送数据时,设定一个定时器,当超过指定的时间后,没有收到对方的 ACK 确认应答报文,就会重发该数据,也就是我们常说的超时重传
- TCP 会在以下两种情况发生超时重传:
- 数据包丢失
- 确认应答丢失
超时时间应该设置为多少呢?
-
回忆学过的概念:
- RTT:Round-Trip Time 往返时延
- RTO:Retransmission Timeout 超时重传时间
- TTL:Time to Live 数据包经过路由器的数量,也叫跳数
- MSL:Maximum Segment Lifetime 报文最大生存时间
- MSS:Maximum Segment Size TCP报文段有效载荷的最大长度
-
我们先来了解一下什么是 RTT(Round-Trip Time 往返时延),从下图我们就可以知道:
- RTT 指的是数据发送时刻到接收到确认的时刻的差值,也就是包的往返时间
- 超时重传时间是以 RTO (Retransmission Timeout 超时重传时间)表示
- 假设在重传的情况下,超时时间 RTO 「较长或较短」时,会发生什么事情呢?
- 上图中有两种超时时间不同的情况:
- 当超时时间 RTO 较大时,重发就慢,丢了老半天才重发,没有效率,性能差
- 当超时时间 RTO 较小时,会导致可能并没有丢就重发,于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发
- 精确的测量超时时间 RTO 的值是非常重要的,这可让我们的重传机制更高效
- 根据上述的两种情况,我们可以得知,超时重传时间 RTO 的值应该略大于报文往返 RTT 的值
-
至此,可能大家觉得超时重传时间 RTO 的值计算,也不是很复杂嘛
-
好像就是在发送端发包时记下 t0 ,然后接收端再把这个 ack 回来时再记一个 t1,于是 RTT = t1 – t0。没那么简单,这只是一个采样,不能代表普遍情况
-
实际上「报文往返 RTT 的值」是经常变化的,因为我们的网络也是时常变化的。也就因为「报文往返 RTT 的值」 是经常波动变化的,所以「超时重传时间 RTO 的值」应该是一个动态变化的值
-
我们来看看 Linux 是如何计算 RTO 的呢?
-
估计往返时间,通常需要采样以下两个:
- 需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个平滑 RTT 的值,而且这个值还是要不断变化的,因为网络状况不断地变化
- 除了采样 RTT,还要采样 RTT 的波动范围,这样就避免如果 RTT 有一个大的波动的话,很难被发现的情况
-
RFC6289 建议使用以下的公式计算 RTO:
- 其中 SRTT 是计算平滑的RTT ,DevRTR 是计算平滑的RTT 与 最新 RTT 的差距。
- 在 Linux 下,α = 0.125,β = 0.25, μ = 1,∂ = 4。别问怎么来的,问就是大量实验中调出来的
- 如果超时重发的数据,再次超时的时候,又需要重传的时候,TCP 的策略是超时间隔加倍
- 也就是每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送
- 超时触发重传存在的问题是,超时周期可能相对较长。那是不是可以有更快的方式呢?
- 于是就可以用「快速重传」机制来解决超时重发的时间等待
3.5.2.什么是快速重传?
-
TCP 还有另外一种快速重传(Fast Retransmit)机制,它不以时间为驱动,而是以数据驱动重传
-
快速重传机制,是如何工作的呢?其实很简单,一图胜千言
-
在快速重传机制中,TCP在收到乱序的包后,会立马确认
-
在上图,发送方发出了 1,2,3,4,5 份数据:
- 第一份 Seq1 先送到了,于是就 Ack 回 2;
- 结果 Seq2 因为某些原因没收到,Seq3 到达了,于是还是 Ack 回 2;
- 后面的 Seq4 和 Seq5 都到了,但还是 Ack 回 2,因为 Seq2 还是没有收到;
- 发送端收到了三个 Ack = 2 的确认,知道了 Seq2 还没有收到,就会在定时器过期之前,重传丢失的 Seq2。
- 最后,收到了 Seq2,此时因为 Seq3,Seq4,Seq5 都收到了,于是 Ack 回 6 。 所以,快速重传的工作方式是当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段。
-
快速重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题。就是重传的时候,是重传一个,还是重传所有的问题。
-
举个例子,假设发送方发了 6 个数据,编号的顺序是 Seq1 ~ Seq6 ,但是 Seq2、Seq3 都丢失了,那么接收方在收到 Seq4、Seq5、Seq6 时,都是回复 ACK2 给发送方,但是发送方并不清楚这连续的 ACK2 是接收方收到哪个报文而回复的, 那是选择重传 Seq2 一个报文,还是重传 Seq2 之后已发送的所有报文呢(Seq2、Seq3、 Seq4、Seq5、 Seq6) 呢?
- 如果只选择重传 Seq2 一个报文,那么重传的效率很低。因为对于丢失的 Seq3 报文,还得在后续收到三个重复的 ACK3 才能触发重传。
- 如果选择重传 Seq2 之后已发送的所有报文,虽然能同时重传已丢失的 Seq2 和 Seq3 报文,但是 Seq4、Seq5、Seq6 的报文是已经被接收过了,对于重传 Seq4 ~Seq6 折部分数据相当于做了一次无用功,浪费资源。
-
可以看到,不管是重传一个报文,还是重传已发送的报文,都存在问题。
-
为了解决不知道该重传哪些 TCP 报文,于是就有 SACK 方法。
3.5.3 什么是SACK 方法?
- 还有一种实现重传机制的方式叫:SACK( Selective Acknowledgment), 选择性确认。
- 这种方式需要在 TCP 头部「选项」字段里加一个 SACK 的东西,它可以将已收到的数据的信息发送给「发送方」,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据。
- 如下图,发送方收到了三次同样的 ACK 确认报文,于是就会触发快速重发机制,通过 SACK 信息发现只有 200~299 这段数据丢失,则重发时,就只选择了这个 TCP 段进行重复。
- 如果要支持 SACK,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)。
3.5.4.什么是Duplicate SACK?
- Duplicate SACK 又称 D-SACK,其主要使用了 SACK 来告诉「发送方」有哪些数据被重复接收了。
- 下面举例两个栗子,来说明 D-SACK 的作用。
栗子一号:ACK 丢包
- 在图中:
- 「接收方」发给「发送方」的两个 ACK 确认应答都丢失了,所以发送方超时后,重传第一个数据包(3000 ~ 3499)
- 于是「接收方」发现数据是重复收到的,于是回了一个 SACK = 3000
3500,告诉「发送方」 30003500 的数据早已被接收了,因为 ACK 都到了 4000 了,已经意味着 4000 之前的所有数据都已收到,所以这个 SACK 就代表着 D-SACK。 - 这样「发送方」就知道了,数据没有丢,是「接收方」的 ACK 确认报文丢了。
栗子二号:网络延时
-
在图中:
- 数据包(1000~1499) 被网络延迟了,导致「发送方」没有收到 Ack 1500 的确认报文。
- 而后面报文到达的三个相同的 ACK 确认报文,就触发了快速重传机制,但是在重传后,被延迟的数据包(1000~1499)又到了「接收方」;
- 所以「接收方」回了一个 SACK=1000~1500,因为 ACK 已经到了 3000,所以这个 SACK 是 D-SACK,表示收到了重复的包。
- 这样发送方就知道快速重传触发的原因不是发出去的包丢了,也不是因为回应的 ACK 包丢了,而是因为网络延迟了。
-
可见,D-SACK 有这么几个好处:
- 可以让「发送方」知道,是发出去的包丢了,还是接收方回应的 ACK 包丢了;
- 可以知道是不是「发送方」的数据包被网络延迟了;
- 可以知道网络中是不是把「发送方」的数据包给复制了;
在 Linux 下可以通过 net.ipv4.tcp_dsack 参数开启/关闭这个功能(Linux 2.4 后默认打开)。
3.6 滑动窗口
3.6.1.引入窗口概念的原因是什么?
- 我们都知道 TCP 是每发送一个数据,都要进行一次确认应答。当上一个数据包收到了应答了, 再发送下一个。
- 这个模式就有点像我和你面对面聊天,你一句我一句。但这种方式的缺点是效率比较低的。
- 如果你说完一句话,我在处理其他事情,没有及时回复你,那你不是要干等着我做完其他事情后,我回复你,你才能说下一句话,很显然这不现实。
- 所以,这样的传输方式有一个缺点:数据包的往返时间越长,通信的效率就越低。
- 为解决这个问题,TCP 引入了窗口这个概念。即使在往返时间较长的情况下,它也不会降低网络通信的效率。
- 那么有了窗口,就可以指定窗口大小,窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值。
- 窗口的实现实际上是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。
- 假设窗口大小为 3 个 TCP 段,那么发送方就可以「连续发送」 3 个 TCP 段,并且中途若有 ACK 丢失,可以通过「下一个确认应答进行确认」。如下图:
- 图中的 ACK 600 确认应答报文丢失,也没关系,因为可以通过下一个确认应答进行确认,只要发送方收到了 ACK 700 确认应答,就意味着 700 之前的所有数据「接收方」都收到了。这个模式就叫累计确认或者累计应答。
3.6.2.窗口大小由哪一方决定?
- TCP 头里有一个字段叫 Window,也就是窗口大小。
- 这个字段是==接收端==告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。
- 所以,通常窗口的大小是由接收方的窗口大小来决定的。
- 发送方发送的数据大小不能超过接收方的窗口大小,否则接收方就无法正常接收到数据。
3.6.3.发送方的滑动窗口
- 我们先来看看发送方的窗口,下图就是发送方缓存的数据,根据处理的情况分成四个部分,其中深蓝色方框是发送窗口,紫色方框是可用窗口:
-
在图中:
- #1 是已发送并收到 ACK确认的数据:1~31 字节
- #2 是已发送但未收到 ACK确认的数据:32~45 字节
- #3 是未发送但总大小在接收方处理范围内(接收方还有空间):46~51字节
- #4 是未发送但总大小超过接收方处理范围(接收方没有空间):52字节以后
-
在下图,当发送方把数据「全部」都一下发送出去后,可用窗口的大小就为 0 了,表明可用窗口耗尽,在没收到 ACK 确认之前是无法继续发送数据了。
- 在下图,当收到之前发送的数据 32
36 字节的 ACK 确认应答后,如果发送窗口的大小没有变化,则滑动窗口往右边移动 5 个字节,因为有 5 个字节的数据被应答确认,接下来 5256 字节又变成了可用窗口,那么后续也就可以发送 52~56 这 5 个字节的数据了。
3.6.4.程序是如何表示发送方的四个部分的呢?
-
看看就行,不是很重要
-
TCP 滑动窗口方案使用三个指针来跟踪在四个传输类别中的每一个类别中的字节。其中两个指针是绝对指针(指特定的序列号),一个是相对指针(需要做偏移)。
-
在图中:
- SND.WND:表示发送窗口的大小(大小是由接收方指定的);
- SND.UNA(Send Unacknoleged):是一个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是 #2 的第一个字节。
- SND.NXT:也是一个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是 #3 的第一个字节。
- 指向 #4 的第一个字节是个相对指针,它需要 SND.UNA 指针加上 SND.WND 大小的偏移量,就可以指向 #4 的第一个字节了。
-
那么可用窗口大小的计算就可以是:
- 可用窗口大小 = SND.WND -(SND.NXT - SND.UNA)
3.6.5.接收方的滑动窗口
- 接下来我们看看接收方的窗口,接收窗口相对简单一些,根据处理的情况划分成三个部分:
- #1 + #2 是已成功接收并确认的数据(等待应用进程读取);
- #3 是未收到数据但可以接收的数据;
- #4 未收到数据并不可以接收的数据;
- 其中三个接收部分,使用两个指针进行划分:
- RCV.WND:表示接收窗口的大小,它会通告给发送方。
- RCV.NXT:是一个指针,它指向期望从发送方发送来的下一个数据字节的序列号,也就是 #3 的第一个字节。
- 指向 #4 的第一个字节是个相对指针,它需要 RCV.NXT 指针加上 RCV.WND 大小的偏移量,就可以指向 #4 的第一个字节了。
3.6.6.接收窗口和发送窗口的大小是相等的吗?
- 并不是完全相等,接收窗口的大小是约等于发送窗口的大小的。
- 因为滑动窗口并不是一成不变的。比如,当接收方的应用进程读取数据的速度非常快的话,这样的话接收窗口可以很快的就空缺出来。那么新的接收窗口大小,是通过 TCP 报文中的 Windows 字段来告诉发送方。那么这个传输过程是存在时延的,所以接收窗口和发送窗口是约等于的关系。