多路复用

1|0多路复用

  1. 概念
    • 事先把需要监听的文件描述符加入到集合中,然后在规定的时间内或者无限时间进行等待,如果在规定时间内,集合内的文件描述符没有数据变化,则超时接受,就会进入下一个规定时间等待,一旦集合中的文件描述符有数据变化,则其它没有发生数据变化的文件描述符会被踢出到集合外
      阻塞IO:只能同时监听一个套接字
      非阻塞IO:一直询问,问IO口有没有数据到达。非常浪费cpu资源
      信号驱动:主要用于UDP通信

所谓多路复用,指的是通过某个特定的接口(select()、poll()等),来同时监听多路套接字,就达到不需要多进程与多线程,又可以处理多个阻塞套接字的目的。
对于套接字sockfd,其就绪状态有三种:
读就绪,写就绪,异常就绪

  1. 特点

    • 不再由应用程序自己去监听客户端连接。取而代之的是由内核去代替应用程序监视文件,并且可以同时对多个IO口进行监听。由多路复用实现的TCP服务器就叫多路IO转接服务器,也叫做多任务IO服务器
  2. 函数接口

#include <sys/select.h> // 函数作用:对集合中的文件描述符进行监听 int select( int nfds, // 参数1:nfds参数指定要测试的描述符的范围。在每个集合中检查第一个nfds描述符;那即,检查描述符集中从0到nfds-1的描述符。 fd_set *restrict readfds, // 参数2:如果readfds参数不是空指针,它指向fd_set类型的对象,该对象在输入时指定文件描述符要检查是否准备好读取,并在输出时指示哪些文件描述符准备好读取。 fd_set *restrict writefds, // 参数3:如果writefds参数不是空指针,它指向fd_set类型的对象,该对象在输入时指定文件描述符要检查是否准备写入,并在输出时指示哪些文件描述符准备写入。 fd_set *restrict errorfds, // 参数4:如果errorfds参数不是空指针,它指向fd_set类型的对象,该对象在输入时指定文件描述符要检查挂起的错误条件,并在输出时指示哪些文件描述符有挂起的错误条件。 struct timeval *restrict timeout // 参数5:设置等待时间,3种情况 // 1.设置为NULL,永远等待下去,这个函数就阻塞,知道由文件描述符状态发生变化 // 2.设置timeval,等待固定时间 // 3.设置timeval里的时间均为0,检测文件描述符会立即返回,轮询-->非阻塞 ); // 返回值:成功返回状态发生变化的文件描述符的总数 struct timeval { long tv_sec; long tv_usec } // 此宏将文件从set中删除描述符fd。删除一个不存在于set中的集合时无操作的,不会产生错误。 void FD_CLR(int fd , fd_set * set ); // select()根据以下描述的规则对sets的内容进行修改。在调用select()之后,可以使用 FD_ISSET() 宏来测试文件描述符是否仍然存在于集合中。 如果文件描述符 fd 存在于集合中,则 FD_ISSET() 返回非零值,否则返回零。 int FD_ISSET(int fd , fd_set * set ); // 此宏将文件描述符fd添加到set中。添加一个已经存在于set中的集合时无操作的,不会产生错误。 void FD_SET(int fd , fd_set * set ); // 此宏用于清除(从中删除所有文件描述符)set。它应该被调用作为初始化文件描述符set的第一步。 void FD_ZERO(fd_set * set );

注意:

  1. select能监听的文件描述符个数受限于FD_SETSIZE,一般是1024,单纯改变进程打开的文件描述符个数,不能改变select监听文件个数
  2. 解决1024以下的客户端时用select是合适的,但如果连接的客户端过多,select采用大的事轮询,会大大降低服务器响应效率
  3. 可以进行跨平台

2|0信号驱动

  1. 信号驱动原理
    • 所谓信号驱动,即用信号来驱使服务器妥善处理多个远端套接字,信号方式的思路比较简单;每当远端有数据到达,那么就在本端触发信号SIGIO,然后利用信号的异步特性来处理远端消息。
