- 传输层
- 再谈端口号
- 端口号的划分
- netstat
- pidof
- UDP协议
- UDP的特点
- UDP缓冲区
- UDP使用注意事项
- UDP报头的理解
- 基于UDP的应用层协议
- TCP协议
- 4位首部长度
- 16位窗口大小
- 确认应答机制
- 32位序号和32位确认序号
- 6个标记位
- 超时重传机制
- 连接管理机制
- 流量控制
- 快重传机制
- 再谈序号
- 延迟应答
- 面相字节流
- 粘包问题
- TCP异常处理
- TCP小结
- 深入理解文件描述符和socket的关系
传输层
再谈端口号
端口号(Port)标识了一个主机上进行通信的不同的应用程序。一台主机可能绑定各种各样的服务。
服务器收到来自于客户端A、B。客户端的端口号可能是一样的,但是IP地址是不一样,所以服务器构建响应返回时,就可以根据IP地址区分不同的主机
客户端A(浏览器)可能给服务器发送很多次请求,画面1和画面2用的是不同的端口,客户端IP地址都是同一个,他怎么知道返回给谁呢?因为有端口号,就可以根据端口号分发给不同的页面
在公网中IP表示唯一的一台主机,端口号表示主机的唯一个进程。
在TCP/IP协议中,用"源IP",“源端口号”,“目的IP”,“目的端口号”,"协议号"这样一个五元组来识别一个通信。
端口号的划分
0-1023:知名端口号,HTTP,FTP,SSH等这些广为使用的应用层协议,他们的端口号都是固定的。(这不是你能随便使用的,如果你非要绑定这些端口,普通用户是绑定不了的,除非你是root用户)
1024-65535:操作系统动态分配的端口号,客户端程序的端口号,就是由操作系统从这个范围分配的。(这些端口号还可以细分为随机绑定和固定使用,如mysql为3306)
有些服务器是非常常用的,为了使用方便,人们约定一些常用的服务器,都是用以下这些固定的端口号
服务器 | 端口号 |
---|---|
ssh | 22 |
ftp | 21 |
telnet | 23 |
http | 80 |
https | 443 |
这些端口号和服务器的名称基本上可以画等号,如我们现实生活中:119火警、110警察的关系
可用
cat /etc/services
查看端口号
我们自己写一个程序使用端口号时, 要避开这些知名端口号
netstat
netstat是一个用来查看网络状态的重要工具
语法:netstat [选项]
功能:查看网络状态
常用选项:
- n 拒绝显示别名,能显示数字的全部转化成数字
- l 仅列出有在 Listen (监听) 的服務状态
- p 显示建立相关链接的程序名
- t (tcp)仅显示tcp相关选项
- u (udp)仅显示udp相关选项
- a (all)显示所有选项,默认不显示LISTEN相关
前面是tcp/udp的是网络通信。unix是域间套接字为本地通信(POSIX版本,基于socket接口,用于本地通信方案,和管道差不多)
pidof
语法:pidof [进程名]
功能:通过进程名, 查看进程id
以前我们每次kill进程的时候都要先查进程id。那我们有了pidof,能不能直接用pidof杀掉呢?
为什么不行呢?因为管道是将pidof程序的标准输出的信息以标准输入传给kill程序的。但kill需要的是命令行参数,不是标准输入。
如何将标准输出的信息已命令行参数传给kill程序呢?xargs能将标准输入转为命令行参数传过去
还有一种方法
UDP协议
操作系统怎么知道要将这个报文交给上层哪个协议?根据16位端口号。
将这个报文发送到目标主机后,如何将这个报文的报头和有效载荷分离呢?他采用的方式是定长报头(8字节)。
你怎么知道收到报文后,数据是完整的呢?16位UDP长度:表示整个数据报(UDP首部+UDP数据)的最大长度。2^16最大为65536字节 减去8字节剩下就为有效载荷长度
16位UDP校验和:如果有效载荷的数据出现偏差了,校验失败就会将报文丢弃。不是说UDP不保证可靠性吗?因为这不属于可靠性,虽然UDP不保证可靠性,但必须保证收上来的数据是对的。
UDP的特点
- 无连接。知道对端的IP和端口号就直接进行传输,不需要建立连接。我们往期文章中写UDP服务器通信的时候,服务器是直接recvfrom,阻塞住,客户端是直接sendto,并没有connect、listen、accept
- 不可靠。没有确认机制,没有重传机制;如果因为网络故障无法到达对方,UDP协议层不会给应用层返回任何错误信息
- 面向数据报。应用层交给UDP多长的报文,UDP原样发送,几不会拆分,也不会合并。用UDP传输100个字节的数据,如果发送端调用一次sendto,发送100个字节,那么接收端也必须调用对应的一次recvfrom,接收100个字节。
UDP传输过程就类似于寄信,我发一份邮件你就收到一份邮件,不管你在不在家(不管你是否建立连接),你不会收到半个邮件。我发10封邮件,你就会收到10封邮件(并不像TCP,可能会收到10封粘着成了1封的邮件)
UDP缓冲区
UDP没有真正意义·1上的发送缓冲区。因为它完全不需要,因为有数据直接交给内核UDP传输层,UDP层直接发送(将数据传给网络层协议进行后续的传输动作),发送后什么也不管了,不用操心后面可能会发生的各种各样的事情,如超时重传,流量控制,拥塞控制,等等。
UDP具有接收缓冲区。
1.这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致。
如果你发送的数据报的时候是1、2、3,收到的顺序可能是3、2、1等等。为什么会接受到不同的顺序呢?因为网络的情况是很复杂的,有的路由器很阻塞,所以必定可能会导致数据到达对方的时间顺序是不一致的。这种情况我们叫做:数据报乱序,乱序是不可靠的一种场景,因为UDP不保证可靠性,所以UDP对这种情况不处理。
2.如果缓冲区满了,在到达的UDP数据就会被丢弃。
UDP接收方不会像TCP会通知发送方自己的接受缓冲区快慢了,所以如果接受缓冲区被打满了,发送方还在不断的发送,就会导致大量的UDP报文丢包,这也是不可靠性的一种
3.UDP的socket既能读,也能同时写。叫做全双工。我们上个章节写UDP聊天室的时候,一个线程读另一个线程可以同时写,这就能证明UDP是全双工通信
UDP使用注意事项
我们注意到,UDP协议有一个16为的最大长度,也就是说UDP能传输的数据最大长度是64KB(包含UDP首部),然而64KB在当今的互联网环境下,是一个非常小的数字
当我想发一个报文超过64KB了,那我还能不能用UDP呢?能,但是你要发送这么大的数据你就需要再应用层把报文拆开,最大64KB然后再发。
UDP报头的理解
虽然图上图上是这样画的,但我想知道这个报文究竟是什么样的
传输层和网络层都是由操作系统实现的,而linux操作系统是用C语言实现的,协议报头是用结构体定义的,这个不难理解
语言上是位段类型,结构上是UDP报头
struct udp_hear
{
uint32_t src_port:16;
uint32_t dst_port:16;
uint32_t length:16;
uint32_t check_code:16;
};
内核的所有协议都类似是这样实现的,每个字段代表什么含义双方都约定好了
因为它是自定义类型,所以自然可以定义变量,开辟空间。
struct udp_header h;
h.src_port = 1234;
h.dst_port = 8888;
…
这就是填充报头字段
在UDP接受缓冲区一定存在大量的UDP报文,所以需要对报文进行管理,如何管理呢?先描述,再组织
对报文的管理就转化成了对链表的管理。
基于UDP的应用层协议
NFS:网络文件系统
TFTP:简单文件传输协议
DHCP:动态主机配置协议
BOOTP:启动协议(用于无盘设备启动)
DNS:域名解析协议
比如我们在看视频、看直播,你就发现看视频的时候,有高清版,超清版,蓝光版。其实就是对像素点做局部性采样,动态调整视频的清晰度其实就是对采集像素点进行调整。你看视频或看直播的的时候会突然的糊一下,或者突然的卡顿一下,中间某个包丢了,这就是UDP的不可靠传输造成的。
TCP协议
TCP几乎是传输层中最重要的协议,很多应用层协议都会用TCP。
我们调用的write、read、send、recv、sendto、recvfrom等函数本质都是把缓冲区的内容拷贝到自己的缓冲区或把自己的缓冲区的内容拷贝到缓冲区。至于在传输层的发送缓冲区:数据什么时候发送,发送多少,出错了怎么办?全部都有tcp自主决定,也就是为什么它叫做传输层控制协议。
为什么udp有没有这些功能呢?没有,因为它连发送缓冲区都没有
总结:tcp是全双工,能传输控制的协议
Tcp协议段式
一行为32个bit,4个字节。报头=请求报头(20字节)+选项(长度不固定)。16位源端口好和16位目的端口号:能找到应用层要交给哪个协议/进程。
4位首部长度
1.问题:报头和有效载荷如何分离?
4位首部长度:表示整个报头的大小。
有人说了4位才多大,0000->1111即0->15,你这最多只有15个字节,连最基本的请求报头都表示不了。实际上在计算的时候,有基本的大小单位:4字节。所以0->15就变为了0->60,最多为60字节了,因为请求报头位固定的20字节,0->20是固定的请求报头长度,20->x是选项长度。所以选项是最多40个字节。
所以能通过4位首部长度,能准确的将我们的报头从报文里准确的去掉,这种将报头和有效在和分离的方法就叫做:固定长度(20字节)+自描述字段(4位首部长度)
16位窗口大小
16位窗口大小,是什么呢?
如果没有流量控制。客户端是并不清楚服务器的接收能力的,所以可能客户端会一直发送,直到服务器的接受缓冲区打满,你的客户端还在一直发,则会导致数据会出现大面积丢包的情况。
有了流量控制,客户端就可以知道服务器的接受情况,空间不足,发送的速度就会变慢,发送的内容也会变少,甚至不发送,从而减少了大面积丢包的情况。之前不是说了,丢包了,发送方还会补发吗?虽然补发能解决丢包问题,但是我们有更好的方法去解决丢包问题,补发+流量控制
我们如何保证对方收到了呢?我发送给对方,对方会给我们一个应答。这就是确认应答机制
发送的速度会变慢,内容变少,依据是什么呢?
对于发送方来讲,发送速度由对方的接收缓冲区中剩余空间的大小决定
如何得知对方的空间大小呢?对方会给我们一个应答,应答会携带16位窗口大小告知对方,自己的缓冲区大小。
确认应答
没有应答的数据,我们无法保证可靠性。最新的一条消息是没有应答的,所以我们无法确保发出去的消息是100%可靠的。
我们在实际tcp通信的时候,没必要对应答再做应答,只需要保证我有实际意义的消息发送的消息到对端就可以了,没必要保证全局的可靠性,只需要保证两个单方向上的可靠性就可以了。
下面是tcp最原始的通信
假如client端只想把tcp数据A传到server端,server端只想把tcp数据B传到client端
收到了应答就确认了我这个tcp数据发了过去,就保证了我这个方向上的可靠性–这也叫做TCP的确认应答机制
如果数据丢了或者应答丢了呢?那对方不会给我发送应答,你是否收到了数据我不知道啊,所以会有超时重传机制。这样就保证了双方发送数据的可靠性
所以在收到应答之前,我们需要把发送的数据放到缓冲区,以方便下次超时重传的时候,可以直接再次发送
我:我们去吃饺子吧?
你:好的
你:去哪家呢?
我:好的
我:去XXX饺子馆
这样的话,沟通的效率太低了,现实中这样也是不合理的,我们将这两个信息可以合并到一起
我:我们去吃饺子吧?
你:(好的)去哪家呢?
我:(好的)去XXX饺子馆
这就叫做捎带应答。
上面是最原始的tcp通信,事实上,我发送一条消息的时候,并不是在收到应答之后再发送–这样的话效率太低了,事实上在收到应答之前仍然可以发送消息
只要收到了应答,就能保证消息对方接收到了。
数据到达的顺序和对方接收的顺序是否是一样的呢?如果不一样,这种情况就叫做数据包乱序问题。造成乱序,本身就是不可靠的一种。为什么会有乱序问题呢?因为发送出去的数据包走的路径,经过的路由器是有差别的,所以到达的时间有差别。
32位序号和32位确认序号
怎么保证不乱序呢?32位序号/32位确认序号。序号的作用之一:保证数据的按序到达
序号是什么?
天然每个字节都会有自己的编号(本质就是数组下标),序号就是发送的数据的最后一个字符的下标
确认序号:收到的报文的序号+1。表明接收方已经收到ACK序号之前的所有的报文
为什么这么规定,原因有很多,先只讲一点,剩下的后面再讲
如果只收到了3001,但没收到1001、2001的ack,因为确认序号的意义为:我已经接收到了确认序号之前的所有报文。说明了对方收到了我发送的1000、2000、3000。所以应答允许有少量的丢失
为什么有32位序号和32位确认序号,两组序号呢?如果只有一组序号,比如我收到了1000序号,我直接把1000序号改为1001当做确认序号直接给发回去。
1.双方序号的意义上是对等的,你发送序号的意义是数据内容的标志,到我这里就成为了确认接收内容的标志。
2.捎带应答,自己发消息的同时可能回应。既要表明我发送了哪些内容,又需要确认我之前收到了哪些内容
6个标记位
为什么要有标记位?
服务器数量 :客户端数量 = 1 :N
一个客户端TCP通信建立连接的时候会发送报文,正常通信的时候会发送报文,TCP断开连接的时候也会发送报文。所以对于服务器来说报文一定是有各种“类型”的。服务器就要根据不同的TCP报文,要有不同的处理动作。
就如我开了餐厅,我是老板,里面有吃饭的人,美团的,饿了么的,我会对吃饭的人说是什么,对美团的说美团来取餐。
那接收方如何得知,报头的类型各自是什么呢?6个标记位
标记位存在的意义:区分TCP报文的类型。
ACK:确认序号是否有效。如果你有应答,就把ACK标志位置为1。如捎带应答,既有发送信息又要应答。
SYN:请求建立连接;我们把捎带SYN表示的成为同步报文段。表明这是想和我三次握手建立连接。如上传百度网盘的时候,你可以暂停上传和重新上传,说明请求的时候一方面能正常通信,一方面还能对服务器进行特殊的交互。
FIN:对方通知我,它要关闭连接了。被称为结束报文段
PSH:提示接收端应用程序立刻把TCP缓冲区中的数据读走
对方接受缓冲区阻塞的时候,不知道对方是否有空间,如何知道现在能不能发呢?
1.会定期询问接收方,如果没有应答消息返回或者有应答消息说自己还是拥堵状态,则不会发送
2.对方会发送消息给我,说自己更新了缓冲区。
如果对方缓冲区空间不足了,我就会发送PSH这个标志位的报文。让对方赶快处理接收缓冲区
RST:要求对方重新建立连接,我们把将RST置为1的报文表示的称为复位报文段
网络的错综复杂环境中,可能会存在一方认为连接建立成功,另一方没有认为连接建立成功的场景,为了解决这种问题,就有了RST标志位
客户端确认将消息发送了出去(①)就建立连接,还是对方收到了消息(②)才建立了连接?因为前两次握手是有可靠性的,但无法确认第三次的可靠性。所以结论:客户端在三次握手中,认为只要把三次握手中第三次报文发出,就认为连接建立好了
TCP虽然保证可靠性,但是TCP允许连接建立失败。如果最后的ACK丢了,则服务器不会建立连接,一方认为建立好了连接,另一方认为没有建立好连接。这就叫做连接不一致问题。
客户端认为建立好了连接当然可以给服务器发送消息,而服务器就发现我没和你建立连接你还给我发送消息,就会发送带有RST的报文,要求让客户端重新建立连接。
还有一种情况:服务器关闭后重启,客户端的连接还在,继续发送,则会收到服务器的RST报文
URG:紧急指针是否有效
我们知道TCP是按需到达,有些情况我们想让某些数据插队优先处理。若将URG置为0,16位紧急指针无效,若置为1,16位紧急指针为从有效载荷起始位置开始的偏移量,默认长度为1字节。
send发送的时候设置flags选项为MSG_OOB就可以发送紧急数据了,recv接受的时候也需要设置紧急数据
什么场景下会用到它呢?
服务器比较卡顿的时候,不知道是因为处理大量的用户信息这么卡,还是因为故障卡,想知道服务器现在处于什么状态,客户端就能发送紧急数据,服务器检查到有紧急数据到来就会根据偏移量读取紧急数据,并会返回对应的状态编号,客户端收到后,就会根据这个编号判断出服务器现在处于什么状态。
超时重传机制
情况一:如果主机A发送的数据丢了,在某一个时间点,主机A并不知道主机B是否收到了报文并回响应了,还是在过程中数据丢了,主机A并不知道,所以只能定义特定的时间间隔判定,如果没收到应答,则很有可能丢了,则要重传。这也叫超时重传
情况二:消息发送了过去,但是确认应答丢了,主机A没收到应答,也会超时重新发送。
情况二主机B可能会收到重复,重复的报文也算是不可靠性的一种。所以我们需要有去重策略。如何去重?因为发送的报文时是会携带序列号的,如果发现两个序列号是相同的,说明这个报文之前已经收过了。
超时的时间如何确定?
最理想的情况下,找到一个最小的时间,确保“确认应答一定在这个时间内返回”。但是这个时间的长短,随着网络环境的不同,是有差异的。如果超时时间设太长,会影响整体的重传效率。如果超时时间设的太短,有可能会频繁发送重复的包。
TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间
Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。如果重发一次之后,仍然得不到应答,等等2*500ms后再进行重传。如果仍然得不到应答,等待4 *500ms进行重传。依此类推,以指数形式递增。累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接。
连接管理机制
connect本质让客户端构建一个报文交给服务器,只发起三次握手。后面握手的过程是双方的操作系统来完成。当客户端完成第二次握手的时候,则认为建立好了连接
accept并不参与三次握手的过程,只负责把建立好的连接拿上来,服务器若收到了最后一次ACK,则认为建立好了连接,状态为ESTABLISHED,如果没有建立好连接,accept则会继续阻塞住
客户端close的时候,双方操作系统会自动配合FIN和ACK第一二次挥手。客户端断开的时候也要确认服务器没有数据发了,才能真正断开连接,这也是可靠性的一种。服务器再调用close,双方操作系统会自动陪好FIN和ACK第三四次挥手。
TCP通信,基于连接的建立和断开,三次握手和四次挥手。为什么是三次和四次呢?
三次握手其实是四次握手,因为有捎带应答,将四次握手压为了三次,为什么可以捎带呢?客户端发起三次握手请求的时候,服务器接收了ACK,因为服务器是提供服务的,服务器必须无偿同意,接下来就肯定会SYN响应。
那为什么四次挥手不压缩为三次挥手?客户端断开连接了,表示的是客户端的用户数据发完了,但是服务器可能还有数据要发送,所以此时还不能立即和你最后两次挥手。
一次握手行不行?不行,无法验证全双工是否通畅。
那两次握手好像可以验证全双工是否通信,也不能验证全双工的通畅性,因为只能验证Server端可以收消息,不能验证Server端是否能发消息
假如一次握手成功了,就会有恶意的客户端,不给你发送正常消息,就只给你发送SYN,每一个连接就会消耗服务器一点内存,连接就很容易打满服务器,这也被称为SYN洪水。
假如两次握手呢?则服务器会收到SYN就会建立连接。如果客户端ACK不要了,就和上面的情况一样了,服务器很容易被攻击。
假如四次握手呢?和两次握手的情况一样,服务器会比客户端早建立连接
偶数次握手会导致服务器比客户端早建立连接。奇数次握手能保证握手失败的成本是嫁接在client端的,更为重要的一点,三次握手都可行了,为什么还要四次五次握手浪费资源和时间。
假如有10000次连接,有100次连接失败,服务器这100次连接不会建立,这些成本给的是客户端。
即使是三次握手也仍然会有SYN洪水问题,比如你在网络上浏览了不好的网站,被黑客植入了病毒,你的机器后台会定期的领取黑客的任务,比如说12点同意去访问某一个网站,使这个服务器一次性会收到大量的连接请求,服务器资源可能会被消耗殆尽,使一些正常的用户不能访问该网站。这种就属于法律、信息安全的范畴,和TCP没关系了
用两台机器做实验。观察连接管理时的各个状态的现象
一台启动服务器,另一台启动多个客户端,来连接服务器
对之前实现网络版本计算器的代码进行修改。仅将start里面的代码全部注释掉,listen的第二个参数设置为1
更新TcpServer.hpp代码
#include <iostream>
#include <unistd.h>
#include <functional>
#include <signal.h>
#include "Socket.hpp"using namespace std;#define defaultIp "0.0.0.0"class TcpServer
{using func_t = function<string(string&)>;
public:TcpServer(const uint16_t serverPort, const string& serverIp = defaultIp):_serverIp(serverIp),_serverPort(serverPort){}void Init()//初始化{_listensock.CreateSocket();_listensock.Bind(_serverPort);_listensock.Listen();}void Start(func_t func){signal(SIGCHLD, SIG_IGN);//父进程不再关系子进程是否退出,即父进程不管子进程也不会资源泄漏_func = func;while (1){sleep(100);// string clientIp;// uint16_t clienPort;// int socket = _listensock.Accept(&clientIp, &clienPort);// if (fork() == 0)// {// _listensock.Close();// string streamBuffer;//Tcp发送完整的报文的时候可能只发送一部分,为了保证获得一个完整的报文,我们就可以这样处理// while (1)// {// char readBuffer[4096];// int n = read(socket, readBuffer, sizeof(readBuffer) - 1);// if (n > 0)// {// readBuffer[n] = 0;// streamBuffer += readBuffer;// while (1)// {// string ret = _func(streamBuffer);//服务器对客户端的字符串进行处理// if (ret.empty())//说明没有要处理的报文// break;// write(socket, ret.c_str(), ret.size());// }// }// else if (n == 0)// {// lg(Info, "[clientIp:%s, clientPort:%d] closed", clientIp.c_str(), clienPort);// break;// }// else// {// lg(Warning, "server read failed:%s", strerror(errno));// break;// }// }// exit(0);// }// close(socket);}}
private:Socket _listensock; //监听套接字string _serverIp; //服务器Ip地址,一般设置为全0uint16_t _serverPort;//服务器端口号func_t _func; //回调函数
};
启动TCP服务器后
可以看到服务器处于监听状态
用一个客户端来连接服务器
此时客户端状态–处于ESTABLISHED状态。说明客户端已经建立了连接
此时服务器状态
可以看到服务器也和这个客户端建立了连接。我们上面的代码并没有accept函数,但已经三次握手成功并建立了连接,这也说明了accept函数并不参与三次握手的过程,仅由客户端connect发起,三次握手由双方操作系统自动完成。
再用一个客户端连接服务器
此时客户端状态,一台机器有两个客户端连接服务器,所以显示了两个客户端连接的状态。可以看到都和服务器建立好了连接。
此时服务器状态
可以看到服务器和两个客户端都建立好了连接
再用一个客户端连接服务器,一共用三个客户端连接服务器
此时客户端状态
此时服务器状态
有两个客户端是ESTABLISHED状态,有一个客户端是SYN_RECV状态。说明有两个建立好了连接,而有一个没有建立好连接,正在处于半连接状态。三个客户端都认为建立好了,但服务器认为有一个连接建立没有成功。
我们之前设置的listen的第二个参数为1。则底层能建立连接的数量为2。
对于这些已经建立好但没有被服务器拿上去处理的连接,需要被操作系统管理起来,操作系统是如何管理的呢?先将这个连接用结构体描述起来,再用队列的形式组织起来。
过了一会儿,我们再查服务器状态
可以看到之前的处于半连接的状态不见了,因为服务器一直没有拿走半连接队列里面的连接,服务端不会长时间维护SYN_RECV,维持一段时间就会自动关闭
再查看客户端状态
客户端认为都建立成功了,但服务器没认为建立成功,这就是client和server的连接不一致问题
想进入全连接队列就必须先进入半连接队列,再半连接队列中,服务器收到了对应的ACK才会将连接拿到全连接队列里面去;如果全连接队列满了,会将客户端最后一次握手的ACK保存起来,如果全连接队列有空间了就能直接从从半连接队列提升到全连接队列。
半连接队列的长度和全连接从长度有关,由操作系统自主决定,我们就不关心了。如果半连接队列也满了,这才是真正意义上的SYN洪水,正常连接就连不上来了。比如过年的时候在12306里抢票,我们看到服务器繁忙,请稍后重试,就是半连接队列打满了。
为什么不能维护全连接过长?如果服务器已经很忙了,来不及处理你这个请求了,就会导致队列长时间阻塞,此时长连接队列不仅占用资源还不会创造出价值。
那不要连接队列呢?如果流量处理完了,没后续服务了,造成资源空闲。如海底捞,老板说满员了你去别家吃吧,先不说客人的体验怎么样,如果刚好有一桌离席了,你想一桌客人来吃饭,可能就会限制30分钟才有人来,则会少赚一桌子的钱。
所以如果没有全连接队列,则会导致服务器资源得不到充分利用。
如果连接在全连接队列里,没有被accept拿上去,则不会close,则不会有四次挥手的现象。所以我们加上accept函数
更新Tcpserver.hpp文件
#include <iostream>
#include <unistd.h>
#include <functional>
#include <signal.h>
#include "Socket.hpp"using namespace std;#define defaultIp "0.0.0.0"class TcpServer
{using func_t = function<string(string&)>;
public:TcpServer(const uint16_t serverPort, const string& serverIp = defaultIp):_serverIp(serverIp),_serverPort(serverPort){}void Init()//初始化{_listensock.CreateSocket();_listensock.Bind(_serverPort);_listensock.Listen();}void Start(func_t func){signal(SIGCHLD, SIG_IGN);//父进程不再关系子进程是否退出,即父进程不管子进程也不会资源泄漏_func = func;while (1){sleep(1);string clientIp;uint16_t clienPort;int socket = _listensock.Accept(&clientIp, &clienPort);sleep(10);close(socket);}}
private:Socket _listensock; //监听套接字string _serverIp; //服务器Ip地址,一般设置为全0uint16_t _serverPort;//服务器端口号func_t _func; //回调函数
};
更新TcpClient.cc文件
#include "Protocol.hpp"
#include "Socket.hpp"static void Usage(const std::string &proc)
{std::cout << "\nUsage: " << proc << "\tserverIp\tport\n" << std::endl;
}
int main(int argc, char* argv[])
{if (argc != 3){Usage(argv[0]);exit(0);}string serverIp = argv[1];uint16_t serverPort = atoi(argv[2]);Socket skt;skt.CreateSocket();skt.Connect(serverIp, serverPort);sleep(20);close(skt.Fd());return 0;
}
服务器为主动关闭的一方。
一个客户端连接服务器
此时客户端状态
此时服务器状态
10s后,服务器close客户端的socket
此时客户端的状态
此时服务器的状态
20s后,客户端close服务器的socket
此时服务器的状态
此时客户端的状态
客户端状态完全关闭,而服务器维护了一会儿TIME_WAIT状态
再过一会儿,服务器状态完全关闭
主动断开的一方,在四次挥手完成后,要进入time_wait状态,等待若干时长后,会自动释放。因为连接没有彻底断开所以IP和Port正在被使用。所以这就是为什么我们关闭了服务器立马再启动服务器的时候会提示地址正在被使用的原因。
假如一个服务器的资源被打满,服务器直接挂掉了,有这个设定则服务器无法立即重启,损失取决于time_wait的时间。如双十一,一秒钟就会损失千万万。
如何解决这样的问题呢?
int opt = 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
创建完socket后加上setsockopt函数就可以了。
为什么客户端没有这个现象呢?因为客户端的端口号会不断变化。每次重启地址都是不一样的。
TIME_WAIT等多长时间呢?
TCP协议规定,主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态。
可用cat /proc/sys/net/ipv4/tcp_fin_timeout
命令查看MSL时间
为什么要TIME_WAIT等待呢?
1.让双方的历史数据得以消散。如果一个客户端给服务器发消息,此消息还在传播中,服务器重启了,则服务器有可能收到这条之前发送的消息。新起的服务器收到老的数据这是不允许的。所以让数据尽可能消散,避免这种事件发生
2.让四次挥手具有较好的容错性。让四次挥手能够正常的完成,如果第三次挥手FIN或第四次挥手ACK丢失了,则一方则会补发FIN,另一方仍在TIME_WAIT状态,并没有完全退出,则会接收这次补发的FIN并响应ACK,使四次挥手正常完成。
避免新起的服务器收到老的数据还有一种方法:在后面有讲解
流量控制
接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,因此TCP会根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制。
第一次用户发送数据的时候怎么保证发送数据量是合理的?不要理解三次握手只是三次握手,双方也交换了报文,已经协商了双方的接收能力。第三次握手的时候,可以携带数据,捎带应答。
流量控制过程
滑动窗口是TCP可靠性的一种,也是能提高效率的一种。
滑动窗口
发送端会根据对方接收缓冲区的大小(16位窗口大小)来调整自己滑动窗口的大小
对每一个发送的数据段, 都要给一个ACK确认应答. 收到ACK后再发送下一个数据段.这样做有一个比较大的缺点, 就是性能较差. 尤其是数据往返的时间较长的时候.既然这样一发一收的方式性能较低, 那么我们一次发送多条数据, 就可以大大的提高性能.
滑动窗口能支持TCP支持重传。如果我们发送的数据,没有收到应答之前,我们必须将自己的已经发送的数据暂时保存起来,为了支持超时重传。会被保存在哪里呢?不是被保存,本来就在缓冲区里,我们只需要对缓冲区进行划分就可以了。
红色区域被称为滑动窗口
1.滑动窗口是我们发送缓冲区的一部分
2.滑动窗口的范围大小,目前认为是对方的接收窗口大小(其实是min{拥塞窗口,16位窗口大小})
3.是如何区域划分的?通过指针/下标来进行区域划分。
如何理解用指针/下标来进行区域划分?
窗口的滑动本质就是指针的向右移动
情况一:数据包已经到达,ACK丢了
我们对于确认序列号的定义:确认序列号是x,则说明x之前的报文我们全部收到了。所以允许少量的ACK丢失,不用担心中间的ACK丢失,只要收到最后一个ACK就行。
收到一个ACK就调整一次滑动窗口,这样就保证了滑动窗口,线性的连续的向后更新,不会出现跳跃的情况
快重传机制
情况二:数据包丢了
如果发送端主机连续三次收到了同样一个 “1001” 这样的应答, 就会将对应的数据 1001 - 2000 重新发送。这种机制被称为 高速重发控制(也叫 “快重传”)。
已经有了快重传,为什么还要有超时重传呢?
快重传是有条件的,收到了三个同样的确认应答,才会快重传,本意是提高效率的。超时重传是给我们兜底的,如果发送倒数第二个报文。最多只能收到一个ACK,你不会收到三个,就不会快重传。
滑动窗口会向左移动?会向右移动?移动的时候,大小会变化吗?怎么变化?
第一种情况:右指针不变,左指针向右移动。发送方正常接收到ACK,对方一直没有取走接收缓冲区中的数据,对方缓冲区一直在变小。
第二种情况:左右指针都向右整体滑动,但窗口变大。发送方正常收到ACK,对方接收缓冲区及时取走的数据。
第三种情况:左右指针都向右整体滑动,但窗口变小。发送方正常收到ACK,对方接收缓冲区取走的数据不多,逐步接收缓冲区变小。
所以滑动窗口会动态变化。取决于对方的滑动窗口的大小。
左右指针
int start = 根据确认序号设置。
int end = 确认序号 + 对方窗口大小
收到ACK报文后,1.根据确认序号,更新start指针。2.更新end指针,start + 窗口大小。
滑动窗口,会在发送缓冲区中越界吗?
滑动窗口采用了类似环状算法,如循环队列底层一样。
再谈序号
双方通信的序号并不是从0开始的。如果服务器没有TIME_WAIT状态,一个客户端给服务器发消息,此消息还在传播中,服务器重启了,则服务器有可能收到这条之前发送的消息。新起的服务器就可能收到老的数据。如果序列号是从0开始的话,我就能和你上次滞留的报文的序号对应上,就有可能被误收。
所以一般三次握手的时候,会协商序号,序号是随机生成的,一般是双方随机的序号的最小值作为起始序号
如:起始下标为:1234。
发送方填写序号时:1234 + 数组下标。
接收方拿到序号时:数组下标 = 序号 - 1234。
接收方填写确认序号时:序号 + 1。
发送收到确认序号时:下次发送的数据在缓冲区中的位置 = 确认序号 - 1234 。
所以有服务器有TIME_WAIT状态 + 随机序列号,基本上就不会出现新起的服务器就可能收到老的数据的状况了。
延迟应答
窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。
为了让接收方给对方通告一个更大的窗口,收到报文不着急应答,这就叫做延迟应答。
所以我们在写服务器的时候,比较推荐的做法:每次都尽快通过read、recv尽快的把数据拿上来。
那么所有的包都可以延迟应答吗?肯定也不是。
数量限制:每隔N个包就延迟应答一次。时间限制:超过最大延迟时间就应答一次。
具体的数量和超时时间,已操作系统不同也有差异。一般N取2,超时时间取200ms
捎带应答
客户端服务器在应用层也是 “一发一收” 的. 意味着客户端给服务器说了 “How are you”, 服务器也会给客户端回一个 “Fine, thank you”; 那么这个时候ACK就可以搭顺风车, 和服务器回应的 “Fine, thank you” 一起回给客户端。如我们之前讲的三次握手就是捎带应答。
拥塞控制
如果发送数据出现问题,不仅仅是对方主机出现了问题,也可能是网络出现了问题。
1.如果通信的时候,出现了少量丢包。则是常规情况
2.如果通信的时候,出现了大量的丢包。则是网络出现了问题。
如果通信双方出现了大量的数据丢包问题,TCP会判断网络出现了问题。
如果出现滑动窗口内的大量数据超时了,则发送方则要等待或者少量发送。为什么不能立即对报文进行超时重发?因为会加重网络的阻塞
网络资源是大家共享的。所以网络阻塞是出现是各个主机“共识”的。则你我都会不发送,则能减轻网络的阻塞。
TCP引入 慢启动 机制 – 指数增长, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据
发送开始的时候,定义拥塞窗口大小为1 。每次收到一个ACK应答,拥塞窗口加1 (再发2个,收到2个,则加2,再发4个,收到4个,则加4…)。
滑动窗口大小 = min{16位窗口大小,拥塞窗口}。每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小作比较,取较小的值作为实际发送的窗口
上面这样的拥塞窗口增长速度是指数级别的。为了不增长那么快,因此不能使拥塞窗口单纯的加倍。引入了阈值,当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。
少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞;
当TCP通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降;
拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案。
面相字节流
我们刚才讲的TCP的所有机制,所有通信机制都是由操作系统自动完成的
如何理解面向字节流?
调用write时, 数据会先写入TCP发送缓冲区中;
如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;
如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
在发送TCP的发送缓冲区里,何时发送,是否拆分发送都是由操作系统自主决定,也完全不管你用户的数据是不是一个整体,TCP只以字节为单位,完全可以把你的一个完整的数据分成稀碎再一个一个发出去。也完全可以把你用户多个完整的数据一次性发送出去。
由于缓冲区的存在, TCP程序的读和写不需要一一匹配, 例如:
写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节;
读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次read一个字节, 重复100次;
粘包问题
粘包问题:是用户层的概念,我拿数据包时,也多拿了另外数据包。如吃蒸包子时,我只想拿一个包子吃,因为包子都粘在一起了,拿的时候可能会多拿,少拿。
如何解决呢?在应用层通过协议,明确报文和报文之间的边界。
- 定长报文。假如read上来980个字节,我们定的长度为100个字节,只用100、100…读,最后剩80,存到缓冲区里面,等待数据长度为100后再读。
- 使用特殊字符。发送过来的是文本(假如里面没有换行符),你就可以以\n作为分隔符,读的时候遇到\n就算一个报文
- 使用自描述字段 + 定长报头。规定报头是8个字节,8字节中,前4个bit位来描述有效载荷有多长(如UDP协议就是这样实现的)
- 使用自描述字段 + 特殊字符。如http的content-Length字段(为自描述字段),表示正文的长度,以\n为结尾。
如我们之前写的网络版本计算器,用的就是4号方案。
我们自己定的协议"长度\nx + y\n"
。这就是写Encode和Decode的原因。解析成x + y后,再序列化和反序列化,这是更上层的协议。
对数据一个一个(while)的处理。
判断报文是否是完整的,如果是完整的继续后续处理,不完整则继续等待数据完整。
TCP中,我发一个报文你就收一个报文,这种理解是很不正确的,你发一个报文,我接受到半个,三分之一个都有可能,如果没有上面这样的逻辑,read上来就可能会解析失败
字节流特别特别像家里的自来水,你不管别人是怎么将水塞到管道里的,什么河水,雨水,你想怎么接收,拿水杯接,桶接是你的事。
TCP异常处理
如果客户端和服务器突然挂掉了,我们曾经建立的连接会怎么样?
进程终止:三次握手后,建立的连接和文件的生命周期是随进程的,不管进程正常终止还是异常终止,都表示进程结束。所以会正常进行四次挥手。
机器重启:和进程终止的情况是一样的。
机器掉电/网线断开:客户端是没有机会给服务器发送四次握手的,此时服务器也并不清楚,客户端已经断开连接了。不用担心,服务器有保活机制,服务器给客户端发几次保活信息,过一段时间,客户端一直没有发消息,服务器会自动断开这个连接。
TCP小结
为什么TCP这么复杂?因为要保证可靠性,同时又尽可能的提高性能
可靠性:
- 校验和
- 序号(按序到达,去重)
- 确认应答
- 超时重发
- 连接管理
- 流量控制
- 拥塞控制
提高性能:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
三次握手的功能:
- 建立连接
- 协商其实序号
- 协商双方的接收缓冲区大小
深入理解文件描述符和socket的关系
创建网络套接字的时候,操作系统会给我们创建很多结构,其中一个就是struct_file,这样就能通过文件描述符找到对应的套接字。