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)
相关推荐
乙己4073 小时前
计算机网络——网络层
运维·服务器·计算机网络
幽兰的天空5 小时前
介绍 HTTP 请求如何实现跨域
网络·网络协议·http
lisenustc5 小时前
HTTP post请求工具类
网络·网络协议·http
心平气和️5 小时前
HTTP 配置与应用(不同网段)
网络·网络协议·计算机网络·http
心平气和️5 小时前
HTTP 配置与应用(局域网)
网络·计算机网络·http·智能路由器
qzhqbb5 小时前
web服务器 网站部署的架构
服务器·前端·架构
Mbblovey6 小时前
Picsart美易照片编辑器和视频编辑器
网络·windows·软件构建·需求分析·软件需求
北顾南栀倾寒7 小时前
[Qt]系统相关-网络编程-TCP、UDP、HTTP协议
开发语言·网络·c++·qt·tcp/ip·http·udp
GZ_TOGOGO7 小时前
PIM原理与配置
网络·华为·智能路由器
7ACE7 小时前
Wireshark TS | 虚假的 TCP Spurious Retransmission
网络·网络协议·tcp/ip·wireshark·tcpdump