void my_fun(int sig) { // 读取数据 } signal(SIGIO, my_fun);

套接字sockfd------>只要是远端有数据到来,这个信号就会自动产生,捕捉信号就去执行函数读取数据出来

  1. 适用场景

    • 由于不管套接字接受何种数据,内核一律触发SIGIO,因此这种看似理想的方式,却不适合TCP,因为在TCP中,当客户端发来连接请求,普通数据,数据回执等情况就会触发信号,这就使得服务器端仅凭此信号无法知道下一步要做什么,因此信号驱动模型的服务器模型,一般只适合用于UDP协议
  2. 信号驱动的实现步骤

    1. 设置SIGIO的响应函数,信号SIGIO默认会杀死进程,因此必须要设其响应函数,当进程收到信号的时候,说明有数据到达,则在响应函数中接收数据即可
    2. 设置SIGIO的属主进程,信号SIGIO由内核针对套接字产生,而内核套接字可以在多个应用程序中有效(例如父子进程将套接字遗传给子进程),因此必定指定该信号属主。
    3. 给套接字设置信号触发模式,也就是让套接字工作在信号模式下。因此在默认情况下,套接字收到数据就不会触发SIGIO,必须将套接字文件描述符设定为异步工作模式,它才会触发该信号
    4. 设置套接字属主:fcntl()
#include <unistd.h> #include <fcntl.h> F_SETOWN(int) int fcntl(sock_fd, F_SETOWN, getpid());--------------->sock_fd与自己的进程绑定在一起
5. 添加信号触发模式属性
int status = fcntl(sock_fd, F_GETFL); status |= O_ASYNC; fcntl(sock_fd, F_SETFL, status);

案例:

#include <stdio.h> #include <strings.h> #include <sys/socket.h> #include <fcntl.h> #include <arpa/inet.h> #include <unistd.h> #include <signal.h> #define SERVER_PORT 8080 int sock_fd; struct sockaddr_in otherAddr; int len = sizeof(otherAddr); char buf[1024] = ""; void my_func(int sig) { bzero(buf, sizeof(buf)); bzero(&otherAddr, sizeof(otherAddr)); recvfrom(sock_fd, buf, sizeof(buf), 0, (struct sockaddr*)&otherAddr, &len); printf("recv: %s\n", buf); } int main(int argc, char const *argv[]) { sock_fd = socket(PF_INET, SOCK_DGRAM, 0); if(-1 == sock_fd) { perror("socket"); return -1; }ss struct sockaddr_in ownAddr; bzero(&ownAddr, sizeof(ownAddr)); ownAddr.sin_family = AF_INET; ownAddr.sin_port = htons(SERVER_PORT); ownAddr.sin_addr.s_addr = htonl(INADDR_ANY); bind(sock_fd, (struct sockaddr*)&ownAddr, sizeof(ownAddr)); signal(SIGIO, my_func); fcntl(sock_fd, F_SETOWN, getpid()); int status = fcntl(sock_fd, F_GETFL); status |= O_ASYNC; fcntl(sock_fd, F_SETFL, status); while(1) pause(); return 0; }

3|0多路复用epoll

  1. 概念

    • epoll是linux下多路复用IO接口,select/poll增强版本,它能显著提高程序在带量并发连接中只有少量活跃的情况下的cpu利用率,因为它会复用文件描述符集合来传递结果而不用迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合,另一个原因就是获取事件的时候,它无需遍历轮询整个被监听的文件描述符集合,只要遍历那些被内核IO事件异步唤醒而加入到ready队列的文件描述符集合即可。
      目前epoll是linux大规模并发网络程序中的热门模型。epoll除了提供select/poll那种IO事件的电平触发外,还提供了边沿触发,这就使得用户空间有可能缓存IO的状态,减少epoll_wait, epoll_pwait
  2. epoll函数介绍

// 函数作用:创建一个epoll句柄,参数size用来告诉内核监听的文件描述符的个数,跟内存有关 #include <sys/epoll.h> int epoll_create(int size); // 参数:创建的红黑树的监听结点的数量 // 返回值:成功返回指向新创建的红黑树的根结点的fd,失败-1

句柄:这种标识叫做句柄(handle), 是数一数二的翻译非常烂的名词. 但看其英文handle, 可以理解, 这就是一种资源的"把柄", 你只需要拥有这个"把柄", 就可以任意支配, 使用这个资源.
所以函数句柄, 可以认为就是函数指针, 它指示了这个了函数在内存中的位置, 通过这个句柄, 便可以轻松调用该函数.

#include <sys/epoll.h> // 函数作用:这个系统调用用于添加、修改或删除文件引用的实例的兴趣列表中的条目 int epoll_ctl( int epfd, // 参数1:红黑树根结点fd int op, // 参数2:表示动作,用三个宏表示 int fd, // 参数3:待监听的文件描述符 struct epoll_event *event // 参数4:告诉内核需要监听的事件,传入struct epoll_event结构体的地址 );

image
image

EPOLL_CTL_ADD
向epoll文件描述符epfd的兴趣列表添加一个条目。该条目包括文件描述符fd,一个引用‐
EPOLL_CTL_MOD
将兴趣列表中与fd相关的设置更改为event中指定的新设置。
EPOLL_CTL_DEL
从兴趣列表中删除(注销)目标文件描述符fd。event参数被忽略,可以为NULL

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 */ };
  • EPOLLIN:表示对应的文件描述符可以读
  • EPOLLOUT:表示对应的文件描述符可以写
  • EPOLLRDHUP:表示对应的文件描述符读挂断
  • EPOLLPRI:表示对应的文件描述符有紧急的数据要读(带外数据)
  • EPOLLERR:表示对应的文件描述符发生错误
  • EPOLLHUP:表示对应的文件描述符被挂断
  • EPOLLET:将EPOLL设为边缘触发模式
  • EPOLLONESHOT:只监听一次事件,当监听完一次事件,如果还需要监听,要重新把socket加入到epoll队列
    EPOLLRDHUP是从Linux内核2.6.17开始由GNU引入的事件。
    当socket接收到对方关闭连接时的请求之后触发,有可能是TCP连接被对方关闭,也有可能是对方关闭了写操作。
    如果不使用EPOLLRDHUP事件,我们也可以单纯的使用EPOLLIN事件然后根据recv函数的返回值来判断socket上收到的是有效数据还是对方关闭连接的请求。
