「前言」文章内容大致是传输层协议,TCP协议讲解的第二篇,续上篇TCP。
「归属专栏」网络编程
「主页链接」个人主页
「笔者」枫叶先生(fy)
首先明确,TCP是面向连接的,TCP通信之前需要先建立连接,就是因为TCP的各种可靠性保证都是基于连接的,要保证传输数据的可靠性的前提就是先建立好连接。
TCP连接不直接保证可靠性,但是会间接保证可靠性
TCP进行连接会进行三次握手,断开连接会进行四次挥手。
双方在进行TCP通信之前需要先建立连接,建立连接的这个过程我们称之为三次握手。
通过三次握手,客户端和服务器都确认了对方的请求,并建立了可靠的连接。
注意:三次握手是连接通信的策略,即三次握手也可能会出现失败的情况
为什么是三次握手??一次握手、两次握手、四次握手行不行??
首先明确,三次握手是策略,不一定百分之百成功,也可能出现失败。
比如,通信双方在进行三次握手时,其中前两次握手能够保证被对方收到,因为前两次握手都有对应的应答,但第三次握手是没有对应的应答报文的,如果第三次握手时客户端发送的ACK报文丢失了,那么连接建立就会失败。
但是,我们不怕失败丢包,因为TCP有配套的解决方案:
RST
标志位设置为1,发给客户端,让客户端与服务端进行重连注意:第一次和第二次握手不携带数据,第三次握手可能会携带数据
一次握手行不行?
绝对不行的。
一次握手的话,只要客户端发起连接,就可以直接建立连接了(服务端认为连接已经建立好了),这样就会导致单主机下SYN洪水攻击
一次握手会发生SYN洪水攻击,就是有人搞事,通过大量伪造的SYN报文向目标服务器发送连接请求,从而消耗服务器资源,当服务器的半连接队列被耗尽后,合法用户的连接请求无法被处理,导致服务不可用。
还有一点就是无法验证全双工,即无法保证全双工通信通道是流畅的,因为TCP是全双工的
二次握手同上
为什么三次握手可以?
因为三次握手是验证全双工通信信道流畅的最小次数
注意:TCP的工作是建立通信信道,服务器受到攻击本身就不是TCP要解决的。但是如果三次握手有明显的漏洞,让客户端利用了,这就是你TCP的问题了
四次握手行不行?五次、六次…呢?
三次握手也可以叫四次握手,原因如下:
三次握手时的状态变化
CLOSED -> LISTEN
] 服务器端调用listen后进入LISTEN
状态,等待客户端连接LISTEN -> SYN_RCVD
] 一旦监听到连接请求(同步报文段),就将该连接放入内核等待队列中,并向客户端发送SYN确认报文SYN_RCVD -> ESTABLISHED
] 服务端一旦收到客户端的确认报文,就进入ESTABLISHED
状态,可以进行读写数据了客户端:
CLOSED -> SYN_SENT
] 客户端调用connect,发送同步报文段SYN_SENT -> ESTABLISHED
] connect调用成功,则进入ESTABLISHED
状态,开始读写数据TCP通信结束之后就需要断开连接,断开连接的这个过程我们称之为四次挥手。
这样,双方都确认对方已经断开连接,完成四次挥手后,TCP连接就彻底关闭。
为什么要四次挥手?
四次挥手也可能变成三次挥手,原因如下:
四次挥手时的状态变化
客户端状态转化:
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
下面进行做实验,查看这两个状态
代码直接采用socket套接字TCP多线程版的,前面已经讲解过了,就不再解释
初始化服务器initServer函数步骤大致如下:
启动服务器start函数步骤大致如下:
服务端提供的服务,什么也不做,等待20秒服务端就直接退出即可,我们在这20秒内操作,操作就晕在客户端连接好了之后,客户端在20秒内主动退出即可
即演示的目的效果是:
TIME_WAIT
CLOSE_WAIT
tcpServer.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
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结构体全部初始化为0
local.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*)&local
if (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
// 使用手册
// ./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 int
unique_ptr<tcpServer> tsvr(new tcpServer(port));
tsvr->initServer(); // 初始化服务器
tsvr->start(); // 启动服务器
return 0;
}
下面需要使用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提权
先运行服务端,然后使用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
状态可能无法保证ACK被对方接收,数据在网络中消散。TIME_WAIT
状态,等待两个MSL
的时间才能进入CLOSED
状态。这样可以确保连接的可靠关闭。查看Linux的MSL
时间长度:
cat /proc/sys/net/ipv4/tcp_fin_timeout
为什么是
TIME_WAIT
的时间是2MSL
?
MSL
是TCP报文的最大生存时间, 因此TIME_WAIT
持续存在2*MSL
的话ACK
丢失,那么服务器会再重发一个FIN
。这时虽然客户端的进程不在了,但是TCP连接还在,仍然可以重发LAST_ACK
)如果服务器出现了大量的
CLOSE_WAIT
状态,说明服务器:
close(sock)
绑定失败现象:
有客户端连着服务端,服务端主动退出(需要进行重启),紧接着服务端再次启动绑定相同的端口就会出现绑定失败的现象
绑定失败的原因:
TIME_WAIT
TIME_WAIT
持续存在2*MSL
,即TIME_WAIT
状态需要等待两个MSL
的时间才能进入CLOSED
状态2*MSL
时间内,服务器绑定的端口还一直被占用现象演示
代码依旧是上面的,运行服务器,客户端进行连接,然后服务端主动退出,再进行重启就会出现绑定端口失败
绑定端口失败的危害
解决方法
使用setsockopt()
设置socket描述符的选项SO_REUSEADDR
为1,表示允许创建端口号相同但IP地址不同的多个socket描述符
// 设置地址复用
int opt = 1;
setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
修改代码
在创建套接字后面设置即可
编译运行,再进行测试,bind绑定失败问题没有了
这个在前面的16位窗口大小已经谈过一部分了(上一篇),这里再来详细介绍。
接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应
因此TCP支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制(Flow Control)
16位窗口大小:16位最大表示65535,那TCP窗口最大就是65535吗?
第一次向对方发送数据时如何得知对方的窗口大小?
前面已经提到过TCP的工作模式了(上一篇),TCP的工作模式有两种
第一种串行发送数据(不是TCP真正的工作模式)
第二种并行发送数据(TCP真正的工作模式)
并行发送数据,可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)
第二种情况是TCP的真正工作模式,即主流,但是也会存在第一种工作模式,第一种情况是很少的,但也会存在
发送方可以一次发送多个报文给对方,此时也就意味着发送出去的这部分报文当中有相当一部分数据是暂时没有收到应答的
发送缓冲区当中的数据可以分为三部分:
发送缓冲区的第二部分就叫做滑动窗口
窗口大小指的是无需等待确认应答而可以继续发送数据的最大值
例如
发送缓冲区建模1:
win_start
指针指向滑动窗口的左侧,win_end
指针指向滑动窗口的右侧。win_start
和win_end
之间的区间范围内的数据可以被称为滑动窗口。
当发送端收到对方的ACK应答时,如果响应当中的确认序号为xxx
,窗口大小为win
,此时就可以将win_start
更新为xxx
,而将win_end
更新为win_start+win
(暂时这样理解,下面再详细解释)
滑动窗口大小是怎么设定的??未来怎么变化?
总之,滑动窗口的大小是根据网络条件和接收方的接收能力来动态设定和调整的,以实现更高效的数据传输和网络拥塞控制。
16位窗口大小与滑动窗口
总之,16位窗口大小与滑动窗口的关系是,发送方根据接收方的16位窗口大小来确定发送窗口的大小,并根据接收方的确认消息来动态调整发送窗口的大小,以实现数据的可靠传输和流量控制。
滑动窗口会向左滑动吗?滑动窗口整体一定会向右滑动么?
滑动窗口大小会一直不变吗?会变小吗?会变大吗??
丢包问题
当发送端一次发送多个报文数据时,此时的丢包情况也可以分为两种。
情况一: 数据包已经抵达,ACK丢包。
TCP协议的重传机制,当发送端连续收到三次相同的确认序号时(触发重传机制),会触发高速重发控制,也叫快重传
发送缓冲区建模2:如果滑动窗口一直向后滑动,空间大小不够了怎么办??
快重传 VS 超时重传
Fast Retransmit
)是指当发送方连续收到三个重复的确认序号时,即接收方对同一个数据包的确认被重复确认了三次,发送方会立即重传该数据包。这是因为连续收到重复确认序号通常意味着该数据包已经丢失了,为了快速恢复丢失的数据包,发送方会触发快速重传机制,立即重传该数据包,而不必等待超时重传。Timeout Retransmission
)是指当发送方发送一个数据包后,等待一段时间(超时时间)后仍未收到对应的确认消息时,发送方会认为该数据包丢失了,会触发超时重传机制,重新发送该数据包。超时时间是根据网络状况和往返时间动态调整的,如果网络延迟较高或丢包较多,超时时间会相应增加。综上所述,快速重传和超时重传是TCP协议中两种常用的重传机制,根据不同的情况选择合适的重传策略,以提高数据的可靠传输性能。
以上话题都是端到端,客户端到服务端,服务端到客户端,没有考虑有网络的,TCP也有网络问题方面的机制控制,就是拥塞控制。
为什么会有拥塞控制?
举个例子:
所以,TCP不仅考虑了通信双端主机的问题,同时也考虑了网络的问题。
从另一个视角看待
拥塞控制
虽然TCP有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据。但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题
因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵,在不清楚当前网络状态下,贸然发送大量的数据,是很有可能引起雪上加霜
TCP引入 慢启动机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据
即自己的滑动窗口的大小 = min(拥塞窗口,对端窗口大小),两者取较小值
像上面这样的拥塞窗口增长速度,是指数级别的, “慢启动” 只是指初使时慢,但是增长速度非常快
少量的丢包,我们仅仅是触发超时重传;大量的丢包,我们就认为网络拥塞。
当TCP通信开始后,网络吞吐量会逐渐上升;随着网络发生拥堵,吞吐量会立刻下降。
拥塞控制,归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。(拥塞控制也是为了保证可靠性和传输速率)
如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小
如果接收数据的主机收到数据后立即进行ACK应答,此时返回的窗口可能比较小。
延迟应答的目的不是为了保证可靠性,而是为了提高数据的传输效率。(保证在网络不拥塞的情况下尽量提高传输效率)
那么所有的报文都可以延迟应答么?
答案肯定也不是,延迟应答会有以下两个限制:
具体的数量和超时时间,依操作系统不同也有差异;一般N
取2
,超时时间取200ms
TCP连接不直接保证可靠性,但是会间接保证可靠性
以上便是TCP所有的策略
当创建一个TCP的socket时,同时在内核中会创建一个发送缓冲区和一个接收缓冲区。
这个缓冲区在前面已经详细谈过了,这里就不展开说了
由于缓冲区的存在,TCP程序的读和写不需要一一匹配(面向字节流),例如:
写100个字节数据时,可以调用一次write写100字节,也可以调用100次write,每次写一个字节。
读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read100个字节,也可以一次read一个字节,重复100次。
于TCP来说,它并不关心发送缓冲区当中的是什么数据,在TCP看来这些只是一个个的字节数据,它的任务就是将这些数据准确无误的发送到对方的接收缓冲区当中就行了,而至于如何解释这些数据完全由上层应用来决定,这就叫做面向字节流
比对面向数据报
1 : 1
什么是粘包?(基于TCP的应用层问题)
如何解决粘包问题
要解决粘包问题,本质就是要明确报文和报文之间的边界。
对于UDP协议来说,是否也存在 “粘包问题” ?
因此UDP是不存在粘包问题的,根本原因就是UDP报头当中的16位UDP长度记录的UDP报文的长度,因此UDP在底层的时候就把报文和报文之间的边界明确了,而TCP存在粘包问题就是因为TCP是面向字节流的,TCP报文之间没有明确的边界。
(1)进程终止
当客户端与服务端已经建立好连接了,如果客户端进程突然终止,此时建立好的连接会怎么样?
(2)机器重启
当客户端与服务端已经建立好连接了:
(3)机器掉电(断电源)/网线断开
当客户端与服务端已经建立好连接了:一端突然断电或断网了
比如是客户端断电或断网后,服务器端在短时间内无法知道客户端掉线了,因此在服务器端会维持与客户端建立的连接,但这个连接也不会一直维持,因为TCP是有保活策略的。
此外,应用层的某些协议,也有一些类似的检测机制,例如基于长连接的HTTP,也会定期检测对方的存在状态。
为什么TCP这么复杂?
因为要保证可靠性,同时又尽可能的提高性能
可靠性:
提高性能:
除此之外,还有一些定时器(超时重传定时器, 保活定时器, TIME_WAIT定时器等)
常见的基于TCP的应用层协议如下:
当然,也包括你自己写TCP程序时自定义的应用层协议
TCP是可靠连接, 那么是不是TCP一定就优于UDP呢?
TCP和UDP之间的优点和缺点,不能简单绝对的进行比较,不存在谁好谁不好的问题,他们只是应用场景不同:
归根结底,TCP和UDP都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判定
如何用UDP实现可靠传输
参考TCP的可靠性机制,在应用层实现类似的逻辑,例如:
listen函数的作用是设置套接字为监听状态,该函数的第二个参数之前没有谈,现在来谈一下
第二个参数backlog:全连接队列的最大长度。
如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不能设置太大
下面进行做实验:
该实验不进行accept获取_listensock
套接字新连接,什么也不干,只进行监听连接的到来,backlog
设置为2
tcpServer.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
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结构体全部初始化为0
local.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*)&local
if (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
// 使用手册
// ./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 int
unique_ptr<tcpServer> tsvr(new tcpServer(port));
tsvr->initServer(); // 初始化服务器
tsvr->start(); // 启动服务器
return 0;
}
编译运行服务器,此时启动 3 个客户端同时连接服务器, 用 netstat 查看服务器状态, 一切正常
但是启动第四个客户端时, 发现服务器对于第四个连接的状态存在问题了
客户端状态正常, 但是服务器端出现了SYN_RECV
状态, 而不是ESTABLISHED
状态
这是因为, Linux内核协议栈为一个tcp连接管理使用两个队列:
SYN_SENT
和SYN_RECV
状态的请求)established
状态,但是应用层没有调用accept取走的请求)这个全连接队列不能太长,也不能没有:
而全连接队列的长度会受到 listen 第二个参数的影响
上述实验,我们设置的全连接队列大小是2,前三次连接正常,但是到了第四次连接的处于半链接队列,处于了SYN_RECV
状态
但是在客户端看来,连接已经建立好了,但是在服务端看来没有建立连接成功,因为服务端对于第三次握手的ACK进行了忽略
TCP内容真多,终于完结了,TCP写了差不多三万字
--------------------- END ----------------------
「 作者 」 枫叶先生
「 更新 」 2023.7.30
「 声明 」 余之才疏学浅,故所撰文疏漏难免,
或有谬误或不准确之处,敬请读者批评指正。