IO多路转接

IO多路转接

从系统角度看待数据接收

网卡接收数据

网卡接收数据分为如下几步:

  1. 网卡收到网线传过来的数据

  2. 网卡会把接收到的数据写入内存

  3. 网卡通知操作系统读取网络数据

网卡如何通知操作系统读取网络数据

网卡通知系统读取网络数据是通过中断的方式完成的,当网卡收到数据将其写入内存之后,网卡会给CPU发送一个中断信号,由硬件产生的中断信号优先级较高,CPU应该优先进行处理,CPU会执行中断程序,将网络数据拷贝到接收缓冲区

recv阻塞读取,底层发生了什么?

下面是一段经典的TCP服务端代码:

cpp 复制代码
int listenSock = socket(AF_INET, SOCK_STREAM, 0);
bind(listenSock,...);
listen(listenSock,...);
int fd=accept(listenSock,...);
recv(fd,...);

当程序运行到socket时,Linux文件系统在底层会创建一个文件对象struct file,通过文件描述符可以找到该文件对象,通过bind和listen可以让文件对象与网卡建立关联,程序运行到accept会从TCP的全连接队列获取连接,通过recv读取对端发送过来的数据。

  1. 每一个连接在底层都是一个socket对象,本质是一个网络文件,用户通过文件描述符可以访问到socket对象
  2. 每一个socket对象都有发送缓冲区和接收缓冲区,除此之外,每一个socket对象还有一个等待列表,等待列表是所有需要等待该socket事件就绪的进程集合,一般而言,一个socket对象的等待列表中只有一个进程,但是一个进程可以同时处于多个socket对象的等待列表,也就是IO多路复用
  3. 网卡将接收到的数据写入内存之后,会向CPU发送中断信号,CPU接收到中断信号之后,执行中断处理程序,中断处理程序主要完成2件工作:将网络数据拷贝到socket对象的接收缓冲区、将阻塞的进程从socket对象的等待列表中移除,并添加到CPU运行队列,此时进程执行recv就可以直接从socket对象的接收缓冲区读取数据
  4. 进程阻塞就是进程在socket对象的等待列表中等待数据就绪,进程阻塞不占用CPU资源

操作系统如何知道网络数据对应哪一个socket对象?

一个服务端可能有多个客户端与之连接,底层就会存在多个socket对象,网卡接收到数据之后,操作系统是如何得知将数据拷贝到哪一个socket对象的接收缓冲区呢?

实际上,在accpet获取新连接的时候,操作系统知道发起连接的客户端对应的IP和端口

c 复制代码
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

操作系统在底层维护了socket对象与客户端的映射(即文件描述符fd与客户端的映射),网卡接收到的数据中,存在源IP地址和源端口号,根据客户端IP和端口就可以索引到fd,找到socket对象,从而将数据写入指定socket对象的缓冲区

如何同时监视多个文件描述符?

从原理上来讲,同时监视多个文件描述符就是将一个进程添加到多个socket对象的等待队列中,只要这多个socket对象中有1个或者1个以上有数据就绪,就可以将进程从所有socket对象的等待队列中移除,将其添加到CPU的运行队列。这就是select和poll的基本原理

select

c 复制代码
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);

select原理

调用select函数的进程,会被添加到多个socket对象的等待队列中,只要网卡有数据到来,CPU就会执行中断处理程序,select的中断回调与recv的中断回调不同,select的中断回调完成的功能:

  1. 将网络数据写入对应socket对象的接收缓冲区
  2. 将进程从所有socket对象的等待队列中移除

select特点

  • select可以监视的文件描述符有上限,一般为1024,即sizeof(fd_set)*8
  • select每一次在调用时都要将fd_set结构从用户拷贝到内核,即用户告诉内核,你需要帮我检测哪些文件描述符上的哪些事件,select返回时需要将数据由内核拷贝到用户,即内核告知用户,哪些文件描述符上的哪些事件已经就绪。这2次拷贝存在较大的开销,出于效率的考量,才规定了select最大监视的文件描述符数量
  • fd_set结构不可重用,每一次内核返回时都会对fd_set进行修改,下一次调用select需要重新设置fd_set
  • select返回时,用户只知道有几个文件描述符就绪,但是不知道有哪些文件描述符就绪,因此用户只能一个一个的进行遍历,效率较低。需要注意的是,select返回时,内核是知道哪些文件描述符就绪的,但是由于select函数在设计上没有告知用户有哪些文件描述符就绪,只是告诉了用户有几个文件描述符就绪,因此用户需要进行遍历操作
  • select可以同时监视多个文件描述符,相比于recv等接口,在一定程度上提高了效率

poll

c 复制代码
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
    int   fd;         /* file descriptor */
    short events;     /* requested events */
    short revents;    /* returned events */
};

poll原理