#include <sys/epoll.h> // 函数作用:等待所监控文件描述符上有事件的产生。类似于select()的调用 int epoll_wait( int epfd, // 参数1:红黑树根结点fd struct epoll_event *events, // 参数2:用来存储内核得到的事件集合,实际上就是结构体数组的地址 int maxevents, // 参数3:告知内核这个events有多大,这个值不能超过epoll_create()时的size int timeout // 参数4:超时时间 -1:阻塞 0:立即返回(非阻塞) >0:指定毫秒超时 ); // 返回值:成功返回有多少个文件描述符 出错返回-1
  1. epoll的用法
    如下的代码中,先用epoll_create 创建一个 epol l对象 epfd,再通过 epoll_ctl 将需要监视的 socket 添加到epfd中,最后调用 epoll_wait 阻塞等待数据。
int s = socket(AF_INET, SOCK_STREAM, 0); bind(s, ...); listen(s, ...) int epfd = epoll_create(...); epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中 while(1) { int n = epoll_wait(...); for(接收到数据的socket){ //处理 } }
  1. 原理
    epoll 通过两个方面,很好解决了 select/poll 的问题。

第一点,epoll 在内核里
使用红黑树来跟踪进程所有待检测的文件描述符,红黑树是一种高效率的二叉查找树,在内核中使用红黑树来维护待检测的fd集,增删改查的效率都很高。把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
1.
第二点, epoll 使用
事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
从下图你可以看到 epoll 相关的接口作用:
image
epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。

  1. 触发模式——水平触发和边缘触发
    epoll 支持两种事件触发模式,分别是水平触发和边缘触发。select/poll 只有水平触发模式。
  • 水平触发模式

  • 边缘触发模式
    一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数。
    如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误。


__EOF__

本文作者若达萨罗
本文链接https://www.cnblogs.com/bcc0729/p/17693438.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!
posted @   若达萨罗  阅读(71)  评论(0编辑  收藏  举报
编辑推荐:
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
· AI 智能体引爆开源社区「GitHub 热点速览」
点击右上角即可分享
微信分享提示