应用层将数据向下交付给传输层后,其数据的发送和处理由传输层决定,TCP是传输层协议,即数据的发送和处理由TCP协议来决定,因此TCP被称为传输控制协议。
报头大小=4位首部长度*4字节
。即tcp报头总长度范围[0,60],但标准报头占20字节,因此tcp报头总长度范围[20,60]。超出20字节的部分即为选项部分。若此时报头大小为20字节,此时4位首部长度为[0101]。struct file*
,该指针指向进程文件描述符表。实际上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报文
,接着继续向下交付。实际上不止TCP/IP四层模型中存在协议,硬件中也存在协议。
内存和其他外设相连接的线称为IO总线。内存和CPU相连接的线称为系统总线。而设备之间通信必然是通过协议通信。而外设之间通信很少存在不可靠性问题,其中原因包括外设之间距离狠心,指令、数据传输不容易出现异常。而网络通信不是在本地单主机上通信,而是主机之间通信,而主机之间通信的桥梁是网络,在该前提上数据通信就存在可靠性问题。
网络通信不存在绝对的可靠性,但存在相对的可靠性。
通过32位序列号和32位确认序列号来直接确保可靠性
该场景没有涉及到超时重传机制,只谈论序号和确认序号的作用
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
表示读取需要紧急处理的数据。
URG标定报文含有需要紧急处理的数据。16位紧急指针表示需要紧急处理的数据在有效载荷中的偏移量。
而该需要被紧急处理的数据大小只能为1字节,即TCP的紧急指针只能传输1个字节的数据。
RST标志位标识复位,发送给对方表示需要重置连接
16位校验和: 发送端填充, CRC校验,接收端校验不通过, 则认为数据有问题。此处的检验和不光包含TCP首部, 也包含TCP数据部分。
发送数据给对方,对方超过一定时间没有响应应答,自身重新发送数据给对方
客户端向服务器发送数据,会有以下两种场景:客户端认为服务器没有收到数据,客户端重新发送数据给服务器。
场景一:客户端向服务器发送数据,数据在服务器收到之前丢包了,即服务器没有收到数据,因此服务器就没有向客户端响应ACK报文。经过一定时间后,客户端触发超时重传机制,重新向服务器发送数据。
场景二:客户端向服务器发送数据,服务器收到了数据,向客户端响应了ACK报文,但ACK报文掉包了,即客户端没有收到服务器发送来的响应,此时客户端会认为服务器没有收到数据,经过一段时间后,客户端会重新向服务器发送数据。
这个场景下服务器就会收到两份相同的报文,收到重复的报文也是不可靠性的一种,因此服务器需要对报文进行去重操作,通过报文的序号进行去重。
实际上三次握手所发送的是具备一定类型的TCP报头
三次握手不一定非得成功,在三次握手中最后一个ACK才是最新消息,因此前两条通信报文丢失了会触发重连或者重传,而最后一个ACK就无法保证可靠性。
因此三次握手能够保证需要保证以下几点:
建立连接的保证为什么是三次握手?
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
状态。CLOSE_WAIT
状态。服务器在响应ACK后立刻发送FIN断开请求。客户端收到服务器发送过来的断开请求FIN,立刻响应ACK报文,同时进入TIME_WAIT
状态。总结一下:
TIME_WAIT
状态并维持一段是时间。被动断开连接的一方,两次挥手完成后,会进入CLOSE_WAIT
状态。实际上在客户端与服务器通信时,可以让客户端主动与服务器断开连接,然后让服务器不close sock也不退出进程,那么服务器就处于CLOSE_WAIT
状态。
httpserver.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#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_WAIT状态,要么是服务器压力过大来不及执行close(服务端还有数据没有推送完),要么是你的close直接就是忘写了。
需要注意的是:
断开连接的一方从TIME_WIAT
状态到CLOSED
状态会有一个超时机制,该超时时间为2MSL(Maximum Segment Lifetime—最长报文段寿命:它是任何报文在网络上存在的最长的最长时间,超过这个时间报文将被丢弃。)。该时间设定为2MSL的原因有:
TIME_WAIT
状态并维持一段时间,随后回复服务器ACK表示应答。然而由于网络问题使得该ACK丢包了,服务器经过一段时间没有收到ACK应答后会触发重传机制从新向客户端发送FIN断开请求,并等待客户端响应自己ACK应答。此时由于客户端没有立刻进入CLOSED
状态还处于TIME_WAIT
状态,允许接收FIN请求并相应服务器ACK应答。由于主动断开连接的一方最后会处于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。
在server的TCP连接没有完全断开之前不允许重新监听, 某些情况下可能是不合理的
因此可以使用setsockopt()
设置socket描述符的 选项SO_REUSEADDR
为1, 表示允许创建端口号相同但IP地址不同的多个socket描述符。
socksetopt函数原型
#include
#include
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
:选项值的长度。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);
}
}
_listensock
表示该参数为需要设置选项的套接字。SOCK _STREAM
表示该套接字用于创建面向连接的可靠字节流套接字。SO_REUSEADDR
表示允许在套接字关闭后立即重用相同的地址和端口。接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。
因此TCP支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制(Flow Control)
、实际上在TCP首部有一个16位窗口大小,该窗口大小存放了接收缓冲区大小字段。而16位数字最大表示64KB,因此TCP首部40字节的选项中还包含了一个窗口扩大因子M,实际上窗口大小是窗口字段的值左移M位。
实际上通信双方并不是一条请求响应一个应答式的通信,而是并发式的发送大量请求,大量应答。而能够做到并发式的通信,要基于滑动窗口机制。
由于TCP通信是全双工的,因此通信双方在通信时会交换自己的接收缓冲区大小。这里以客户端发送数据,服务器接收数据为例。客户端向服务器发送数据,服务器响应应答。客户端发送数据的速度和数据大小基于服务器响应的接收缓冲区大小。在TCP协议中,将发送缓冲区分为4个部分。
建模一:数组
win_start
指针作为起始点,在末尾有一个win_end
作为终点。滑动窗口是指在两个指针之间的区域。实际上win_start
就是发送数据序号的起点,win_end
是发送数据序号的终点。滑动窗口大小与以下几点相关:
win_start
=0,win_end
=win_start
+tcp_win
(对方剩余接收缓冲区大小)。即未来滑动窗口怎么动使得对方都能够接收,不会造成对方接收缓冲区满了还发送数据造成丢包问题。win_start
=ACK_SEQ
(确认序号),win_end
=win_start
+tcp_win
。因此窗口在滑动实际上是下标在进行更新。建模二:环形队列
滑动窗口考虑到TCP通信双方,但没有考虑到网络可能会出现问题。拥塞控制是用来解决TCP通信中网络出现问题的机制。
客户端向服务器发送数据,发了1000条报文,但服务器只收到了1条报文,意味着999条报文丢失了。而大部分的报文丢失意味着可能并不是主机问题而是网络问题,若通过重传机制重新发送报文,这样会造成大量的报文在网络中堵塞,只会加重网络的故障,导致网络堵塞问题。因此应该不使用重传机制,缓解网络的压力,网络有自己的恢复机制,等待网络的恢复。
TCP引入慢启动机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据。
像上面这样的拥塞窗口增长速度, 是指数级别的. “慢启动” 只是指初使时慢, 但是增长速度非常快。
为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍。此处引入一个叫做慢启动的阈值ssthresh,即从指数增长到线性增长的阈值。当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长。
当TCP开始启动的时候, 慢启动阈值等于窗口最大值。
在ssthresh之前,拥塞窗口已指数规律增长,在ssthresh之后,拥塞窗口以线性规律增长。
在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回1。
然后进行新一轮的拥塞窗口探测,整个过程会呈现出周期性摆动,但也不一定,因为网络同时在波动,因此整个过程就是一种探测行为。
少量的丢包, 我们仅仅是触发超时重传。而大量的丢包, 我们就认为网络拥塞。当TCP通信开始后, 网络吞吐量会逐渐上升。随着网络发生拥堵, 吞吐量会立刻下降。拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案。该方法即保证了不失可靠性的同时提高了效率。
如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小。
例如:
总结一下:延迟应答可以在上层处理数据极快的前提下,扩大窗口大小即扩大每次通信的吞吐量。
窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输
效率。因此可以设定数量限制,即每隔N个包应答一次;也可以设定时间限制,即超过最大延迟时间就应答一次。
在延迟应答的基础上,我们发现客户端和服务器通信时发送数据是以一发一收的方式,其实可以在发送数据时捎带ACK应答。
基于以上对TCP协议的学习,现在重新认识一下字节流概念。
创建一个TCP的socket的同时在内核中创建一个发送缓冲区 和一个接收缓冲区。
由于缓冲区的存在, TCP程序的读和写不需要一一匹配。
例如:
要避免粘包问题,就需要明确两个包之间的边界。
另外, 应用层的某些协议, 也有一些这样的检测机制,例如HTTP长连接中, 也会定期检测对方的状态。 例如QQ, 在QQ 断线之后, 也会定期尝试重新连接。
#include
#include
int listen(int sockfd, int backlog);
sockfd
:套接字描述符,指定要设置为监听状态的套接字。backlog
:等待连接队列的最大长度。它指定了在调用accept
之前,可以排队等待连接的最大连接数。在Linux系统中,服务器所能接收的客户端全连接个数为backlog
+1。
服务器会将正在进行通信的连接放到连接队伍。当客户端和服务器需要进行连接个数超过backlog+1
时,操作系统会将后来的连接放到半连接队伍。在半连接队伍中服务器会与客户端完成两次握手,为短期内能够进入全连接队伍有空位提前做好准备。而在半连接队伍中超过一定时间后OS会自动断开与客户端的连接。