传输层协议—TCP协议
文章目录
- 传输层协议—TCP协议
- TCP协议段格式
- 四位首部长度
- TCP协议如何根据目的端口号将数据传输给指定进程?
- 32位序列号和32位确认序列号
- 可靠性问题
- TCP报头标志位
- 16位紧急指针
- 16位检验和
- 确认应答机制
- 超时重传机制
- 再谈三次握手四次挥手
- 连接管理机制
- 服务器状态转化
- 客户端状态转化
- 解决TIME_WAIT状态引起的bind失败的方法
- 流量控制
- 滑动窗口
- 拥塞控制
- 延迟应答
- 捎带应答
- 面向字节流
- 粘包问题
- TCP异常
- 理解listen函数的第二个参数
TCP全称为 “传输控制协议(Transmission Control Protocol”). 人如其名, 要对数据的传输进行一个详细的控制;
应用层将数据向下交付给传输层后,其数据的发送和处理由传输层决定,TCP是传输层协议,即数据的发送和处理由TCP协议来决定,因此TCP被称为传输控制协议。
TCP协议段格式
四位首部长度
- 四位首部长度标识报头大小。有时没有选项,标准报头不含选项。因此标准报头大小为20字节。而四位首部长度范围为0000~1111即[0,15]。该范围并不能涵盖报头大小。因此
报头大小=4位首部长度*4字节
。即tcp报头总长度范围[0,60],但标准报头占20字节,因此tcp报头总长度范围[20,60]。超出20字节的部分即为选项部分。若此时报头大小为20字节,此时4位首部长度为[0101]。
TCP协议如何根据目的端口号将数据传输给指定进程?
- 在操作系统内以链表的形式对进程pcb做管理外,还会以hash的方式对进程pcb做管理。以端口号为键值对进程pcb进行管理。
- 接收到tcp报文后,根据目的端口号去操作系统中管理进程pcb的hash数据结构中找到对应进程pcb。进程需要维护自己所管理的文件描述符表。进程pcb内有
struct file*
,该指针指向进程文件描述符表。 - 在进程文件描述符表内能够找到进程所管理的文件。文件内有自己的读写缓冲区。即在传输层中将tcp报头分离后,直接将tcp的有效载荷发送到进程所管理的读写缓冲区。即以读取文件的方式读取到网络数据。
实际上tcp报头底层也是一个结构体,其处理方式和处理udp方式大同小异。
struct tcp_hdr
{
uint32_t src_port;
uint32_t dst_port;
uint32_t req;
uint32_t ack_req;
uint32_t header_length;
......
};
- 传输层接收到应用层向下交付的数据后。开辟一块空间,在该空间内创建tcp_hdr结构体,将结构体内的属性填充完整,然后
该空间+应用层交付的数据=tcp报文
,接着继续向下交付。
32位序列号和32位确认序列号
实际上不止TCP/IP四层模型中存在协议,硬件中也存在协议。
内存和其他外设相连接的线称为IO总线。内存和CPU相连接的线称为系统总线。而设备之间通信必然是通过协议通信。而外设之间通信很少存在不可靠性问题,其中原因包括外设之间距离狠心,指令、数据传输不容易出现异常。而网络通信不是在本地单主机上通信,而是主机之间通信,而主机之间通信的桥梁是网络,在该前提上数据通信就存在可靠性问题。
可靠性问题
网络通信不存在绝对的可靠性,但存在相对的可靠性。
- 双方在通信,历史信息的可靠性建立在收到响应的前提上。例如小蓝法信息给小绿“今天学习了吗?”,这条信息本身不具备可靠性。小绿回复“学了学了好久呢”,即响应了小蓝发送给小绿的历史短信,此时“今天学习了吗?”这条信息才具备可靠性。
- 历史的信息具备可靠性,但最新消息不具备可靠性。小绿回复小蓝“忘了”,该短信在没收到小蓝的回复之前都不具备可靠性。
通过32位序列号和32位确认序列号来直接确保可靠性
该场景没有涉及到超时重传机制,只谈论序号和确认序号的作用
- tcp报头中有序号和确认序号。序号标识发送出去的信息。由于tcp协议是无状态的,因此需要序号标定状态和连续性。当收到报文时,需要去查询报头的序号,然后响应信息中包含确认序号。例如服务器收到客户端发送来的报文序号是10,那么发送回去的响应的确认序号是11,表示11之前的序号的报文接收成功,且确认序号需要要求是连续的。例如客户端发送給服务器三条信息,依次序号是10,11,12,但序号11标定的信息掉包了导致服务器没有收到,因此服务器响应信息的确认序号只能是11。让客户端知道服务器没有收到序号为10之后的报文,触发重传机制重新发送序号为10之后的报文。
- 而实际上客户端和服务器通信不是客户端发送一条请求给服务器,服务器响应一个ack给客户端。而是并发式的相互交流。数据对于接收方而言数据乱序是一种不可靠的表现,要通过序号来对数据进行排序,保证数据的按序到达。即序号和确认序号保证了信息的顺序性和连续性。
- tcp协议是全双工的,因此需要两组序号来保证信息朝向的可靠性。在发送信息的同时给对方确认ack,这样的报文提高了通信的效率。
TCP报头标志位
TCP报文具有类型区别,区别在于其标志位的设置。实际上该标志位底层是位图,若标志位需要被设置,就由0置1。
SYN标志位标识请求报文
在三次次握手中,客户端向服务器发送的请求报文中的YSN标志位就被置为1。
FIN标志位标识断开报文
在四次挥手中客户端向服务器发送挥手请求的报文中FIN标志位就被置为1。
ACK标志位标识确认报文
在网络通信中ACK标识的报文标识确认应答。
PSH标志位标识催促报文
客户端和服务器通信时,可能会存在接收方处理数据不及时导致接收缓冲区满了,发送方无法再次发送数据的情况。
由于TCP是全双工的,通信双方都具备接收缓冲区和发送缓冲区。客户端向服务器发送数据,服务器将受到的数据放到接收缓冲区。服务器上层调用read
将数据从接收缓冲区读取到上层进行处理。会存在上层处理数据的速度慢,客户端发送的数据快,导致服务器的接收缓冲区早早满了。此时客户端再发送数据就会造成丢包问题,而维护连接是需要消耗资源的,通信双方不能由于不能发送数据而长期维护连接。
因此客户端可以将PSH标志位由0置1,只将该标识PSH属性的报文发送给服务器,通知催促服务器尽快处理数据,给接收缓冲区腾出空间来接收新的报文。实际上报头的PSH标志位为1的报文都具备催促含义。
URG标志位标识需要紧急处理的数据
数据对于接收方而言,数据乱序本身就是不可靠的表现。因此可以通过序号对报文标记,对序号进行一定策略的排序,保证数据的按序到达。而按序读取数据自然就产生了等待问题。对于某些需要特殊紧急处理的数据而言,按序等待处理就成了问题。因此需要用URG标定报文含有需要紧急处理的数据,即提示对方上层尽快将该数据读取进行处理。
实际上发送数据函数sendto
就可以传递相关参数标识发送的报文具有需要紧急处理的含义
手册说明标志位
MSG_OOB
Sends out-of-band data on sockets that support this notion (e.g., of type SOCK_STREAM); the underlying protocol must
also support out-of-band data.
sendto
的第四个参数传参MSG_OOB
表示所发送的数据需要被紧急处理,即out-of-band
(带外数据),带外数据的处理策略与tcp流的完全分开的,属于独立一套数据处理策略。
接收函数recv
也可以传参MSG-OOB
表示读取需要紧急处理的数据。
16位紧急指针
URG标定报文含有需要紧急处理的数据。16位紧急指针表示需要紧急处理的数据在有效载荷中的偏移量。
而该需要被紧急处理的数据大小只能为1字节,即TCP的紧急指针只能传输1个字节的数据。
RST标志位标识复位,发送给对方表示需要重置连接
- 客户端和服务器双方通信时,先进行三次握手确定连接成功。然后客户端向服务发送数据。三次握手结束后,服务器由于设备原因单方面掉线,服务器重启后不再认定之前的三次握手,即此时服务器单方面认为没有完成连接,而客户端不知道服务器掉线因此单方面认为连接成功。然后客户端向服务器发送数据。服务器收到了客户端的数据,但由于没有与该客户端建立好连接而收到了发送来的数据感到疑惑,因此服务器需要发送一个具有RST标志位的报文给客户端,表示重新与客户端进行三次握手,即重置连接。
16位检验和
16位校验和: 发送端填充, CRC校验,接收端校验不通过, 则认为数据有问题。此处的检验和不光包含TCP首部, 也包含TCP数据部分。
确认应答机制
- 在TCP协议中,实际上通信双方的接收缓冲区和发送缓冲区是以数组的方式进行管理。数组天生具有下标,即TCP报头的序号。而确认序号为序号+1可以理解为以序号为下标之前的数据全部接收完毕,下次发送数据给我以确认序号作为下标为起点往后开始发送。
超时重传机制
发送数据给对方,对方超过一定时间没有响应应答,自身重新发送数据给对方
客户端向服务器发送数据,会有以下两种场景:客户端认为服务器没有收到数据,客户端重新发送数据给服务器。
场景一:客户端向服务器发送数据,数据在服务器收到之前丢包了,即服务器没有收到数据,因此服务器就没有向客户端响应ACK报文。经过一定时间后,客户端触发超时重传机制,重新向服务器发送数据。
场景二:客户端向服务器发送数据,服务器收到了数据,向客户端响应了ACK报文,但ACK报文掉包了,即客户端没有收到服务器发送来的响应,此时客户端会认为服务器没有收到数据,经过一段时间后,客户端会重新向服务器发送数据。
这个场景下服务器就会收到两份相同的报文,收到重复的报文也是不可靠性的一种,因此服务器需要对报文进行去重操作,通过报文的序号进行去重。
- 客户端发送数据,到重新发送数据期间有一个时间间隔。由于这两次发送的数据是相同的,因此这份数据在收到应答之前应该在接收缓冲区中保存。客户端迟迟没有收到应答,超过特定的时间间隔后重新发送数据给服务器。
- 该决定超时重传机制的特定时间间隔不应该是固定的,因为网络通信的时间长短不只由通信双方决定,还由网络决定,网络是变化的波动的,因此可以认定该特定的时间间隔也是变化的波动的。
- Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时
时间都是500ms的整数倍。如果重发一次之后, 仍然得不到应答, 等待 2 * 500ms 后再进行重传。如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增。累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接。
再谈三次握手四次挥手
实际上三次握手所发送的是具备一定类型的TCP报头
- 客户端向服务器发送SYN实际上是发送一个TCP报头,该TCP报头的SYN标记位被置为1,表示请求。
- 服务器响应客户端的SYN+ACK实际是发送一个TCP报头,该报头的SYN和ACK标记位都被置为1,表示请求和确认应答。
- 客户端响应服务器ACK实际上是发送一个TCP报头,该报头的ACK标记位被置为1,表示确认应答。
三次握手不一定非得成功,在三次握手中最后一个ACK才是最新消息,因此前两条通信报文丢失了会触发重连或者重传,而最后一个ACK就无法保证可靠性。
因此三次握手能够保证需要保证以下几点:
- 客户端能够发送请求,然后能够接收到服务器发送的响应,即保证了客户端具备了发、收的能力。
- 服务器接收到了客户端的第一通信即syn请求,接收到了客户端第二通信即ack应答,即保证了服务器也具备了发、收的能力。
- 客户端和服务器都具备了发送数据、接收数据的能力,才能保证tcp连接是全双工的。
- 客户端先向服务器发送连接请求,那么就要求客户端先确保能够建立好连接,即优先确保客户端具备发送数据、接收数据的能力。服务器被客户端连接,就要求服务器需要在客户端确保能够建立连接之后确保建立好连接。
建立连接的保证为什么是三次握手?
- 操作系统中会有许多进程,因此当该主机作为通信一方时,操作系统中就会存在很多连接。连接是需要被管理起来的,而维护一个连接是具有成本的。因此建立连接的方式尤为重要。
- 一次握手就连接建立成功的话,很容易导致恶意客户端多次向服务器发送syn请求建立多个连接导致服务器再也无法与其它需要建立连接的客户端通信,即容易造成syn洪水问题。
- 两次握手就建立成功的话,当客户端发送出syn请求时保证了客户端建立好了连接,服务器发送了ACK+syn响应时保证了服务器建立好了连接,其原理跟一次握手无太大差异,也容易造成syn洪水问题。
- 三次握手是以最小成本验证全双工通信信道是通畅的。
- 服务器受到攻击,而三次握手是用来保证双工建立好连接的,并不能解决受到攻击问题。
tcp通信需要建立连接,建立连接保证了可靠性,实际上连接并不能直接确保可靠性。经过三次握手后,操作系统中会根据三次握手双方交互的信息建立连接结构体,连接结构体能够保证连接管理机制、超时重连机制、流量控制等等,这些机制直接保证了连接的可靠性。
连接管理机制
在正常情况下, TCP要经过三次握手建立连接, 四次挥手断开连接。
服务器状态转化
- [CLOSED -> LISTEN] 服务器端调用listen后进入LISTEN状态, 等待客户端连接。
- [LISTEN -> SYN_RCVD] 一旦监听到连接请求(同步报文段), 就将该连接放入内核等待队列中, 并向客户端发送SYN确认报文。
- [SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文, 就进入ESTABLISHED状态, 可以进行读写数据了。
- [CLOSE_WAIT -> LAST_ACK] 进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据); 当服务器真正调用close关闭连接时, 会向客户端发送FIN, 此时服务器进入LAST_ACK状态, 等待最后一个ACK到来(这个ACK是客户端确认收到了FIN)。
- [LAST_ACK -> CLOSED] 服务器收到了对FIN的ACK, 彻底关闭连接。
客户端状态转化
- [CLOSED -> SYN_SENT] 客户端调用connect, 发送同步报文段。
- [SYN_SENT -> ESTABLISHED] connect调用成功, 则进入ESTABLISHED状态, 开始读写数据。
- [ESTABLISHED -> FIN_WAIT_1] 客户端主动调用close时, 向服务器发送结束报文段, 同时进入
FIN_WAIT_1。- [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状态。
- 通信双方经过三次握手后,在操作系统中建立好了连接结构体。结构体内有位图记录连接的状态。
在四次挥手期间:
断开连接是双方的事情,需要征得双方同意。
- 先发送断开请求的一方进入
FIN_WAIT 1
状态。例如客户端主动与服务器断开连接,客户端向服务器发送断开请求FIN后,立刻进入FIN_WAIT 1
状态。 - 服务器收到断开请求后,响应ACK,然后进入
CLOSE_WAIT
状态。服务器在响应ACK后立刻发送FIN断开请求。客户端收到服务器发送过来的断开请求FIN,立刻响应ACK报文,同时进入TIME_WAIT
状态。
总结一下:
- 主动断开连接的一方,四次挥手完成后,最终状态是
TIME_WAIT
状态并维持一段是时间。被动断开连接的一方,两次挥手完成后,会进入CLOSE_WAIT
状态。
实际上在客户端与服务器通信时,可以让客户端主动与服务器断开连接,然后让服务器不close sock也不退出进程,那么服务器就处于CLOSE_WAIT
状态。
httpserver.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/wait.h>
#include <signal.h>
#include <pthread.h>
#include <functional>
#include "protocol.hpp"
#define NUM 1024
static const uint16_t gport = 8080;
static const int gbacklog = 5;
using namespace std;
namespace Server
{
enum
{
USAGE_ERR = 1,
SOCK_ERR,
BIND_ERR,
LISTEN_ERR
};
class httpserver;
using func_t = function<bool(const HttpRequest &, HttpResponse &)>; // 重定义func_t
class httpserver
{
public:
httpserver(func_t func, const uint16_t &port = gport) : _port(port), _listensock(-1), _func(func) {}
void inithttpserver()
{
// 1.创建套接字
_listensock = socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0)
{
exit(SOCK_ERR);
}
// 2.bind ip和port
struct sockaddr_in local;
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(_listensock, (struct sockaddr *)&local, sizeof(local)) < 0) // 绑定失败
{
exit(BIND_ERR);
}
// 3.将套接字设置为监听模式
if (listen(_listensock, gbacklog) < 0)
{
exit(LISTEN_ERR);
}
}
void HandlerHttp(int sock)
{
while(true)
{
sleep(1);
}
}
void start()
{
while (true)
{
struct sockaddr_in cli;
socklen_t len = sizeof(cli);
bzero(&cli, len);
int sock = accept(_listensock, (struct sockaddr *)&cli, &len);
if (sock < 0)
{
continue;
}
cout << "accept sock: " << sock << endl;
// 多进程版---
pid_t id = fork(); // 创建子进程
if (id == 0) // 子进程进入
{
close(_listensock); // 子进程不需要用于监听因此关闭该文件描述符
if (fork() > 0)
exit(0);
// //孙子进程
HandlerHttp(sock); // 调用操作函数
// close(sock);
// exit(0);//不关闭sock也不退出进程
}
// 父进程
close(sock); // 父进程不使用文件描述符就关闭
waitpid(id, nullptr, 0);
}
}
~httpserver() {}
private:
int _listensock; // 用于监听服务器的sock文件描述符
uint16_t _port; // 端口号
func_t _func;
};
}
- 服务器不close文件描述符,也不退出进程。
如果服务器出现大量的CLOSE_WAIT状态,要么是服务器压力过大来不及执行close(服务端还有数据没有推送完),要么是你的close直接就是忘写了。
需要注意的是:
断开连接的一方从TIME_WIAT
状态到CLOSED
状态会有一个超时机制,该超时时间为2MSL(Maximum Segment Lifetime—最长报文段寿命:它是任何报文在网络上存在的最长的最长时间,超过这个时间报文将被丢弃。)。该时间设定为2MSL的原因有:
- **确保即使对方没有收到ACK也有足够的时间发送FIN使我方收到,并且回复ACK让对方收到。**例如客户端主动向服务区断开连接。客户端经历三次挥手后进入
TIME_WAIT
状态并维持一段时间,随后回复服务器ACK表示应答。然而由于网络问题使得该ACK丢包了,服务器经过一段时间没有收到ACK应答后会触发重传机制从新向客户端发送FIN断开请求,并等待客户端响应自己ACK应答。此时由于客户端没有立刻进入CLOSED
状态还处于TIME_WAIT
状态,允许接收FIN请求并相应服务器ACK应答。 - **使得历史的滞留的报文消散。**例如客户端与服务器四次挥手中,服务器向客户端一共发送了两次FIN请i去,第一个请求丢失,第二个到达了客户端。假如服务器向客户端发送的第一个报文没有丢失,而是在某些网络节点停留了,以至于延误到连接释放后的某个时间才到达客户端。这本来是已失效的报文段,但客户端并不知道,就会又建立一次连接。而等待的这2MSL就是为了解决这个问题的,服务器在发送完最后一个确认报后,在经过时间2MSL,就可以使本链接持续时间内所产生的所有报文段都从网络中消失,这样就可以使下一个新的连接中不会出现这种旧的连接请求报文段。
由于主动断开连接的一方最后会处于TIME_WAIT
状态并维持一段时间。那么服务器主动断开连接时,就不能立刻重启与客户端建立连接,而是处于TIME_WAIT
状态。而处于TIME_WAIT
状态即说明TCP协议层的连接没有完全断开,因此不能再次监听(使用)同样的端口号。
$ ./httpserver
bind error:Address already in use
可以通过cat /proc/sys/net/ipv4/tcp_fin_timeout
查看MSL的值。MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同, 在Centos7上默认配置的值是60s。
解决TIME_WAIT状态引起的bind失败的方法
在server的TCP连接没有完全断开之前不允许重新监听, 某些情况下可能是不合理的
- 服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短, 但是每秒都有很大数量的客户 端来请求)。
- 这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃, 就需要被服务器端主动清理掉), 就会产 生大量TIME_WAIT连接。
- 由于我们的请求量很大, 就可能导致TIME_WAIT的连接数很多, 每个连接都会占用一个通信五元组(源ip, 源端口, 目的ip, 目的端口, 协议). 其中服务器的ip和端口和协议是固定的. 如果新来的客户端连接的ip和 端口号和TIME_WAIT占用的链接重复了, 就会出现问题。
因此可以使用setsockopt()
设置socket描述符的 选项SO_REUSEADDR
为1, 表示允许创建端口号相同但IP地址不同的多个socket描述符。
socksetopt函数原型
#include <sys/types.h>
#include <sys/socket.h>
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
sockfd
:套接字描述符,指定要设置选项的套接字。level
:选项的级别,用于指定选项所属的协议族或套接字类型。常用的级别包括SOL_SOCKET
(通用套接字选项)和IPPROTO_TCP
(TCP协议选项)等。optname
:选项的名称,用于指定要设置的具体选项。设置SO_REUSEADDR表示允许在套接字关闭后立即重用相同的地址和端口。optval
:指向存储选项值的缓冲区的指针。optlen
:选项值的长度。- 返回值:返回值为0表示设置选项成功,返回-1表示设置选项失败。
httpserver.hpp
void inithttpserver()
{
// 1.创建套接字
_listensock = socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0)
{
exit(SOCK_ERR);
}
//2.1套接字关闭后立即重用相同的地址和端口
int opt=1;
int k=setsockopt(_listensock,SOCK_STREAM,SO_REUSEADDR,&opt,sizeof(opt));
if(k<0)
{
perror("setsockopt error");
exit(1);
}
// 2.2.bind ip和port
struct sockaddr_in local;
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(_listensock, (struct sockaddr *)&local, sizeof(local)) < 0) // 绑定失败
{
exit(BIND_ERR);
}
// 3.将套接字设置为监听模式
if (listen(_listensock, gbacklog) < 0)
{
exit(LISTEN_ERR);
}
}
- 给setsockopt传参
_listensock
表示该参数为需要设置选项的套接字。 - 传参
SOCK _STREAM
表示该套接字用于创建面向连接的可靠字节流套接字。 - 传参选项
SO_REUSEADDR
表示允许在套接字关闭后立即重用相同的地址和端口。 - optval设置为非零值,表示启用了该选项。
流量控制
接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。
因此TCP支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制(Flow Control)
、实际上在TCP首部有一个16位窗口大小,该窗口大小存放了接收缓冲区大小字段。而16位数字最大表示64KB,因此TCP首部40字节的选项中还包含了一个窗口扩大因子M,实际上窗口大小是窗口字段的值左移M位。
- 接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段, 通过ACK端通知发送端;
- 窗口大小字段越大, 说明网络的吞吐量越高;
- 发送端接受到这个窗口之后, 就会根据窗口大小去控制自己的发送的报文大小和发送速度;
- 如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数 据段, 使接收端把窗口大小告诉发送端。
滑动窗口
实际上通信双方并不是一条请求响应一个应答式的通信,而是并发式的发送大量请求,大量应答。而能够做到并发式的通信,要基于滑动窗口机制。
由于TCP通信是全双工的,因此通信双方在通信时会交换自己的接收缓冲区大小。这里以客户端发送数据,服务器接收数据为例。客户端向服务器发送数据,服务器响应应答。客户端发送数据的速度和数据大小基于服务器响应的接收缓冲区大小。在TCP协议中,将发送缓冲区分为4个部分。
- 接收缓冲区会被分为已发送且已应答区域,已发送但未应答区域,未发送区域,和没有数据即空区域。
- 应用层自顶向下将数据写入到传输层的发送缓冲区,由于前三个部分已存在数据,因此是将数据写入到空区域。
- 发送缓冲区自左向右将数据发送到通信对方的接收缓冲区。应用层自顶向下的将数据填充到空区域。
建模一:数组
- 把接收缓冲区抽象成一个数组,那么在滑动窗口的起始就可以有一个
win_start
指针作为起始点,在末尾有一个win_end
作为终点。滑动窗口是指在两个指针之间的区域。实际上win_start
就是发送数据序号的起点,win_end
是发送数据序号的终点。 - 滑动窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。
滑动窗口大小与以下几点相关:
- 滑动窗口大小根据对方的接收缓冲区剩余大小有关。三次握手可以得知对方的16位窗口大小(剩余接收缓冲区大小)和确认序号。因此一开始
win_start
=0,win_end
=win_start
+tcp_win
(对方剩余接收缓冲区大小)。即未来滑动窗口怎么动使得对方都能够接收,不会造成对方接收缓冲区满了还发送数据造成丢包问题。 - 通信过后收到对方发送来的报文,其中报头有确认序号(ACK_SEQ),该确认序号就是对方接收缓冲区下一次接收数据的起始地址。意味着小于该确认序号即之前的数据都接收且响应了,此时
win_start
=ACK_SEQ
(确认序号),win_end
=win_start
+tcp_win
。因此窗口在滑动实际上是下标在进行更新。
- 滑动窗口可能会变大也可能会变小,因此滑动窗口是动态变化的。例如对方上层一次性将接收缓冲区全部读取,此时接收缓冲区空间,因此发送过来的报文携带的tcp_win最大。也可能对方的接收缓冲区满了,那么发送过来的报文携带的tcp_win=0,因此win_start=win_end,即滑动窗口变小了。
- 确认序号若为1001,表示1001之前的所有数据都接收到且应答了,若之前就丢包了,那么响应的确认序号不会是1001,而是在1001之前的确认序号,且会触发重传机制。因此不需要担心存在滑动窗口中的两边数据对方收到,但中间数据没有被对方收到的问题。
- 划分发送出去但未收到应答的区域即滑动窗口是为了保证对方能够收到数据,即使丢包也能够完成重传机制。
建模二:环形队列
- 实际上发送缓冲区是一个环形队列。当应用层自顶向下填充数据到发送缓冲区中且空区域不够容纳数据时,操作系统会将已发送且已应答区域划分为空区域,将数据已覆盖式写入该区域。
拥塞控制
滑动窗口考虑到TCP通信双方,但没有考虑到网络可能会出现问题。拥塞控制是用来解决TCP通信中网络出现问题的机制。
客户端向服务器发送数据,发了1000条报文,但服务器只收到了1条报文,意味着999条报文丢失了。而大部分的报文丢失意味着可能并不是主机问题而是网络问题,若通过重传机制重新发送报文,这样会造成大量的报文在网络中堵塞,只会加重网络的故障,导致网络堵塞问题。因此应该不使用重传机制,缓解网络的压力,网络有自己的恢复机制,等待网络的恢复。
TCP引入慢启动机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据。
- 因此引入哟一个概念“拥塞窗口”,该窗口是发送端根据网络接收能力定义的发送量。
- 发送开始的时候, 定义拥塞窗口大小为1。
- 每次收到一个ACK应答, 拥塞窗口加1。
- 每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的16位窗口大小做比较, 取较小的值作为实际发送的窗口。即滑动窗口=min(拥塞窗口,接收端的16位窗口大小)
像上面这样的拥塞窗口增长速度, 是指数级别的. “慢启动” 只是指初使时慢, 但是增长速度非常快。
-
为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍。此处引入一个叫做慢启动的阈值ssthresh,即从指数增长到线性增长的阈值。当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长。
-
当TCP开始启动的时候, 慢启动阈值等于窗口最大值。
-
在ssthresh之前,拥塞窗口已指数规律增长,在ssthresh之后,拥塞窗口以线性规律增长。
-
在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回1。
-
然后进行新一轮的拥塞窗口探测,整个过程会呈现出周期性摆动,但也不一定,因为网络同时在波动,因此整个过程就是一种探测行为。
-
少量的丢包, 我们仅仅是触发超时重传。而大量的丢包, 我们就认为网络拥塞。当TCP通信开始后, 网络吞吐量会逐渐上升。随着网络发生拥堵, 吞吐量会立刻下降。拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案。该方法即保证了不失可靠性的同时提高了效率。
延迟应答
如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小。
例如:
- 接收端缓冲区为1M. 一次收到了500K的数据; 如果立刻应答, 返回的窗口就是500K。
- 但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了。
- 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来。
- 如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M。
总结一下:延迟应答可以在上层处理数据极快的前提下,扩大窗口大小即扩大每次通信的吞吐量。
窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输
效率。因此可以设定数量限制,即每隔N个包应答一次;也可以设定时间限制,即超过最大延迟时间就应答一次。
捎带应答
在延迟应答的基础上,我们发现客户端和服务器通信时发送数据是以一发一收的方式,其实可以在发送数据时捎带ACK应答。
面向字节流
基于以上对TCP协议的学习,现在重新认识一下字节流概念。
创建一个TCP的socket的同时在内核中创建一个发送缓冲区 和一个接收缓冲区。
- 调用write时, 数据会先写入发送缓冲区中。
- 如果发送的字节数太长, 会被拆分成多个TCP的数据包发出。
- 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去。
- 接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区。
- 然后应用程序可以调用read从接收缓冲区拿数据。
- 另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做全双工。
由于缓冲区的存在, TCP程序的读和写不需要一一匹配。
例如:
- 写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节。
- 读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read100个字节, 也可以一次 read一个字节, 重复100次。
粘包问题
- 首先要明确, 粘包问题中的 “包” , 是指的应用层的数据包。而粘包问题即是读取到的不是一个完整的报文,读到半个报文、一个半报文都是粘包问题。
- 在TCP的协议头中, 没有如同UDP一样的 “报文长度” 这样的字段, 但是有一个序号这样的字段标定数据报文的顺序性和独立性。
- 站在接收方的传输层的角度, TCP是一个一个报文过来的,按照序号排好序放在缓冲区中。
- 站在接收方应用层的角度, 看到的只是一串连续的字节数据。因此应用程序看到了这么一连串的字节数据,,就不知道哪个部分作为独立的数据报文,而是将一连串的字节数据看作是一个完整的应用层数据包。
要避免粘包问题,就需要明确两个包之间的边界。
- 对于定长的包, 保证每次都按固定大小读取即可。
- 对于变长的包, 可以在包头的位置, 约定一个包总长度的字段, 从而就知道了包的结束位置。也可以在包和包之间使用明确的分隔符(应用层协议, 是由我们自己来定的, 只要保证分隔符不和正文冲突即可)。
- 对于UDP, 如果还没有上层交付数据, UDP的报文长度仍然在。同时, UDP是一个一个把数据交付给应用 层,就有很明确的数据边界。站在应用层的站在应用层的角度, 使用UDP的时候, 要么收到完整的UDP报文, 要么不收, 不会出现"半个"的情况。
- 而TCP是面向字节流的,其接收端接收到报文是放到传输层的接收缓冲区,任应用层读取不需要严格按照数据报文的规格读取,而是自定义读取缓冲区的方式读取。
TCP异常
- 进程终止。操作系统会帮助进程的连接正常进行四次挥手,从而断开连接。进程由于某些原因终止退出了,此时进程的资源由操作系统回收。Linux下一切皆文件。连接也属于文件的一种,也需要被操作系统管理,因此进程终止了,操作系统会正常给连接进行四次挥手,关闭连接文件。
- 机器重启。操作系统也会正常进行四次挥手,关闭连接。客户端关机了,操作系统会接管正在运行的进程的资源,正常执行四次挥手,关闭连接文件。
- 机器掉电/网线断开。客户端并不知道网络掉线,因此并不能做出对连接进行操作。而服务器端会定期询问客户端是否在线,若在线会进行reset重新连接,客户端不在线也会正常将连接关闭。
另外, 应用层的某些协议, 也有一些这样的检测机制,例如HTTP长连接中, 也会定期检测对方的状态。 例如QQ, 在QQ 断线之后, 也会定期尝试重新连接。
理解listen函数的第二个参数
#include <sys/types.h>
#include <sys/socket.h>
int listen(int sockfd, int backlog);
sockfd
:套接字描述符,指定要设置为监听状态的套接字。backlog
:等待连接队列的最大长度。它指定了在调用accept
之前,可以排队等待连接的最大连接数。
在Linux系统中,服务器所能接收的客户端全连接个数为backlog
+1。
服务器会将正在进行通信的连接放到连接队伍。当客户端和服务器需要进行连接个数超过backlog+1
时,操作系统会将后来的连接放到半连接队伍。在半连接队伍中服务器会与客户端完成两次握手,为短期内能够进入全连接队伍有空位提前做好准备。而在半连接队伍中超过一定时间后OS会自动断开与客户端的连接。
- 半链接队列(用来保存处于SYN_SENT和SYN_RECV状态的请求)。
- . 全连接队列(accpetd队列)(用来保存处于established状态,但是应用层没有调用accept取走的请求)。