目录
一再谈端口号
1端口号范围划分
2两个问题
3理解进程与端口号的关系
二UDP协议
1格式
2特点
3进一步理解
3.1关于UDP报头
3.2关于报文
4基于UDP的应用层协议
三TCP协议
1格式
2TCP基本通信
2.1关于可靠性
2.2TCP通信模式
3超时重传
4连接管理
4.1建立连接
4.2断开连接
5再次理解
5.1建立连接,为什么要三次握手?
5.2重新理解四次挥手
5.3验证状态
CLOSE_WAIT
TIME_WAIT
6流量控制
7滑动窗口
7.1两个问题
7.2丢包问题
编辑
7.3关于滑动窗口
8拥塞控制
9延迟应答
10捎带应答
11TCP异常问题
12基于 TCP 应用层协议
13TCP小结
四UDP与TCP
1两者的面向问题
UDP面向数据报
TCP面向字节流
2粘包问题
对于UDP来说,没有粘包问题
对于TCP来说,存在粘包问题
解决粘包问题
扩展理解
3两者的对比
五TCP全连接队列
1listen的第二个参数
2理解全连接队列
3内核层里socket和连接
4使用TCP dump抓包
4.1安装 tcpdump
4.2使用
捕获所有网络接口上传输的 TCP 报文
捕获指定网络接口上的 TCP 报文
捕获特定源或目的 IP 地址的 TCP 报文
捕获特定端口的 TCP 报文
保存捕获的数据包到文件
4.3代码观察现象
一再谈端口号
端口号(Port)标识了一个主机上进行通信的不同的应用程序
在 TCP/IP 协议中, 用 "源 IP", "源端口号", "目的 IP", "目的端口号", "协议号" 这样一个五元组来标识一个通信(可以通过 netstat -n 查看)
1端口号范围划分
@0 - 1023: 知名端口号, HTTP, FTP, SSH 等这些广为使用的应用层协议, 他们的端口号都是固定的
@1024 - 65535: 操作系统动态分配的端口号. 客户端程序的端口号
有些服务器是非常常用的, 为了使用方便, 人们约定一些常用的服务器, 都是用以下这些固定的端口号:
服务器 | 端口号 |
---|---|
ftp | 21 |
ssh | 22 |
telnet | 23 |
http | 80 |
https | 443 |
执行下面的命令, 可以看到知名端口号:
vim /etc/services
我们自己写一个程序使用端口号时, 要避开这些知名端口号!
2两个问题
1. 一个进程是否可以 bind 多个端口号?
2. 一个端口号是否可以被多个进程 bind?
端口号是用来标识主机内进程的唯一性:所以一个端口号是不能被多个进程bind的;
而一个进程可以bind多个端口号(但要避开知名端口号)
3理解进程与端口号的关系
端口号通过传输层要与进程进行配对时:(通常做法)
会通过一个hash表(储存着bind进程与端口号的映射),找到对应的进程
但如果bind进程映射的端口号不是唯一的(还有其它bind进程也与该端口号进行关联),
端口号在进行配对时就不确定是哪个进程了(hash冲突):所以这样就说明了端口号是不能bind多个进程的!!
二UDP协议
1格式
理解协议都要来认识它们之间的共性:
1如何解包
a提取前8个字节(报头大小);
b读取UDP长度,用它-8字节看看等不等于0:等于0说明没数据;
不等于0说明数据大小=UPD长度-8字节:剩下的都是数据
2.如何分用
报文中有16位目的端口号:当主机收到UDP报文时,读取源端口后进行查表(bind的进程对应的端口号)找到指定进程,再填写端口号就能进行通信了
2特点
UDP传输的过程类似送信的过程;
无连接: 知道对端的 IP 和端口号就直接进行传输, 不需要建立连接;
不可靠: 没有确认机制, 没有重传机制; 如果因为网络故障该段无法发到对方,UDP 协议层也不会给应用层返回任何错误信息;
面向数据报: 不能够灵活的控制读写数据的次数和数量;
3进一步理解
3.1关于UDP报头
UDP报头在内核中以结构体的方式存在
我们在使用UDP协议时,进行反序列化让结构体转成字符串,再以网络序列发送出去;对方在接收时也是按照这个结构体来进行反序列化(这个过程是在内核中规定好了的);
如果我们自己在应用层能不能实现类似结构体进行序列化与反序列化 ?
可以的:在前面的网络版本计算器中我们就实现过了;但是不推荐
双方在进行通信时设备可能完全不一样(编写语言不同,大小端,内存对齐...)
而在操作系统内核中能这么干:双方内核都是C语言写的:不会引进其它新的结构体字段;网络序列基本是固定的,只要考虑好大小端问题就OK了
3.2关于报文
在OS中:可能在某一时间段内有很多的报文:一些有可能要向上交付,一些要向下交付;一些可能发生失败要进行丢弃...而OS要对这些报文进行管理:如何管理?先描述,在组织!
描述报文的结构体:struct sk_buff
在协议栈中往下交付时(其实传的是sk_buff)指针*head往左移:添加报文(封包)
往上交付时指针*head往右移:删除报文(解包)
4基于UDP的应用层协议
NFS | 网络文件系统 |
TFTP | 简单文件传输协议 |
DHCP | 动态主机配置协议 |
BOOTP | 启动协议(用于无盘设备启动) |
DNS | 域名解析协议 |
三TCP协议
TCP 全称为 "传输控制协议(Transmission Control Protocol"):对数据的传输进行一个详细的控制
1格式
学习协议首先要理解:
a如何解包
在TCP报头中有4位首部长度(单位4字节)范围[0,15] ->换成字节范围[0,60]
如果报头大小为20字节,那么4位首部长度为:0101(20/4=5)
1.提取20字节(报头大小);
2.读取4位首部长度,用它 - 报头大小看看等不等于0;如果为0则没有选项:剩下的都是数据
不为0则有选项(选项=首部长度-报头大小):(假设选项大小为16)提取16字节:剩下的都是数据
b如何分用
发送TCP前我们要先填16位目的端口(发送给谁);对方收到后报头里的16位源端口代表谁发送的:读取它并把它往上交付给指定的进程便能进行通信了
2TCP基本通信
2.1关于可靠性
举一个例子:当你给朋友发微信:你吃了吗?过了一会朋友回你:还没吃!朋友进行了回复就说明他一定是送到了我发给他的消息,否则就不会无缘无故会发信息说:还没吃!
在tcp中也是如此:
当client发送报文给server后,server有应答:那我们就认为serer一定是收到了报文
(但总会有最新的消息还没有应答,这个我们不保证:对方收到了应答就说明历史数据被对方收到了
对于clinet来讲: 有没有可能收不到应答的情况?是有可能的(有可能是发出去的数据丢失,也可能是server的应答丢失):但这种情况我们都认为报文丢失了
收到应答100%就认为server收到报文,反之报文丢失(后面说):这便是TCP协议的:可靠性
TCP也是全双工的:那么server到client(反过来)也是保证可靠性的;思路与上面类似
2.2TCP通信模式
TCP共有2种通信模式:
第一种:一个报文一个报文的发送(类似你去快递站一个快递拿回家,拆完后又去快递站拿)
另一种是(在某个时间段)同时发送多个报文,同时送到多个应答报文(TCP常用的通信方式)
确认应答中:1001数字是填在应答报文的确认序号里:即代表1001之前的数据已经全部收到
下次发从1001开始(不一定是1001,还要加上数据长度~)
那这时有人要问了:把发送过来的报文里的32位序号修改成1001不就行了吗?为什么要有两个序号
要记住:TCP时全双工的,server也有可能给client发报文(数据),此时历史数据也要给client发应答报文,难道server要发两个报文??
server可以在一个报文里填上两个序号来代表我既要给你发数据,也要对临时数据作应答:这种方式叫做捎带应答,提高了发送效率
我们知道:发送数据本质是发送到对方的发送缓冲区中;我们从逻辑上把缓冲区中的字节流看成是数组的形式(实际比较复杂):那么序列号的填写工作不就是转换成最后一个值的数字下标吗!
而在TCP中:client发送的报文请求每次可能是不同的:有建立连接的,发送数据的,断开连接的,应答的,应答+发送数据的;server怎么知道收到的报文是什么请求(类型)呢?
通过报头里的保留(6位)的标记位来进行判断;ack标记为1说明该报文时应答报文,该报文里有数据说明该报文类型是:应答 + 发送数据类型
3超时重传
共有两种情况:主机A发送数据时丢包和主机B应答时丢包;这时就会触发TCP的超时重传机制在特定的时间间隔内重新发送数据
但在下面的情况中:主机B收到的两个重复报文,怎么办?
TCP判定两个报文序号一样,会去重!
那如果主机B收到了很多报文,要对报文进行判别那个是先到那个是后到的,又怎么办?
通过序号的大小进行排序:保证按序到达!
既然丢包后会超时重传,那特定的时间间隔是多久呢?
这就要取决于网络状态了:网络好时间间隔短,网络差时间间隔长:在500ms整数倍浮动
如果一直丢包,一直重传:TCP就会给我们断开连接,帮助我们控制成本!
4连接管理
在正常情况下, TCP 要经过三次握手建立连接, 四次挥手断开连接
但我们在平时写TCP代码是只有listen,accept,connect;根本就没有这种感觉;这其实是OS在内部帮助我们在做~
4.1建立连接
以故事引入主题:
建立连接这件事就好比男女确定男女关系一样:男:做我女朋友好吗?女:好啊,那你做我男朋友好吗? 男:好啊
我们在前面说了:TCP只保证历史数据被对方收到了,不保证最新的数据被对方收到,那么最后一次握手时client给serverACK时不是就可能出现丢包的情况吗?怎么解决这个问题?
我们先来想下一个问题:建立连接一定要成功吗?不一定吧!
建立连接的本质是:在赌最后一个ACK对方一定收到了!
如果在最后一次出现丢包了,server是不知道这回事的,它会以为连接以及建立好了,接下来就给server发送数据,但server收到数据后在想:连接不是还没建立好吗?怎么这么快就给我发消息,哦,我明白了:出现丢包了;所以server会给client发送报头中把reset标记位置1来告诉server:连接异常要进行释放,重新建立连接(三次握手)
4.2断开连接
还是先以故事引入主题:
断开连接这件事就好比男女要分手类似:女:我们分手吧 男:好啊 男:那我也要给你分手 女:好,那我们从此就不是一路人了
client给serverFIN,serverACK:这就表明client要给你发的数据已经发完了,我要给你断开连接!server也给clientFIN,clientACKserver:此时双方之间就完全断开了链接(四次挥手完成)
但server也可以选择不给clinetFIN:你发完了,但我要给你发的数据还没完;server每给client发时,虽然在client那边认为我已经跟server断开连接的,但还是会给serverACK应答,怎么理解?
client给serverFIN说明client要给server发送的用户数据已经发完了,关闭sockfd(清理发送缓冲区)断开‘连接’了:但实际上TCP为了配合对方(发送ACK)连接没彻底断开,server端还是能给clinet发送数据的!
此时client收到server的数据,怎么读上来?
之前使用close(sockfd):表面读写端都断开了;如果用 shutdown(sockfd,SHUT WR) :我只关闭写端,还能正常读数据!
5再次理解
在OS中我们会有很多连接:有请求的连接,正在通信的连接,请求断开的连接;怎么多连接OS怎么管理?
先描述,在组织:建立内核结构体进行管理
这也说明了一件事:建立连接,是有成本(时间+空间)的!
在上面我们讲了三次握手:那一次,两次握手行不行呢?
一次握手与两次握手会带来server安全问题:SYN洪水;
一次握手就建立连接的话,client可以给server发送大量SYN:server建立连接时需要成本的,每个连接占一点资源有可能会让server崩溃
两次握手也同理(server给clientACK client可以不处理)那如果这样的话三次握手也是可能造成SYN洪水的啊!
但三次握手相对而言比一次两次来说更好些:最后一次ACK时client发的,clientOS内部也要管理连接(需要成本),持续攻击client也会崩溃
5.1建立连接,为什么要三次握手?
1验证全双工,验证网络的连通性 (距离问题)
client给serverSYN,serverACK:说明clinet给server发的消息 server能收到
server给clientSYN,clientACK:说明server给clinet发的消息 clinet能收到
这样既能说明双方网络连通,也说明了client能发和收,server能收和发
2建立双方通信的共识意愿(意见问题)
client给serverSYN,serverACK:说明server同意与clinet进行连接
server给clientSYN,clinetACK:说明clinet同意与server进行连接
而server给client的SYN和ACK实际在握手时采用的是捎带应答(一次搞定效果是一样的!)
这些问题都是一次握手两次握手无法来验证的!!
5.2重新理解四次挥手
与为什么三次握手的理由类似:也是要知道双方的距离与意见问题;至于为什么是四次而不是三次,主要是为了处理我们上面的特殊情况:client要断开连接而server不想断开的情形
5.3验证状态
CLOSE_WAIT
被动断开的一方,会在第一次挥手完成时处于CLOSE_WAIT
将http写的代码中把请求处理好后不关闭sockfd就能看到现象
//TcpServer.hpp
#pragma once
#include <functional>
#include "Socket.hpp"
#include "Log.hpp"
#include "InetAddr.hpp"
using namespace socket_ns;
static const int gport = 8888;
using service_io_t = std::function<std::string(std::string)>;
class TcpServer
{
public:
TcpServer(service_io_t server, uint16_t port = gport)
: _server(server), _port(port), _listensockfd(std::make_shared<TcpSocket>()), _isrunning(false)
{
_listensockfd->Tcp_ServerSocket(_port); // socket bind listen
}
void Start()
{
_isrunning = true;
while (_isrunning)
{
InetAddr client;
SockPtr newsock = _listensockfd->AcceptSocket(&client);
if (newsock == nullptr)
continue; // 断开连接
// 进行服务
// version 2 -- 多线程 -- 不能关fd -- 共享
pthread_t pid;
PthreadDate *date = new PthreadDate(newsock, this, client);
pthread_create(&pid, nullptr, Excute, date);
}
_isrunning = false;
}
struct PthreadDate
{
SockPtr _sockfd;
TcpServer *_self;
InetAddr _addr;
PthreadDate(SockPtr sockfd, TcpServer *self, const InetAddr &addr)
: _sockfd(sockfd),
_self(self),
_addr(addr)
{
}
};
static void *Excute(void *args)
{
pthread_detach(pthread_self());
PthreadDate *date = static_cast<PthreadDate *>(args);
std::string requeststr;
date->_sockfd->Recv(&requeststr);
std::string reponsestr=date->_self->_server(requeststr); // 进行回调
date->_sockfd->Send(reponsestr);
//date->_sockfd->Close(); // 不关闭 sockfd
delete date;
return nullptr;
}
private:
service_io_t _server;
uint16_t _port;
SockPtr _listensockfd;
bool _isrunning;
};
用指令查
netstat -natp
如何我们的服务器卡顿,查一下是不是存在大量的CLOSE_WAIT状态:是的话把sockfd关闭
TIME_WAIT
主动断开的一方,会在第四挥手完成时,等待一定的时长
如果我们想看到TIME_WAIT状态:先让server退,在让client退:在server就能看到现象
平时在运行server时,如果server退了想再次重启时我们会发现bind失败,要换端口号才能解决:原因就是进程连接退了,但OS所维护的连接没退,还在TIME_WAIT中(端口号被占用)
使用setsockopt设置创建的sockfd就能解决TIME_WAIT问题(解决历史遗留问题)
void ReuseAddr()
{
int opt = 1;
::setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
}
至于TIME_WAIT的时间,它=2*MSL(Maximum Segment Lifetime)最大报文生成时间
可以通过指令查这个时间
cat /proc/sys/net/ipv4/tcp_fin_timeout
那TIME_WAIT有什么好处?(为什么是2MSL)(MSL > RTO(超时重传时间))
我们都知道:但一方把数据发送给对方时,我们在某个时间间隔没有进行ACK时就要进行重传;当对方没收到该报文有可能是网络差,报文正处在某个路由器阻塞着:这么说这个报文的生存时间明显要大于重传时间,长时间下来在网络里可能存在着很多积攒下来的报文:四次挥手后在clinet看来是断开连接的,实际上在OS中并没有断开,在等待着网络中历史报文,收到并清除,防止后面我们在进行连接时(用相同的端口号)进行一定干扰!
有了这个TIME_WAIT时间:在第四次挥手时如果ACK丢了,对方会持续想我们发送FIN来提醒我们丢包了,好让我们进行ACK应答!
6流量控制
在发送报文时,有时会遇到:发送方发送过去的报文,接收方的接收缓冲区满了,会对收到的报文进行丢弃,这合理吗?
有人可能会说:报文被丢弃了,TCP保证可靠性不是有超时重传吗?重传下不就好了!
这虽然有道理,但我把报文经过CPU资源,流量等花费,千里迢迢到达目的地,接收方只是因为接收缓冲区满了就丢弃了,它有没有什么错!所以我们要根据接收方的接收能力??
那什么是接收方的接收能力?--> 接收方接收缓冲区剩余空间的大小
那我作为发送方怎么知道?
发送报文时,接收方不是要给我们ACK吗?除了在报头里吧ACK标志位置1,它还要更新:16位窗口大小(填写是自己的接收能力):对方送到后就能动态调整发送数据的大小啦!
接收方接收缓冲区满了,我们就要把发的速度由快变慢;但有没有可能接收方缓冲区很大,发送方
发送还太慢呢? 这时完全有可能的:这时接收方上层已经嗷嗷待哺了,发送方发送速度还这么慢这时完全不合理的!
所以流量控制不仅能控制变慢,也能控制变快!
现在两台主机都知道对方的接收能力了,可以根据剩余空间大小做调整:但如果发送方首次给接收方发送报文,应该发多大??
有人可能会说:首次不清楚的情况下发小一点不就可以吗!
但关键是要发多少?100字节?200字节?那如果接收方接收缓冲区故意设置得很小呢?
首次给对方发报文这个说法不怎么准确:在那期间我们双方进行连接时要进行三次握手:这不就是给对方发送报文吗!发送报文除了把SYN置1/ACK置1,自己的属性数据(如流量大小)也要填写后发送告知对方:也就是说在三次握手期间,双方就已经交换了双方的接收能力!
既然双方都知道了接收能力也就能根据对方实际情况发送合理的数据了;这时我们把它推导到极端:接收方剩余空间为0,发送方要怎么办?接收方不为0了接收方又要怎么办?
接收方缓冲区满了, 就会将窗口置为 0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测(空报文)看看接收方的情况;当接收方窗口不为0时,会主动给发送方发送窗口更新通知(空报文)发送方:我能够接收数据了,快来给我发送消息吧!
在这里如果接收方窗口不更新,发送方一直窗口探测的结果一直是0,这该怎么办?
那么这时发送方就会将要发送的报文中:把PSH标记位置1:让接收方尽快处理数据(把接收缓冲区的数据尽快取走)
那么现在还有最后一个标记位URG没说:现在就来说说它的作用
URG:紧急指针是否有效
16位紧急指针:标识那部分数据是紧急指针
紧急指针是数据中偏移量(数组下标)的数据(只有一个字节)
紧急指针下的数据一般是我们规定好的(0:正常 1:暂停 2:取消)常用于上传资源过程中途取消
(如果没有紧急指针,那么我们想取消的这个需求等前面数据传输后才能取消,这样就很费资源)
过程:读到报文中URG置1(紧急指针有效) -> 紧急指针偏移量下的数据 -> 根据数据进行处理动作
在写代码过程中要想进行发送接收紧急数据时就要用到recv,send参数中的falgs(MSG_OOB)
要想实现我们可以启用两个线程:一个正常通信,一个负责紧急数据处理;但这个实用性不高(如果要更换需求,整个代码可能会受影响);一般推荐用ftp协议(两个端口分开处理,后续改动不大)
7滑动窗口
前面我们讲了流量控制和超时重传:通过两个问题来引出滑动窗口
1流量控制:发送方如何根据对方接收能力,来发送数据?
流量控制是通过滑动窗口来实现的!
2超时重传:在超时重传时间内,已经发送的报文不能丢弃,它保存在哪里?
保存在滑动窗口中!
先不着急理解这些,我们先来看看TCP的两种通信模式:一种是发送一个报文,确认应答后再发送报文,我们说这种效率不高;所以有了第二种方式:在一个时间段内发送多个报文,同时会收到多个应答;在这里我们发送的多个报文的前提下是对方要来得及接收的!所以在发送方这里我们要来规定出一个概念:
滑动窗口:在滑动窗口内的数据可以直接发送,暂时不需要收到应答
滑动窗口就相当于发送缓冲区中的其中一块缓冲区:
左边是已发送已确认数据,自己是暂时不用收到应答直接发送;右边是未发送未确认数据
(暂时)所以目前可以得出:滑动窗口大小 = 接收方的接收能力
7.1两个问题
滑动窗口只能向右滑动吗?可以向左滑动吗?
以目前来说好像是只能向右滑,不能向左滑,因为往右滑动时左边的数据是已发送已应答了,没必要再次发重复数据
滑动窗口是一直不变的吗?可以变大吗?可以变小吗?可以为零吗?
窗口大小(暂时)代表着接收方的接收能力;当本次发送报文时接收方给我ACK的窗口大小变大,滑动窗口不就变大了,变小也类似;如果接收方不处理接收缓冲区的数据,ACK的窗口大小肯定会越来越小,甚至最后变0!也就是滑动窗口为零!
之前我们说了:我们可以把发送缓冲区连接成 char outbuffer[N] 数组,那里序号就是数组下标
而滑动窗口本质上就是两个下标指针:[int win_start,int win_end]来控制大小
当接收方ACK报文时,更新下标指针:win_start = ack_seq;win_end = win_start + win
要想指针所指的范围变大:win_end变大的速度大于win_start:也就是更新窗口大小变大
要想指针所指的范围变小甚至变成零:win_start变大的速度大于win_end甚至win_start = win_end:也就是接收方不处理接收缓冲区的数据,窗口大小变小(最后变为零)
7.2丢包问题
关于丢包问题,共有3种(1种)情况
最左侧报文丢失
比如:1001~2000的报文丢失,而后面的报文发送成功:那么主机B ACK应答时,确认序号全是1001:代表1001之前的数据主机B已经收到;TCP识别主机A收到3个以上ACK应答就会认为最左侧丢失,触发快重传机制进行补发!
有了快重传这么高效率的机制,超时重传是不是显得有点累赘了?
一点都不会;如果双方通信接近末期了,主机A收到了2个相同的ACK确认应答,快重传没触发,此时就由超时重传来接管;也就是说:快重传是在超时重传的基础上提高效率,而超时重传是在为快重传没法触发时兜底的!
那如果不是最左侧报文丢失,而是ACK应答丢失了呢?
这种我们就看最新的ACK应答来判断那个报文丢失从而进行补发(最新丢了看次新的...)
总结:
a由于确认序号约束,滑动窗口的wit_start不动;
b快重传 | 超时重传进行补发
中间报文丢失:因为中间报文丢失而最左侧报文发送成功并收到了应答,win_start向右移动,此时中间报文变成最左侧报文!
最右侧报文丢失:前面的报文发送成功并收到应答,win_start = ack_seq此时的最右侧报文就又变成了最左侧报文!
7.3关于滑动窗口
滑动窗口向右滑动过程中会不会发生越界?
不会!我们在逻辑上把发送缓冲区理解成数组,但物理上它是环形队列,不会出现越界情况
滑动窗口左边的数据已经是已发送已应答了,要把它们删除吗?
不用;滑动窗口向右滑动也就把左边数据默认已经是没用数据,随时可被新数据所覆盖~
8拥塞控制
以上的TCP策略我们只考虑发送方到接收方的问题,不考虑网络问题;那如果此时通信出现网络拥塞了,如何识别出来是网络的问题??
(上帝视角)当server给client发送1000个报文时,有999个报文被对方收到,只有一个出现丢包:这时server会认为很正常,重新对丢包报文进行补发;但如果是999个报文出现丢包,server就会认为出现严重的网络拥塞了,停止发送报文
这时可能会有人说了:不就是网络拥塞了吗,重新补发999个报文不就好了,为什么还要停止发送报文呢?网络通信,你不要以为只有你们(server和clinet)在通信,还有其它server和client也在通信,继续补发不是更加加剧网络拥塞吗!
解决网络拥塞的意义在于多个使用同一个网络通信的主机,有拥塞避免的共识!
TCP引入慢启动机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据;
在这里我们要引入拥塞窗口(int变量)这个概念:前面讲了滑动窗口 = 应答窗口 这个说法是暂时的,实际上滑动窗口 = min(应答窗口,拥塞窗口) 网络好的情况用应答窗口,网卡情况用拥塞窗口,使得滑动窗口的大小最终一定是小于等于对方接收能力的!
发送开始的时候, 定义拥塞窗口大小为1;每次收到一个 ACK 应答, 拥塞窗口加1;
由于网络状态是浮动的,那么也就说明拥塞窗口大小也必然是浮动的:那主机应该这样得知拥塞窗口的大小是多大??
经过多轮尝试(发送1,2,.4...)来确定拥塞窗口的大小
多轮尝试(慢启动)发送数据呈现指数级增长(2^n);前期慢,后期快,增幅大:减小网络发送,让网络恢复(前期慢);网络一旦恢复了,我们的通信过程也要快速恢复起来(后期快)
而拥塞窗口大小时刻都在发送变化:为了不增长的那么快, 不能使拥塞窗口单纯的加倍!
此处引入慢启动的阈值当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长
线性增长探测到网络拥塞了,更新出拥塞窗口大小(线性增长更新的大小较准确)了,下次传输从1开始重新进行探测拥塞窗口大小(新的ssthresh值是上次更新的拥塞窗口的一半)
那有没有可能状态一直稳定,探测一直线性增长??
这是有可能的:但在不同的系统中拥塞窗口大小一般是有上限的!而网络状态一定在某个时刻会出现些许波动,不可能一直是流畅的状态(毕竟万事无绝对嘛)
9延迟应答
如果接收数据的主机立刻返回 ACK 应答, 这时候返回的窗口可能比较小
• 假设接收端缓冲区为 1M. 一次收到了 500K 的数据; 如果立刻应答, 返回的窗口就是 500K;
• 但实际上可能处理端处理的速度很快, 10ms 之内就把 500K 数据从缓冲区消费掉了;
• 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也
能处理过来;
• 如果接收端稍微等一会再应答, 比如等待 200ms 再应答, 那么这个时候返回的
窗口大小就是 1M;
(网络健康状态下)窗口越大, 网络吞吐量就越大, 传输效率就越高
如果延迟应答后上层还是未处理接收缓冲区的数据,ACK的窗口大小不还是跟之前的一样!(在这种情况下)延不延迟好像没必要吧?
延迟应答是保证数据能在短时间内被上层处理,好给对方ACK告诉对方我能接收更多的数据:如果短时间内未被处理,慢ACK与快ACK是不影响通信的,而慢ACK有概率触发数据被上层处理,提高传输效率!
那么所有的包都可以延迟应答么? 肯定也不是
• 数量限制: 每隔 N 个包就应答一次;
• 时间限制: 超过最大延迟时间就应答一次;
具体的数量和超时时间, 依操作系统不同也有差异; (Linux)一般 N 取 2, 超时时间取 200ms;
10捎带应答
在延迟应答的基础上, 我们发现, 很多情况下, 客户端服务器在应用层也是 "一发一收"
的. 意味着客户端给服务器说了 "How are you", 服务器也会给客户端回一个 "Fine,
thank you";
那么这个时候 ACK 就可以搭顺风车, 和服务器回应的 "Fine, thank you" 一起回给客户端
在建立连接时:第三次握手client给server发ACK时就可以捎带应答(前两次不行):在给serverACK的同时携带数据(因为给serverACK时,client认为连接已经是建立好了的)
11TCP异常问题
进程终止(机器重启):进程终止会释放文件描述符, 仍然可以发送 FIN. 和正常关闭没有什么区别
机器掉电/网线断开:server认为连接还在, 一旦server有写入操作, client没反应,server就会发现连接已经不在了, 就会进行 reset;即使没有写入操作, TCP 自己也内置了一个保活定时器, 会定期询问对方是否还在. 如果对方不在, 也会把连接释放;这种做法叫做保活机制
给client定期发消息检测连接是否存在的做法也可以在应用层中体现:例如前面的socket编程中,给client echo_server 信息就是其中一种
12基于 TCP 应用层协议
• HTTP
• HTTPS
• SSH
• Telnet
• FTP
• SMTP
13TCP小结
为什么 TCP 这么复杂? 因为既要保证可靠性, 也要提高性能!
可靠性:
• 校验和
• 序列号(按序到达)
• 确认应答
• 超时重传
• 连接管理(三次握手四次挥手)
• 流量控制
• 拥塞控制
提高性能:
• 滑动窗口
• 快速重传
• 延迟应答
• 捎带应答
其他:
• 定时器(超时重传定时器, 保活定时器, TIME_WAIT 定时器等)
四UDP与TCP
1两者的面向问题
UDP面向数据报
应用层交给 UDP 多长的报文, UDP 原样发送, 既不会拆分, 也不会合并
用 UDP 传输 100 个字节的数据:
• 发送端调用一次 sendto, 发送 100 个字节, 接收端也必须调用对应的一次recvfrom,接收100字节
• UDP 没有真正意义上的 发送缓冲区:调用 sendto 会直接交给内核, 由内核将数据传给网络层协议进行后续的传输动作;
• UDP 具有接收缓冲区:但是这个接收缓冲区不能保证收到的 UDP 报的顺序和发送 UDP 报的顺序一致; 如果缓冲区满了, 再到达的 UDP 数据就会被丢弃;
注意:UDP 协议首部中有一个 16 位的最大长度:也就是说一个 UDP 能传输的数据最大长度是 64K(包含 UDP 首部);然而 64K 在当今的互联网环境下, 是一个非常小的数字.如果我们需要传输的数据超过 64K,就需要在应用层手动的分包, 多次发送, 并在接收端手动拼装;
TCP面向字节流
创建一个 TCP 的socket的同时:会在内核中创建一个发送缓冲区和一个接收缓冲区;
• 调用 write 时, 数据会写入发送缓冲区中;
• 如果发送的字节数太长, 会自动被拆分成多个 TCP 的数据包发出;
• 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
• 接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
由于缓冲区的存在, TCP的读和写不需要一一匹配, 例如:
• 写100 个字节数据时,可以调用一次write写100 个字节,也可以调用 100 次write, 每次写一个字节;
这里的字节流与我们在之前学习的语言级别的字节流是一样的!
2粘包问题
这个问题就如同北方城市蒸包子:蒸好的包子之间如果没有距离,你去拿一个包子时会无从下手:可能拿上来是一个半包子,也可能是半个包子...
而粘包问题本质上是解决两个包之间的边界问题
对于UDP来说,没有粘包问题
• 对于 UDP, 如果还没有上层交付数据, UDP 的报文长度仍然在;同时, UDP 是一个一个把数据交付给应用层. 就有很明确的数据边界
• 站在应用层的角度:recvform读取字节一定是对方发送的字节数,不可能出现少读多读的情况:
这样读到的一直是完整的报文
对于TCP来说,存在粘包问题
• 在 TCP 的协议头中, 没有如同 UDP 一样的 "报文长度" 这样的字段, 但是有一个序号这样的字段
• 站在传输层的角度, TCP 是一个一个报文过来的. 按照序号排好序放在缓冲区中保证按需到达
• 站在应用层的角度, 看到的是字节流(在缓冲区中)
• 这样就不知道从哪个部分开始到哪个部分, 是一个完整的报文
解决粘包问题
• 对于定长的包, 保证每次都按固定大小读取即可;(request,reponse结构)
• 对于变长的包, 可以在包头的位置, 约定一个包总长度的字段(添加包长度作为包的报头)
• 对于变长的包, 还可以在包和包之间使用明确的分隔符(例如用\r\n表示包的结束位置)
扩展理解
在之前学习文件时:我们往文件里写入各种数据结构(int,float,char类型...),将文件里的数据给读上来时,会发现很难进行解析:我们会这样? 因为打开文件时就是面向字节流的!语言层学习文件时少了:粘包问题与反序列化的相关知识:所以会发现往文件里写好写,读就不好读(粘包问题要解决)
在之前的序列与反序列化中:我们用json进行序列化成字符串在encode(添加报头用分隔符隔开)后往网络里写:
现在变成往文件里写,要把文件读上来不就是当时写client的逻辑:decode(读取有效载荷后删除报文)后进行反序列化得到我们想要的数据!
而在未来我们要学习的数据库(redis,mysqt,mongodb...):把数据存到文件里,再把数据读上来本质上是序列化与反序列化(自己实现的协议)!
3两者的对比
我们说了 TCP 是可靠连接, 那么是不是 TCP 一定就优于 UDP 呢? TCP 和 UDP 之间的优点和缺点, 不能简单, 绝对的进行比较
• TCP 用于可靠传输的情况, 应用于文件传输, 重要状态更新等场景;
• UDP 用于对高速传输和实时性要求较高的通信领域, 例如, 早期的 QQ, 视频传输等. 另外 UDP 可以用于广播;
对于要使用哪个协议,还用通过具体场景与具体需求去判定
五TCP全连接队列
1listen的第二个参数
正常server与client通信的代码:把server的accept部分给注释掉(修改backlog)还能不能连接??
//tcpserver.cc
#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/wait.h>
#include <unistd.h>
const static int default_backlog = 1;//修改成1
enum
{
Usage_Err = 1,
Socket_Err,
Bind_Err,
Listen_Err
};
#define CONV(addr_ptr) ((struct sockaddr *)addr_ptr)
class TcpServer
{
public:
TcpServer(uint16_t port) : _port(port), _isrunning(false)
{
}
// 都是固定套路
void Init()
{
// 1. 创建socket, file fd, 本质是文件
_listensock = socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0)
{
exit(0);
}
int opt = 1;
setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
// 2. 填充本地网络信息并bind
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = htonl(INADDR_ANY);
// 2.1 bind
if (bind(_listensock, CONV(&local), sizeof(local)) != 0)
{
exit(Bind_Err);
}
// 3. 设置socket为监听状态,tcp特有的
if (listen(_listensock, default_backlog) != 0)
{
exit(Listen_Err);
}
}
void ProcessConnection(int sockfd, struct sockaddr_in &peer)
{
uint16_t clientport = ntohs(peer.sin_port);
std::string clientip = inet_ntoa(peer.sin_addr);
std::string prefix = clientip + ":" + std::to_string(clientport);
std::cout << "get a new connection, info is : " << prefix << std::endl;
while (true)
{
char inbuffer[1024];
ssize_t s = ::read(sockfd, inbuffer, sizeof(inbuffer)-1);
if(s > 0)
{
inbuffer[s] = 0;
std::cout << prefix << "# " << inbuffer << std::endl;
std::string echo = inbuffer;
echo += "[tcp server echo message]";
write(sockfd, echo.c_str(), echo.size());
}
else
{
std::cout << prefix << " client quit" << std::endl;
break;
}
}
}
void Start()
{
_isrunning = true;
while (_isrunning)
{
// 4. 获取连接
//struct sockaddr_in peer;
//socklen_t len = sizeof(peer);
//int sockfd = accept(_listensock, CONV(&peer), &len);
//if (sockfd < 0)
//{
// continue;
//}
ProcessConnection(sockfd, peer);
}
}
~TcpServer()
{
}
private:
uint16_t _port;
int _listensock; // TODO
bool _isrunning;
};
using namespace std;
void Usage(std::string proc)
{
std::cout << "Usage : \n\t" << proc << " local_port\n"
<< std::endl;
}
// ./tcp_server 8888
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
return Usage_Err;
}
uint16_t port = stoi(argv[1]);
std::unique_ptr<TcpServer> tsvr = make_unique<TcpServer>(port);
tsvr->Init();
tsvr->Start();
return 0;
}
//client.cc
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>
int main(int argc, char **argv)
{
if (argc != 3)
{
std::cerr << "\nUsage: " << argv[0] << " serverip serverport\n"
<< std::endl;
return 1;
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
int clientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (clientSocket < 0)
{
std::cerr << "socket failed" << std::endl;
return 1;
}
sockaddr_in serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(serverport); // 替换为服务器端口
serverAddr.sin_addr.s_addr = inet_addr(serverip.c_str()); // 替换为服务器IP地址
int result = connect(clientSocket, (struct sockaddr *)&serverAddr, sizeof(serverAddr));
if (result < 0)
{
std::cerr << "connect failed" << std::endl;
::close(clientSocket);
return 1;
}
while (true)
{
std::string message;
std::cout << "Please Enter@ ";
std::getline(std::cin, message);
if (message.empty())
continue;
send(clientSocket, message.c_str(), message.size(), 0);
char buffer[1024] = {0};
int bytesReceived = recv(clientSocket, buffer, sizeof(buffer) - 1, 0);
if (bytesReceived > 0)
{
buffer[bytesReceived] = '\0'; // 确保字符串以 null 结尾
std::cout << "Received from server: " << buffer << std::endl;
}
else
{
std::cerr << "recv failed" << std::endl;
}
}
::close(clientSocket);
return 0;
}
server端与client端启动时,现象:
我们所用的公网IP是云服务器厂商虚拟出来的,机器真实的IP是内网IP:但内网IP不便于进行公网访问;但使用公网IP时,内网IP会路由到公网IP里;远程使用时用不了内网IP所以在对方看来就用公网IP来标识连接对方身份
再启动多个client端时:
listen的第二个参数的作用:全连接队列中已经建立三次握手成功的连接个数 = backlog + 1
在服务器来不及对连接进行accept时,底层的TCP会允许用户进行三次握手,但建立连接的个数不能太多: 最大是backlog + 1
2理解全连接队列
(内核中)在传输层TCP中建立accept_queue队列(维护的来不及处理的连接):当有客户端来进行连接(三次握手)时,在队列后面进行链接(结构体内包含着各种基本信息);当应用层进行accept获取连接时,实际上是获取建立连接的结构体(连接的本质是内核中的一种数据结构)
在上面我们模拟的是应用层accept给注释掉(accept非常忙来不及进行accept):此时accept_queue队列的长度最大:backlog + 1
为什么全连接队列不能为空,也不能太长??
a应用层要用全连接队列中去拿,新到来的连接加入到全连接队列中,这不就是生产者消费者模型吗!
b队列为空会增加服务器的闲置率,减少给用户提供服务的效率与体验
c队列太长会浪费资源(空间),使得用户体验不好(新到来的用户连接等待时间长)
3内核层里socket和连接
当我们创建出一个套接字的时候,在struct file* fd array[]申请一个空间(3号数组下标listen_socket)返回给上层,服务(进程)先要为我们创建出一个file对象,这个file对象在底层创建出socket,再通过type创建出tcp_scok(udp_sock)对象(连接):然后我们就能通过file对象的private data找到socket,再通过socket里的sock(多态的方式)访问到tcp_scok(udp_sock)里的所有数据!
进行accept时,在struct file* fd array[]申请一个空间(4号数组下标,普通socket)返回给上层来进行IO,系统先创建出file和socket对象(通过指针连接起来),(三次握手)通过socket里的sock找到tcp_sock(udp_sock):这样在上层就能通过普通socket进行通信啦
内核代码证明file与sock的联系
关于连接:
全连接队列与链表管理报文示意图
4使用TCP dump抓包
TCPDump 是一款强大的网络分析工具, 主要用于捕获和分析网络上传输的数据包。
4.1安装 tcpdump
tcpdump 通常已经预装在大多数 Linux 发行版中。如果没有安装,可以使用包管理器进行安装。 例如 Ubuntu, 可以使用以下命令安装:
sudo apt-get install tcpdump
在 Red Hat 或 CentOS 系统中, 可以使用以下命令:
sudo yum install tcpdump
4.2使用
捕获所有网络接口上传输的 TCP 报文
sudo tcpdump -i any tcp
-i any 指定捕获所有网络接口上的数据包,tcp 指定捕获 TCP 协议的数据包。i 可以理解为 interface
捕获指定网络接口上的 TCP 报文
$ ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.18.45.153 netmask 255.255.192.0 broadcast
172.18.63.255
inet6 fe80::216:3eff:fe03:959b prefixlen 64 scopeid
0x20<link>
ether 00:16:3e:03:95:9b txqueuelen 1000 (Ethernet)
RX packets 34367847 bytes 9360264363 (9.3 GB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 34274797 bytes 6954263329 (6.9 GB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
$ sudo tcpdump -i eth0 tcp
捕获特定源或目的 IP 地址的 TCP 报文
捕获特定源IP地址
sudo tcpdump src host IP地址 and tcp
捕获目的 IP 地址
sudo tcpdump dst host IP地址 and tcp
两种都捕获
sudo tcpdump src host IP地址 and dst host IP地址 and tcp
捕获特定端口的 TCP 报文
sudo tcpdump port 端口号 and tcp
保存捕获的数据包到文件
sudo tcpdump -i eth0 port 端口号 -w data.pcap
从文件中读取数据分析
tcpdump -r data.pcap
4.3代码观察现象
//tcpclient.cc
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>
int main(int argc, char **argv)
{
if (argc != 3)
{
std::cerr << "\nUsage: " << argv[0] << " serverip serverport\n"
<< std::endl;
return 1;
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
int clientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (clientSocket < 0)
{
std::cerr << "socket failed" << std::endl;
return 1;
}
sockaddr_in serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(serverport); // 替换为服务器端口
serverAddr.sin_addr.s_addr = inet_addr(serverip.c_str()); // 替换为服务器IP地址
int result = connect(clientSocket, (struct sockaddr *)&serverAddr, sizeof(serverAddr));
if (result < 0)
{
std::cerr << "connect failed" << std::endl;
::close(clientSocket);
return 1;
}
while (true)
{
std::string message;
std::cout << "Please Enter@ ";
std::getline(std::cin, message);
if (message.empty())
continue;
send(clientSocket, message.c_str(), message.size(), 0);
char buffer[1024] = {0};
int bytesReceived = recv(clientSocket, buffer, sizeof(buffer) - 1, 0);
if (bytesReceived > 0)
{
buffer[bytesReceived] = '\0'; // 确保字符串以 null 结尾
std::cout << "Received from server: " << buffer << std::endl;
}
else
{
std::cerr << "recv failed" << std::endl;
}
}
::close(clientSocket);
return 0;
}
//tcpserver.cc
#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/wait.h>
#include <unistd.h>
const static int default_backlog = 6;
enum
{
Usage_Err = 1,
Socket_Err,
Bind_Err,
Listen_Err
};
#define CONV(addr_ptr) ((struct sockaddr *)addr_ptr)
class TcpServer
{
public:
TcpServer(uint16_t port) : _port(port), _isrunning(false)
{
}
// 都是固定套路
void Init()
{
// 1. 创建socket, file fd, 本质是文件
_listensock = socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0)
{
exit(0);
}
int opt = 1;
setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
// 2. 填充本地网络信息并bind
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = htonl(INADDR_ANY);
// 2.1 bind
if (bind(_listensock, CONV(&local), sizeof(local)) != 0)
{
exit(Bind_Err);
}
// 3. 设置socket为监听状态,tcp特有的
if (listen(_listensock, default_backlog) != 0)
{
exit(Listen_Err);
}
}
void ProcessConnection(int sockfd, struct sockaddr_in &peer)
{
uint16_t clientport = ntohs(peer.sin_port);
std::string clientip = inet_ntoa(peer.sin_addr);
std::string prefix = clientip + ":" + std::to_string(clientport);
std::cout << "get a new connection, info is : " << prefix << std::endl;
while (true)
{
char inbuffer[1024];
ssize_t s = ::read(sockfd, inbuffer, sizeof(inbuffer)-1);
if(s > 0)
{
inbuffer[s] = 0;
std::cout << prefix << "# " << inbuffer << std::endl;
std::string echo = inbuffer;
echo += "[tcp server echo message]";
write(sockfd, echo.c_str(), echo.size());
}
else
{
std::cout << prefix << " client quit" << std::endl;
break;
}
}
}
void Start()
{
_isrunning = true;
while (_isrunning)
{
// 4. 获取连接
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = accept(_listensock, CONV(&peer), &len);
if (sockfd < 0)
{
continue;
}
ProcessConnection(sockfd, peer);
sleep(1);//才能看到四次挥手
close(sockfd);
}
}
~TcpServer()
{
}
private:
uint16_t _port;
int _listensock; // TODO
bool _isrunning;
};
using namespace std;
void Usage(std::string proc)
{
std::cout << "Usage : \n\t" << proc << " local_port\n"
<< std::endl;
}
// ./tcp_server 8888
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
return Usage_Err;
}
uint16_t port = stoi(argv[1]);
std::unique_ptr<TcpServer> tsvr = make_unique<TcpServer>(port);
tsvr->Init();
tsvr->Start();
return 0;
}
现象
以上便是 TCP 协议的全部内容,有错误欢迎指正,最后感谢您的观看!