poll的原理和select类似,也是将一个进程添加到多个socket对象的等待队列,在网卡接收到数据后,CPU执行中断程序将数据写入到接收缓冲区,然后将进程从所有等待队列中移除

poll的特点

  • 监视的文件描述符理论上没有上限,由用户提供的fds数组决定
  • poll把监听事件与就绪事件进行分离,在接口上使用起来更加方便
  • fds是可重用的,在每一次重用fds之前,只需要对revents进行清空即可
  • poll在调用时也需要将fds从用户拷贝到内核,在返回时从内核拷贝到用户,存在一定开销
  • poll可以同时监视多个文件描述符,一定程度上提高了效率

epoll

c 复制代码
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
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

epoll_create接口用于创建一个eventpoll结构体,eventpoll结构体的主要成员:

c 复制代码
struct eventpoll{
    //...
    struct mutex mtx;
    spinlock_t lock;//mtx和lock保证访问eventpoll是线程安全的
    wait_queue_head_t wq;//wait queue,等待队列
    struct list_head rdllist;//ready list,就绪列表,双向链表
    struct rd_root_cached rbr;//red balck tree root,红黑树根节点
};

epoll_ctl

epoll_ctl可以向eventpoll中的红黑树添加或删除节点,如果调用epoll_ctl添加文件描述符,那么内核会封装一个epitem结构体,将epitem中的rbn添加到eventpoll的红黑树中,同时将eventpoll添加到socket对象的等待队列

c 复制代码
struct epitem{
    struct rb_node rbn;//red black tree node,将该结点添加到eventpoll中的红黑树中
    struct epoll_filefd ffd;//文件描述符
    struct list_head rdlink;//当文件描述符上的事件就绪时,会将rdlink插入到eventpoll的就绪列表中
    struct eventpoll* ep;//指向eventpoll
    struct epoll_event event;//监视的事件
};

在epoll中,并不是进程直接在socket对象的等待队列中等待数据就绪,而是让eventpoll在多个socket对象的等待队列中进行等待 ,当socket对象上有数据时,触发回调,让eventpoll的rdllist引用已经就绪的socket对象

epoll_wait

调用epoll_wait时,用户请求内核检测eventpoll的rdllist是否为空,若为空,则进程阻塞在eventpoll的wq上,若不为空,则内核将rdllist中的文件描述符拷贝到用户空间,同时epoll_wait返回已经就绪的文件描述符个数。

c 复制代码
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

内核会将已经就绪的文件描述符信息写入events数组中,用户就不需要对events数组进行完全遍历,只需要依据epoll_wait的返回值取出events数组中头部指定个数的元素即可,例如epoll_wait返回3,用户在遍历时只需要:

c 复制代码
for(int i=0;i<3;i++){
    if(events[i].events&EPOLLIN){
        //...
    }
    if(events[i].events&EPOLLOUT){
        //...
    }
}

epoll的特点

  • epoll将"用户告知内核"与"内核通知用户"在接口上进行了分离,使其使用更加方便
  • 使用epoll_ctl添加文件描述符,就是将epitem中的rbn添加到红黑树,时间复杂度是O(logN)
  • epoll让eventpoll结构等待多个socket对象是否有数据就绪,让eventpoll的rdllist引用已经就绪的socket对象。eventpoll作为进程与socket对象的中间层,socket的数据接收并不直接影响进程,进程只需要在eventpoll的wq中等待即可,当rdllist成功引用socket对象时,epoll会通过回调的方式唤醒进程,通过rdllist+回调机制,进程索引文件描述符的时间复杂度为O(1)
  • epoll可以同时监视多个文件描述符,提高了效率

epoll的工作方式

epoll的工作方式有水平出发 (LT,level triggered)和边缘触发(ET,edge triggered),LT指的是只要底层有数据就绪,epoll_wait就会一直通知上层读取,ET指的是只有数据从无到有或者从有到多,epoll_wait才会通知上层一次,如果用户没有把数据读取干净,那么以后也不会通知了,epoll如果采用ET模式,必须反复读取,并且将文件描述符设置为非阻塞,原因是在进行recv读取时,如果恰好底层没有数据,并且对端没有关闭连接,此时recv就会被阻塞,导致服务无法正常运行。

一般而言,ET模式要比LT模式效率高,因为内核只需要通知一次即可,但是如果采用LT模式每一次底层通知事件就绪时上层都把数据读完,那么效率也是很高的。

如何设置文件描述符为非阻塞?

通过fcntl可以设置一个文件描述符为非阻塞

cpp 复制代码
bool SetNoBlock(int sock) {
    int fl = fcntl(sock, F_GETFL);
    if (fl < 0) {
        return false;
    }
    fl |= O_NONBLOCK;
    return fcntl(sock, F_SETFL, fl) == 0;
}

如何设置ET模式?

在调用epoll_ctl添加文件描述符时,设置EPOLLET

