「前言」文章内容大致是传输层协议,TCP协议讲解的第二篇,续上篇TCP。
「归属专栏」网络编程
「主页链接」个人主页
「笔者」枫叶先生(fy)
目录
- 二、TCP协议
- 2.9 TCP连接管理机制
- 2.9.1 三次握手
- 2.9.2 四次挥手
- 2.9.3 演示查看TIME_WAIT和CLOSE_WAIT状态
- 2.9.4 telnet命令
- 2.9.5 演示
- 2.10 解决TIME_WAIT状态引起的bind失败的问题
- 2.11 流量控制
- 2.12 滑动窗口
- 2.13 拥塞控制
- 2.14 延迟应答
- 2.15 捎带应答
- 2.16 面向字节流
- 2.17 粘包问题
- 2.18 TCP异常情况
- 2.19 TCP小结
- 2.20 基于TCP应用层协议
- 2.21 TCP/UDP对比
- 三、TCP实验:理解listen的第二个参数
二、TCP协议
2.9 TCP连接管理机制
首先明确,TCP是面向连接的,TCP通信之前需要先建立连接,就是因为TCP的各种可靠性保证都是基于连接的,要保证传输数据的可靠性的前提就是先建立好连接。
TCP连接不直接保证可靠性,但是会间接保证可靠性
TCP进行连接会进行三次握手,断开连接会进行四次挥手。
TCP协议的客户端/服务器程序的一般流程:
2.9.1 三次握手
双方在进行TCP通信之前需要先建立连接,建立连接的这个过程我们称之为三次握手。
三次握手的流程如下:
- 第一次握手:客户端向服务器发送一个SYN(同步)报文,请求与服务器建立连接。
- 第二次握手:服务器收到客户端的SYN报文后,向客户端发送一个SYN/ACK(同步/确认)报文,表示同意建立连接。
- 第三次握手:客户端收到服务器的SYN/ACK报文后,向服务器发送一个ACK(确认)报文,表示连接建立成功。
通过三次握手,客户端和服务器都确认了对方的请求,并建立了可靠的连接。
注意:三次握手是连接通信的策略,即三次握手也可能会出现失败的情况
为什么是三次握手??一次握手、两次握手、四次握手行不行??
首先明确,三次握手是策略,不一定百分之百成功,也可能出现失败。
比如,通信双方在进行三次握手时,其中前两次握手能够保证被对方收到,因为前两次握手都有对应的应答,但第三次握手是没有对应的应答报文的,如果第三次握手时客户端发送的ACK报文丢失了,那么连接建立就会失败。
但是,我们不怕失败丢包,因为TCP有配套的解决方案:
- 前面两次握手成功后,在客户端看来,连接已经建立好了,但是在服务端看来,连接还没有建立好。
- 如果第三层握手ACK发生丢包了,一段时间后服务端还是没有收到ACK报文,此时服务端就会,重新发起SYN/ACK(同步/确认)报文
- 又或者客户端认为连接建立好之后,就直接发数据报文了,此时服务端还没有收到ACK确认,即服务端看来连接还没有建立好,此时服务端就会收到该数据报文,服务端说:你都没有跟我建立连接,发什么数据报文,重连。此时服务端就会把
RST
标志位设置为1,发给客户端,让客户端与服务端进行重连
注意:第一次和第二次握手不携带数据,第三次握手可能会携带数据
一次握手行不行?
绝对不行的。
- 首先明确,连接是需要被管理起来的,被OS管理起来,如果管理连接??先描述,再组织。OS维护一个连接是有成本的。
- 即如果连接过多,OS管理不过来,即代表服务器要寄了
一次握手的话,只要客户端发起连接,就可以直接建立连接了(服务端认为连接已经建立好了),这样就会导致单主机下SYN洪水攻击
一次握手会发生SYN洪水攻击,就是有人搞事,通过大量伪造的SYN报文向目标服务器发送连接请求,从而消耗服务器资源,当服务器的半连接队列被耗尽后,合法用户的连接请求无法被处理,导致服务不可用。
还有一点就是无法验证全双工,即无法保证全双工通信通道是流畅的,因为TCP是全双工的
二次握手同上
为什么三次握手可以?
因为三次握手是验证全双工通信信道流畅的最小次数
- TCP是全双工通信的,因此连接建立的核心要务实际是,验证双方的通信信道是否是连通的。
- 而三次握手恰好是验证双方通信信道的最小次数,通过三次握手后双方就都能知道自己和对方是否都能够正常发送和接收数据
- 还有一点就是有效规避单主机下的SYN洪水攻击
注意:TCP的工作是建立通信信道,服务器受到攻击本身就不是TCP要解决的。但是如果三次握手有明显的漏洞,让客户端利用了,这就是你TCP的问题了
四次握手行不行?五次、六次…呢?
- 三次握手已经是最小成本验证了全双工,再多余就是浪费时间
三次握手也可以叫四次握手,原因如下:
- 第二次握手时,服务器收到客户端的SYN报文后,向客户端发送一个SYN/ACK(同步/确认)报文,这个SYN/ACK报文也可以分两次发送给客户端
- 但是这种情况几乎不存在。
三次握手时的状态变化
服务端:
- [
CLOSED -> LISTEN
] 服务器端调用listen后进入LISTEN
状态,等待客户端连接 - [
LISTEN -> SYN_RCVD
] 一旦监听到连接请求(同步报文段),就将该连接放入内核等待队列中,并向客户端发送SYN确认报文 - [
SYN_RCVD -> ESTABLISHED
] 服务端一旦收到客户端的确认报文,就进入ESTABLISHED
状态,可以进行读写数据了
客户端:
- [
CLOSED -> SYN_SENT
] 客户端调用connect,发送同步报文段 - [
SYN_SENT -> ESTABLISHED
] connect调用成功,则进入ESTABLISHED
状态,开始读写数据
2.9.2 四次挥手
TCP通信结束之后就需要断开连接,断开连接的这个过程我们称之为四次挥手。
四次挥手的过程如下:
- 第一次挥手:客户端向服务器发送一个FIN报文,表示客户端不再发送数据。
- 第二次挥手:服务器接收到客户端的FIN报文后,向客户端发送一个ACK报文,表示已经收到客户端的断开请求。
- 第三次挥手:服务器向客户端发送一个FIN报文,表示服务器也不再发送数据。
- 第四次挥手:客户端接收到服务器的FIN报文后,向服务器发送一个ACK报文,表示已经收到服务器的断开请求。
这样,双方都确认对方已经断开连接,完成四次挥手后,TCP连接就彻底关闭。
为什么要四次挥手?
- TCP是全双工的通信协议,建立连接时需要双方都确认建立连接,而断开连接时需要双方都确认断开连接。因此,四次挥手的过程是必要的。
- 即断开连接需要征得双方的同意
- 每两次挥手对应就是关闭一个方向的通信信道,因此断开连接时需要进行四次挥手
四次挥手也可能变成三次挥手,原因如下:
- 第二次挥手:服务器接收到客户端的FIN报文后,向客户端发送一个ACK报文,表示已经收到客户端的断开请求。
- 第三次挥手:服务器向客户端发送一个FIN报文,表示服务器也不再发送数据
- 这两个报文可能会合在一起发,即ACK+FIN
- 因为FIN和ACK都是不同的标志位,不会影响双方
四次挥手时的状态变化
客户端状态转化:
- [
FIN_WAIT_1 -> FIN_WAIT_2
] 客户端收到服务器对结束报文段的确认,则进入FIN_WAIT_2
,开始等待服务器的结束报文段 - [
FIN_WAIT_2 -> TIME_WAIT
] 客户端收到服务器发来的结束报文段,进入TIME_WAIT
, 并发出LAST_ACK
- [
TIME_WAIT -> CLOSED
] 客户端要等待一个2MSL
(Max Segment Life
, 报文最大生存时间)的时间,才会进入CLOSED
状态
服务端状态转化:
- [
ESTABLISHED -> CLOSE_WAIT
] 当客户端主动关闭连接(调用close),服务器会收到结束报文段,服务器返回确认报文段并进入CLOSE_WAIT
- [
CLOSE_WAIT -> LAST_ACK
] 进入CLOSE_WAIT
后说明服务器准备关闭连接(需要处理完之前的数据);当服务器真正调用close关闭连接时,会向客户端发送FIN,此时服务器进入LAST_ACK
状态,等待最后一个ACK到来(这个ACK是客户端确认收到了FIN) - [
LAST_ACK -> CLOSED
] 服务器收到了对FIN的ACK,彻底关闭连接
注:触发四次挥手是上层双方调用close(sock)
- 主动断开连接的一方,最终状态是
TIME_WAIT
- 被动断开连接的一方,两次挥手完成,进入
CLOSE_WAIT
下面进行做实验,查看这两个状态
2.9.3 演示查看TIME_WAIT和CLOSE_WAIT状态
代码直接采用socket套接字TCP多线程版的,前面已经讲解过了,就不再解释
初始化服务器initServer函数步骤大致如下:
- 调用socket函数,创建套接字。
- 调用bind函数,为服务端绑定一个端口号
- 调用listen函数,将套接字设置为监听状态
启动服务器start函数步骤大致如下:
- 调用accept函数,获取新链接
- 为客户端提供服务
服务端提供的服务,什么也不做,等待20秒服务端就直接退出即可,我们在这20秒内操作,操作就晕在客户端连接好了之后,客户端在20秒内主动退出即可
即演示的目的效果是:
- 客户端主动退出,最终状态是
TIME_WAIT
- 服务端是被动断开连接的一方,被动断开连接的一方,两次挥手完成,进入
CLOSE_WAIT
tcpServer.hpp
#pragma once#include <iostream>
#include <string>
#include <strings.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>using namespace std;static const int gbacklog = 5;// 错误类型枚举
enum
{UAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LISTEN_ERR
};class tcpServer; // 声明
class ThreadDate
{
public:ThreadDate(tcpServer *self, int sockfd): _self(self), _sockfd(sockfd){}public:tcpServer *_self;int _sockfd;
};class tcpServer
{
public:tcpServer(const uint16_t &port): _listensock(-1), _port(port){}// 初始化服务器void initServer(){// 1.创建套接字_listensock = socket(AF_INET, SOCK_STREAM, 0);if (_listensock == -1){cout << "create socket error" << endl;exit(SOCKET_ERR);}// 2.绑定端口// 2.1 填充 sockaddr_in 结构体struct sockaddr_in local;bzero(&local, sizeof(local)); // 把 sockaddr_in结构体全部初始化为0local.sin_family = AF_INET; // 未来通信采用的是网络通信local.sin_port = htons(_port); // htons(_port)主机字节序转网络字节序local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY 就是 0x00000000// 2.2 绑定int n = bind(_listensock, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&localif (n == -1){cout << "bind socket error" << endl;exit(BIND_ERR);}// 3. 把_listensock套接字设置为监听状态if (listen(_listensock, gbacklog) == -1){cout << "listen socket error" << endl;exit(LISTEN_ERR);}}// 启动服务器void start(){for (;;){// 4. 获取新链接,accept从_listensock套接字里面获取新链接struct sockaddr_in peer;socklen_t len = sizeof(peer);// 这里的sockfd才是真正为客户端请求服务int sockfd = accept(_listensock, (struct sockaddr *)&peer, &len);if (sockfd < 0) // 获取新链接失败,但不会影响服务端运行{cout << "accept error, next!" << endl;continue;}cout << "accept a new line success, sockfd: " << sockfd << endl;// 5. 为sockfd提供服务,即为客户端提供服务// 多线程版pthread_t tid;ThreadDate *td = new ThreadDate(this, sockfd);pthread_create(&tid, nullptr, threadRoutine, td);}}static void *threadRoutine(void *args){pthread_detach(pthread_self()); // 线程分离ThreadDate *td = static_cast<ThreadDate *>(args);td->_self->serviceIo(td->_sockfd);close(td->_sockfd); // 必须关闭,由新线程关闭delete td;return nullptr;}// 提供服务void serviceIo(int sockfd){sleep(20); // 20秒之后线程关闭_sockfd,线程也退出}~tcpServer(){}private:int _listensock; // listen套接字,不是用来数据通信的,是用来监听链接到来uint16_t _port; // 端口号
};
tcpServer.cc
#include "tcpServer.hpp"
#include <memory>// 使用手册
// ./tcpServer port
static void Uage(string proc)
{cout << "\nUage:\n\t" << proc << " local_port\n\n";
}int main(int argc, char *argv[])
{if (argc != 2){Uage(argv[0]);exit(UAGE_ERR);}uint16_t port = atoi(argv[1]); // string to intunique_ptr<tcpServer> tsvr(new tcpServer(port));tsvr->initServer(); // 初始化服务器tsvr->start(); // 启动服务器return 0;
}
下面需要使用telnet命令,先介绍该命令
2.9.4 telnet命令
telnet命令
Telnet是一种用于远程登录和管理网络设备的协议,同时也可以用于测试网络连接和端口的连通性。Telnet客户端可以通过命令行或者图形界面进行操作。
在命令行中,可以使用telnet命令来连接到远程主机或者测试网络连接。以下是使用telnet命令的一些常见用法:
1、连接到远程主机:
telnet <hostname> [port]
是要连接的远程主机的域名或者IP地址,[port
]是要连接的端口,默认为23(Telnet默认端口)
例如,要连接到主机example.com
的Telnet服务,可以使用以下命令:
telnet example.com
2、测试端口连通性:
telnet <hostname> <port>
<hostname
>是要测试的主机的域名或者IP地址,<port
>是要测试的端口号
例如,要测试主机example.com
的80
端口是否连通,可以使用以下命令:
telnet example.com 80
2、退出Telnet会话:
在Telnet会话中,可以使用以下命令退出:
quit
或者按下Ctrl+]
,然后输入quit
。
4、安装
如果Linux没有安装,先安装telnet客户端
yum install -y telnet
注:普通用户需要sudo提权
2.9.5 演示
先运行服务端,然后使用telnet命令连接服务端
注:由于没有多余的机器,只在一台机器下测试
打循环查看tcpServer:(查看服务端的)
while : ;do netstat -natp | grep tcpServer; sleep 1; echo "-----------------"; done
打循环查看telnet:(查看客户端)
while : ;do netstat -natp | grep telnet; sleep 1; echo "-----------------"; done
打循环查看TIME_WAIT
:(查看客户端)
while : ;do netstat -natp | grep TIME_WAIT; sleep 1; echo "-----------------"; done
打循环查看TIME_WAIT
是因为,telnet退出后查不到该进程了
准备工作完成,先运行循环,再启动服务端,再进行telnet,客户端要在20秒内退出
0、运行循环
1、启动服务端
2、telnet
3、telnet在20秒内退出连接
服务端关闭sock,并退出
客户端退出后,可以查到客户端的TIME_WAIT
状态
上述是我演示的是四次挥手的过程
- 主动断开连接的一方,最终状态是
TIME_WAIT
- 被动断开连接的一方,两次挥手完成,进入
CLOSE_WAIT
TIME_WAIT
状态持续一段时间后才进入真正的关闭
TIME_WAIT的等待时长是多少?
- 太长的
TIME_WAIT
状态会导致等待方维持连接的成本增加,浪费资源。 - 太短的
TIME_WAIT
状态可能无法保证ACK被对方接收,数据在网络中消散。 - TCP协议规定,主动关闭连接的一方在四次挥手后要进入
TIME_WAIT
状态,等待两个MSL
的时间才能进入CLOSED
状态。这样可以确保连接的可靠关闭。
查看Linux的MSL
时间长度:
cat /proc/sys/net/ipv4/tcp_fin_timeout
Centos7上默认配置的值是60秒(可以修改)
为什么是
TIME_WAIT
的时间是2MSL
?
MSL
是TCP报文的最大生存时间, 因此TIME_WAIT
持续存在2*MSL
的话- 就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的)
- 同时也是在理论上保证最后一个报文可靠到达(假设最后一个
ACK
丢失,那么服务器会再重发一个FIN
。这时虽然客户端的进程不在了,但是TCP连接还在,仍然可以重发LAST_ACK
)
如果服务器出现了大量的
CLOSE_WAIT
状态,说明服务器:
- 服务器有bug,没有做关闭文件符操作
close(sock)
- 服务器有压力,可能服务端一直推送消息给客户端,导致来不及close文件描述符
2.10 解决TIME_WAIT状态引起的bind失败的问题
绑定失败现象:
有客户端连着服务端,服务端主动退出(需要进行重启),紧接着服务端再次启动绑定相同的端口就会出现绑定失败的现象
绑定失败的原因:
- 服务端主动断开连接(服务端退出),最终状态是
TIME_WAIT
TIME_WAIT
持续存在2*MSL
,即TIME_WAIT
状态需要等待两个MSL
的时间才能进入CLOSED
状态- 在
2*MSL
时间内,服务器绑定的端口还一直被占用 - 即服务端立马退出,再进行重启,这时候就会存在绑定端口失败的问题
现象演示
代码依旧是上面的,运行服务器,客户端进行连接,然后服务端主动退出,再进行重启就会出现绑定端口失败
绑定端口失败的危害
- 比如在某些场景下,618,双11,这时候服务器的压力会比较大,如果说服务器崩溃了,需要立即马上进行重启,并且能够立马重启成功,如果在一分钟内或两分钟内无法重启成功,就会造成巨大的损失(金钱),618,双11都是分秒必争的
解决方法
使用setsockopt()
设置socket描述符的选项SO_REUSEADDR
为1,表示允许创建端口号相同但IP地址不同的多个socket描述符
// 设置地址复用
int opt = 1;
setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
修改代码
在创建套接字后面设置即可
编译运行,再进行测试,bind绑定失败问题没有了
2.11 流量控制
这个在前面的16位窗口大小已经谈过一部分了(上一篇),这里再来详细介绍。
接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应
因此TCP支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制(Flow Control)
- 接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段,通过ACK端通知发送端
- 窗口大小字段越大,说明网络的吞吐量越高
- 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端
- 发送端接受到这个窗口之后,就会减慢自己的发送速度
- 如果接收端缓冲区满了,就会将窗口置为0,这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端
16位窗口大小:16位最大表示65535,那TCP窗口最大就是65535吗?
- 理论上确实是这样的,但实际上TCP报头当中40字节的选项字段中包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移M位得到的
第一次向对方发送数据时如何得知对方的窗口大小?
- 双方在进行TCP通信之前需要进行三次握手建立连接。
- 在握手过程中,除了验证通信信道是否通畅,双方还会交换其他信息,其中包括告知对方自己的接收能力。
- 这样,在双方正式开始通信之前,双方已经了解对方的接收数据能力。
- 因此,双方在发送数据时可以根据对方的接收能力进行调整,避免缓冲区溢出的问题。
2.12 滑动窗口
前面已经提到过TCP的工作模式了(上一篇),TCP的工作模式有两种
第一种串行发送数据(不是TCP真正的工作模式)
一发一收的方式性能较低(串行)
第二种并行发送数据(TCP真正的工作模式)
并行发送数据,可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)
第二种情况是TCP的真正工作模式,即主流,但是也会存在第一种工作模式,第一种情况是很少的,但也会存在
发送方可以一次发送多个报文给对方,此时也就意味着发送出去的这部分报文当中有相当一部分数据是暂时没有收到应答的
发送缓冲区当中的数据可以分为三部分:
- 已经发送并且已经收到ACK的数据。
- 已经发送还但没有收到ACK的数据。
- 还没有发送的数据。
- 还有一个就是剩余空间,即没有数据的空间,这个剩余空间可能有也可能没有
发送缓冲区的第二部分就叫做滑动窗口
窗口大小指的是无需等待确认应答而可以继续发送数据的最大值
- 下图的窗口大小就是4000个字节(四个段)
- 发送前四个段的时候,不需要等待任何ACK,直接发送
- 收到第一个ACK后,滑动窗口向后移动,继续发送下一个段的数据,依次类推
- 操作系统内核为了维护这个滑动窗口,需要开辟发送缓冲区来记录当前还有哪些数据没有应答,只有确认应答过的数据,才能从缓冲区删掉
- 窗口越大, 则网络的吞吐率就越高
例如
- 1-1000、1001-2000数据接收端已经收到了并且进行了ACK,则1-1000、1001-2000的数据不在发送缓冲区的滑动窗口里面了
- 现在连续发送2001-3000、3001-4000、4001-5000、5001-6001的四个段数据,此时不需要等待任何ACK,可以直接进行发送
- 当收到对方应答的确认序号为3001时,说明2001-3000这个数据段已经被对方收到了,此时该数据段应该被归入发送缓冲区当中的已发送&&已收到ACK部分
- 而由于我们假设对方的窗口大小一直是4000,因此滑动窗口现在可以向右移动,继续发送6001-7000的数据段,以此类推
- TCP的重传机制要求暂时保存发出但未收到确认的数据,这些数据实际上位于滑动窗口中。
- 滑动窗口的左侧是已经被对方可靠接收到的数据,因此只有滑动窗口左侧的数据可以被覆盖或删除。
- 滑动窗口除了限定可以直接发送的数据,还支持TCP的重传机制,即当发送的数据未收到确认时,可以重新发送滑动窗口中的数据。
- 这样可以确保数据的可靠传输,提高通信的可靠性和效率。
发送缓冲区建模1:
- 缓冲区的本质就是一个char类型的数组,而滑动窗口就在发送缓冲区内,所以滑动窗口本质也是一个char类型的数组
- 滑动窗口可以被看作是由两个指针限定的一个范围(指向数组下标)。比如,我们可以用
win_start
指针指向滑动窗口的左侧,win_end
指针指向滑动窗口的右侧。 - 在
win_start
和win_end
之间的区间范围内的数据可以被称为滑动窗口。 - 通过移动这两个指针,可以实现滑动窗口的滑动和调整大小,以适应不同的网络条件和接收方的接收能力。这样的设计可以有效地控制发送方的发送速率,并支持TCP的重传机制,以确保数据的可靠传输。
当发送端收到对方的ACK应答时,如果响应当中的确认序号为xxx
,窗口大小为win
,此时就可以将win_start
更新为xxx
,而将win_end
更新为win_start+win
(暂时这样理解,下面再详细解释)
滑动窗口大小是怎么设定的??未来怎么变化?
- 滑动窗口的大小是根据网络条件和接收方的接收能力来设定的。通常,滑动窗口的大小由发送方和接收方之间的协商来确定。
- 在建立TCP连接时,双方会通过握手过程交换窗口大小的信息。发送方通常会根据接收方的通知来设定初始的滑动窗口大小。这个初始大小可以是固定的值,也可以是根据网络条件动态调整的值。
- 未来,滑动窗口的大小可以根据网络条件和接收方的反馈信息进行动态变化。TCP协议中有一种叫做拥塞控制的机制,可以根据网络拥塞的程度来调整滑动窗口的大小。当网络拥塞时,发送方会减小滑动窗口的大小,以降低发送速率,从而减轻网络负载。当网络状况改善时,发送方可以增大滑动窗口的大小,以提高发送速率。(拥塞控制下面谈)
总之,滑动窗口的大小是根据网络条件和接收方的接收能力来动态设定和调整的,以实现更高效的数据传输和网络拥塞控制。
16位窗口大小与滑动窗口
- 16位窗口大小指的是TCP协议中的窗口字段的大小,它用16位二进制数来表示。窗口字段表示接收方还能接收多少字节的数据,用于控制发送方的发送速率。
- 滑动窗口是一种数据传输的机制,用于控制发送方发送数据的速率和接收方接收数据的能力。滑动窗口的大小可以根据网络条件和接收方的接收能力进行调整。
- 滑动窗口的大小可以与16位窗口大小相关联。发送方在发送数据时,会根据接收方的窗口大小来确定发送窗口的大小。如果接收方的16位窗口大小为N,发送方可以将发送窗口的大小设置为N,以确保发送的数据不会超过接收方的窗口大小。
- 当发送方发送了一段数据后,接收方会发送确认消息(ACK)给发送方,同时更新窗口字段的值。发送方根据接收方的窗口字段的值来调整发送窗口的大小,以控制发送的数据量。
总之,16位窗口大小与滑动窗口的关系是,发送方根据接收方的16位窗口大小来确定发送窗口的大小,并根据接收方的确认消息来动态调整发送窗口的大小,以实现数据的可靠传输和流量控制。
滑动窗口会向左滑动吗?滑动窗口整体一定会向右滑动么?
- 一定不会向左滑动,滑动窗口左边的数据一定是已经发送了并且是对方已经收到了
- 在正常情况下,滑动窗口整体是向右滑动的,即发送方不断发送新的数据,接收方不断确认收到数据,并将窗口向右滑动。这样可以实现高效的数据传输。
- 然而,在某些特殊情况下,滑动窗口整体可能会不滑动,滑动窗口还可能一直变小。比如,对方的接收缓冲区的上层不取数据,缓冲区慢慢会被打满,此时滑动窗口的大小也慢慢变小,并且整体不会向右滑动
滑动窗口大小会一直不变吗?会变小吗?会变大吗??
- 滑动窗口的大小是根据网络条件和接收方的接收能力来动态设定和调整的,即一直会发生变化(也可能不变,不会是一直不变,如上面例子确认3001、4001)
- 滑动窗口可能会变小,上面的例子,确认5001、6001,最小就是变为0
- 滑动窗口可能会变大,比如,对方的接收缓冲区的上层一下子读取了完了全部的数据,此时接收缓冲区的接收能力就会变大,滑动窗口也随之变大,提高发送数据的速率
丢包问题
当发送端一次发送多个报文数据时,此时的丢包情况也可以分为两种。
情况一: 数据包已经抵达,ACK丢包。
- 部分ACK丢包并不要紧,此时可以通过后续的ACK进行确认
- 在这种情况下,由于接收方的ACK对于2001-3000和4001-5000的数据包丢失了,发送方无法直接知道这些数据包是否已经成功到达。但是,发送方可以通过接收到的最后一个有效ACK(5001-6000数据包的确认)来推断一些信息。
- TCP协议的规定,收到一个确认ACK序号为6001的确认消息表示接收方成功接收了序号为1-6000的字节数据。因此,发送方可以推测在丢失的这一段(2001-3000和4001-5000)之前的数据包也应该已经到达了接收方。
情况二: 数据包真的丢了。
- 当发送端连续收到三次确认序号为1001的响应报文,即接收方对于1001-2000的数据包的确认被重复确认了三次,发送端会认为这些数据包丢失了。
- TCP协议的重传机制,当发送端连续收到三次相同的确认序号时,会触发快速重传。在这种情况下,发送端会立即重传1001-2000的数据包,以确保接收方能够正确接收到这些数据。
- 通过快速重传,发送端可以快速恢复丢失的数据包,提高数据的可靠性和传输效率。
- 因此,在这个特殊情况下,滑动窗口的整体位置不会发生变化,但发送端会根据丢失的数据包触发快速重传机制,重新发送丢失的数据包。
TCP协议的重传机制,当发送端连续收到三次相同的确认序号时(触发重传机制),会触发高速重发控制,也叫快重传
发送缓冲区建模2:如果滑动窗口一直向后滑动,空间大小不够了怎么办??
- 发送缓冲区被内核组织称为了一种环形结构(本质依旧是插入类型的线性数组),通过数据下标进行模运算实现
- 环形结构怎么滑动也不会发生越界问题
快重传 VS 超时重传
- 快速重传和超时重传是TCP协议中两种常用的重传机制,用于处理丢失的数据包。它们的主要区别在于触发重传的条件和重传的时机。
- 快速重传(
Fast Retransmit
)是指当发送方连续收到三个重复的确认序号时,即接收方对同一个数据包的确认被重复确认了三次,发送方会立即重传该数据包。这是因为连续收到重复确认序号通常意味着该数据包已经丢失了,为了快速恢复丢失的数据包,发送方会触发快速重传机制,立即重传该数据包,而不必等待超时重传。 - 超时重传(
Timeout Retransmission
)是指当发送方发送一个数据包后,等待一段时间(超时时间)后仍未收到对应的确认消息时,发送方会认为该数据包丢失了,会触发超时重传机制,重新发送该数据包。超时时间是根据网络状况和往返时间动态调整的,如果网络延迟较高或丢包较多,超时时间会相应增加。 - 快速重传和超时重传都是为了处理丢失的数据包,但触发条件和重传时机不同。快速重传是基于连续收到重复确认序号触发的,可以更快地恢复丢失的数据包,减少了等待超时的时间。而超时重传是基于超时时间触发的,适用于网络延迟较高或丢包较多的情况。
综上所述,快速重传和超时重传是TCP协议中两种常用的重传机制,根据不同的情况选择合适的重传策略,以提高数据的可靠传输性能。
以上话题都是端到端,客户端到服务端,服务端到客户端,没有考虑有网络的,TCP也有网络问题方面的机制控制,就是拥塞控制。
2.13 拥塞控制
为什么会有拥塞控制?
- 两个主机在进行TCP通信的过程中,偶尔出现个别数据包丢失的情况是很正常的,此时可以通过快速重传或超时重传来补发丢失的数据包。(TCP认为是自己的问题)
- 但如果双方在通信时出现大量数据包丢失的情况,这就不再是正常现象了。(TCP认为不是自己的问题)
举个例子:
- 比如,高数考试,一个班有30人,考试下来全班只有张三一个人挂了,张三认为这是自己的问题
- 那如果考试下来,全班只有张三一个人通过了考试,其他全挂了,这时其他人认为不是自己的问题也不是张三的问题,而是试卷的问题
- 同理TCP也是如此
- 比如,客户端发送了10000个报文,服务端收到了9999个,只丢了一个报文,TCP认为这是自己的问题
- 如果服务端只收到了10个,丢了9990个报文,这时TCP就不会再认为是自己的问题(即不会进行重传报文),而是网络的问题
所以,TCP不仅考虑了通信双端主机的问题,同时也考虑了网络的问题。
- 拥塞窗口:考虑双方通信时网络的问题,如果发送的数据超过了拥塞窗口的大小,可能引起网络拥塞。在双方网络通信时,偶尔出现少量的丢包是允许的,但一旦出现大量的丢包,这就是量变引起质变,此时TCP不再假设是双方接收和发送数据的问题,而是判断双方通信信道网络出现了拥塞问题。
- 网络拥塞:是指在计算机网络中,当网络中的数据流量超过网络链路或节点的处理能力时,导致网络性能下降、延迟增加、丢包率增加等现象。网络拥塞通常发生在网络的瓶颈点,即网络中的某些关键节点或链路无法处理大量的数据流量。
从另一个视角看待
- 如果出现了网络拥塞问题,网络拥塞不仅仅影响单个主机,几乎会影响网络中的所有主机(其他主机也是会发生大量丢包)。
- 所以,出现了网络拥塞之后,双方主机可以减少数据传输的速率,尽量少发数据甚至不发数据,等待网络状况恢复后再逐渐恢复数据传输速率(减少网络负担)
- 如果出现了网络拥塞,双发出现了大量的丢包,双方的主机还进行重传报文的,只会给网络雪上加霜,网络上其他主机也是如此(雪崩的时候没有一片雪花是无辜的)
- 因此,所有使用TCP传输控制协议的主机都需要执行拥塞避免算法(拥塞控制),以统一减少发送窗口的大小,从而避免网络拥塞问题的进一步恶化和传播。
- 因此,拥塞控制虽然看似只是针对单个主机的通信策略,但实际上是所有主机在网络崩溃后都应该遵守的策略。
- 当网络发生拥塞时,所有主机都需要执行拥塞避免算法,这样才能有效地缓解网络拥塞问题,防止雪崩效应的发生,并尽快恢复网络的正常运行。
拥塞控制
虽然TCP有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据。但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题
因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵,在不清楚当前网络状态下,贸然发送大量的数据,是很有可能引起雪上加霜
TCP引入 慢启动机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据
拥塞窗口:
- 发送开始的时候,定义拥塞窗口大小为1
- 每次收到一个ACK应答,拥塞窗口加1
- 每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口
即自己的滑动窗口的大小 = min(拥塞窗口,对端窗口大小),两者取较小值
- 滑动窗口:自己的
- 拥塞窗口:网络的
- 窗口大小:对端的,对端的接收能力
像上面这样的拥塞窗口增长速度,是指数级别的, “慢启动” 只是指初使时慢,但是增长速度非常快
- 为了不增长的那么快,因此不能使拥塞窗口单纯的加倍
- 此处引入一个叫做慢启动的阈值
- 当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长
- 当TCP开始启动的时候,慢启动阈值等于对端窗口的最大值
- 在每次超时重发的时候,慢启动阈值会变成原来的一半,同时拥塞窗口置回1
- 指数增长。刚开始进行TCP通信时拥塞窗口的值为1,并不断按指数的方式进行增长
- 加法增大。慢启动的阈值初始时为对方窗口大小的最大值,图中慢启动阈值的初始值为16,因此当拥塞窗口的值增大到16时就不再按指数形式增长了,而变成了的线性增长。
- 乘法减小。拥塞窗口在线性增长的过程中,在增大到24时如果发生了网络拥塞,此时慢启动的阈值将变为当前拥塞窗口的一半,也就是12,并且拥塞窗口的值被重新设置为1,所以下一次拥塞窗口由指数增长变为线性增长时拥塞窗口的值应该是12。
少量的丢包,我们仅仅是触发超时重传;大量的丢包,我们就认为网络拥塞。
当TCP通信开始后,网络吞吐量会逐渐上升;随着网络发生拥堵,吞吐量会立刻下降。
拥塞控制,归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。(拥塞控制也是为了保证可靠性和传输速率)
2.14 延迟应答
如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小
如果接收数据的主机收到数据后立即进行ACK应答,此时返回的窗口可能比较小。
- 假设对方接收端缓冲区剩余空间大小为1M,对方一次收到500K的数据后,如果立即进行ACK应答,此时返回的窗口就是500K。
- 但实际接收端处理数据的速度很快,10ms之内就将接收缓冲区中500K的数据消费掉了。
- 在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来。
- 如果接收端稍微等一会再进行ACK应答,比如等待200ms再应答,那么这时返回的窗口大小就是1M。
延迟应答的目的不是为了保证可靠性,而是为了提高数据的传输效率。(保证在网络不拥塞的情况下尽量提高传输效率)
那么所有的报文都可以延迟应答么?
答案肯定也不是,延迟应答会有以下两个限制:
- 数量限制:每隔N个包就应答一次
- 时间限制:超过最大延迟时间就应答一次(不能比超时重传的时间长)
具体的数量和超时时间,依操作系统不同也有差异;一般N
取2
,超时时间取200ms
2.15 捎带应答
- 主机A给主机B发送了一条消息,当主机B收到这条消息后需要对其进行ACK应答,但如果主机B此时正好也要给主机A发生消息,此时这个ACK就可以搭顺风车,而不用单独发送一个ACK应答,此时主机B发送的这个报文既发送了数据,又完成了对收到数据的响应
- 这就是捎带应答,捎带应答也是为了提高传输效率
TCP连接不直接保证可靠性,但是会间接保证可靠性
- TCP连接确实不直接保证可靠性,但它通过一系列机制间接保证了数据的可靠传输。
- 比如:序号和确认机制、滑动窗口、拥塞控制、流量控制等,这些机制是直接保障可靠性,
- 这些机制建立的基础是已经连接已经建立成功,所以TCP连接确实不直接保证可靠性,而是间接保证可靠性
以上便是TCP所有的策略
2.16 面向字节流
当创建一个TCP的socket时,同时在内核中会创建一个发送缓冲区和一个接收缓冲区。
- 调用write函数就可以将数据写入发送缓冲区中,此时write函数就可以进行返回了,接下来发送缓冲区当中的数据就是由TCP自行进行发送的。
- 如果发送的字节数太长,TCP会将其拆分成多个数据包发出。如果发送的字节数太短,TCP可能会先将其留在发送缓冲区当中,等到合适的时机再进行发送。
- 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区,可以通过调用read函数来读取接收缓冲区当中的数据。
- 而调用read函数读取接收缓冲区中的数据时,也可以按任意字节数进行读取。
这个缓冲区在前面已经详细谈过了,这里就不展开说了
由于缓冲区的存在,TCP程序的读和写不需要一一匹配(面向字节流),例如:
写100个字节数据时,可以调用一次write写100字节,也可以调用100次write,每次写一个字节。
读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read100个字节,也可以一次read一个字节,重复100次。
于TCP来说,它并不关心发送缓冲区当中的是什么数据,在TCP看来这些只是一个个的字节数据,它的任务就是将这些数据准确无误的发送到对方的接收缓冲区当中就行了,而至于如何解释这些数据完全由上层应用来决定,这就叫做面向字节流
比对面向数据报
- 应用层交付给UDP多长的报文,UDP就原样发送,既不会拆分,也不会合并,这就叫做面向数据报
- 比如用UDP传输100个字节的数据,发送端调用一次发送函数,发送100字节,那么接收端也必须调用对应的一次接收函数,接收100个字节;如果发送端调用十次发送函数,则接收端也必须调用对应的十次接收函数,即UDP协议,发送函数的次数 : 接收函数的次数 =
1 : 1
2.17 粘包问题
什么是粘包?(基于TCP的应用层问题)
- 首先要明确,粘包问题中的“包”,是指的应用层的数据包。
- 在TCP的协议头中,没有如同UDP一样的“报文长度”这样的字段。
- 站在传输层的角度,TCP是一个一个报文过来的,按照序号排好序放在缓冲区中。
- 但站在应用层的角度,看到的只是一串连续的字节数据。
- 那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。
如何解决粘包问题
要解决粘包问题,本质就是要明确报文和报文之间的边界。
- 对于定长的包,保证每次都按固定大小读取即可。
- 对于变长的包,可以在报头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。
- 对于变长的包,还可以在包和包之间使用明确的分隔符。因为应用层协议是程序员自己来定的,只要保证分隔符不和正文冲突即可。
对于UDP协议来说,是否也存在 “粘包问题” ?
- 对于UDP,如果还没有上层交付数据,UDP的报文长度仍然在,同时,UDP是一个一个把数据交付给应用层的,有很明确的数据边界。
- 站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收,不会出现“半个”的情况。
因此UDP是不存在粘包问题的,根本原因就是UDP报头当中的16位UDP长度记录的UDP报文的长度,因此UDP在底层的时候就把报文和报文之间的边界明确了,而TCP存在粘包问题就是因为TCP是面向字节流的,TCP报文之间没有明确的边界。
2.18 TCP异常情况
(1)进程终止
当客户端与服务端已经建立好连接了,如果客户端进程突然终止,此时建立好的连接会怎么样?
- 当一个进程退出时,该进程曾经打开的文件描述符都会自动关闭,因此当客户端进程退出时,相当于自动调用了close函数关闭了对应的文件描述符,此时双方操作系统在底层会正常完成四次挥手,然后释放对应的连接资源。
- 也就是说,进程终止时会释放文件描述符,TCP底层仍然可以发送FIN,和进程正常退出没有区别。
(2)机器重启
当客户端与服务端已经建立好连接了:
- 选择重启主机时,操作系统会先杀掉所有进程然后再进行关机重启,因此机器重启和进程终止的情况是一样的,此时双方操作系统也会正常完成四次挥手,然后释放对应的连接资源。
(3)机器掉电(断电源)/网线断开
当客户端与服务端已经建立好连接了:一端突然断电或断网了
比如是客户端断电或断网后,服务器端在短时间内无法知道客户端掉线了,因此在服务器端会维持与客户端建立的连接,但这个连接也不会一直维持,因为TCP是有保活策略的。
- 服务器会定期发送保活探测报文给客户端,以检测客户端的存在状况。如果连续多次都没有收到客户端的应答,服务器就会认为客户端已经掉线,并关闭这条连接。
- 此外,客户端也可以定期向服务器发送心跳消息,以确保服务器知道自己的存在。如果服务器长时间没有收到客户端的心跳消息,也会认为客户端已经掉线,并关闭对应的连接。
- 综上所述,TCP通过保活探测和心跳消息机制,间接地检测客户端的在线状态,并在一定时间内关闭掉线的连接,以保证连接的有效性和资源的合理利用。
此外,应用层的某些协议,也有一些类似的检测机制,例如基于长连接的HTTP,也会定期检测对方的存在状态。
2.19 TCP小结
为什么TCP这么复杂?
因为要保证可靠性,同时又尽可能的提高性能
可靠性:
- 检验和
- 序列号
- 确认应答
- 超时重传
- 连接管理
- 流量控制
- 拥塞控制
提高性能:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
除此之外,还有一些定时器(超时重传定时器, 保活定时器, TIME_WAIT定时器等)
2.20 基于TCP应用层协议
常见的基于TCP的应用层协议如下:
- HTTP(超文本传输协议)
- HTTPS(安全数据传输协议)
- SSH(安全外壳协议)
- Telnet(远程终端协议)
- FTP(文件传输协议)
- SMTP(电子邮件传输协议)
当然,也包括你自己写TCP程序时自定义的应用层协议
2.21 TCP/UDP对比
TCP是可靠连接, 那么是不是TCP一定就优于UDP呢?
TCP和UDP之间的优点和缺点,不能简单绝对的进行比较,不存在谁好谁不好的问题,他们只是应用场景不同:
- TCP用于可靠传输的情况,应用于文件传输,重要状态更新等场景
- UDP用于对高速传输和实时性要求较高的通信领域。例如,早期的QQ,视频传输等,另外UDP可以用于广播
归根结底,TCP和UDP都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判定
如何用UDP实现可靠传输
参考TCP的可靠性机制,在应用层实现类似的逻辑,例如:
- 引入序列号, 保证数据顺序
- 引入确认应答,确保对端收到了数据
- 引入超时重传,如果隔一段时间没有应答,就重发数据
- …
三、TCP实验:理解listen的第二个参数
listen函数的作用是设置套接字为监听状态,该函数的第二个参数之前没有谈,现在来谈一下
第二个参数backlog:全连接队列的最大长度。
如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不能设置太大
下面进行做实验:
该实验不进行accept获取_listensock
套接字新连接,什么也不干,只进行监听连接的到来,backlog
设置为2
tcpServer.hpp
#pragma once#include <iostream>
#include <string>
#include <strings.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>using namespace std;static const int gbacklog = 2; // 全连接队列大小// 错误类型枚举
enum
{UAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LISTEN_ERR
};
class tcpServer
{
public:tcpServer(const uint16_t &port): _listensock(-1), _port(port){}// 初始化服务器void initServer(){// 1.创建套接字_listensock = socket(AF_INET, SOCK_STREAM, 0);if (_listensock == -1){cout << "create socket error" << endl;exit(SOCKET_ERR);}// 1.1 设置地址复用int opt = 1;setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));// 2.绑定端口// 2.1 填充 sockaddr_in 结构体struct sockaddr_in local;bzero(&local, sizeof(local)); // 把 sockaddr_in结构体全部初始化为0local.sin_family = AF_INET; // 未来通信采用的是网络通信local.sin_port = htons(_port); // htons(_port)主机字节序转网络字节序local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY 就是 0x00000000// 2.2 绑定int n = bind(_listensock, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&localif (n == -1){cout << "bind socket error" << endl;exit(BIND_ERR);}// 3. 把_listensock套接字设置为监听状态if (listen(_listensock, gbacklog) == -1){cout << "listen socket error" << endl;exit(LISTEN_ERR);}}// 启动服务器void start(){for (;;){sleep(1); // 什么也不做,不从_listensock套接字里面获取新连接}}~tcpServer(){}
private:int _listensock; // listen套接字,不是用来数据通信的,是用来监听链接到来uint16_t _port; // 端口号
};
tcpServer.cc
#include "tcpServer.hpp"
#include <memory>// 使用手册
// ./tcpServer port
static void Uage(string proc)
{cout << "\nUage:\n\t" << proc << " local_port\n\n";
}int main(int argc, char *argv[])
{if (argc != 2){Uage(argv[0]);exit(UAGE_ERR);}uint16_t port = atoi(argv[1]); // string to intunique_ptr<tcpServer> tsvr(new tcpServer(port));tsvr->initServer(); // 初始化服务器tsvr->start(); // 启动服务器return 0;
}
编译运行服务器,此时启动 3 个客户端同时连接服务器, 用 netstat 查看服务器状态, 一切正常
但是启动第四个客户端时, 发现服务器对于第四个连接的状态存在问题了
客户端状态正常, 但是服务器端出现了SYN_RECV
状态, 而不是ESTABLISHED
状态
这是因为, Linux内核协议栈为一个tcp连接管理使用两个队列:
- 半连接队列(用来保存处于
SYN_SENT
和SYN_RECV
状态的请求) - 全连接队列(accpetd队列)(用来保存处
established
状态,但是应用层没有调用accept取走的请求)
这个全连接队列不能太长,也不能没有:
- 如果全连接队列过长,会导致服务器资源的浪费。每个连接请求都会占用一定的内存和其他资源,如果队列过长,服务器可能无法及时处理所有的连接请求,导致资源耗尽和服务质量下降
- 相反,如果全连接队列没有长度,也会导致问题。如果服务器无法及时处理所有的连接请求,就会导致连接请求被丢弃,客户端无法建立连接。这会导致客户端的请求失败和连接超时,影响用户体验和服务可用性。
而全连接队列的长度会受到 listen 第二个参数的影响
- 全连接队列满了的时候,就无法继续让当前连接的状态进入 established 状态了
- 这个全连接队列的长度通过上述实验可知,全连接的长度是 listen 的第二个参数 + 1
上述实验,我们设置的全连接队列大小是2,前三次连接正常,但是到了第四次连接的处于半链接队列,处于了SYN_RECV
状态
但是在客户端看来,连接已经建立好了,但是在服务端看来没有建立连接成功,因为服务端对于第三次握手的ACK进行了忽略
TCP内容真多,终于完结了,TCP写了差不多三万字
--------------------- END ----------------------
「 作者 」 枫叶先生
「 更新 」 2023.7.30
「 声明 」 余之才疏学浅,故所撰文疏漏难免,或有谬误或不准确之处,敬请读者批评指正。