文章目录
- 端口
- 套接字
- 网络相关函数
- 拓展函数
- 术语
- TCP握手挥手
- 密钥安全问题
- 三接口timeout参数
-
-
- [1. select](#1. select)
- [2. poll](#2. poll)
- [3. epoll](#3. epoll)
- 总结
-
- select
- poll
- epoll
- 三接口对比
- 比较TCP四种服务器
- Reactor
端口
在计算机网络中,端口是用于区分不同服务和应用程序的标识符。端口号在TCP/IP协议中占用2个字节,即16位,范围从0到655351。
端口的分类
端口号可以根据其用途和分配方式分为三类:
- 公认端口(Well-Known Ports) :范围是0到1023。这些端口由IANA(互联网号码分配局)管理,分配给一些重要的应用程序。例如,FTP使用21端口,HTTP使用80端口2。
- 注册端口(Registered Ports) :范围是1024到49151。这些端口由公司和其他用户向ICANN(互联网名称与数字地址分配机构)登记,用于特定的应用程序和服务2。
- 动态或私有端口(Dynamic or Private Ports) :范围是49152到65535。这些端口通常由客户端进程在运行时动态选择,主要用于临时通信3。
端口的作用
端口号的主要作用是表示一台计算机中的特定进程所提供的服务。通过IP地址可以标识一台计算机,但同一台计算机上可能运行多个服务,如Web服务、FTP服务等。端口号用于区分这些不同的服务。例如,21端口表示FTP服务,80端口表示HTTP服务.
套接字
套接字的种类大致分三种:
- 域间socket:基于套接字的管道通信/主客通信/本地通信
- 原始socket:用来创建工具/应用层跨传输层到网络层/应用层直接到数据链路层.从应用层直接绕开传输层,直接去访问底层协议,通常用于各种抓包软件、网络侦测工具
- 网络socket:跨主机网络通信,也支持本地通信
IP地址的绑定0.0.0.0
监听所有可用接口:监听机器上所有可用的网络接口。服务器可以接受来自任何IP地址的连接请求,无论是本地还是远程。
灵活性:允许服务器在多个网络接口上运行,这对于具有多个IP地址或网络连接的机器特别有用。服务器可以响应来自不同网络的连接请求。
外部访问:当服务器需要接受来自外部网络(如互联网)的连接时,必须绑定到0.0.0.0或特定的公共IP地址。仅绑定到127.0.0.1将阻止外部访问。
网络相关函数
字节序转换
cpp
typedef uint32_t in_addr_t;
typedef uint16_t in_port_t;
struct sockaddr
{
sa_family_t sa_family;
char sa_data[14];
};
struct sockaddr_in
{
sa_family_t sin_family;
in_port_t sin_port; // uint16_t
struct in_addr sin_addr;
};
struct in_addr
{
in_addr_t s_addr; // uint32_t
};
// 第一套接口
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
// 第二套接口
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
// 点分十进制ip_str --> 32位主机序列 --> 32位网络序列
// 将cp指向的IPv4点分十进制格式的字符串转换为二进制格式
int inet_aton(const char *cp, struct in_addr *inp); // 二进制存到in_addr
char *inet_ntoa(struct in_addr in); // 二进制转点分十进制存到静态缓冲区
in_addr_t inet_addr(const char *cp); // 通过返回值传递二进制 失败时返回INADDR_NONE(-1)
in_addr_t inet_network(const char *cp); // 通过返回值传递二进制 失败时返回INADDR_NONE(-1)
// 将网络部分和主机部分组合成一个完整的IPv4地址
struct in_addr inet_makeaddr(in_addr_t net, in_addr_t host);
// 获取IPv4地址中的主机部分。
in_addr_t inet_lnaof(struct in_addr in);
// 获取IPv4地址中的网络部分。
in_addr_t inet_netof(struct in_addr in);
// 第三套接口
#include <arpa/inet.h>
int inet_pton(int af, const char *src, void *dst);
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
字节序转换常用总结
cpp
#include <arpa/inet.h>
// 点分十进制转二进制===主机转网络
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
int inet_aton(const char* strptr, struct in_addr* addrptr);
in_addr_t inet_addr(const char* strptr);
in_addr_t inet_network(const char *cp);
int inet_pton(int family, const char* strptr, void* addrptr);
// 二进制转点分十进制===网络转主机
char* inet_ntoa(struct in_addr inaddr); //二进制转点分十进制存到静态缓冲区 存在线程安全问题
const char* inet_ntop(int family, const void* addrptr, char* strptr, size_t len);
// 成功返回strptr 失败返回NULL并设置errno
ipv4 -- 4byte -- 32bit
ipv6 -- 16byte -- 128位
// 对于 IPv4 地址,strptr缓冲区大小至少应为 INET_ADDRSTRLEN(通常为 16 字节);
// 对于 IPv6 地址,缓冲区大小至少应为 INET6_ADDRSTRLEN(通常为 46 字节)。
//inet_pton 和 inet_ntop 可以转换 IPv6 的 in6_addr,因此函数接口是 void* addrptr.
// struct in_addr ip_bin = { .s_addr = 0x01020304 }; // 示例的 IPv4 地址(二进制格式)
inet_ntoa的线程安全问题
在APUE(Advanced Programming in the UNIX Environment[一本书])中, 明确提出inet_ntoa不是线程安全的函数;
在centos7上测试, 并没有出现问题, 可能内部的实现加了互斥锁;
在多线程环境下, 推荐使用inet_ntop, 这个函数由调用者提供一个缓冲区保存结果, 可以规避线程安全问题.
套接字编程
cpp
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
int listen(int sockfd, int backlog);
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
struct sockaddr
{
sa_family_t sa_family;
char sa_data[14];
};
struct sockaddr_in
{
sa_family_t sin_family;
in_port_t sin_port; // uint16_t
struct in_addr sin_addr;
};
struct in_addr
{
in_addr_t s_addr; // uint32_t
};
//domain取值:
AF_UNIX Local communication unix(7)
AF_LOCAL Synonym for AF_UNIX
AF_INET IPv4 Internet protocols ip(7)
AF_INET6 IPv6 Internet protocols ipv6(7)
// type取值
SOCK_STREAM:提供流式套接字,通常用于 TCP 协议。
SOCK_DGRAM:提供数据报套接字,通常用于 UDP 协议。
SOCK_RAW:提供原始套接字,可以访问底层协议。
【文件都是字节流式的,udp是面向数据报的,故udp不能直接使用文件的接口】
// protocol取值
IPPROTO_TCP: 使用TCP协议。
IPPROTO_UDP: 使用UDP协议。
IPPROTO_RAW: 使用原始IP协议。
IPPROTO_SCTP: 使用流控制传输协议(Stream Control Transmission Protocol)
protocol 参数指定非零值时,它必须与 domain 和 type 参数所指定的域和类型相匹配
protocol 参数设置为0,系统将自动选择合适的协议
// backlog
未完成连接队列长度的参数。当多个客户端同时尝试连接到服务器时,那些尚未被accept函数处理的连接请求将被放在队列中等待。backlog定义了队列中允许的最大连接数。一旦队列满了,新的连接请求可能会被拒绝。需要注意的是,这个值并不一定严格限制队列大小,它更多是一个提示给操作系统,实际的队列大小可能会根据系统实现有所不同。
// listen报错
EBADF:提供的sockfd不是一个有效的文件描述符。
ENOTSOCK:提供的sockfd不是一个套接字文件描述符。
EOPNOTSUPP:提供的套接字不支持监听操作(例如,它可能是一个数据报套接字而不是流套接字)。
EINVAL:sockfd没有绑定到任何地址,或者backlog参数的值非法。
// connect报错
ECONNREFUSED:服务器拒绝了连接请求。
ETIMEDOUT:连接请求超时。
EHOSTUNREACH:服务器主机不可达。
ENETUNREACH:网络不可达。
EADDRINUSE:本地地址已在使用中。
EINPROGRESS:连接正在进行中(非阻塞模式下)。
// accept报错 包括上面四个
EWOULDBLOCK 或 EAGAIN:套接字被设置为非阻塞模式,且没有连接请求等待处理。
ECONNABORTED:一个已建立的连接被对端异常关闭。
EMFILE:进程已达到打开文件描述符的上限。
ENFILE:系统范围内打开文件描述符的数量已达到上限。
// accept
接受一个来自客户端的连接请求,并返回一个新的套接字描述符,这个新的套接字描述符用于和客户端之间的通信。原始的sockfd套接字描述符仍然用于监听新的连接请求。accept函数会阻塞调用线程(除非套接字被设置为非阻塞模式),直到有连接请求可用。当有连接请求时,accept函数会从队列中提取第一个连接,创建一个新的套接字描述符,并返回这个新的描述符给调用者。
// connect
connect函数是阻塞的,它会等待连接建立完成或发生错误。如果希望connect在连接建立时不阻塞调用线程,可以设置套接字为非阻塞模式,可以通过fcntl函数或使用O_NONBLOCK标志在socket或open调用时完成。
// IP地址
网络地址为INADDR_ANY, 这个宏表示本地的任意IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个IP 地址, 这样设置可以在所有的IP地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个IP 地址
设置套接字选项
cpp
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int getsockopt(int sockfd, int level, int optname,
void *optval, socklen_t *optlen);
int setsockopt(int sockfd, int level, int optname,
const void *optval, socklen_t optlen);
setsockopt
是一个在 Linux 中用于设置套接字选项的系统调用。它允许程序调整接口的行为和特性,以便更好地满足应用程序的需求。
setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, &opt, sizeof(opt));
函数原型
c
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
参数说明
- sockfd: 套接字描述符,表示要设置选项的套接字。
- level : 选项的层级,可以是
SOL_SOCKET
(套接字层)或其他协议层(如IPPROTO_TCP
)。 - optname: 要设置的选项名称,具体取决于选定的层级。
- optval : 指向选项值的指针,根据选项名称的要求来确定其类型。//将
opt
设置为1
,表示启用这些选项 - optlen: 选项值的长度。
常用选项
以下是一些常见的套接字选项:
-
SO_REUSEADDR: 允许重用本地地址。这对于服务器端特别有用,可以在程序崩溃后快速重启服务。
cint optval = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)); SO_REUSEADDR:允许多个套接字(在相同协议栈上)绑定到相同的地址和端口。这在一些情况下很有用,特别是在快速重启服务器时,能够避免"地址已在使用"错误。 SO_REUSEPORT:允许多个套接字(在相同的地址和端口上)共享负载。这在多线程或多进程服务器中非常有效,可以提高性能,因为它允许操作系统将传入连接分配给不同的进程或线程。
-
SO_KEEPALIVE: 启用保持活动探测,以确保连接仍然有效。
cint optval = 1; setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &optval, sizeof(optval));
-
TCP_NODELAY: 禁用 Nagle 算法,减少延迟,适合实时数据传输应用。
cint optval = 1; setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &optval, sizeof(optval));
-
SO_RCVBUF 和 SO_SNDBUF: 设置接收和发送缓冲区的大小。
cint rcvbuf = 1024 * 4; // 4 KB setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));
返回值
setsockopt
成功时返回 0
,失败时返回 -1
,并且设置 errno
值以指示错误原因。
选项讲解
SO_REUSEADDR
允许套接字绑定到一个已经在使用(或最近使用过但仍在TIME_WAIT状态)的本地地址和端口上。在服务器程序重新启动或服务器进程崩溃后,能够立即重启并监听相同的端口,而不需要等待之前的连接完全关闭(这可能需要几分钟,尤其是在TIME_WAIT状态下)。在某些情况下,防止因地址已被使用而导致的"Address already in use"错误。允许多个服务器进程(通常在不同的IP地址或不同的网络接口上)绑定到相同的端口。
SO_REUSEPORT
允许多个套接字绑定到同一个本地地址和端口上。在多进程或多线程服务器中,使得多个服务器进程/线程可以共享相同的端口,而每个进程/线程都能接收到客户端的连接。提高服务器的可扩展性和性能,因为多个进程/线程可以并行处理连接。在负载均衡环境中,使得多个后端服务器可以监听同一个前端IP和端口,而流量可以在这些服务器之间自动分配。在某些操作系统中,SO_REUSEPORT还可以与快速回收和分发套接字(也称为"套接字接受加速")相结合,以减少接收新连接时的系统开销。
收发信息
cpp
#include <sys/types.h>
#include <sys/socket.h>
// flags:MSG_PEEK(查看数据但不从队列中移除)和 MSG_DONTWAIT(非阻塞接收)
// src_addr:存储发送方的地址信息。如果不需要地址信息,可以设置为NULL。
// addrlen:指向一个整数的指针,用于存储src_addr结构的大小。addrlen 参数既用于告诉recvfrom有多少空间来存储地址信息,也用于在函数返回后告知实际存储了多少信息。
// msg:接收操作所需的各种信息,如缓冲区、控制消息等。
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
// ###############################################################################
#include <sys/types.h>
#include <sys/socket.h>
// dest_addr:指向目的地址结构的指针。
// addrlen:目的地址结构的长度。
// 想要发送数据到之前已经通过connect函数连接过的地址,这个参数可以设置为NULL,并且addrlen参数也应该设置为0。
// UDP是无连接的,每次发送数据都需要指定目标地址。而在TCP编程中,通常会在建立连接(通过connect函数)后使用send函数发送数据,此时就不需要指定目标地址。
// sendto需要显式提供目标地址(通过dest_addr和addrlen参数),而send则假定目标地址已经在之前的connect调用中指定。
// send通常用于已连接的套接字(如TCP),而sendto用于无连接的套接字(如UDP)。
// falgs:MSG_DONTWAIT 非阻塞方式发送
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
recv参数
- 0:默认值,表示没有特别的选项。
- MSG_WAITALL:请求尽可能多地接收数据,直到缓冲区已满或连接关闭。此标志确保在调用返回之前,尽量接收到指定长度的数据。
- MSG_PEEK :查看接收缓冲区的数据,但不从缓冲区中移除它们。这意味着后续的
recv
调用仍然可以接收到相同的数据。 - MSG_OOB :接收带有紧急数据的消息。如果套接字配置为支持紧急数据,则该标志会使
recv
只接收紧急数据。 - MSG_DONTWAIT :使接收操作变为非阻塞。如果没有数据可供接收,
recv
将立即返回,而不是等待数据到达。 - MSG_NOSIGNAL:防止在接收时因对方关闭连接而产生 SIGPIPE 信号。使用此标志可以避免程序被意外终止。
拓展函数
popen
cpp
#include <stdio.h>
FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);
popen的行为【type=="r"】父读子写
调用pipe创建一个管道(pipe)。
调用fork来创建一个子进程。
子进程关闭读端pfd[0],将标准输出重定向到写端pfd[1];
父进程关闭写端。
popen()
返回一个文件指针(FILE*
),这个指针可以用来与创建的管道进行读写操作。
cpp
#include <stdio.h>
#include <stdlib.h>
int main()
{
FILE *fp;
char path[1035];
/* 打开一个命令用于读取 */
fp = popen("ls -l", "r");
if (fp == NULL)
{
printf("执行命令失败\n" );
exit(1);
}
/* 读取命令的输出 */
while (fgets(path, sizeof(path)-1, fp) != NULL)
{
printf("%s", path);
}
/* 关闭 */
pclose(fp);
return 0;
}
popen报错
cpp
fp = popen("1", "r");
if (fp == NULL)
{
perror("popen failed");
return 1;
}
// sh: 1: 1: not found
不走if语句 popen直接终止
strcasestr
在一个字符串(haystack)中搜索另一个字符串(needle),同时忽略大小写。这个函数在GNU C库(glibc)中提供,但不是标准C库的一部分,因此可能不在所有的C库实现中都可用。
cpp
#include <string.h>
char *strcasestr(const char *haystack, const char *needle);
术语
URI和URL
在HTTP领域,URI(统一资源标识符)和URL(统一资源定位符)是两个相关但不同的概念。以下是它们之间的主要区别:
URI(Uniform Resource Identifier)
- 定义:URI是用来标识某个资源的字符串,可以是一个URL、URN(统一资源名称)或两者的组合。
- 结构:URI可以包含多个部分,例如协议、主机、路径、查询字符串等。
- 示例 :
http://example.com/resource
(一个URL,即URI的一种形式)urn:isbn:0451450523
(一个URN,表示书籍的ISBN)
URL(Uniform Resource Locator)
- 定义:URL是URI的一种特定类型,它不仅标识资源,还提供获取该资源的方法(通常是通过网络地址)。
- 结构:URL包括了协议、主机、端口、路径、查询字符串等信息。
- 示例 :
https://www.example.com/index.html?query=123
(完整的URL)
总结
- URI 是一个更广泛的概念,包括所有标识资源的方式,而 URL 则是URI的一个特例,它明确指出了如何定位到这个资源。
- 所有的URL都是URI,但不是所有的URI都是URL。
希望这些解释能帮助你理解URI和URL之间的区别!
状态码
301:(永久移动) 请求的网页已永久移动到新位置。 服务器返回此响应(对 GET 或 HEAD 请求的响应)时,会自动将请求者转到新位置。
308:(永久转移)这个请求和以后的请求都应该被另一个URI地址重新发送。308 状态码不允许浏览器将原本为 POST 的请求重定向到 GET 请求上。
302:(临时移动) 服务器目前从不同位置的网页响应请求,但请求者应继续使用原有位置来进行以后的请求。用户代理可能会在重定向后的请求中把 POST 方法改为 GET方法。如果不想这样,应该使用 307。
307:(临时重定向) 服务器目前从不同位置的网页响应请求,但请求者应继续使用原有位置来进行以后的请求。
303 状态码表示服务器要将浏览器重定向到另一个资源。从语义上讲,重定向到的资源并不是你所请求的资源,而是对你所请求资源的一些描述。比如303 常用于将 POST 请求重定向到 GET 请求,比如你上传了一份个人信息,服务器发回一个 303 响应,将你导向一个"上传成功"页面。
503 由于临时的服务器维护或者过载,服务器当前无法处理请求。这个状况是临时的,并且将在一段时间以后恢复。
403 禁止访问,服务器理解请求客户端的请求,但是拒绝执行此请求(比如权限不足,ip被拉黑等一系列原因)
TCP握手挥手
密钥安全问题
- 只使用对称加密:假设初始只有服务端有对称密钥X,初次通信需要把X发给客户端,此时X可能被捕获。
- 只是用非对称加密:假设初始只有服务端有非对称密钥S[公钥] S'[私钥],初次通信需要把S发给客户端,此时S可能被捕获。之后客户端用S加密后发送,该通道安全,因为只有服务端有S'。但是服务端到客户端不安全:服务端用S'加密,中间人和客户端都有S。【除此之外还有安全问题】
- 都使用非对称加密:服务端拥有公钥S与对应的私钥S',客户端拥有公钥C与对应的私钥C。客户和服务端交换公钥。客户端给服务端发信息: 先用S对数据加密,再发送,只能由服务器解密,因为只有服务器有私钥S'。服务端给客户端发信息:先用C对数据加密,在发送,只能由客户端解密,因为只有客户端有私钥C'。【貌似安全但效率低】
- 非对称+对称:服务端有非对称公钥S和私钥S'。客户端发起https请求,获取服务端公钥S。客户端在本地生成对称密钥C。客户端把对称密钥C用公钥S加密,发送给服务器。由于中间的网络设备没有私钥S', 即使截获了数据,也无法还原出内部的原文,也就无法获取到对称密钥C【不一定,先假设】。服务器通过私钥S"解密,还原出客户端发送的对称密钥C,并且使用这个对称密钥加密给客户端返回的响应数据。后续客户端和服务器的通信都只用对称加密即可。由于该密钥只有客户端和服务器两个主机知道,其他主机/设备不知道密钥。即使截获数据也没有意义==》利用非对称加密安全的交换对称加密的密钥。
上述2/3/4提到的额外的安全问题:
-
服务器具有非对称加密算法的公钥S,私钥S'
-
中间人具有非对称加密算法的公钥M,私钥M'
-
客户端向服务器发起请求,服务器明文传送公钥S给客户端。
-
中间人劫持数据报文,提取公钥S并保存好,然后将被劫持报文中的公钥S替换成为自己的公钥M,并将伪造报文发给客户端
-
客户端收到报文,提取公钥M(自己当然不知道公钥被更换过了),自己形成对称秘钥X,用公钥M加密X,形成报文发送给服务器
-
中间人劫持后,直接用自己的私钥M'进行解密,得到通信秘钥X,再用曾经保存的服务端公钥S加密后,将报文推送给服务器
-
服务器拿到报文,用自己的私钥S'解密,得到通信秘钥X
-
双方开始采用X进行对称加密,进行通信。但是一切都在中间人的掌握中,劫持数据,进行窃听甚至修改,都是可以的。
问题本质出在哪里了呢?
客户端无法确定收到的含有公钥的数据报文,就是目标服务器发送过来的!
CA认证
服务端目前拥有:1. 申请CSR文件后获得的S' 2. CSR文件{组织信息/服务端公钥S}
CA机构把CSR文件通过某算法形成数据摘要,然后通过CA' 加密生成数字签名。
数字签名 + CSR文件[明文] == 证书
方案五
- 设CA自己的公钥为A,私钥为A'。服务端自己的公钥为S,私钥为S'。客户端自己的对称密钥X。
- 客户端向服务端发起请求 请求CA证书 服务端给客户端自己的CA证书 证明自己的身份
- 客户端对证书的明文数据用MD5算法形成数据摘要m,对数字签名用A解密形成数据摘要n,比对mn是否相等,相等说明服务端身份合法。
- 提取证书中明文数据中服务端的公钥S。对X用S加密发送给服务端。服务端接收到后用S'解密获取X,之后双方用X加密交流。
三接口timeout参数
在 Linux 中,select
、poll
和 epoll
是用于多路 I/O 复用的系统调用,它们都支持超时(timeout)参数。以下是这三个函数关于超时参数的取值说明:
cpp
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
1. select
- timeout 参数 :
struct timeval *timeout
- 取值 :
- NULL: 表示永远阻塞,直到有事件发生。
- 0: 立即返回,不阻塞。如果没有可用的数据,返回 0。
- 正数 : 指定等待时间,例如
{tv_sec, tv_usec}
,表示等待tv_sec
秒和tv_usec
微秒。在这个时间内,如果有事件发生,则立即返回。
2. poll
- timeout 参数 :
int timeout
- 取值 :
- 负数: 永远阻塞,直到有事件发生。
- 0: 立即返回,不阻塞。如果没有可用的数据,返回 0。
- 正数: 表示最大等待时间(以毫秒为单位)。如果在这个时间内有事件发生,则返回,否则返回 0。
3. epoll
- timeout 参数 :
int timeout
- 取值 :
- 负数: 永远阻塞,直到有事件发生。
- 0: 立即返回,不阻塞。如果没有可用的数据,返回 0。
- 正数: 表示最大等待时间(以毫秒为单位)。在这个时间内,如果有事件发生则返回,否则返回 0。
总结
- 所有三者都支持永远阻塞(通过传递负值或 NULL)、立即返回(通过传递 0)和指定超时(通过传递正值)。
- 对于
select
,超时时间以struct timeval
结构体表示;而对于poll
和epoll
,超时时间以毫秒为单位的整数表示。
select
cpp
/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
/* According to POSIX.1-2001, POSIX.1-2008 */
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
#include <sys/select.h>
int pselect(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, const struct timespec *timeout,
const sigset_t *sigmask);
struct timeval
{
long tv_sec; /* 秒 */
long tv_usec; /* 微秒 */
};
参数
-
nfds:是一个整数值,指定了被监听的文件描述符集(fd_set)中最大文件描述符值加1。这并非指 select 实际监视的文件描述符数量,而是为了效率而做的一个限制:select通过扫描内核中fd_array,遍历所有文件描述符,以此来获得fd的状态,有nfds,select在调用时就可以不把所有fd遍历完,遍历到nfds即可。
-
readfds:指向一个 fd_set 结构体,该结构体中包含了需要监视的、希望进行读操作的文件描述符。
-
writefds:指向一个 fd_set 结构体,该结构体中包含了需要监视的、希望进行写操作的文件描述符。如果不需要监视写操作,可以设为 NULL。
-
exceptfds:指向一个 fd_set 结构体,该结构体中包含了需要监视的、希望进行异常条件捕获的文件描述符。如果不需要监视异常条件,可以设为 NULL。
-
timeout:是一个指向 timeval 结构体的指针,它指定了 select 调用等待的最长时间。如果 timeout 为 NULL或负值,select 将无限期地等待,直到一个文件描述符就绪(变为阻塞式)。如果 timeout 的时间值为零,select 将立即返回,这可以用于轮询(非阻塞轮询)。
-
只想关心x的读/写:把x只设置进rfds/wfds;既想关心x的读又想关心写:都设置;先关心读后关心写:先设置进rfds再设置进wfds。
返回值
select 返回监视的文件描述符集中就绪的文件描述符数量。如果超时,返回0。如果发生错误,返回-1,并设置 errno 以指示错误原因。
缺点
- 文件描述符数量限制:select 能够监视的文件描述符数量受限于 FD_SETSIZE,这通常是1024。对于需要处理大量连接的应用来说,这是一个严重的限制。fd_set 是一个位图,并且是一个具体的类型,fd_set 有具体的大小,只要有实际的大小,那么 fd_set 就一定有它位图中比特位的个数,也就是说 select 等待多个文件描述符一定是有上限的!
- 效率问题:select 会修改传入的 fd_set 集合,这使得在调用之间保存和恢复文件描述符集合变得复杂和低效。
- 数据拷贝:select 需要将文件描述符集合从用户空间拷贝到内核空间,然后再从内核空间拷贝回用户空间,这增加了开销。
- 对于需要高性能和高并发性的应用,更现代的 I/O 多路复用机制,如 poll 和 epoll(特别是 epoll),通常是更好的选择。
poll
cpp
struct pollfd
{
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <signal.h>
#include <poll.h>
int ppoll(struct pollfd *fds, nfds_t nfds,
const struct timespec *tmo_p, const sigset_t *sigmask);
参数
fds:是一个指向struct pollfd结构体数组的指针,每个元素指定了一个要检查的文件描述符及其感兴趣的事件。
nfds:指定了fds数组中元素的数量。
timeout:指定了poll调用阻塞的时间,以毫秒为单位。如果设置为-1,poll将无限期地等待;如果设置为0,poll将立即返回;如果设置为正数,poll将在指定的毫秒数后超时。
select和poll
-
文件描述符数量限制:
select:通常受到系统文件描述符数量限制的限制,一般在1024个左右。
poll:没有文件描述符数量的硬限制,理论上可以监控更多的文件描述符(但实际上受限于系统资源和内存限制)。
-
数据结构:
select:使用三个独立的位图(bitmap)来跟踪文件描述符的状态,这些位图在内核空间维护。
poll:使用一个struct pollfd结构体数组来存储要监控的文件描述符及其事件,这个数组在用户空间维护。
-
事件信息丰富度:
select:需要程序遍历位图来查找事件,对于每个文件描述符的状态变化,select提供的信息相对较少。
poll:struct pollfd结构体中的revents字段在事件发生时会被内核设置,提供了更丰富的信息关于每个文件描述符的状态变化。
-
性能:
select:在文件描述符数量较多时,性能会下降,因为需要遍历整个位图来查找就绪的文件描述符。
poll:虽然也需要在用户空间和内核空间之间复制文件描述符集合,但由于其数据结构的设计,在处理大量文件描述符时可能具有更好的性能。然而,如果监控的文件描述符数量非常大,仍然可能遇到性能瓶颈。
epoll
epoll特别适用于需要处理大量并发连接且连接中大部分时间处于非活跃状态的网络应用,如Web服务器、数据库服务器等。
cpp
#include <sys/epoll.h>
int epoll_create(int size);// size参数被忽略 只需要大于0
int epoll_create1(int flags);
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
int epoll_pwait(int epfd, struct epoll_event *events,
int maxevents, int timeout,
const sigset_t *sigmask);
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event
{
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
当用户态程序调用epoll_create函数时,内核会创建一个eventpoll对象{红黑树/就绪fd队列}。
- epoll_create() 创建 epoll 模型本质就是创建红黑树,创建就绪队列以及注册底层的回调机制。
- 红黑树用于存放通过epoll_ctl方法向epoll对象中添加进来的事件,这些事件都会挂载在红黑树中。
- 所有添加到epoll模型中的事件都会与设备(网卡)驱动程序建立回调关系。
- 当响应的事件发生时会调用回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表即fd就绪队列中。
在epoll中,对于每一个事件,都会建立一个epitem结构体【红黑树结点】
cpp
struct epitem
{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
} // fd event 就绪队列 所处的epoll模型 所处的红黑树结点
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。这个操作的时间复杂度是O(1)。epoll_wait函数会检查就绪列表,如果列表不为空,则将事件信息拷贝到用户空间,并清空就绪列表。
epoll原理
- 操作系统在内部会帮我们维护一颗红黑树
红黑树中的节点包含的最重要的字段是:int fd 和 uint32_t event,分别代表内核要关心的文件描述符和要关心的事件。
- 操作系统会为我们维护一个就绪队列
一旦红黑树中有特定的一个节点上的文件描述符的某个事件就绪了,就可以把该节点添加到就绪队列中;其中该就绪队列中每个节点中的字段包含 int fd 和 uint32_t event,分别代表已经就绪的文件描述符和已经就绪的事件。
- 操作系统的底层网卡驱动是允许操作系统去注册一些回调机制
操作系统内部会提供回调函数。网卡通过硬件中断的方式将数据搬到了网卡驱动层。当网卡驱动层中的数据链路层有数据就绪了,主动会调用该回调函数。然后该回调方法会做如下几个操作:向上交付;交付给 TCP 的接收队列;根据文件描述符为键值查找红黑树,确认这个接收队列和哪一个文件描述符是关联的;判断该 fd 是否关心了 EPOLLIN 或者 EPOLLOUT 读写事件,如果有,构建就绪节点,插入到就绪队列。
实际上我们用 epoll 的时候,操作系统就会把该回调函数注册到底层,然后底层数据一旦就绪就会自动回调执行上面的四个方法。所以对于用户来说,只需要在就绪队列中获取就绪节点即可!整套机制都是由操作系统完成的!
pcb_files_struct ---- fd_array[i] ---- file_struct ---- eventpoll
epoll的优点
- 判断有没有事件就绪的时间复杂度为 O(1)。【只需要看队列是否为空】
- 获取就绪的时间复杂度为 O(n),需要将就绪队列中的节点一个一个拷贝到应用层。
- fd 和 event 没有上限,该红黑树有多大由操作系统承载。
- 不需要在用户层由用户维护一个数组这样的数据结构来管理所有的文件描述符及其要关心的事件。
- epoll_wait() 的返回值 n表示有 n 个 fd 就绪了。该接口会将已经就绪的节点放入输出型参数 events ,所以就绪事件是连续的,有 n 个!上层用户处理已经就绪的事件不需要像以前一样检测有哪些 fd 是非法的,哪些是没有就绪的了,只需要根据返回值 n,遍历 events 即可!
三接口对比
在Linux中,select
、poll
和epoll
都是用于处理多路I/O复用的系统调用,它们各自有不同的优缺点。以下是对这三者的简要比较:
1. select
优点:
- 兼容性好:支持的广泛,几乎所有Unix-like操作系统都支持。
- 简单易用:API相对简单,适合小型应用程序。
缺点:
- 文件描述符数量限制:最多只能监视1024个文件描述符(可以通过重新编译内核来改变,但不便)。
- 性能问题:每次调用都会复制fd_set结构到内核,随着监视的文件描述符增多,性能会降低。
- 事件通知方式:无法直接获取哪些文件描述符就绪,需要遍历所有描述符。
2. poll
优点:
- 没有文件描述符限制:理论上支持监视任意数量的文件描述符。
- 更灵活的事件类型支持:可以根据需求扩展事件类型,实现更复杂的I/O处理。
缺点:
- 性能下降 :与
select
一样,每次调用都需要复制数据结构到内核,且在高并发场景下性能也会下降。 - 线性扫描:每次调用都需要遍历所有的文件描述符,导致在大量连接时效率低下。
3. epoll
优点:
- 高效:使用事件驱动机制,只在有事件发生时才进行处理,避免了无效的遍历。
- 支持边缘触发和水平触发:可以根据需求选择不同的触发模式,适应不同应用场景。
- 没有文件描述符数量限制:可以监视大量的文件描述符。
缺点:
- 复杂性:API相对复杂,需要更多的代码来实现。
- 只有Linux支持 :
epoll
是Linux特有的,不具备跨平台的优势。
总结
- 对于小规模、多频繁的I/O操作,可以选择
select
或poll
。 - 对于大规模、高性能需求的应用,推荐使用
epoll
比较TCP四种服务器
普通
cpp
void start()
{
_ptrToThreadPool->run();
while (true)
{
struct sockaddr_in src_sockAddr;
socklen_t len = sizeof(src_sockAddr);
int serviceSocket = accept(_listenSocket, (struct sockaddr *)&src_sockAddr, &len);
if (serviceSocket < 0)
{
logMsg(ERROR, "accept::%d:%s", errno, strerror(errno));
continue;
}
Task taskDictionary(serviceSocket, client_ip, client_port, Task_Dictionary);
_ptrToThreadPool->pushTask(taskDictionary);
}
}
select:接收缓冲区中有无数据;发送缓冲区中是否有空间;是否有连接到来;发生错误
在用户态维护一个fd数组fdArr,存储需要关心的fd。
在服务器死循环中每次调用select前,遍历fdArr设置进内核数据结构fd_set。
select对这些fd做检测,有事件就绪后,就绪fd存在select的输出型参数rfds中。
再次遍历fdArr,根据frds判断哪些fd就绪,执行对应回调。
连接事件就绪后,调用accept,把服务套接字添加至fdArr。
读事件就绪,调用read,如果没有读到数据(对端关闭或发生错误),从fdArr中移除该fd。
cpp
void Start()
{
int listensock = _listenSock.getSocketFd();
fd_array[0] = listensock;
while(true)
{
fd_set rfds;
FD_ZERO(&rfds);
int maxfd = fd_array[0];
for (int i = 0; i < fd_num_max; i++) // loop one
{
if (fd_array[i] == defaultFd)
continue;
FD_SET(fd_array[i], &rfds);
if (maxfd < fd_array[i])
{
maxfd = fd_array[i];
lg(Info, "max fd update, max fd is: %d", maxfd);
}
}
struct timeval timeout = {0, 0};//null:阻塞等待; {0, 0}:非阻塞; 其余:指定时长
int n = select(maxfd + 1, &rfds, nullptr, nullptr, /*&timeout*/ nullptr);
switch (n)
{
case 0:
cout << "time out, timeout: " << timeout.tv_sec << "." << timeout.tv_usec << endl;
break;
case -1:
cerr << "select error" << endl;
break;
default:
cout << "get a new link!!!!!" << endl;
Dispatcher(rfds);
break;
}
}
}
void Dispatcher(fd_set &rfds)
{
for (int i = 0; i < fd_num_max; i++) //loop two
{
int fd = fd_array[i];
if (fd == defaultFd)
continue;
if (FD_ISSET(fd, &rfds))
{
if (fd == _listenSock.getSocketFd())
Accepter(); // 连接管理器
else
Recver(fd, i); // 读事件管理器
}
}
}
poll:文件描述符关联有一个或多个等待队列,用于存放等待该文件描述符上特定事件发生的进程或线程。poll函数会轮询检查每个文件描述符的等待队列,查看是否有事件发生。如果有文件描述符上发生了感兴趣的事件,poll函数会立即返回,并将这些事件记录在对应pollfd结构体的revents字段中
在用户态维护一个结构体数组stArr,存储需要关心的fd及其感兴趣的事件。
poll遍历传入的结构体数组,为每个感兴趣的文件描述符注册一个等待事件。这些等待事件会被挂接到内核中相应的等待队列上
poll轮询检查每个文件描述符的等待队列,有事件就绪后,就绪事件存在revents中。
遍历fd,将就绪事件派发给对应回调。
连接事件就绪后,调用accept,把服务套接字添加至stArr。
读事件就绪,调用read,如果没有读到数据(对端关闭或发生错误),从stArr中移除该fd。
cpp
void Start()
{
_event_fds[0].fd = _listensock.getSocketFd();
_event_fds[0].events = POLLIN;
int timeout = 3000; // 3s
while(true)
{
int n = poll(_event_fds, fd_num_max, timeout);
switch (n)
{
case 0:
cout << "time out... " << endl;
break;
case -1:
cerr << "poll error" << endl;
break;
default:
cout << "get a new link!!!!!" << endl;
Dispatcher();
break;
}
}
}
void Dispatcher()
{
for (int i = 0; i < fd_num_max; i++) // loop
{
int fd = _event_fds[i].fd;
if (fd == defaultFd)
continue;
if (_event_fds[i].revents & POLLIN)
{
if (fd == _listensock.getSocketFd())
Accepter(); // 连接管理器
else
Recver(fd, i); // non listenfd
}
}
}
epoll
cpp
void Start()
{
// 将listensock添加到epoll中 -> listensock和他关心的事件 添加到内核epoll模型中rb_tree
_epollerPtr->EpllerUpdate(EPOLL_CTL_ADD, _listenSocketPtr->getSocketFd(), ET_EVENT_IN);
struct epoll_event revs[num];
while(true)
{
int n = _epollerPtr->EpollerWait(revs, num);
if (n > 0) // 有事件就绪
{
lg(Debug, "event happened, first event fd is : %d", revs[0].data.fd);
Dispatcher(revs, n);
}
else if (n == 0)
lg(Info, "time out ...");
else
lg(Error, "epll wait error");
}
}
void Dispatcher(struct epoll_event revs[], int num)
{
for (int i = 0; i < num; i++)
{
uint32_t events = revs[i].events;
int fd = revs[i].data.fd;
if (events & ET_EVENT_IN) // 客户联连接读取事件就绪
{
if (fd == _listenSocketPtr->getSocketFd())
Accepter();
else // 普通读取事件就绪
Recver(fd);
}
// else if (events & EVENT_OUT){}
// else{}
}
}
void Accepter() // 获取了一个新连接
{
std::string clientip;
uint16_t clientport;
int sock = _listenSocketPtr->Accept(&clientip, &clientport);
if (sock > 0)
{
// 不能直接读取 原因select/poll已讲
_epollerPtr->EpllerUpdate(EPOLL_CTL_ADD, sock, ET_EVENT_IN);
lg(Info, "get a new link, client info@ %s:%d", clientip.c_str(), clientport);
}
}
void Recver(int fd)
{
char buffer[1024];
// 1.不一定是完整的报文
// 2.读到部分报文 下次的Recver()函数中buffer是局部变量
// A报文如果发两次过来 也无法拼接成完整报文
ssize_t n = read(fd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
std::cout << "get a messge: " << buffer << std::endl;
std::string echo_str = "server echo $ ";
echo_str += buffer;
write(fd, echo_str.c_str(), echo_str.size());
}
else if (n == 0)
{
lg(Info, "client quit, me too, close fd is : %d", fd);
_epollerPtr->EpllerUpdate(EPOLL_CTL_DEL, fd, 0);
close(fd);
}
else
{
lg(Warning, "recv error: fd is : %d", fd);
_epollerPtr->EpllerUpdate(EPOLL_CTL_DEL, fd, 0);
close(fd);
}
}
Reactor
之前编写的 epoll - echo服务器的代码中,在其他普通的 fd 读取事件就绪时,也就是在 Recver() 中,读取是有问题的,因为我们不能区分每次读取上来的数据是一个完整的报文。另外还有其它各种问题,所以我们要对上面的代码使用 Reactor 的设计模式作修改。
Reactor 是一种设计模式,称为反应堆模式。用于处理事件驱动的系统中的并发操作。提供了一种结构化的方式来处理输入事件,并将其分发给相应的处理程序。Reactor 模式通常用于网络编程中,特别是在服务器端应用程序中。
要进行正确的 IO 处理,就应该有如下的理解:在应用层一定存在大量的连接,每一个连接在应用层都叫做文件描述符。而在读取每一个文件描述符上的数据的时候,可能根本就没有读取完,此时我们就需要把该文件描述符上的数据临时保存起来。所以我们在写服务器的时候,我们要保证每一个文件描述符及其连接及其缓冲区,都是独立的!
Reactor 其实是一个半同步半异步模型,IO 等于等待+数据拷贝,Reactor 的半同步半异步体现在,等待是由 epoll 完成,体现同步;异步体现在 Reactor 可以进行回调处理。
在 Reactor 模式中,有一个事件循环(Event Loop)负责监听和分发事件。当有新的事件到达时,事件循环会将其分发给相应的处理程序进行处理。这种方式可以实现高效的并发处理,避免了线程创建和销毁的开销。
对读写事件的关心区别
epoll/select/poll, 因为写事件(发送缓冲区是否有空间,经常是OK的), 经常就是就绪的
如果我们设置对EPOLLOUT关心,EPOLLOUT几乎每次都有就绪
导致epollserver经常返回,浪费CPU的资源
结论:对于读,设置常关心。对于写,按需设置 什么是按需设置?
怎么处理写呢?直接写入,如果写入完成,就结束,如果写入完成,但是数据没有写完,outbufer里还有内容,我们就需要设置对写事件进行关心了。如果写完了,去掉对写事件的关心!