cpp 复制代码
struct epoll_event event;
memset(&event, 0, sizeof(event));
event.events = EPOLLIN|EPOLLET;
event.data.fd = accept(...);
SetNoBlock(event.data.fd);
epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &event)

思考

为什么eventpoll的rdllist使用双向链表,单链表不行吗?

在调用epoll_ctl时,除了向红黑树中添加节点,还有可能删除节点,如果删除的那个文件描述符上有事件就绪,需要将它从rdllist中移除,如果rdllist是单链表,那么删除的时间复杂度就是O(N),如果是双向链表,删除的时间复杂度是O(1)

epoll保存监视的文件描述符为什么使用红黑树?使用哈希表可以吗?

epoll_ctl可以支持的功能有插入、删除和修改,eventpoll就需要使用一种便于插入、删除和寻找的数据结构,红黑树正是满足条件的结构,插入、删除和修改时间复杂度都是O(logN),使用哈希表从理论上而言可行,但是哈希表在最糟糕的情况下可能所有的键都哈希到了同一个桶,那么这时查找起来时间复杂度也退化为O(N),不如红黑树稳定,除此之外,哈希表不支持范围查找

eventpoll中的mtx和lock作用是什么?在什么情况下会有多个执行流访问eventpoll结构?

mtx和lock的作用是保证eventpoll在访问时是线程安全的。存在这样一种场景:用户调用epoll_wait访问eventpoll中的rdllist,恰好此时eventpoll等待的socket对象上有数据就绪,需要将对应epitem中的rdlink添加到eventpoll的rdllist,就出现了2个执行流同时访问rdllist的情况,因此需要通过eventpoll的mtx和lock成员保证rdllist线程安全

为什么select和poll索引就绪文件描述符的时间复杂度是O(N),epoll索引就绪文件描述符的时间复杂度是O(1)?

主要是底层使用的结构和机制不同,对于select和poll,都是由进程直接在socket对象的等待队列中阻塞,返回时用户只知道就绪的文件描述符个数,不知道具体是哪些文件描述符,因此需要进行遍历操作,时间复杂度是O(N);对于epoll,由于其使用了先进的结构eventpoll、rddlist,并且通过回调让rddlist引用已经就绪的socket对象,进程只需要在eventpoll的wq中等待,只需要关系rdllist,无需关心socket对象,返回时根据rdllist就可以直接索引到就绪的文件描述符,无需遍历,因此时间复杂度是O(1)

总结

系统调用 select poll epoll
事件集合 通过fd_set设置关心事件,返回时也是通过fd_set获取就绪事件,fd_set不可重用 通过events和revents分别表示关心事件和就绪事件,pollfd结构可重用 通过epoll_ctl设置关心事件,通过epoll_wait检测eventpoll的rdllist是否为空,从而获取就绪事件
索引就绪文件描述符的时间复杂度 O(N) O(N) O(1)
最大支持的文件描述符个数 1024 使用ulimit -a查看 使用ulimit -a查看
工作模式 LT LT LT和ET
内核实现和工作效率 底层一个进程在多个socket对象的等待队列上等待,只要其中一个有事件就绪就返回,需要进行遍历操作,时间复杂度为O(N) 底层一个进程在多个socket对象的等待队列上等待,只要其中一个有事件就绪就返回,需要进行遍历操作,时间复杂度为O(N) 通过eventpoll、rdllist、wq、rbr等先进结构,让eventpoll在socket对象的等待队列等待,使用rdllist引用就绪的文件描述符,进程只需要在wq中等待并获取rdllist中就绪的文件描述符,时间复杂度为O(1)
相关推荐
我言秋日胜春朝★5 分钟前
【Linux】冯诺依曼体系、再谈操作系统
linux·运维·服务器
wowocpp38 分钟前
ubuntu 22.04 server 格式化 磁盘 为 ext4 并 自动挂载 LTS
服务器·数据库·ubuntu
wclass-zhengge41 分钟前
Netty篇(入门编程)
java·linux·服务器
方方怪1 小时前
与IP网络规划相关的知识点
服务器·网络·tcp/ip
weixin_442643422 小时前
推荐FileLink数据跨网摆渡系统 — 安全、高效的数据传输解决方案
服务器·网络·安全·filelink数据摆渡系统
阑梦清川2 小时前
JavaEE初阶---网络原理(五)---HTTP协议
网络·http·java-ee
Karoku0662 小时前
【企业级分布式系统】Zabbix监控系统与部署安装
运维·服务器·数据库·redis·mysql·zabbix
半桶水专家2 小时前
用go实现创建WebSocket服务器
服务器·websocket·golang
布值倒区什么name2 小时前
bug日常记录responded with a status of 413 (Request Entity Too Large)
运维·服务器·bug
FeelTouch Labs3 小时前
Netty实现WebSocket Server是否开启压缩深度分析
网络·websocket·网络协议