【Linux网络】多路转接之select
多路I/O转接服务器是一种高效管理多个I/O操作的技术,核心目的是允许单线程或单进程同时监控和处理多个I/O事件。
另外为什么需要I/O多路转接呢?
传统阻塞I/O模型中,处理多个客户端连接的常见方式是:为每个客户端创建一个独立线程,线程阻塞在read()或write()操作上,等待数据到达。这样会大幅度增加资源消耗,另外我们如果每个套接字设置为非阻塞,然后采用单线程轮询所有连接会造成CPU的浪费,如果采用多线程,依旧属于治标不治本。
这时候就体现出I/O多路转接的核心价值了:用单线程高效管理多连接。不仅能大幅度减少资源消耗还能高效处理"连接多,活跃少"的场景。还简化了代码,不用考虑多线程锁的竞争,死锁等问题。
select
多路转接核心作用:对多个文件描述符进行等待,并通知上层哪些fd已经就绪,本质是一种对IO事件就绪的通知机制
#include <sys/select.h>int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
nfds:监控的文件描述符集里最大文件描述符加1,因此参数会告诉内核前多少个文件描述符的状态。
struct timeval {time_t tv_sec; /* seconds */suseconds_t tv_usec; /* microseconds */};
struct timeval *timeout:1.如果设置为NULL表示阻塞等待 ,等待多个fd,至少有一个fd就绪,select就会返回 2.如果timeout设置为{0,0}表示非阻塞等待 ,有多个fd,没有一个就绪,也立即返回。如果有就绪,也是立即返回。3.如果timeout设置为{5,0}表示5s以内阻塞,超时后,立即返回。
另外timeout为输入输出型 ,什么是输出,当select返回的时候,表示还剩余多少事件(例子:如果输入timeout为{5,0}超时返回输出为{0,0}如果在2秒内接受到一个fd那么输出为{3,0})
timeout: 返回值有3种 1. n > 0 : n就绪了多少个fd 2. n(-1) < 0: select等待失败了 3.n == 0:底层fd没有就绪,也没有出错 与 timeout配合使用
fd_set *readfds 读文件描述符集,关心读事件->fd是否可读->接受缓冲区是否有数据!
fd_set *writefds 写文件描述符集,关心写事件->fd是否可写->发送缓冲区是否有空间
fd_set *exceptfds 异常文件描述符集,关心异常事件->fd是否出现异常->fd错误的fd
fd_set:本质是一个位图,用位图中对应的位来表示要监视的文件描述符。
fd_set是OS给用户提供的一种具体的数据类型 (固定大小)也就是说fd_set能够包含的fd的个数是有上限的(可以添加多个文件描述符0,1,2,3,4.....)
/* fd_set for select and pselect. */
typedef struct{/* XPG4.2 requires this member name. Otherwise avoid the namefrom the global namespace. */
#ifdef __USE_XOPEN__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->fds_bits)
#else__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
#endif} fd_set;
int main()
{fd_set fds;std::cout << "fds: " << sizeof(fds) * 8 << std::endl; // sizeof(fds)表示字节数,*8表示位数
}
在我的机器上fd的个数为1024。
再举个readfds的具体例子:
1.fd_set在输入的时候,用户告诉内核:你要帮我关心readfds位图中被设置了的fd上的读事件比如0101 0010 表示关心1,4,6(左向右从)文件描述符的读事件。
2.输出的时候:内核告诉用户,你让我关心的readfds中,有哪些fd已经就绪了 0000 0010 表示1号位置的读事件已经就绪了,而4,6位置的没有就绪,而就绪的文件描述符就进行读取,读取一次的时候,一定不会被阻塞因为对应的fd的读事件已经就绪了。
此外每次调用select,都要对输入设置参数进行重新设置!为什么???因为我每次输出从内核告诉用户的时候已经修改了readfds中的位图,如上面的例子1号位置已经就绪了,难道就不用管4,6的位图了吗?也就是说我们要对历史上所有的fd进行服务器保存起来,方便我们多次添加到fd_set中,如果还是没有理解可以看看下面的代码:
另外对fd_set位图操作 ,系统提供了对应的封装
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);
代码
这里先复习一下对应的系统调用中的函数: 就是对系统调用相关的函数以及数据结构进行解析了解。想了解select代码可以跳过这一部分
int socket(int domain, int type, int protocol);
domain:domain参数指定通信域;
这是选择用于通信的协议族。这些族在<sys/socket.h>中定义。控件当前可以理解的格式Linux内核包括:
type:
套接字具有指定的类型,它指定通信语义。目前定义的类型有:
当
domain
和type
组合存在多种协议实现时,protocol
用于精确指定使用哪种协议,不过protocol
默认写0return 返回值:
成功返回一个新的文件描述符,失败返回-1。
int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
这个sockfd就是我们所创建socket的返回值。
操作系统在内部定义了一套自己的数据结构就是struct sockaddr,先看看下面的图:
socket的种类会多一点1.网络socket 本地+网络 2.本地socket(unix域间socket) 3.原始socket
这里我侧重说一下1,2
struct sockaddr_in表示网络通信in->inet,struct sockaddr_un表示本地通信un->unix。
这三者struct sockaddr, struct sockaddr_in,struct sockaddr_un有什么区别,其实可以理解C++中的基类与派生类之间的关系,struct sockaddr为基类其余那两个都是派生类。
struct sockaddr为了区分是本地通信还是网络通信在struct sockaddr前两个比特位设置了16位地址类型标识是本地还是网络通信,其实这前两个比特位就是宏定义。
写一个伪代码判断一下是否是网络通信还是本地通信
if(addr -> add_type == AF_INET) net else unix
socklen_t len 其实就是绑定 struct sockaddr *addr字节数长度。
return 返回值:
成功返回0,失败返回-1,并设置全局变量errno以指示错误类型。
这里写一下绑定的过程吧
先了解一下sockaddr_in中的数据结构
/* Structure describing an Internet socket address. */ struct sockaddr_in{__SOCKADDR_COMMON (sin_); //sin_familyin_port_t sin_port; /* Port number. */struct in_addr sin_addr; /* Internet address. */// 下面的是填充字段/* Pad to size of `struct sockaddr'. */unsigned char sin_zero[sizeof (struct sockaddr)- __SOCKADDR_COMMON_SIZE- sizeof (in_port_t)- sizeof (struct in_addr)];};
写一个简单的TCP服务端,bind过程
int sockfd = socket(AF_INET, SOCK_STREAM, 0);// 创建一个基于 IPv4 地址族(AF_INET)的 TCP 套接字(SOCK_STREAM)// 第三个参数为 0 表示使用默认协议(TCP)struct sockaddr_in local;bzero(&local, sizeof(local));// 注意:这里只是填充了结构体local.sin_family = AF_INET;// 设置地址族为 IPv4,必须与 socket() 函数的第一个参数一致// 这告诉系统如何解析后续的地址信息local.sin_port = htons(8080); // 要发送到网络中 主机 -> 网络local.sin_addr.s_addr = INADDR_ANY; // 也可以这么写::inet_addr("127.0.0.1")// 本质就是string ip->4bytes 2.转为网络序列network order // bind:设置进入内核中int n = ::bind(sockfd, (struct sockaddr*)&local, sizeof(local));
下面聊一下大小端:
因为主机到网络中需要转成大端字节序列:
比如我的机器上就是小端存储:
小端存储模式:低位字节存于低地址,高位字节存于高地址。在
0x11223344
中,0x44
是低位字节,存储在低地址;0x11
是高位字节,存储在高地址。若从内存地址0x1000
开始存储,存储顺序为:| 内存地址 | 存储内容 | |0x1000|0x44| |0x1001|0x33| |0x1002|0x22| |0x1003|0x11|
大端存储模式:高位字节存于低地址,低位字节存于高地址。对于
0x11223344
,0x11
作为高位字节,存于低地址;0x44
作为低位字节,存于高地址。假设从内存地址0x1000
开始存储,存储顺序为:| 内存地址 | 存储内容 | |0x1000|0x11| |0x1001|0x22| |0x1002|0x33| |0x1003|0x44|
tcp需要将socket设置成为监听状态
int listen(int sockfd, int backlog);
backlog
:指定全连接队列的最大长度。
当队列已满时,新的连接请求(已完成三次握手)会被拒绝(客户端收到 RST 包)。
int accept(int sockfd, struct sockaddr *_Nullable restrict addr,socklen_t *_Nullable restrict addrlen);
如果没有人连接,会阻塞(此外fcntl可以将一个fd设置为非阻塞,有兴趣可以了解一下)
其中这里比较重要的就是sockfd,和这个返回值返回的也是sockfd,有什么区别
总而言之就是监听套接字是,仅用于接受连接请求,可通过listen持续监听多个客户端,生命周期:从socket创建到close关闭。
连接套接字是主动套接字,每个客户端对应一个实例,负责与特定客户端进行数据交互(read/write/recv/send)生命周期从accept返回到close关闭释放资源。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
这里connect是客户端要用的系统调用,创建sockfd 设置 sockaddr_in这个结构体中的字段然后connect进行连接
recvfrom & sendto 是UDP ,recv & send 是TCP相关的函数。这就不做过多赘述
#pragma once#include <iostream>
#include <string>
#include <memory>#define NUM 1024
#define gdefaultfd -1using namespace LogMudule;
using namespace SocketMudule;
class SelectServer
{
public:SelectServer(int port): _port(port), _listen_socket(std::make_unique<TcpSocket>()), _is_running(false){}~SelectServer(){}void Init(){_listen_socket->BuildTcpSocketMethod(_port);for (int i = 0; i < NUM; i++){_fd_array[i] = gdefaultfd;}_fd_array[0] = _listen_socket->fd();}void Start(){// 读文件描述符集fd_set rfds; _is_running = true;while (_is_running){// 清空rfdsFD_ZERO(&rfds);struct timeval timeout = {10, 0};int maxfd = gdefaultfd;for (int i = 0; i < NUM; i++){if (_fd_array[i] == gdefaultfd){continue;}// 将合法的fd加入rfdsFD_SET(_fd_array[i], &rfds);// 更新maxfdif (_fd_array[i] > maxfd){maxfd = _fd_array[i];}}// 我们不能让accept来阻塞检测新连接到来,而应该让select来负责进行就绪事件的检测int n = select(maxfd + 1, &rfds, nullptr, nullptr, &timeout);switch (n){case 0:std::cout << "select timeout" << std::endl;break;case -1:perror("select error");break;default:// 有事件就绪// rfds: 内核告诉用户,你关心的rfds中的fd,有哪些已经就行了std::cout << "有事件就绪...., timeout " << timeout.tv_sec << std::endl;HandlerEvent(rfds);break;}}_is_running = false;}void HandlerEvent(fd_set &rfds){for (int i = 0; i < NUM; i++){if (_fd_array[i] == gdefaultfd)continue;// 如果是listen_socketif (_fd_array[i] == _listen_socket->fd()){// 判断listensockfd是否在rfdsif (FD_ISSET(_listen_socket->fd(), &rfds)){InetAddr client;// listen_socket有新连接到来int newfd = _listen_socket->Accepter(&client); // 这里不会被阻塞,因为我们在select中注册了listen_socketif (newfd < 0){std::cout << "accept error" << std::endl;return;}else{std::cout << "Get new client " << newfd << " connected" << std::endl;// 这里能直接recv? 读事件是否就绪,我们并不清楚,所以要进行托管。让select帮我关心新的sockfd上面的读事件就绪// 如果不能直接recv,那么就需要自己维护一个读缓冲区,然后在select中注册读缓冲区,然后在读事件就绪的时候,把读到的内容放到读缓冲区中// 然后在HandlerEvent中,从读缓冲区中取出数据,然后处理// 如何把newfd托管给select来管理?把newfd加入到_fd_array中int pos = -1;for (int j = 0; j < NUM; j++){if (_fd_array[j] == gdefaultfd){pos = j;break;}}if (pos == -1){LOG(LogLevel::ERROR) << "服务器满载....";close(newfd);}else{_fd_array[pos] = newfd;}}}}else{if (FD_ISSET(_fd_array[i], &rfds)){// 合法的,就绪的,普通的fdchar buffer[1024];// 这里的recv,对不对呢???不完善,要把这个写对必须有协议ssize_t n = recv(_fd_array[i], buffer, sizeof(buffer) - 1, 0); // select告诉我已经就绪了if (n > 0){buffer[n] = '\0';std::cout << "client " << buffer << std::endl;// 把读到的信息,在回显回去std::string message = "echo# " + std::string(buffer);send(_fd_array[i], message.c_str(), message.size(), 0); // bug}else if (n == 0){LOG(LogLevel::DEBUG) << "客户端退出,sockfd: " << _fd_array[i];close(_fd_array[i]);_fd_array[i] = gdefaultfd;}else{LOG(LogLevel::DEBUG) << "客户端读取退出,sockfd: " << _fd_array[i];close(_fd_array[i]);_fd_array[i] = gdefaultfd;}}}}}private:uint16_t _port;std::unique_ptr<Socket> _listen_socket;bool _is_running;int _fd_array[NUM];
};
select的特点:
可监控的文件描述符个数取决于 sizeof(fd_set)的值
fd 加入 select 监控集的同时,还要再使用一个数据结构 array 保存放到 select监控集中的 fd
注:fd_set 的大小可以调整,可能涉及到重新编译内核。
select缺点
每次调用 select, 都需要手动设置 fd 集合(readfds输入输出,fd_set每次都需要修改)
每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大(位图修改)
同时每次调用 select 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大。
select 支持的文件描述符数量太小(虽然进程打开fd是有上限的,但是不管select中fd有上限的理由)