之前讲过的http与https都是应用层协议,当应用层协议将报文构建好之后就要将报文往下层传输层进行传递,而传输层就是负责将数据能够从发送端传到接收端。
端口号(port)标识了一个主机上进行通信的不同的应用程序,在TCP/IP协议中,用源IP,源端口号,目的IP,目的端口号,协议号,这样一个五元组来标识一个通信。
cat /etc/services
就可以看到知名端口号netstat是一个用来检查网络状态的重要工具
语法: netstat [选项]
功能:查看网络状态
常用选项:
在查看服务器的进程id时非常方便.
语法: pidof [进程名]
功能:通过进程名, 查看进程id
tcp/ip是属于操作系统的,而Linux下操作系统是用C语言实现的,那么udp协议也就是用C语言实现的。报头(协议)的本质就是结构化数据struct
struct udp_header // 结构体
{
uint16_t src_port;
uint16_t dst_port;
uint16_t udp_len;
uint16_t check;
}
struct udp_header // 位段
{
uint32_t src_port: 16;
uint32_t dst_port: 16;
// ...
}
// 使用的时候就可以申请空间,对空间进行强转
((struct udp_header*)p)->src_port = // ...
((struct udp_header*)p)->dst_port = // ...
((struct udp_header*)p)->udp_len= // ...
((struct udp_header*)p)->check= // ...
UDP传输的过程类似于寄信。
应用层交给UDP多长的报文,UDP原样发送,既不会拆分,也不会合并;
用UDP传输100个字节的数据:
我们注意到,UDP协议首部中有一个16位的最大长度。也就是说一个UDP能传输的数据最大长度是64K(包含UDP首部)。
然而64K在当今的互联网环境下,是一个非常小的数字。如果我们需要传输的数据超过64K,就需要在应用层手动的分包,多次发送,并在接收端手动拼装;
TCP协议全称为"传输控制协议"
与UDP协议一样我们先来关注两个问题就是1.报头和有效载荷如何分离;2.有效载荷如何做到交付;
首先,在TCP的协议中新增了一个选项字段,若是没有这个字段就表示者报头的大小就是20字节,若是在选项字段中存有数据,那么其长度就可以从四位首部长度中进行获取。首部长度计算有基本单位:4字节,这个对应的就是报文每一行的长度,那么4位首部长度表示的就是报文的报头一共有多少行,这样进行计算的话就可以的值整个报头的范围就是0-60字节,因此选项的有效长度就是40字节。举例一个例子,没有选项字段的报头是20字节,经过折算就表示在4位首部长度中需要填写的数值就是5 -> 0101。
众所周知,TCP的可靠的传输协议,那么下面我们就从可靠性出发来理解TCP协议:
要理解可靠性,我们首先就要知道什么情况是不可靠的:丢包(少量,大量)、乱序、重复、校验失败、发送太慢/太快,网络出问题,会出现上述问题的关键就是因为通信双方的距离变长了。以丢包来说,遇到这种情况有一个问题就是,收发双方怎么知道这个报文丢了,那么我们就需要正确的理解确认应答机制。
如上图所示,当C给S发送了数据时,当C收到了来自于S的应答,那么对于C来说S就100%收到了来自于C的数据,如果在等待了一段时间之后,C收不到来自于S的应答,那么C就会直接的认为报文丢失。TCP的可靠性就是通过收到应答来进行保证的。对于TCP来说server端与Cilent端的地位是对等的,都是通过这种确认应答的方式进行可靠性的保证的,但是上述就还有一个问题就是,虽然有着确认的机制,但是最新的一次报文始终无法保证能被可靠的送达。我们能保证的只有局部的可靠性,在收到确认应答的报文的时候意味着对应数据报文一定是被收到的。
在TCP报头中必定要包含序号,例如发送序号为10的一个报文,那么响应的报文的确认序号一定是11,假设响应的报文的确认序号也为10,下一次接收到的报文的序号是12,应答的报文也是12,这种情况下就无法判定11号报文是否收到,因此响应的报文的确认序号一定是发送序号的下一位,这样就可以表示X-1之前的报文已经全部收到了,下一次的发送请从X编号开始。
那为什么序号与确认序号不能是一个字段?必须是不同的字段,对于一条响应报文来说,他的首要功能就是对于报文的确认,同时因为响应报文与接收到的报文格式都差不多的,那么同样可以在这条响应报文中发送数据,这样就可以将原本需要两条报文的信息压缩成一个应答请求报文,32位序号表示S->C的数据序号,32位的确认序号表示S->C的历史数据的确认。
TCP协议进行工作时,将需要传输的数据拷贝到TCP的发送缓冲区,然后将数据发送到接受方的接收缓冲区,当接受方的接收缓冲区已经打满的时候,此时接收方就会直接将报文进行丢弃,但是这样的处理显然是不合理的,报文经过网络传输到接收端已经消耗了很多的网络资源,不能够随意地丢弃。因此我们就需要对传输的数据的大小进行判断,根据接收端的处理能力即接受缓冲区的大小来决定发送端的速度,这个机制就叫做流量控制,而16位窗口大小就是为了给对方发送自己的接受缓冲区的大小,告诉对方自己的接受能力。
校验和是为了对UDP/TCP的报头和有效载荷进行校验,校验失败直接将报文丢弃。在TCP中就可以通过确认应答机制进行重传处理。
在同一个时刻有很多的客户端会向服务端发起各种各样的TCP请求,比如有发起链接的、结束链接的、维持通信的等等,正是因为报文是有不同的类型,那么对于不同的报文需要有不同的处理动作,而6位标志位就是为了用来表示不同类型的报文。
在TCP发送数据的时候需要将数据拷贝到发送缓冲区中,那么我们将发送缓冲区看做是一个char类型的数组,将每个字节的数据都进行了编号,即为序列号。
主机A发送数据给B之后,可能因为网络拥堵等原因,数据无法到达主机B;如果主机A在一个特定时间间隔内没有收到B发来的确认应答, 就会进行重发;但是主机A未收到B发来的确认应答,也有可能是因为ACK丢失了因此主机B会收到很多重复数据。那么TCP协议需要能够识别出那些包是重复的包, 并且把重复的丢弃掉。这时候我们可以利用前面提到的序列号, 就可以很容易做到去重的效果。此时还有需要注意的就是在未接收到应答报文之前已经发送过的报文不能被发送缓冲区清理
TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。
正常情况下,TCP要经过三次握手建立连接,四次挥手断开连接
在进行TCP通信的时候在服务端和客户端会建立很多的连接,在OS内部一定会同时存在多个建立好的连接,并将这些建立好的连接进行管理 -> 先描述,再组织,OS同样要建立管理连接的数据的数据结构。创建维护连接是有成本的,这些操作都需要消耗内存与CPU。
三次握手由客户端首先发起TCP的SYN报文,然后服务端会响应SYN+ACK的报文,当客户端收到服务端发送的报文之后就会在操作系统中建立连接,最后客户端会向服务端再次发送一个响应ACK报文,当服务端收到之后,便会在自己的OS中建立并管理相应的连接。三次握手的过程,由双方的OS中的TCP层自主完成。connect触发连接,等待完成;accept等待建立完成获取连接。在上述的建立连接的过程中,我们只能保证前两次报文是一定被对方收到的,至于最后一次的ACK报文,是无法保证对方一定收到,如果服务端收到就会完成连接,若是未收到此时客户端以为连接已经建立,开始发送数据报文,但服务端未建立,那么就会发送RST报文重新建立连接。因此三次握手的本质就是在赌最后一次的ACK被对方收到。
如果两次握手,就意味着服务端在第一次收到客户端的SYN报文的时候就要建立连接,连接很简单的就被建立,一旦由人不断地发送SYN报文就会不断的建立连接,这很容易就会受到攻击 – SYN洪水;如果是奇数次握手,由于一定是客户端先发起连接,那么最后一次的应答报文一定是客户端发起的,此时出现连接建立异常时的成本就会从服务端嫁接到客户端,服务端只需要发起重新建立连接的请求即可。如果是四次握手或者是偶数次握手,就有可能出现服务端先建立连接,客户端后建立连接的情况,这样一旦建立连接出现异常,服务端的资源一定会收到影响,这是不可取的。
断开连接的四次挥手起始和建立连接的三次挥手是相似的,建立连接的三次挥手中的第二次了可以进行拆分,拆分为SYN与ACK两条报文,只不过在建立连接的时候进行了合并。断开连接是双向的,不仅客户端需要断开,服务端同样需要断开。若是客户端主动断开连接,客户端会进入FIN_WAIT_1状态,并发送FIN报文,然后服务端就会发出响应ACK报文,并将状态转变为CLOSE_WAIT,当客户端接收到ACK报文时将自己的状态转变为FIN_WAIT_2。然后服务端向客户端发起断开连接的报文,并将自己的状态转变为LAST_ACK,客户端收到FIN报文的时候,发送ACK应答报文并将自己的状态置为TIME_WAIT等待一段时间后,将连接关闭。
从下图中就可以看出连接建立成功,将客户端先退出,再退出服务端的时候,客户端就会出现第二张图中的TIME_WAIT状态。
在上述的第一张图片中我们可以看到里面有CLOSE_WAIT和FIN_WAIT2状态,这是因为我们在编写代码的时候,在通信结束的时候没有关闭fd,这样服务端就会一直保持在CLOSE_WAIT状态,而客户端的状态在保持一定时间之后就会自动退出。因此编写代码的时候一旦忘记关闭fd,那么未使用的文件描述符就会越来越少,乃至于最后程序崩溃。
现在做一个测试,首先启动server,然后启动client,然后用Ctrl-C使server终止,这时马上再运行server,结果是:
可以看到对应的TCP连接状态是如下图所示的:
同样,我们如果在服务端与客户端连接已经建立完成的前提下,关闭客户端,也会出现客户端的TIME_WAIT现象,但是由于客户端是由OS自动分配端口号的,因此不会出现上述的问题。出现上述问题的原因就是:当服务端关闭应用程序的时候,此时底层的TCP连接并没有断开,相对应的端口号还在被占用,因此短时间内是无法使用重新该端口号的。
使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符int opt = 1; setsockopt(listenfd, SOLSOCKET, SO_REUSEADDR, &opt, sizeof(opt));
从上述的内容中我们可以学习到对于每一个发送的数据段都要给一个ACK确认应答,收到确认应答之后再发送下一个数据段,这样做有一个比较大的缺点,收发的效率比较低下。既然一收一发的效率比较低下,我们就可以一次发送多条的数据,可以大大的提高性能。接收方是有接受能力上限的,发送方发送数据一定要在对方能接受的前提下进行并发的发送,根据目前我们将讲过的知识,发送数据的量由对方的窗口大小决定。
滑动窗口其实就是发送缓冲区的一部分,滑动窗口的大小和对方的接受能力有关,滑动窗口本质上就是一段用数组下标表示的缓冲区区间。
当某一段报文段丢失之后,发送端会一直收到 1001 这样的ACK,就像是在提醒发送端 "我想要的是 1001"一样;如果发送端主机连续三次收到了同样一个 “1001” 这样的应答,就会将对应的数据 1001 - 2000 重新发送;这个时候接收端收到了 1001 之后,再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中;这种机制被称为 “高速重发控制”(也叫 “快重传”)。超时重传与快重传,一个决定了重传的下限,一个决定了重传的上限。
前面的叙述已经对收发双方的通信进行了部分讨论,但是TCP的通信不仅仅只有收发双方,还有网络,不同的网络状态同样会影响数据的传输。当出现少量丢包的时候,可以通过重传机制进行数据的重新发送,当出现大量丢包的情况时就说明网络出现问题,需要进行等待,但是光光的等待也是不行的,最佳的方案是减少发送量。当出现网络拥塞的时候1.要保证网络拥塞不能加重;2. 在网络拥塞有起色的情况下,尽快恢复网络通信。
TCP引入慢启动机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
此处引入一个概念程为拥塞窗口:发生了网络拥塞,发送方要基本得知网络拥塞的严重情况,必须要进行网络状态的检测->对当前状态的网络情况进行衡量 – 拥塞窗口;网络的状态是变化的,衡量网络健康状态即拥塞窗口的大小一定是变化的;作为主机,我们为了知道网络的健康状况就需要不断的进行尝试与探测。想办法得到当前网络的拥塞窗口。因此前文中所述的滑动窗口的大小应该为对端主机接受能力与网络的拥塞窗口的较小值。
如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小。
创建一个TCP的socket,同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区;
粘包问题中的"包"指的是应用层的数据包。在TCP的协议头中,没有如同UDP一样的 “报文长度” 这样的字段,但是有一个序号这样的字段。站在传输层的角度,TCP是一个一个报文过来的。按照序号排好序放在缓冲区中。站在应用层的角度,看到的只是一串连续的字节数据。那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。
避免粘包问题就是要明确两个包之间的边界。
对于UDP,如果还没有上层交付数据,UDP的报文长度仍然在。同时,UDP是一个一个把数据交付给应用层。就有很明确的数据边界。站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收。不会出现"半个"的情况
进程终止:进程终止会释放文件描述符,仍然可以发送FIN,和正常关闭没有什么区别。
机器重启:和进程终止的情况相同。
机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。
另外, 应用层的某些协议, 也有一些这样的检测机制。例如HTTP长连接中,也会定期检测对方的状态。例如QQ,在QQ断线之后,也会定期尝试重新连接。
为什么TCP这么复杂? 因为要保证可靠性,同时又尽可能的提高性能。