当前位置: 首页 > news >正文

【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:

套接字具有指定的类型,它指定通信语义。目前定义的类型有:

 当domaintype组合存在多种协议实现时,protocol用于精确指定使用哪种协议,不过protocol默认写0

return 返回值:

成功返回一个新的文件描述符,失败返回-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有上限的理由)

http://www.xdnf.cn/news/1040797.html

相关文章:

  • windows 开发
  • JavaScript性能优化实战指南:从理论到案例的全面解析
  • 【医疗电子技术-7.2】血糖监测技术
  • 高效同步Linux服务器文件技巧
  • Spring Bean 生命周期:注册、初始化、注入及后置操作执行顺序
  • 湖北理元理律师事务所债务规划方法论:法律框架下的可持续还款体系
  • Java反射机制深度解析
  • 微信小程序实现文字逐行动画效果渲染显示
  • 《Origin画百图》之核密度图
  • JAVA中关于Animal和Dog类的类型转换,可能出现ClassCastException的情况
  • AndroidMJ-mvp与mvvm
  • 贪心算法经典问题
  • 思科交换机远程登录配置
  • XCTF-misc-Test-flag-please-ignore
  • Trino权威指南
  • DP刷题练习(一)
  • Java内存模型与垃圾回收:提升程序性能与稳定性!
  • 戴维南端接与 RC端接
  • 源码开发详解:搭建类似抖音小店的直播带货APP需要掌握哪些技术?
  • Codeforces Round 1030 (Div. 2)
  • OpenVINO使用教程--resnet分类模型部署
  • QCombobox设置圆角下拉列表并调整下拉列表位置
  • EffRes-DrowsyNet:结合 EfficientNetB0 与 ResNet50 的新型混合深度学习模型用于驾驶员疲劳检测算法实现
  • 网络安全防护:Session攻击
  • Java大模型开发入门 (12/15):Agent实战 - 打造能调用外部API的智能助手
  • 更新! Windows 10 32位 专业版 [版本号19045.5912]
  • 2025-06-14[避坑]解决不支持中文路径读取图像的方法
  • 2025.06.11-华子第三题-300分
  • Python 继承的优缺点(处理多重继承)
  • 25年股票交易半年小结~~