目录
- [1. 回顾端口号](#1. 回顾端口号)
- [2. UDP协议](#2. UDP协议)
-
- [2.1 理解报头](#2.1 理解报头)
- [2.2 UDP的特点](#2.2 UDP的特点)
- [2.3 UDP的缓冲区及注意事项](#2.3 UDP的缓冲区及注意事项)
- [3. TCP协议](#3. TCP协议)
-
- [3.1 报头](#3.1 报头)
- [3.2 流量控制](#3.2 流量控制)
- [2.3 数据发送模式](#2.3 数据发送模式)
- [3.4 捎带应答](#3.4 捎带应答)
- [3.5 URG && 紧急指针](#3.5 URG && 紧急指针)
- [3.6 PSH](#3.6 PSH)
- [3.7 RES](#3.7 RES)
1. 回顾端口号
-
在 TCP/IP 协议中,用 "源IP", "源端口号", "目的IP","目的端口号","协议号" 这样一个五元组来标识一个通信(可以通过
netstat -n
查看)。 -
端口号范围划分:
-
0 - 1023:知名端口号,HTTP,FTP,SSH等这些广为使用的应用层协议,他们的端口号都是固定的。
ssh(22),ftp(21),telnet(23),http(80),https(443)
-
1024 - 65535:操作系统动态分配的端口号。客户端程序的端口号,就是由操作系统从这个范围分配的。
-
-
cat /etc/services
:看到知名端口号。 -
两个问题(详细见 网络基础(二)):
- 一个进程是否可以bind多个端口号? ------ 可以。
- 一个端口号是否可以被多个进程bind? ------ 不可以。
2. UDP协议
-
以UDP为例:当我们在客户端的应用层,通过 sendto 系统调用,将数据发送到服务端,站在网络协议栈的角度看待,该数据实际上是先被拷贝到传输层(传输层属于OS),而这部分数据就是 UDP 中看到的数据,应用层的报文经由传输层时,OS会封装一层传输层的报头,即 UDP 报头。
分装报头时,发送端会随机形成一个源端口,并填充目的端口号。其中的 UDP 长度代表的是整个 UDP 报文的长度,检验和则是检验整个报文在传输过程中是否出错。
因此,我们过去在 udp_socket 编程时,客户端需要在发送请求的同时,填充服务端的端口号,以及定义端口号时,我们采用的类型是 uint16_t,这一切都是源于 UDP 的报头决定的!
-
而因为网络通信时,报文在协议栈中自顶向下是要分装的,最后再自底向上解包分用。因此,我们需要解决
- 报头和有效载荷的分离问题:固定报头长度。
- 有效载荷是如何向上交付的问题:绑定目的端口号。
- UDP 是面向数据报的,如何保证接收到的报文是完整的? ------ 通过报头长度确定,如果报头长度小于8字节,报头都有问题,整个报文直接丢弃;大于8字节,UDP长度 - 报头长度 = 数据长度。
2.1 理解报头
-
我们在各种教科书上看到的报头都形如上图,但是报头究竟是个什么东西呢?
一个描述报头的结构体,所谓报头,就是一个结构化的数据。
cstruct udphdr { uint16_t src_port, dst_port; uint16_t udp_len, checksum; }
但是,一个服务器是同时对接多个客户端的,因此服务器内是会存在大量的报文的,因此操作系统内可能积攒大量的报文,可能还没有向应用层进行交付,那么操作系统就需要对这些 UDP/TCP 报文管理起来。
内核中存在一个 sk_buff 结构体,其中有 *data、*tail 两个字段的指针(指向同一位置),并且在内核中定义一段缓冲区,用户保存接收到的报文数据。假设应用层通过 sendto 将 "hello" 发送到传输层,那么在拷贝到传输层时,*data 指针就向前移动 5 个字节,用于存储上层发送的数据。接着,还需要定义一个 udphdr 报头对象,*data 指针继续向前偏移 8 个字节,用于存储报头数据。这就是 UDP 封装。
而 UDP 层可能积攒了很多的报文数据,只需要通过
struct sk_buff *next
字段,将报文以链表的形式组织起来。这样一来,对报文的管理,就转变为对链表的增删查改。 -
更重要的是,不同端的主机通信,采用的也是网络协议栈,同样也有传输层。什么是协议?双方都需要按照同样的规则、同样的标准来通信。因此,在不同端,无论收发信息,采用的都是 udphdr 这样的描述结构体,双方使用同样的数据类型来解释收到的 UDP 报头的 8 个字节。因此协议就是结构化数据,协议就是一种约定。
2.2 UDP的特点
-
无连接:知道对端的 IP 和端口号就直接进行传输,不需要建立连接。
-
不可靠:没有确认机制,没有重传机制;如果因为网络故障该段无法发到对方,UDP协议层也不会给应用层返回任何错误信息。
-
面向数据报:不能够灵活的控制读写数据的次数和数量(在向上层交付时,UDP 清楚的报文的有效载荷是多少个字节)。
应用层交给UDP多长的报文,UDP原样发送,既不会拆分,也不会合并。
例如:用UDP传输100个字节的数据,如果发送端调用一次 sendto,发送100个字节,那么接收端也必须调用对应的一次 recvfrom,接收100个字节,而不能循环调用10次 recvfrom,每次接收10个字节。
-
UDP的 socket 既能读,也能写,这个概念叫做全双工。
2.3 UDP的缓冲区及注意事项
-
UDP没有真正意义上的发送缓冲区,调用 sendto 会直接交给内核,由内核将数据传给网络层协议进行后续的传输动作。
-
UDP具有接收缓冲区,但是这个接收缓冲区不能保证收到的 UDP 报的顺序和发送 UDP 报的顺序一致(即可能先发的后收到)。如果缓冲区满了,再到达的UDP数据就会被丢弃。
-
我们注意到,UDP 协议首部中有一个16位的最大长度,也就是说一个 UDP 能传输的数据最大长度是64K(包含UDP首部)。 然而 64K 在当今的互联网环境下,是一个非常小的数字,如果我们需要传输的数据超过64K,就需要在应用层手动的分包,然后多次发送,并在接收端手动拼接。
3. TCP协议
3.1 报头
-
同样的,TCP 报头在内核中也是一种结构化的字段。
-
4 位首部长度:取值范围 [ 0000 , 1111 ] = [ 0 , 15 ] [0000,1111] = [0, 15] [0000,1111]=[0,15],单位是 4个字节,即 4 位首部长度实际的取值范围为 [ 0 , 15 ] ∗ 4 = [ 0 , 60 ] [0, 15] * 4 = [0,60] [0,15]∗4=[0,60],也就意味着选项字段的大小,最多为 40 40 40 字节。
假设 TCP 报头的长度为 20 20 20 字节,那么首部长度即为 x ∗ 4 = 20 x * 4 = 20 x∗4=20, x = 5 x = 5 x=5 ,即 x = 0101 x = 0101 x=0101;
有了首部长度 x x x,默认先读取前 20 20 20 字节,提取首部长度字段,如果 x = 20 x = 20 x=20,即代表报头为 20 20 20 字节;如果 x > 20 x > 20 x>20, x − 20 = x - 20 = x−20= 选项字段长度,剩下的就是数据长度。这样一来,即解决了报头和有效载荷的分离问题。
与 UDP 协议同理,当应用层将数据通过 sendto 传递给传输层后,传输层申请 sk_buff 缓冲区,接着将数据从用户空间拷贝到 sk_buff 缓冲区,再为 TCP 报文构建协议报头并填充字段,最后将报头添加到 sk_buff 缓冲区中。至此,完成对 TCP 报文的封装。
-
6位标志位(后面详讲):
- URG: 紧急指针是否有效
- ACK:确认号是否有效(即应答报文设置的标志位)
- PSH:提示接收端应用程序立刻从 TCP 缓冲区把数据读走
- RST:对方要求重新建立连接,我们把携带 RST 标识的称为复位报文段
- SYN:请求建立连接,我们把携带 SYN标识的称为同步报文段(用于三次握手建立连接)
- FIN:通知对方,本端要关闭了,我们称携带 FIN 标识的为结束报文段(四次挥手关闭连接)
3.2 流量控制
- 与 UDP 不同的是,TCP 拥有独立的 发送和接收 缓冲区。并且,单独的客户端既可以同时发送和接收,因此 TCP 是全双工通信。
-
假设 TCP 通信的双方,一方发的特别快,另一方处理数据比较慢,但是发送方仍一直持续的发送,那么接收缓冲区就会被写满。但是发送方对于接收缓冲区已经被写满并不知情,所以继续在发送数据,但是数据又存不下了,于是报文被直接丢弃了?
丢弃后,虽然 TCP 后续可能对该报文进行重传。但是,可能这份报文可能经过无数个路由器,转发了无数次报文,好不容易到才到达目的地,花费这么大的代价,结果被丢弃了??这种做法是不合理的!
面对上述情况,本质问题就是,发送方无法得知接收方缓冲区的情况。如果接受方来不及接收报文了,发送方 放缓 或 暂停发送,问题就能够得到解决,而这种解决方法称为 流量控制。
在实际 tcp_socket 通信时,我们并没有遇到这类问题,因为上面的工作,已经由发送方的 TCP 完成了,用户并不需要关心,由 TCP 全权负责,这也是为什么 TCP 称为 传输控制协议!在实际的网络通信中,用户层调用 sendto 将数据传递到传输层后,在上层,我们就认为数据已经发送出去了,而实际上,可能数据还存储在本地操作系统的缓冲区内,还未进行发送。
-
TCP 为了保证传输的可靠性,协议要求 TCP 的接收方必须在收到报文后进行确认(向客户端进行响应反馈), 即 确认应答机制。
需要注意的是,假设发送的数据为 " h e l l o " "hello" "hello",真正发送的时候,可不仅仅只有数据,而是将数据封装在报文内,将整个 TCP 报文发送给对方。同样的,接收方在给发送方进行应答时,也是发送的一个 TCP 报文,同时在报头中设置了 ACK 标志位(ACK标志位置 1 1 1)。
-
16 位窗口大小: 记录发送方接收缓冲区的剩余空间大小。
-
那么如何进行流量控制呢?? ------ 发送方能够得知接收方的接收能力。
而接收缓冲区接收能力的强弱,以剩余的空间大小为衡量。因此当接收方在向发送方进行应答时,在应答的报头中的 窗口大小字段,填充自己接收缓冲区的剩余空间大小,那么发送方就能够清楚的进行传输控制了!
即,窗口大小是用于流量控制的字段!
上述一切关于流量控制的方式、细节,全部由 TCP 通信的双方的操作系统协商完成!
-
当接收缓冲区被写满,发送方也就停止发送,但是上层用户可不关心!上层用户还是继续向传输层写写写,那当发送缓冲区也被写满了呢??
这就是典型的生产消费模型,消费者不消费了,生产者就进行要阻塞!即进程就要进入阻塞状态,其本质就是进程在等待 OS 进行发送。进程是一个执行流,OS 也是一个执行流,这就是两个执行流在进行同步!
-
即便 TCP 是传输具有可靠性的,但也无法保证 100% 可靠的网络通信。
假设 C 端给 S 端发送第一条信息,但是在这条信息发送出去,到 S 端进行应答之前这段时间,对于 C 端,它是无法确认这次的报文对方是否收到。
当 S 端给 C 端应答第一条信息后,那么就轮到 S 端无法确认它发出去的报文对方是否收到。那如果要做到 S 端也能够确认自己的报文对方收到了,那就需要 C 端继续发送一次应答,那这次应答的报文,对于 C 端,它又无法确认对方是否收到(因为还没收到对方的应答)。
所以这个是一个死循环,永远无法确认最新的一条信息对方是否收到了,也即最新的一条消息的可靠性其实是无法保证的。但是!可以保证除了最后一条信息之前的所有信息的可靠性,如上图所示,① 和 ② 都是收到了应答,因此除了最新的 ③ 信息,其它都能够保证可靠性。
2.3 数据发送模式
-
像我们上面介绍的通信方式,C 端给 S 端发送数据, S 端需要给 C 端进行应答,反之同理。这些都是串行发送的,通信效率比较慢。
这是 TCP 发送数据常见的方式,将发送时间进行重叠,并发的发送多份报文,并且规定每个报文都需要进行应答,保证可靠性的同时提高效率。
-
由于网络的实际情况错综复杂,因此无法保证接收顺序与发送顺序一致,那么就可能导致数据乱序的问题(可能一份数据拆分为多个报文进行发送),而乱序是不可靠的一种表现,因此在同时发送多份报文的情况下,需要保证报文按序到达。
即报头内的 32 位序号:给报文携带编号。接收方可以根据报头中序号,对接收到的报文进行排序,即可保证报文按序到达。
-
还有一个问题是:C 端发了 4 份报文,S 端是需要给 C 端应答 4 次的,那么 S 端的哪一次应答是对 C 端的哪一次报文做的应答呢???如果不能解决这个问题,那么当发送方发了 10 个报文,最终只收到了 9 个应答,那发送方就无法得知,自己发送的 10 个报文里面,到底是哪个报文丢了!导致也无法进行后续补发报文的操作。
因此报头中还有另一个字段 ----- 32位确认序号:用于记录历史上发送方发送的哪些报文已经被接收方所接收,由接收方在应答报头中填充,对应收到报文的序号值 + 1。
确认序号之前的所有报文已经被接收方收到。例如:确认序号301代表的含义是序号300 200 100 的报文都已经被 S 端收到了。之所以这样设计的原因是:不管是数据还是应答,在网络传输中都有可能丢失,假设 S 端前三次报文都收到了,但是这三次应答都丢失了,最后一份报文的应答 401 没有丢失,那么 C 端在接收到 S 端的 401 应答时,即可以得知 4 份报文, S 端都接收到了。换言之,这样设计,可以允许少量的应答丢失。
3.4 捎带应答
-
看似一切完美,在应答的时候明明可以继续用 32 位序号来保障应答的可靠性,为啥还要多设计出一个32位确认序号呢??
要清楚,我们至始至终,为了方便理解,一直都是以单向通信为例。但实际上的通信是双向的,即 C 端 在向 S 端发数据的同时, S 端 也在给 C 端发数据。
那么 S 端 就需要在应答 C 端的同时,发送自己的数据。如果只有一个 32 位序号,那么应答 和 发数据 这两件事就需要分开来完成,即 S 端需要向 C 端发送两次报文。而有了 32 位确认序号后,这两件事可以同时完成,即发送数据的同时顺带给 C 端应答了!这就叫做捎带应答!!
实际通信时,大部分报文都既是数据,又是对历史报文的应答确认!
总结:TCP 在保证可靠性的同时,还会进行各种提高效率的设定(数据并发发送、捎带应答)!只不过这些工作都有 TCP(内核) 自主完成,用户并不知情。
3.5 URG && 紧急指针
-
标志位:用于标识不同的报文类型。
服务器是同时面对多个客户端的,即需要同时处理来自多个客户端的 TCP 报文,而这些报文可能包含连接建立、连接断开、正常数据传输或异常数据等类型。那么服务器就必须具备识别不同报文类型的能力,才能根据报文类型执行相应的处理动作。
-
我们上面说过,发送方可能同时一次性发送一批报文,并且这批报文的数据是有顺序的,那么就必须保证这批报文按序到达,而按序到达的本质就是队列(先发先收)。但不妨有时候会有一些紧急任务,那么在按序到达的规则下,即便是紧急任务,也要排队!但所谓紧急任务,肯定是需要尽快处理、提前被处理的。
因此面对这种情况,URG 标志位置 1,表明该报文是紧急报文。
-
16位紧急指针:记录紧急数据在有效载荷中的偏移量。
收到报文,先查看标志位,如果 U R G = 1 URG = 1 URG=1,那么根据紧急指针查看紧急数据(紧急数据只有一个字节)。
紧急任务的场景:假设发送方上传一批数据,但是上传过程中发现上传错了,而此时接收方的缓冲区已经积攒了大量的报文还没被处理,这种情况就可以发送紧急指针,示意对方立即终止或暂停上传行为(通过一些固定的标志信息反馈)(如果直接关闭连接,那么接收方需要把缓冲区的数据全部读取完,才能得知发送方已经关闭连接)。
再者,例如当发现服务器运行速度比较慢时,通过给服务器发送紧急指针,进行服务检测。
-
在发送 send 和 接收 recv 的第三个参数 flag,就会提供一些标志位,例如 MSG_OOB 就表示紧急数据。
ssize_t recv(int sockfd, void *buf, size_t len, int flags); MSG_OOB This flag requests receipt of out-of-band data that would not be received in the normal data stream.
3.6 PSH
-
在讲流量控制的时候,我们讲过,当接受发给发送方应答的同时,报头中的窗口大小会记录接收方缓冲区剩余空间大小,以便发送方进行传输控制。
假设接收方上层 http 处理数据比较耗时,导致接收缓冲区被写满,随后接收方在应答报文中将窗口大小设置为 0,告知对方无法接收更多数据。一段时间后,接收方的上层处理了一些数据,缓冲区腾出一些空间,但此时的发送方并不知情,还在傻傻的阻塞等待。
因此,为了避免这种情况,发送方会定期发送空闲时间较长的询问报文(这种方式效率较低,因为它依赖超时来触发询问),然后等待接收方的应答。另一方面,当接收方的缓冲区的剩余空间由 0 0 0 变为 ! 0 !0 !0 时,接收方也会主动向发送方进行窗口大小的更新报文。
-
这里所谓的询问报文,也是完整的 TCP 报头,同时将 PSH 标志位置 1。即 PSH:告知对方,尽快将数据向上层交付。
但 PSH 不仅仅应用于询问报文中,当数据需要被尽快交付时,也可以使用。最典型的案例就是,xshell 远程连接远端服务器时,为了保证更好的用户体验,xshell 尽可能的让用户输入的每一条指令携带 PSH 标志位,让上层的 ssh 尽快交付解释。
3.7 RES
-
我们一直在说通信,但建立 TCP 通信的前提是建立连接,而建立连接就需要三次握手。
虽然 TCP 是保证可靠性的,但不代表三次握手就必须是成功的,也可能是失败的!保证的不是数据 100% 到达目的地,而是数据到达目的地了 或者 数据传输异常了,TCP 需要知道。
-
三个报文的前两个报文丢失都不影响,假设客户端连接建立请求的报文丢失了,服务端没有收到(服务端知情),那么也就不会给应答。客户端收不到服务端的应答,也就同样知情报文丢了(要么是自己的,要么是对方的应答报文),那么客户端就会进行报文重发。而假设服务端的报文丢了,虽然服务端自己不知情,但是客户端没收到,客户端是知情的,它就不会给服务端发送应答报文,那么服务端收不到应答报文,服务端也知情了。
最麻烦的是最后一次 ACK,假设最后一次客户端的 ACK 丢了,但客户端并不知情。站在客户端的角度,客户端认为,最后一次 ACK 报文发送出去即连接建立成功,而服务端需要等到接收了报文才认为连接建立成功。这种情况就会导致双端连接建立认知不一致的问题。
因为客户端认为连接建立好了,所以它就开始向服务端发送数据,服务端收到数据后,脑袋大大的问号,因为它认为三次握手还没完成,于是服务端就向客户端推送一个报文,并在报头中设置 RST 标志位。客户端收到 RST 标志位的报文后,就重置连接,重新开始三次握手。
-
因此,三次握手的最后一次,是无法保证可靠性的,即三次握手不是一定成功的,但可以通过 RST 标志位做容错处理。
同样的, RST 也不仅仅应用于三次握手。例如当双方通信的过程中,客户端网络中断,连接被释放了。随后客户端恢复了网络环境,但服务端并不知道,于是继续向客户端发送数据。这也是双端连接建立认知不一致的情况。客户端恢复网络后,它认为连接还没建立,但服务器不知情,依旧认为连接还在。这种情况,客户端同样可以向服务器发送 RST 重置连接请求。