目录
一.再谈端口概念
二.UDP协议
1.UDP协议格式
2.UDP的特点
3.面向数据报
4.UDP的缓冲区
5.UDP使用注意事项
6.UDP协议在内核中的表现形式
7.基于UDP的应用层协议
三.TCP协议
1.TCP协议格式
2.TCP确认应答机制
3.超时重传机制
4.TCP报文六位标志位
5.滑动窗口
6.流量控制
7.拥塞控制
8.延迟应答
9.捎带应答
10.面向字节流
11.粘包问题
12.连接管理机制
13.listen 的第二个参数
14.TCP异常情况
15.TCP小结
16.基于TCP应用层协议
四.TCP/UDP对比
端口号(Port)标识了一个主机上进行通信的不同的应用程序;
在TCP/IP协议中, 用 "源IP", "源端口号", "目的IP", "目的端口号", "协议号" 这样一个五元组来标识一个通信(可以通过netstat -n查看);
netstat 常用选项:
pidof:在查看服务器的进程id时非常方便。
16位UDP长度: 表示整个数据报(UDP首部+UDP数据)的最大长度;
16位检验和:如果校验和出错, 就会直接丢弃;
UDP协议如何做到报头和有效载荷分离:
UDP协议如何做到向上交付:
UDP传输的过程类似于寄信.
应用层交给UDP多长的报文, UDP原样发送, 既不会拆分, 也不会合并;
用UDP传输100个字节的数据:
Linux内核是由C语言写的,传输层和网络层又隶属于操作系统,那么传输层的协议也是用C语言写的。既然是使用C语言写的那么想UDP这种格式的结构,我们很容易就可以使用,结构体,或者位段来实现。有一个疑惑,无法确定有效载荷的大小,那么又该问怎么定义出结构呢?C99语法支持柔性数组。
struct Udp
{
uint16_t src_port;
uint16_t dst_port;
uint16_t udp_len;
uint16_t check;
char date[];//柔性数组。
};
TCP全称为 "传输控制协议(Transmission Control Protocol"). 人如其名, 要对数据的传输进行一个详细的控制;
当上层应用层服务将需要发送的数据,使用send和write发送到"网络"的时候,实际上对于应用层,他认为只要自己调用了send和write就已经将数发送出去了,实际上并不是这样,数据还需要经过传输层的协议才能发送。对于应用层,他对传输层到底是怎么发送的,是什么时候发送的数据,表示不知道,不清楚,不关心。而怎么发送,什么时候发送,如何确保传输的数据的可靠性,这就是传输层的TCP该做的事情。
1. 源/目的端口号: 表示数据是从哪个进程来, 到哪个进程去;
2. 32位序号/32位确认号: 后面详细讲;
3. 4位TCP报头长度: 表示该TCP头部有多少个32位bit(有多少个4字节); 所以TCP头部最大长度是15 * 4 = 60
4. 6位标志位:
5. 16位窗口大小: 后面再说。
6. 16位校验和: 发送端填充, CRC校验. 接收端校验不通过, 则认为数据有问题. 此处的检验和不光包含TCP首部, 也包含TCP数据部分.
7. 16位紧急指针: 标识哪部分数据是紧急数据;
8. 40字节头部选项: 暂时忽略;
TCP协议如何做到报头和有效载荷分离:
首先读取到四位首部长度冷len,报头长度就是len*4字节,报文去除报头数据,剩下的就是有效载荷。
说明:在没有选项长度的情况下,四位报头的长度就是20字节,自然四位首部长度填写的二进制字段就是1001。
TCP如何做到向上交付:
当我们读取到了报头,自然也就知道了目的端口,目的端口就是我们此次向上交付的应用层进程。
可靠性:
我们一提到TCP首先就能想到可靠性,那么到底哪些是可靠性哪些不是可靠性?
不可靠性:丢失数据(丢包),传输太快了,传输太慢了,乱序,重复等,都是不可靠性。
与之相对的自然也就是可靠性。
确认应答:解决丢包的问题
TCP为保证可靠性,我们向对端主机发送数报文的时候,我们怎么得知对端主机有没有收到我们的报文呢?非常简单,对端只要给你一句回应,我们也就知道了,对方收到了我们发送的报文数据。
但是这样的每次都是一个发一个应答这样的串行的过程,效率难免会有些低,所以在实际中,发报文和应答并不是穿行的,而是并发的。
序号和确认序号的作用:
那么如果有一条数据报文,没有得到回应,而且我们接收端收到的报文顺序也不一定就是发送端发送的顺序,那么怎么确定是哪一条数据报文丢失呢?
在我们的数据,没有发送到对端主机的时候,我们的数据都会存储在TCP的缓冲区里面,那么TCP的缓冲区是什么样子的呢?
实际上TCP的发送缓冲区就是一个char数组,又因为数组天然带有下标,所以TCP对缓冲区的每一个字节都是有编号的。而这个编号其实就是TCP协议报头里的序号。当我们每次发送的数据,都是有序号的,那么接收端只要按序号进行排序和去重,首先可以做到接收端保证数据的有序性,也能够轻松的知道了哪些报文没有收到。
对于接收端,我们收到了一个报文得到了序号,我们就可以给发送端应答一个带有确认信号的应答报文,该应答报文的确认序号就是上一个报文的序号下一个位置,代表下一次你可以从该位置继续发送。
确认序号的机制的意思是告知发送端,下一次的发送位置,换一句话讲,也就是告诉发送方,从当当前确认号X之前的报文我都是收到的。
如果我们发送一个数据报文,但是并没有收到应答,此时我们应该立马给对方补发一个报文吗?不应该,首先我们要知道如果我们没有收到应答,一般会有两种情况:
如果是情况一:虽然我们没有收到当前报文数据的应答报文,但是过了一会我们收到了,下一个报文的应答报文,由于应答报文的机制,我们收到下一个报文的应答,也就代表这当前所有报文对方都是收到的。
如果是情况一,主机B会收到很多重复数据. 那么TCP协议需要能够识别出那些包是重复的包, 并且把重复的丢弃掉.这时候我们可以利用前面提到的序列号, 就可以很容易做到去重的效果.
如果是情况二:我们我们并不知到发送的报文确实丢失了,我们还在期望第一种情况的发生,但是过了一会仍没有收到确认应答,那么此时我们真的需要给出一个补发报文。但是总归到底,我们都是不能直接补发报文的。面对情况二这种等待一段时间之后,仍没有应答报文我们再补发的场景,就是超时重传机制。
那么, 如果超时的时间如何确定?
TCP为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间。
1.ACK
说明:确认好是否有效,也就是说明当前报文确认好如果有效,那么当前报文一定是一个应答报文。
2.RST
说明:对方要求重新建立连接,例如当客户端因为网络抖动,导致连接断开,但是服务器并不知道,这就导致了双方再连接建立上有不一致的地方。客户端就可以发送一个带有SRT的报文,请求重新建立连接。
3.URG
说明:紧急指针是否有效,紧急指针是一个16位整形数据,如果紧急指针生效,这次的报文是可以不需要在接收缓冲区中等待,可以直接插队被上层应用拿到,而且此次的有效载荷中还携带者1字节的紧急数据,16位的紧急指针,就标识了紧急数据在有效载荷中的起始位置。
4.SYN
说明:TCP是面向连接的,那么就会有报文时请求发起TCP连接的,发起连接的报文就会携带SYN标志位。
5.FIN
说明:当双方通信结束,断开连接时,需要有报文提出断开连接的请求。就会携带FIN标志位。
6.PSH
如果通信双方,有一方觉得对方的接收缓冲区,剩余空间不是很充足,可以催促对方的应用层,抓紧把数据从缓冲区读走。这种报文就会携带PSH标志位。
刚才我们讨论了确认应答策略, 对每一个发送的数据段, 都要给一个ACK确认应答. 收到ACK后再发送下一个数据段,这样做有一个比较大的缺点, 就是性能较差. 尤其是数据往返的时间较长的时候.
既然这样一发一收的方式性能较低, 那么我们一次发送多条数据, 就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了).
滑动窗口在哪里?是什么?
今天我们知道了,TCP的传输控制的主要是针对要发送的数据报文,因为数据报文在TCP发送缓冲区中,那么滑动窗口就在TCP发送缓冲区中。滑动窗口就是两个指针。
滑动窗口可以变大吗?可以变小吗?可以为0吗?
可以变大,也可以变小,根据对方的接受能力,仅仅改变两个指针的位置,即可。
可以为0,即发送端不发送数据。
滑动窗口可以一直滑动吗?怎么滑动?
滑动窗口天然的将整个缓冲区划分为三个部分,滑动窗口前是,已经接受到应答的数据报文,滑动窗口后,是还没有发送的报文。滑动窗口中是不需要等待任何ACK, 直接发送的数据报文,或者是已经发送还没有收到应答的数据报文。
当窗口中的已经发送的报文的应答被接受,那就可以直接直接将Win_begin向右边移动。
而且滑动窗口左端的报文已经被对对方接受,所以对于发送缓冲区来说就是空闲的,可以发送缓冲区设计成环状的,滑动窗口也不会出现越界的情况。
华东窗口的大小怎么更新?依据是什么?
滑动窗口的大小决定了此次发送的数据报文量的大小,TCP不仅仅保证数据不会漏掉,即使漏掉了,也能让我及时的知道。还要保证我们每次发的数据量对方有能力接受,如果对方的接受能力弱,我们就少发一点,对方接受能力强,我们就多发一点。所以我们需要知道对方的接收缓冲区还有多大,这就要用到TCP报文格式中的16位窗口大小了。
16为窗口大小:用于通告给对方自己的接受能力。
所以我们的滑动窗口的大小就应该是对方的窗口大小,即:
如果滑动窗口中数据报文有丢失怎么半?
如果有数据丢失那丢失的数据必然在窗口的第一位。因为收到应答的报文将会被移除窗口,所以当出现报文丢失,直接重发第一个报文就可以了。
说明:
窗口大小指的是无需等待确认应答而可以继续发送数据的最大值. 上图的窗口大小就是4000个字节(四个段).发送前四个段的时候, 不需要等待任何ACK, 直接发送;
收到第一个ACK后, 滑动窗口向后移动, 继续发送后面的段的数据; 依次类推;
操作系统内核为了维护这个滑动窗口, 需要开辟 发送缓冲区 来记录当前还有哪些数据没有应答; 只有确认应答过的数据, 才能从缓冲区删掉;窗口越大, 则网络的吞吐率就越高;
快重传:
这种机制被称为 "高速重发控制"(也叫 "快重传").
接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送,就会造成丢包, 继而引起丢包重传等等一系列连锁反应.
因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制(Flow Control);
接收端如何把窗口大小告诉发送端呢? 回忆我们的TCP首部中, 有一个16位窗口字段, 就是存放了窗口大小信息;那么问题来了, 16位数字最大表示65535, 那么TCP窗口最大就是65535字节么?
实际上, TCP首部40字节选项中还包含了一个窗口扩大因子M, 实际窗口大小是 窗口字段的值左移 M 位;
虽然TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据. 但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题.
因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下, 贸然发送大量的数据,是很有可能引起雪上加霜的.
这里是不是太瞧得起我了,我能够发的那几千个数据报文,对于整个网络来说不是九牛一毛吗,怎么会很大可能加重网络的拥塞呢?
我们要有一个共识,互联网中的主机可不止你一台,每一时刻都会有大量的主机向网络中发送数报文,而且大家都是用的是TCP协议。所以当网络出现拥塞时,只要TCP能够制止主机减缓发送,那么也就使得整个网络上的主机都减少了发送给数据。网络的压力就会慢慢恢复。
TCP引入 慢启动 机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据;
像上面这样的拥塞窗口增长速度, 是指数级别的. "慢启动" 只是指初使时慢, 但是增长速度非常快.
少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞;
当TCP通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降;
拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案.
TCP拥塞控制这样的过程, 就好像 热恋的感觉
如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小.
一定要记得, 窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率:
具体的数量和超时时间, 依操作系统不同也有差异; 一般N取2, 超时时间取200ms;
在延迟应答的基础上, 我们发现, 很多情况下, 客户端服务器在应用层也是 "一发一收" 的. 意味着客户端给服务器说了 "How are you", 服务器也会给客户端回一个 "Fine, thank you";
那么这个时候ACK就可以搭顺风车, 和服务器回应的 "Fine, thank you" 一起回给客户端。
简单来说:就是此次的应答不仅仅是一个应答,还携带了一些其他信息,这就叫捎带应答。
创建一个TCP的socket, 同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区;
由于缓冲区的存在, TCP程序的读和写不需要一一匹配, 例如:
那么如何避免粘包问题呢? 归根结底就是一句话, 明确两个包之间的边界.
对于UDP协议来说, 是否也存在 "粘包问题" 呢?
在正常情况下, TCP要经过三次握手建立连接, 四次挥手断开连接。
服务端状态转化:
客户端状态转化:
什么是连接?
在OS内部必然会同时存在大量的连接,操作系统肯定需要对这些连接做管理,那么就会有这些链接数据结构,和管理结构。struct link{……};需要占有内存和CPU资源。
为什么在建立连接时,会是三次握手,2次,4次,5次,行不行?
我们注意就不难发现,如果是两次握手建立连接,那么仅仅需要客户端发起一次SYN,服务端就会建立连接,这样就会导致,服务端使用很低的成本就建立服务端的来连接,连接的的创建也是需要CPU和内存资源的,如果一台机器,发送大量的SYN给服务器,那么很快就会使服务器承载压力过大,这就是SYN洪水攻击。偶数次的连接次数都会有些这样的问题,奇数次,最小成本的建立连接就是三次握手。
第二次握手就是一次捎带应答。
断开连接时的几种状态:
1.TIME_WAIT
我们从图中可以看出,主动断开连接的一方,会进入一个TIME_WAIT状态。
TCP协议规定,主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态.
MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同, 在Centos7上默认配置的值是60s;
可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看msl的值。
这个内核文件可以修改,必须是root用户,但是修改以后没有效果。
测试:
启动服务,Ctrl C断开,再次启动:
我们查看当前8081端口的连接,发现服务仍在使用8081端口。,这也就是使得我们绑定失败的原因。
为什么是TIME_WAIT的时间是2MSL?
解决TIME_WAIT状态引起的bind失败的方法:
在server的TCP连接没有完全断开之前不允许重新监听, 某些情况下可能是不合理的:
使用setsockopt()设置socket描述符的 选项SO_REUSEADDR为1, 表示允许创建端口号相同但IP地址不同的多个socket描述符。
void Bind()
{
// 设置无需等待TIME_WAIT状态
int opt = 1;
setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in host;
host.sin_family = AF_INET;
host.sin_port = htons(_port);
host.sin_addr.s_addr = INADDR_ANY; // #define INADDR_ANY 0x00000000
socklen_t hostlen = sizeof(host);
int n = bind(_listensock, (struct sockaddr *)&host, hostlen);
if (n == -1)
{
Logmessage(Fatal, "bind err ,error code %d,%s", errno, strerror(errno));
exit(BING_ERR);
}
}
2.CLOSE_WAIT状态
当对方主机首先close自己的 fd ,发起FIN请求之后,另一端接收到FIN之后,就会也会进入CLOSE_WAIT状态,关闭通信文件描述符之后发送ACK,进入LAST_ACK状态。但是连接的文件描述符的关闭是程序员自己控制,如果我们不关闭文件描述符呢:
此时服务器进入了 CLOSE_WAIT 状态, 结合我们四次挥手的流程图, 可以认为四次挥手没有正确完成.
小结: 对于服务器上出现大量的 CLOSE_WAIT 状态, 原因就是服务器没有正确的关闭 socket, 导致四次挥手没有正确完成. 这是一个 BUG. 只需要加上对应的 close 即可解决问题.
const static int backlog = 1;
int n = listen(_listensock, backlog);
使服务器只监听,但是不accept,接受连接,但是不把链接拿到上层:
使用telnet连接:
此时启动 2 个客户端同时连接服务器, 用 netstat 查看服务器状态, 一切正常.
但是启动第三个客户端时, 发现服务器对于第三个连接的状态存在问题了。
客户端状态正常, 但是服务器端出现了 SYN_RECV 状态, 而不是 ESTABLISHED 状态
这是因为, Linux内核协议栈为一个tcp连接管理使用两个队列:
而全连接队列的长度会受到 listen 第二个参数的影响.
全连接队列满了的时候, 就无法继续让当前连接的状态进入 established 状态了.
这个队列的长度通过上述实验可知, 是 listen 的第二个参数 + 1.
注意:listen的第二个参数+1,不是服务器的最大处理链接数,而是暂存没有向上拿给应用层的最大链接数。
为什么TCP这么复杂? 因为要保证可靠性, 同时又尽可能的提高性能.
可靠性:
提高性能:
其他:
定时器(超时重传定时器, 保活定时器, TIME_WAIT定时器等)
当然, 也包括你自己写TCP程序时自定义的应用层协议;
我们说了TCP是可靠连接, 那么是不是TCP一定就优于UDP呢? TCP和UDP之间的优点和缺点, 不能简单, 绝对的进行比较:
归根结底, TCP和UDP都是程序员的工具, 什么时机用, 具体怎么用, 还是要根据具体的需求场景去判定。
用UDP实现可靠传输(经典面试题)
参考TCP的可靠性机制, 在应用层实现类似的逻辑;
例如: