变更时间 | 介绍 |
---|---|
2022-12-06 | 初始版本 |
2022-12-07 | 目录与排版 |
网络
网络
网络开发是C++应用范围最广的技术栈。
以下将会详细介绍什么是网络,以及网络中所需要重点关注的技术部分。
1.网络模型 OSI
根据结构层级可划分为四层获七层。笔者个人更倾向于四层模型的描述,故七层模型仅做补充说明
四层模型:应用层、传输层、网络层、数据链路层
七层模型:应用层、表示层、会话层、传输层、网络层、数据链路层、物理层
模型简介
-
数据链路层:实现网卡接口的网络驱动程序
用于处理数据在物理介质上的传输,为上层协议提供一个统一的接口
常用协议是ARP协议和RARP协议,通过IP地址到物理地址的转化,实现寻址到一台机器的功能
-
网络层:实现数据包的选路和转发
主要通过在多个路由器中进行跳转,实现两台主机间的通讯
核心是IP协议,负责判断如何跳转。根据数据包的目的IP寻找每次最合适的路由器,多次重复这一过程,直到到达目标主机或发送失败而丢失
ICMP协议,作为IP协议的补充。主要用于检测网络连接,ping程序就是利用该协议
-
传输层:为两台主机上的程序提供端到端的通讯。
有别于网络层,传输层仅关心起始端和目的端,并不关心过程中的中转。
核心协议包括TCP协议、UDP协议、SCTP协议。TCP提供可靠的、面向连接的流服务;UDP提供不可靠、无连接的报文服务;SCTP协议主要用于电话信号传输
-
应用层:负责应用程序的处理
网络层和传输层一般是在内核中实现,而应用层是在用户空间中。
ping程序(非协议)用于检测网络;telnet远程登录协议;DNS协议实现机器域名到IP地址的转换
调用方式
在发送时,每个协议在上层数据的基础上,添加自己的头部信息,有时也包括尾部信息
在处理时,按照当前协议的不同,处理对应的头部信息
2.IP协议
IP协议是socket网络编程的基础之一,为上层提供无状态、无连接、不可靠的服务
无状态:通信双方不同步数据的状态信息,所有数据的发送、传输、接收都是独立的,但由于不需要分配内核资源,保证了简单。高效的优点
无连接:通信双方无法长久的维持对方的任何信息,所以每次发送时都需要指定IP地址
不可靠:无法保证数据传输的准确性,仅做最大努力的尝试
IPV4
IPV4头部结构如图所示,通常为20字节,尾部有最多40字节的选项。
版本号:指定IP协议的版本
头部长度:标识当前头部有多少个32bit字(4字节)
服务类型:对应的服务模式,包括最小延时、最大吞吐量、最可靠、最小费用
总长度:标记整个IP数据的长度
标识:主机发送数据报的唯一标识
标志:是否对当前数据进行分片操作,以及验证分片后数据的连续性标志
分片位移:每个分片相对于原始数据的偏移量
生存时间:数据在路由器中的最大跳转次数,能够避免路由循环
协议:上层协议的标记,用以区分
校验和:判断头部数据在传输过程中是否损坏,多使用CRC算法
源地址和目标地址:标识数据报的发送端和目的端,一般固定
IPV6
IPV6头部结构如图所示
版本号:指定IP协议的版本
通信类型:指示数据通信类型或优先级
流标签:处理对服务质量较高要求的通信
净荷长度:指扩展长度和应用数据的总和,不包括头部固定长度
下一个包头:标记固定头部后的类型,类似IPV4中的协议
跳数限制:与IPV4中的生存时间功能一致
源地址和目的地址:功能与IPV4的一致,仅写法不同(使用16进制表示)
3.TCP协议
TCP协议更靠近应用层,在程序中拥有很强的操作性和自主性。
TCP是面向连接、可靠的字节流传输协议。通过多种控制机制确保字节安全准确的到达接收端,可能会出现粘包现象,且仅支持一对一的数据传输。
结构层级
TCP头部结构如图所示,通常为20字节,尾部有最多40字节的选项。
16位源端口号 + 16位目标端口号: 告知发送端和接收端的端口号信息
32位序列号seq: 一次TCP通讯过程中,发送包数量的偏移值与建立连接时初始随机值的总和,用以确认发送数据包的标记
32位确认号ack: TCP连接中,对另一方发送的TCP报文的响应,其值一般是当前接收到的序列号(ack)+1。 通信双方的seq和ack是相互对应的。
4位头部长度: 标识TCP头部有多少个4字节,4位最大值15,所以TCP头部最长60字节。
6位保留:目前无用。
6位标记位:
URG标志:代表紧急指针是否有效
ACK标志:表示确认号是否有效
PSH标志:用以提示接收端从缓冲区中读取数据,为后续数据腾出空间
RST标志:表示对方请求重新连接,此标记一般在复位报文段中使用
SYN标志:表示请求建立连接,此标记一般首次请求连接中使用
FIN标志:通知对方将要关闭连接,一般用在结束报文段
16位窗口大小:TCP控制流量的手段。用来通知对方本端的TCP缓冲区还能容纳多少字节的数据,以此控制发送数据的速度
16位校验和:校验整个TCP包的完整性,通常是CRC算法计算,判断接收的TCP数据是否完整
16位紧急指针:通知某一段紧急数据的序列号,一般是紧急指针的值加上当前的序列号
40字节的选项:一些配置信息,一般是1字节的kind+1字节的长度+info数据,用来提高传输性能。
kind = 0/1,无太大意义
kind = 2,建立TCP连接时,双方协商的最大报文段长度
kind = 3,窗口扩大因子选项,
kind = 4,选择性确认,当某段数据丢失时,发送端重传最后一次确认的后续所有报文,有些接收成功的数据也会被重传,降低TCP性能。
kind = 5,kind = 4的工作选项,通过参数告知发送方本端成功但序列号不连续的数据块,从而让发送端减少重传的数据量
结构:每个参数是4字节的序列号值,两个成一组。左侧是不连续的第一个块,右侧是不连续的最后一个块,告知发送其中间隔的数据。由于总体长度有限,故一次最多只能传四组信息块
kind = 8,时间戳选项,便发送端和接收端精准的时间交互,和计算回路时间(可以视为超时响应时间的一部分)。
状态转换
TCP连接状态转换图,包含客户端和服务端
建立连接和关闭连接已在后续详细介绍,此处仅介绍部分状态
CLOSED状态:当前连接处于关闭状态
ESTABLISHED状态:当前连接处于工作状态,通信双方可以进行数据交互
LISTEN状态:当前主机处于等待连接状态,可以处理连接请求,多用于服务端
三次握手
发送端在发送数据前,建立可靠连接的过程。通过三次握手后建立的连接,才是安全的,能够保证通讯管道的可靠。
发送一个连接请求包(seq序号随机值x,标记位中的syn置1),然后状态置为SYN_SENT,接收端接受并同意连接发送一个应答包(seq序号重新随机y,ACK应答序号等于x+1,标记位中的syn置1,ack置1),状态变为SYN_RECV,发送端接收后应答返回(seq是x+1,ack应答值是y+1,标记位中的ack和syn都置为1),状态置为ESTABLISHED,接受端接收到应答返回,状态同样置为ESTABLISHED,至此通信双方完成连接的建立。
四次挥手
稳定安全的断开通信双方TCP连接的过程。由于TCP的全双工和半关闭特性,因此需要四次挥手操作才能完全关闭连接。
发送端发送申请关闭连接请求包(seq序列号u(非随机),标记位fin置为1),状态置为FIN_WAIT1,接收端接受后进行确认应答(seq序列号v,ack应答序号位u+1,标记位ack置1),并将状态置为CLOSE_WAIT,接收端接受到回应包后,本身置为FIN_WAIT2状态,此时发送端到接受端的单向通道关闭,连接处于半双工状态。
接收端发送关闭连接请求包(fin标记位置1,ack标记位置1,),本身置为LAST_ACK状态,发送端接收到包后,发送回应包(ack标记置1),自身置为TIME_WAIT状态,接收端接受到此回应包后,关闭连接,本身置为CLOSE状态,发送端等待2倍的最大报文生存时间后,也置为CLOSE状态。此时通信双方完成全双工通信的断开操作。
控制机制
确认应答
TCP协议中比较基础的一种机制。在接收到发送端的数据后发出的回应数据包,回应包的seq由本身维护,ack为接收的数据包的seq值+1,回应包也可以携带数据信息。
超时重传
解决异常环境下,发出的数据包出现丢失的问题,从而保证传输的可靠性。TCP会为每一个发出的TCP报文段维护一个重传定时器,当定时器结束后仍未获取到对应报文的确认应答时,就会重新发送数据并重置定时器。
定时器的时长并不固定,会根据网络带宽的波动而动态变化。每次超时重传后,定时器会延长倍数,且不会无限次的超时重发,超过一定次数后,即会关闭连接。可以通过修改内核来修改最大重传次数。
在引入窗口概念后,快速重传相对于超时重传更有优势
拥塞控制
可以提高网络利用率、降低丢包率、提高网络相对于每条数据流的公平性。本质上就是对发送端一次向网络中写入的数据量大小的控制(SWND,发送窗口)。
SWND的大小实际上通过接收方的通告窗口(RWND)有关和拥塞窗口(CWND)进行调节,取二者中的小值。接收端的数据处理速度影响RWND,网络波动影响CWND。
窗口概念:两台主机在进行TCP通信时并不是以单个数据包交互作为一次流程(那也太慢了XD)。在实际数据交互中引入窗口这一概念,窗口的大小是发送端无需等待确认应答而可以继续发送数据的最大值。
窗口意义:可以提高网络的传输效率和带宽利用率,同时窗口的引入可以有效处理TCP通信过程中数据丢失的误识别问题(即能够快速判断出是数据丢失,还是确认应答丢失)。
慢启动和拥塞避免
慢启动:建立连接时,由于并不清楚网络情况,先给CWND设定较小的初始值,之后CWND的值会根据单位时间内的数据包交互量呈指数级增长。
拥塞避免:当CWND的大小超过慢启动门限时,修改CWND的计算方式,由指数变为线性,最终趋于上限,从而进入拥塞避免阶段。
快速重传和快速恢复
当网络出现异常波动后,可能会触发超时重传;也可能会触发以下两种:
快速重传:如果窗口内的数据(seq = 1007)如果在网络中丢失,接收端在接收到其后的数据(seq = 1008、1009、1010)时,不会进行对应值的确认应答,而会发送ack = 1008的seq = 1007的确认应答。当发送端连续三次接收到相同的确认应答,即认为数据丢失,立即重传丢失的报文段。
快速恢复:在快速重传触发后,公式计算出当前最优的窗口大小,将CWND设置为最优窗口,同时重新进入拥塞控制阶段。
4.UDP协议
简介
笔者对UDP协议的实际应用不甚了解
正在学习中。。。。。。
5.TCP和UDP补充说明
TCP流协议和UDP报文协议的区别
简要说明:
发送端和接收端是否需要执行相同次数的写和读操作,不相同则是流,相同则是报文
详细说明:
流即意味连续。发送端执行写操作时,先将数据存在发送缓冲区中,当需要发送数据时缓冲区内的数据会被封装成一个个数据包,数据包的数量和写操作并无关系。接收端接受到数据时同样存储在缓冲区中,接收端执行读操作,同样是从接收缓冲区中读取,读取次数和接收包数量无关,和读缓冲区大小有关。
而报文是相互独立的。发送端每执行一次写操作,就是组装成一个报文包并发送,接收端必须及时对每个包进行读操作,否则就会导致数据丢失。
TCP粘包问题
说明:
组包时由于使用nagle算法(将长数据拆分/短数据组合,使数据包大小适配当前网络,防止阻塞),导致前后包数据出现粘连问题
解决方案:
- 通信双方固定每次交互数据的大小,但扩展性较差
- 通信双方在包尾添加固定的结束字符进行标记,但易将通信数据中的字符误判
- 自定传输协议,在每个数据包的首部添加当前数据包的长度
复位报文的意义
意义:
TCP首部中状态位RST置1的报文信息。一般是访问不存在的端口、TCP连接异常终止的场景、对半打开的连接(一端由于网络原因关闭了TCP连接,但另一端仍处于连接状态)等场景下,进行数据发送时,所返回的数据信息,通知对方关闭连接或重新建立连接。
TCP协议中TIME_WAIT状态的意义
TIME_WAIT状态,一般是在四次挥手过程,客户端接收到服务端返回的FIN请求关闭报文,并发送响应报文后。
-
保证TCP连接关闭的可靠性。TIME_WAIT状态下,发送端会等待2倍的报文最大生存时间(MSL)的时间,才会进入CLOSE状态。防止出现服务端未接收到回应报文而重发的问题。如果直接进入CLOSE状态且服务端未接受响应报文并重发,那么只会回复复位报文而被认为错误。
-
让迟来的报文有足够的时间被识别并丢弃。在linux上,一个端口不能被同时打开多次,当处于TIME_WAIT状态时,是无法立即使用该端口来建立新的连接,如果不存在TIME_WAIT并且立即建立一个和当前连接相似的连接(IP和端口号相同),那么新建立的连接就可能接收到原来连接的数据,产生影响。
服务端出现大量CLOSE_WAIT状态
原理:客户端请求断开后,只进行了半双工的关闭,服务端没有发送断开连接的FIN报文
原因:
- 半双工关闭后,服务端仍然在向客户端进行数据传输,没有发送关闭请求
- 半双工关闭后,服务端代码出现异常,未执行close方法
- 使用了派生出的子进程,子进程会继承父进程的socket,接收FIN后仅子进程处理一次,导致socket引用计数不为0,无法回收
6.socket
在UNIX/Linux中,一切都是文件。socket只是某一主机IP和Port的临时标识。下文中的套接字或文件描述符都代表socket。
基础API
以下函数都是创建socket所需要调用的接口或设置的参数
- 用于指定socket地址的所使用的的协议族和地址族。其中PF和AF所表达的值是完全相同的,所以可以根据个人习惯进行混用
协议族 | 地址族 | 描述 |
---|---|---|
PF_UNIX | AF_UNIX | UNIX协议族 |
PF_INET | AF_INET | TCP/IPV4协议族 |
PF_INET6 | AF_INET6 | TCP/IPV6协议族 |
- 设置服务类型
SOCK_UGRAM:数据报服务,一般用于UDP协议中
SOCK_STREAM:流服务,一般用于TCP协议中
- 用于主机字节序到网络字节序的转化,使计算机理解Port所表达的含义
#include <netinet/in.h>
unsigned long int htonl(unsigned long int hostlong);
unsigned short int htons(unsigned short int hostlong);
unsigned long int ntonl(unsigned long int hostlong);
unsigned short int ntohs(unsigned short int hostlong);
根据传入Port参数类型和转化结果,调用对应的接口
- 用于IP地址的转化,使计算机理解IP所表达的含义
#include <arpa/inet.h>
in_addr_t inet_addr(const char* strptr);
int inet_aton(const char* cp, struct in_addr* inp);
char* inet_ntoa(struct in_addr in);
inet_addr和inet_aton功能一致,但inet_addr返回IP转化结果,inet_aton是将转化结果存在在inp中,返回值表示是否成功
inet_ntoa是将网络字节序的IP地址转化为十进制字符串
- TCP协议中的socket结构体。通用结构体sockaddr,专用结构体sockaddr_in与sockaddr_in6。
专用结构体更方便,可以简化设置IP和端口号等操作,但接口参数类型都是sockaddr,使用专用结构体时需要强制转化
struct sockaddr{
sa_family_t sin_family; // 地址族
char sa_data[14]; // 存储socket地址,但14字节过小
}
struct sockaddr_in{
sa_family_t sin_family; // 地址族
u_int16_t sin_port; // 端口号,格式为网络字节序
struct in_addr sin_addr; // IPV4地址结构体
}
struct in_addr{
u_int32_t s_addr; // IPV4地址,格式为网络字节序
}
struct sockaddr_in6{
sa_family_t sin6_family; // 地址族
u_int16_t sin6_port; // 端口号,格式为网络字节序
u_int32_t sin6_flowinfo; // 流信息,一般为0
struct in6_addr sin6_addr; // IPV6地址结构体
u_int32_t sin6_socpe_id; // 尚在实验中
}
struct in6_addr{
unsigned char sa_addr[16]; // IPV6地址,格式为网络字节序
}
笔者一般工作开发仅涉及到IPV4,故以下例子展示均为IPV4
创建socket
socket就是一个可读写、打开、关闭的文件描述符,类型是int。
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数dimain指定底层协议族;参数type指定服务类型;参数protocol一般设置默认0;调用成功返回socket描述符,失败返回-1
// 例子:
int m_socket4 = socket(AF_INET, SOCK_STREAM, 0);
命名socket
将创建的socket与socket地址进行绑定和命名。多用于服务端,方便客户端进行连接,客户端一般采用匿名方式,使用操作系统自动分配的socket地址
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr* my_addr, socklen_t addrlen);
bind可以将未命名的sockfd套接字绑定在my_addr地址上,addrlen指出my_addr地址长度;绑定成功返回0,失败返回-1并设置error
常见的error:
EACCES:绑定的地址是受保护地址,不允许当前程序访问
EADDRINUSE:绑定的地址正在被使用
// 例子:
struct sockaddr_in address;
memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_port = htons(m_port);
address.sin_addr.s_addr = inet_addr(m_ip.c_str());
int iRet = bind(m_socket, (struct sockaddr*)&address, sizeof(address));
监听socket
命名后的socket不能马上接收客户端连接,需要创建一个监听队列存储待处理的客户端连接。一般用于服务端,等待被动连接。
#include <sys/socket.h>
int listen(int sockfd, int backlog);
sockfd代表被监听的socket描述符,backlog提示内核监听队列(处于完全连接状态)的最大长度。当监听队列超过backlog,将不会受理;调用成功返回0,失败返回-1
// 例子:
int iRet = listen(m_socket, 1024);
接受连接
从listen监听队列中接受一个连接
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr* addr, socklen_t* addrlen);
sockfd是执行过listen的监听socket,addr是获取到的被接受连接的远程socket地址;addrlen是对应地址长度;接受成功后返回一个新的socket连接,作为被接受连接的唯一标识,并通过该新的socket进行读写操作,失败则返回-1
// 例子:
struct sockaddr_in client;
int connfd = accept(m_socket, (struct sockaddr*)&client, &sizeof(client));
发起连接
通过某一socket套接字,主动发起指定IP和Port的连接请求,一般用于客户端
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr* serv_addr, socklen_t addrlen);
sockfd是发起请求的socket;serv_addr中记录着服务端监听的socket地址;addrlen指定地址的长度;connect连接成功返回0,失败返回-1
// 例子:
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(m_port);
servaddr.sin_addr.s_addr = inet_addr(m_ip.c_str());
int iRet = connect(m_socket, (struct sockaddr *)&servaddr, sizeof(servaddr));
通过例子可以明显看出客户端connect的调用方式与服务端bind的调用方式很类似。都是使socket与指定的IP、Port产生联系,一个进行绑定,一个建立连接,最终达到通信的效果。
关闭连接
关闭一个TCP连接。实际原理是将该socket的引用计数-1,当引用计数为0后,才是真正的关闭连接。
注意:多进程程序中,父进程fork将使得父进程中打开的socket引用计数+1,因此需要对父子进程都进行close操作才能彻底关闭连接
#include <unistd.h>
int close(int fd);
// 例子:
close(m_socket);
立即终止TCP连接,而不是进行引用计数操作
#include <sys/socket.h>
int shutdown(int sockfd, int howto);
参数howto负责设定终止连接中的哪种行为
SHUT_RD 关闭sockfd的读功能,不允许通过sockfd接收数据,已在读缓冲区中的数据将被丢弃;
SHUT_WR 关闭sockfd的写功能,不允许通过sockfd发送数据,已在写缓冲区的数据会继续发送;
SHUT_RDWR 将读写功能都关闭
数据读写
TCP数据读写
通过socket套接字进行数据接收或发送数据,需要指定发送或接收的缓冲区
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recv(int sockfd, void* buf, size_t len, int flags);
ssize_t send(int sockfd, const void* buf, size_t len, int flags);
sockfd是对应操作的套接字;buf是指定的读写缓冲区;len是指定缓冲的长度;flags选项是对发送数据的额外控制,无选项时为0;返回值是发送或接收到的数据长度
常用flags:
MSG_MORE:通知内核还有数据将要发送,可以避免发送过多的小报文段,提高效率
MSG_NOSIGNAL:向读端关闭的管道写数据时不引发SIPPIPE信号,避免写管道错误
// 例子:
char recvBuf[2048] = { 0 };
int recvLen = recv(m_socket, recvBuf, 2048, 0);
string sendInfo = "test";
int sendLen = send(m_socket, sendInfo.c_str(), sendInfo.length() ,0);
UDP数据读写
UDP传输协议的数据读写操作
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags, struct sockaddr* src_addr, socklen_t* addrlen);
ssize_t sendto(int sockfd, const void* buf, size_t len, int flags, const struct sockaddr* dest_addr, socklen_t* addrlen);
UDP每次进行发送和接收时,都需要通过src_addr重新指定目标端的地址信息,addrlen是地址信息长度,其余参数信息与TCP读写函数一致
socket选项
设定或读取文件描述符的配置信息
#include <sys/socket.h>
int getsockopt(int sockfd, int level, int option_name, void* option_value, socklen_t* option_len);
int setsockopt(int sockfd, int level, int option_name, const void* option_value, socklen_t* option_len);
sockfd操作的套接字;level协议类型;option_name选项名字;option_value被操作选项的;option_len被操作选项的长度
由于选项众多,这里仅展示笔者常用的例子
// 例子
int i = 1;
// 强制解除被TIME_WAIT状态的socket所占用的地址和端口
setsockopt(m_socket, SOL_SOCKET, SO_REUSEADDR, &i, sizeof(i));
// 使用自带的心跳保活机制以维持连接
setsockopt(m_socket, SOL_SOCKET, SO_KEEPALIVE, &i, sizeof(i));
// 禁用Nagle算法,处理时效性高的短数据 但一般不推荐使用
setsockopt(m_socket, IPPROTO_TCP, TCP_NODELAY, &i, sizeof(i));
7.IO函数
IO可以简单的理解为对内存数据的操作行为。例如上一章socket中的数据读写操作就是基础的网络IO函数。
dup和dup2
指定一个文件描述符,将标准输入/输出重定向到文件或网络通信中
#include <unistd.h>
int dup(int file_descriptor);
int dup2(int file_descriptor_one, int file_descriptor_two);
两函数功能相同,都是创建一个新的文件描述符,新的文件描述符与参数file_descriptor指向相同的文件、管道或网络;dup2创建的文件描述符,需要大于参数file_descriptor_two;函数调用失败返回-1
// 例子:
close(STDOUT_FILENO); // 使用前需要关闭标准输入描述符
dup(m_socket); // 创建新的文件描述符,值合并功能等同于m_socket
printf("asdfg"); // 标准输入就会重定向到 m_socket 中
readv和writev
将从套接字中读取到的数据分散到内存块中 以及 将分散的内存数据一并写入套接字中。函数一般用于HTTP/HTTPS协议中。
sendfile
在两个文件描述符中直接传递数据(完全在内核中处理),而不是常规的先读取到内存缓冲区后再操作,避免了内核和用户态缓冲区之间的数据拷贝,提高了传输效率。被称为零拷贝。
主要用于TCP网络通信中,对文件进行传输。
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t* offset, size_t count);
参数in_fd是发送数据的文件描述符,必须是指向真实的文件;参数out_fd接收数据的套接字;参数offset指定读取文件流的位置;参数count设置真实文件的数据量
// 例子:
struct stat stat_buf;
int filefd = open("a.txt", O_RONLY); // 只读方式打开一个文件,返回文件描述符
fstat(filefd, &stat_buf); // 计算文件中的数据量
sendfile(m_socket, filefd, 0, stat_buf.st_size);
spilce
笔者对该函数并不了解,此函数介绍和对应例子可能存在错误
同样是在两个文件描述符间移动数据,也是零拷贝操作。但是传输效率不如sendfile函数,且一般需要搭配管道使用
#include <fcntl.h>
ssize_t splice(int fd_in, loff_t* off_in, int fd_out, loff_t* off_out, size_t len, unsigned int flags);
参数flags
// 例子:
// 在不适用send函数的情况下,回射数据
int pipefd[2];
pipe(pipefd); // 创建管道
// 将confd中的输入数据,定向传递到pipefd管道中
splice(confd, NULL, pipefd[1], NULL, 32768, SPLICE_MOVE);
// 将管道的输出定向到confd套接字中
splice(pipefd[0], NULL, confd, NULL, 32768, SPLICE_MOVE);
tee
在两个文件描述符间进行数据复制,也是零拷贝操作。但不消耗数据,对应复制的数据仍可用于后续操作,需搭配管道使用
#include <fcntl.h>
ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);
fd_in和fd_out都必须是管道文件描述符
// 例子:
// 使用tee函数,将管道pipeInfd中的数据复制到管道pipefd中,而不对管道内数据进行消耗
// 创建管道
int pipefd[2];
int pipeInfd[2];
pipe(pipefd);
pipe(pipeInfd);
// 将标准输入 定向到 pipeInfd输入端
splice(STDIN_FILENO, NULL, pipeInfd[1], NULL, 32768, SPLICE_F_MORE);
// 将pipeInfd的输出 定向复制到 pipefds输入端中
tee(pipeInfd[0], pipefd[1], 32768, SPLICE_F_NONBLOCK);
// 将pipeInfd的输出定向到 标准输出中
splice(pipeInfd[0], NULL, STDIOUT_FILENO, NULL, 32768, SPLICE_F_MORE);
mmap和munmap
mmap申请一块内存空间,可以用于跨进程通信,也可以将文件映射在其中;munmap则是释放mmap申请的内存
类似共享内存,(其中涉及到的共享内存、映射和页表等知识,将在 基础开发 这一章节进行详细介绍)
8.IO复用
IO复用可以使程序能够通过哟一个套接字同时监听多个文件描述符,提高运行效率,但在处理时仍绕只能按顺序依次处理。常用于服务端程序开发。
IO复用机制也有其他用途,但这里仅介绍网络开发部分
select
select原理:
维护一个FD_SET的数组,将连接产生的套接字/描述符存储其中,通过select函数监听数组的状态变化(监听状态一般包括三种场景:可读、可写、异常)来管理多个套接字。当监听数组中的状态发生变化后需顺序遍历FD_SET,找到状态变化的描述符,进行处理,处理完成后继续遍历。同一时间仅处理一个套接字触发,其他触发的套接字存储在内核中。
缺点:
- 能够监听的套接字是有数量限制的,一般是1024,且监听事件越多,性能越差
- 当数组状态发生变化时,只能通过遍历的形式判断描述符是否变化,效率较低
- 需要外部维护数组FD_SET并传入,且每次遍历完数组后,需要重置套接字在数组中的状态
优点:
- 在处理轻量级连接,更加方便,且代码编写较为简单
- linux和windows通用,可移植性较强
#include <sys/select.h>
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);
nfds指定被监听描述符总数;readfds、writefds、exceptfds分别指定需要监听读、写、异常的文件描述符;timeout设定超时时间;调用错误返回-1,超时返回0,有触发返回正值
// 例子:
// 监听 套接字数组的可读状态
fd_set rfds;
FD_ZERO(&rfds); // 重置数组中的套接字状态
FD_SET(m_clinet1, &rfds); // 在读数组中设置套接字 m_clinet1
FD_SET(m_clinet2, &rfds); // 在读数组中设置套接字 m_clinet2
struct timeval timeout;
timeout.tv_sec = 3; // 设定超时时间
timeout.tv_usec = 0;
int iRet = select(m_socket + 1, &rfds, NULL, NULL, &timeout);
poll
原理:
与select类似。同样的数组监听、同样的轮训检测。但接口参数更简洁,可以设定每个套接字感兴趣的事件。
缺点:
当数组状态发生变化时,只能通过遍历的形式判断描述符是否变化,效率较低
需要外部维护数组并在监听时传入
优点:
提高了单个套接字的最大监听数量,值65536
使用时不需要重置数组状态
除基础的读写异常状态外,还包含高优先级读、高优先级写等状态
#include <poll.h>
int poll(struct pollfd* fds, nfds_t nfds, int timeout);
fds指定需要监听的文件描述符上发生的感兴趣的事件,数量较多时使用队列标记 struct polled fds[20] ;ndfs指定监听合集fds的大小;timeout指定poll的超时时间,单位毫秒;
struct pollfd{
int fd; // 指定文件描述符
short events; // 告知poll监听fd上的哪些事件/对哪些事件感兴趣
short revents; // 发生的实际事件,由内核填充
}
常用事件:
POLLIN:数据可读
POLLOUT:数据可写
POLLERR:错误
POLLHUP:挂起
epoll
原理:
Linux特有的一种IO复用机制。相较于上面两种效率较高
将所有连接的事件描述符放入内核一个事件表中,不需要在外部维护一整个数组,而是使用唯一标识事件表的文件描述符。
事件表是触发式的。在插入事件描述符前,先注册每个描述符感兴趣的事件,当套接字对应感兴趣的事件触发后,会通过回调函数将事件写入到事件链表中。
底层是类似红黑树的实现机制,每个触发事件的套接字会被移动到顶部,时间复杂度能够达到O(1)
优点:
不需要传入监听数组,只需要传入一个监听标识符的套接字即可
优化了遍历方式,有事件触发的套接字会通过一个epoll_event返回,遍历epoll_event即可
除水平触发外,还额外支持边缘触发
可以设定每个套接字感兴趣的事件
缺点:
非单一函数实现,需要使用的函数较多,编写较麻烦
#include <sys/epoll.h>
// 创建文件描述符来表示内核中的事件表
int epoll_create(int size);
// 操作内核事件表中的注册事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
epfd是内核事件表
op操作类型,EPOLL_CTL_ADD(注册fd事件)、EPOLL_CTL_MOD(修改fd事件)、EPOLL_CTL_DEL(删除fd事件);
fd是被操作的文件描述符;
event参数指定fd感兴趣的事件
struct epoll_event{
__uint32_t events; // epoll事件 通过按为或操作可以指定多个
epoll_data_t data; // 用户数据
/* 常用的events事件类型 EPOLLIN(可读)、EPOLLOUT(可写)、EPOLLERR(错误)、EPOLLET(边缘触发-详见 三者异同 )、EPOLLONESSHOT(可读)
EPOLLONESSHOT事件:ET模式保证套接字设置多个感兴趣的事件,但每次最多触发其中一个且仅触发一次,当该socket处理完成后,需要重置EPOLLONESSHOT事件以确保其他事件能够触发。此行为可以有效保证某个socket上的事件仅被一个线程捕获并操作。
*/
}
// 等待事件表中的套接字感兴趣事件触发,可以设置超时时间
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
epfd内核事件表的文件描述符;events从内核事件表中返回的所有准备就绪的事件;maxevents可监听事件的最大值;timeout超时时间;成功时返回就绪的文件描述符个数,失败返回-1
// 例子:
// 通过 epollfd 事件表中,监听 监听套接字中的 读取事件
#define MAX_EVENT_NUMBER 1024
// 将套接字设置为非阻塞,触发时不影响其他套接字
void setNoBlock(int fd){
int oldOp = fcntl(fd, F_GETFL);
int newOp = oldOp | O_NONBLOCK;
fcntl(fd, F_SETFL, newOp);
}
// 将套接字fd的EPOLLIN 事件注册到epollfd中,并判断是否注册EPOLLET事件
void addfd(int epollfd, int fd, bool et){
epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN;
if(et){
event.events |= EPOLLET;
}
epoll_ctl(epollfd, EPOLL_CTRL_ADD, fd, &event);
setNoBlock(fd);
}
//
epoll_event events[MAX_EVENT_NUMBER];
int epollfd = epoll_create(5);
addfd(epollfd, listenfd, true); // 将默认监听socket传入 便于处理新连接
addfd(epollfd, confd_1, true); // 传入连接的套接字 confd_1
addfd(epollfd, confd_2, true); // 传入连接的套接字 confd_2
int iRet = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
三者异同
方式 | select | poll | epoll |
---|---|---|---|
原理 | 数组 | 数组 | 事件表 |
时间复杂度 | O(n) | O(n) | O(1) |
最大数量 | 1024 | 65536 | 65536 |
工作模式 | LT | LT | LT和ET |
实现 | 传入三个数组,分别监测读写异常状态的变化,每次调用后都需要重置数组状态。通过轮询的方式找到状态触发的套接字 | 通过注册的方式传入套接字感兴趣的事件,仅需一个数组。通过轮询的方式找到状态触发的套接字 | 通过内核中的事件表,外部仅维护一个文件描述符,在注册时可直接传入套接字感兴趣的事件,状态触发时通过回调的方式,返回所有就绪的事件, |
水平触发(LT)和边缘触发(ET)
水平触发:
默认工作模式,通过wait判断有事件触发时会进行返回,程序可以不立刻处理。当下一次调用wait函数时,之前的事件仍会被重新触发,直到该事件被处理掉。
边缘触发:
epoll拥有的独立模式,当wait上有事件触发时,程序必须立即处理该事件,因为后续wait函数将不会再通知程序,如果处理不及时,会导致事件丢失。
边缘触发可以很大程度上降低一个事件被重复触发的次数,提高运行效率
注解
在接收大量数据时,如何判断数据已经recv完成
对于非阻塞套接字,使用while循环,当recv函数返回值小于0的情况下,通过判断errno的状态,如果是 EAGAIN,那么意味着当前数据已经全部读取完成
在边缘触发的情况下,同样需要上述特殊处理读取事件
9.高性能IO框架库libevent
笔者目前对其使用尚不熟练
正在填坑中。。。。
10.端口复用
允许多个套接字绑定在主机的同一端口,主要用在服务端
用途:
防止服务器重启时,之前的端口仍处于被占用的状态(处于TIME_WAIT状态)/调用bind时出现报错现象 ,进而无法绑定的场景
也有负载均衡的效果,通过操作系统的自动规划,避免一个服务端绑定大量TCP连接的情况
11.碎碎念
以上是网络开发中涉及理论的一小部分,可以随着工作需要再深入研究
12.备注
以上资料来源于《Linux高性能服务器编程》、《图解TCP/IP》、笔者工作开发、学习整理等
部分说明可能存在错误,欢迎指正
部分图片来源于网络,侵删