网络编程(三):I/O多路复用

1,303 阅读20分钟

网络编程(二):Java NIO中讲到了I/O多路复用,在Linux中I/O多路复用是如何实现的呢?

Socket编程

操作系统通过Socket(一套通用网络编程接口)提供一组系统调用,使得应用程序能够访问内核协议提供的服务。

Socket功能

  1. 将应用程序数据从用户缓冲区中复制到TCP/IP内核发送缓冲区,再通过内核来发送数据(send())。
  2. 从内核TCP/IP接收缓冲区复制数据到用户缓冲区读取数据(write())。

Socket基础API

创建Socket

/**
 * @Description :创建socket
 * domain:使用的底层协议族,TCP/IP协议族该参数设置为PF_INET(IPv4)或PF_INET6(IPv6),UNIX本地域协议族该参数设置为PF_UNIX
 * type:指定服务类型,SOCK_STREAM|流服务(表示传输层使用TCP),SOCK_UGRAM|数据报服务(表示传输层使用UDP)
 * protocol:具体使用的协议(domain和type已决定了protocol的值),0|默认协议
 * @return :socket文件描述符 
 */
int socket (int domain, int type, int protocol);

绑定Socket

将一个socket与socket地址绑定,服务器端只有绑定socket后客户端才能知道如何连接它,客户端通常不需要绑定socket(采用匿名方式,使用操作系统自动分配的socket地址)。

/**
 * @Description :将my_addr指向的socket地址分配给socket文件描述符sockfd(将一个socket与socket地址绑定)
 * sockfd:socket文件描述符
 * my_addr:socket地址
 * addrlen:socket地址长度
 * @return :0|成功,-1|失败,并设置error(EACCES|被绑定的地址时受保护的地址,只有超级用户能访问,EADDRINUSE|被绑定的地址正在使用,如绑定到一个处于TIME_WAUT状态的socket地址)
 */
int bind(int sockfd, const struct sockaddr* my_addr, socklen_t addrlen);

监听Socket

创建一个监听队列存放待处理的客户端连接。

/**
 * @Description :创建一个监听队列存放待处理的客户端连接
 * sockfd:被监听的socket
 * backlog:提示内核监听队列的最大长度(处于完全连接状态的socket上限),如果监听队列的长度超过backlog,服务器将不受理新的客户连接,客户端将收到ECONNREFUSED错误信息
 * @return :0|成功,-1|失败
 */

发起连接

/**
 * @Description :与服务器建立连接,一旦成功建立连接,sockfd就唯一标识这个连接,客户端可以通过读写sockfd来与服务器通信
 * sockfd:socket系统调用返回的一个socket
 * serv_addr:服务器监听的socket地址
 * addrlen:serv_addr长度
 * @return :成功|0,失败|-1并设置error(ECONREFUSED|目标端口不存在,连接被拒绝,ETIMEDOUT|连接超时)
 */
int connect(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen);

接受连接

从listen监听队列中接受一个连接(accept()只是从监听队列中获取一个连接,不论连接处于什么状态)。

/**
 * @Description :从listen监听队列中接受一个连接
 * sockfd:执行过listen调用的监听socket
 * addr:获取被接受连接的远端socket地址
 * addrlen:addr长度
 * @return :成功|返回一个新的连接socket,该socket唯一标识被接受的这个连接,服务器可以通过读写这个socket来与被接受连接对应的客户端通信,失败|-1
 */
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

关闭连接

/**
 * @Description :关闭连接,将socket上的读和写同时关闭(只是将fd的引用-1,只有fd的引用=0时才真正关闭连接)
 * fd:待关闭的socket
 * @return :
 */
int close(int fd);

/**
 * @Description :关闭连接(立即终止连接)
 * sockfd:待关闭的socket
 * howto:决定shutdown的行为,SHUT_RD|关闭sockfd上读的一半,SHUT_WR|关闭sockfd上写的一半,SHUT_RDWR|关闭sockfd上的读和写
 * @return :成功|0,失败|-1
 */
int shutdown(int sockfd, int howto);

数据读/写

TCP数据读写

/**
 * @Description :读取sockfd上的数据
 * sockfd:socket
 * buf:读缓冲区位置
 * len:读缓冲区大小
 * flags:可通过flags指定socket是否阻塞、发送或接收紧急数据等
 * @return :成功|返回实际读取到的数据长度,0|通信对方已关闭连接,失败|-1
 */
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

/**
 * @Description :往sockfd上写数据
 * sockfd:socket
 * buf:写缓冲区位置
 * len:写缓冲区大小
 * flags:可通过flags指定socket是否阻塞、发送或接收紧急数据等
 * @return :成功|返回实际写入的数据长度,失败|-1
 */
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

UDP数据读写

/**
 * @Description :读取sockfd上的数据
 * sockfd:socket
 * buf:读缓冲区位置
 * len:读缓冲区大小
 * flags:可通过flags指定socket是否阻塞、发送或接收紧急数据等
 * src_addr:发送端socket地址(因为UDP是无连接的,所以每次读取数据都需要获取发送端的socket地址)
 * addrlen:src_addr长度
 * @return :成功|返回实际读取到的数据长度,0|通信对方已关闭连接,失败|-1
 */
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr* src_addr, socklen_t* addrlen);

/**
 * @Description :往sockfd上写数据
 * sockfd:socket
 * buf:写缓冲区位置
 * len:写缓冲区大小
 * flags:可通过flags指定socket是否阻塞、发送或接收紧急数据等
 * dest_addr:指定接收端地址
 * addrlen:dest_addr长度
 * @return :成功|返回实际写入的数据长度,失败|-1
 */
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, struct sockaddr* dest_addr, socklen_t* addrlen);

Socket API调用过程

服务端:

① 服务器启动后通过socket()创建一个或多个监听socket;

② 通过bind()将该socket绑定到服务器监听的端口上;

③ 然后通过listen()监听客户端请求;

④ 监听到客户端请求后,通过accept()接受请求,并且分配一个新的子进程/子线程处理该请求;

⑤ 通过recv()/send()接收/发送数据;

⑦ 接收到客户端的关闭请求后,执行被动关闭连接;

客户端

① 通过socket()创建一个socket;

② 服务端执行listen()后,客户端可以通过connect()向服务端发起连接请求;(由于客户端请求是随机到达的异步事件,所有服务器端会使用I/O模型来监听)

③ 服务器接受请求后,通过send()/recv()发送/接收数据;

④ 通过close()主动关闭连接;

什么是I/O多路复用?

I/O多路复用是一种I/O通知机制,I/O多路复用使得程序能够同时监听多个文件描述符,应用程序通过I/O多路复用函数向内核注册一组事件,内核通过I/O多路复用函数把其中就绪的事件通知给应用程序。

I/O多路复用函数本身是阻塞的,它能够提高程序效率的原因在它能够同时监听多个I/O事件。当多个文件描述符同时就绪时,只能按顺序依次处理每个文件描述符,可以通过多线程实现并发处理。

内核接收网络数据的过程

① 服务端调用socket()创建一个socket对象;

② 服务端调用bind()绑定该socket监听端口;

③ 服务端调用listen()开始监听,等待客户端连接;

④ 当客户端调用connect()发起连接后,服务端通过调用accept()接受连接;

⑥ 服务端调用recv()阻塞等待客户端数据,recv()会将监听进程加入到该socket的等待队列中(该进程阻塞等待数据到达,不占用CPU);

⑦ 网卡接收到数据,将数据写入内存后产生一个中断通知CPU有数据到达,CPU执行中断程序,该中断程序根据端口号将网络数据写入到对应socket的接收缓冲区,再唤醒等待进程加入工作队列中;

⑧ 进程读取并返回socket接收缓冲区数据;

如何同时监听多个socket?

select

1. 设计思想

用户进程给内核传入一个监听socket数组,如果该数组中没有就绪的socket,则进程阻塞,直到有socket就绪后唤醒该进程,遍历socket数组获取就绪socket进行处理(处理的过程是阻塞的,可使用多线程进行并发处理)。

2. 函数定义

/**
 * @Description :fd_set*结构体中仅包含一个数组,该数组的每一位标记一个文件描述符,fd_set的大小FD_SETSIZE=1024限制了select能够同时处理的文件描述符总数
 * nfds:被监听的文件描述符总数
 * readfds:可读事件对应的文件描述符集合
 * writefds:可写事件对应的文件描述符集合
 * exceptfds:异常事件对应的文件描述符集合
 * timeout:超时时间
 * @return :成功|返回(可读/可写/异常)文件描述符总数,失败|-1(客户端接收到服务器端返回,需要遍历文件描述符集合来获取就绪文件描述符,时间复杂度为O(n))
 */
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);

3. 什么情况下文件描述符被认为可读/可写/异常

  • 可读:
    • socket内核接收缓冲区中的字节数≥低水位标记。
    • socket通信的对方关闭连接。
    • 监听的socket上有新的连接请求。
    • socket上有未处理的错误。
  • 可写:
    • socket内核发送缓冲区中的字节数≥低水位标记。
    • socket写操作被关闭,对写操作被关闭的socket执行写操作将触发一个SIGPIPE信号。
    • socket使用非阻塞connect连接成功或失败或超时后。
    • socket上有未处理的错误。
  • 异常:
    • socket接收到带外数据。

3. select缺点

select允许监听的最大文件描述符数量为1024(fd_set的大小FD_SETSIZE=1024限制了select能够同时处理的文件描述符总数);

② 每次调用select都需要将监听的可读、可写、异常事件fd_set数组传入内核;

③ 每次调用select都需要将进程添加到监听socket的等待队列,唤醒进程时需要将进程从所有socket等待队列移除;

④ 内核通过轮询的方式判断是否有事件到达,若有则返回,用户进程再次通过轮询的方式获取已就绪的事件,时间复杂度为O(n)

poll

poll通过一个pollfd结构体保存监听的可读、可写、异常事件,解决了select监听文件描述符数量最大限制。

poll可通过参数指定最多监听多少个文件描述符和事件,且这两个数值能够达到系统允许打开的最大文件描述符数量(65535)。

1. 函数定义

/**
 * @Description :pollfd结构体包含fd|文件描述符,events|注册的事件(监听的事件),revents:实际发生的事件
 * fds:指定所有感兴趣的文件描述符上发生的可读/可写/异常事件
 * nfds:指定被监听事件集合fds的大小
 * timeout:超时时间
 * @return :成功|返回就绪(可读/可写/异常)文件描述符总数,失败|-1
 */
int poll(struct pollfd* fds, nfds_t nfds, int timeout);

epoll

  • epoll在内核维护一个事件表存放监听事件,最多监听文件描述符数量能够达到系统允许打开的最大文件描述符数量(65535),监听的socket只有在第一次创建时需要调用epoll_create()将该事件拷贝到内核事件表,后续调用epoll不需要再将监听的事件传入内核,解决了select缺点【① ②】;
  • epoll将维护socket等待队列(epoll_ctl())和进程阻塞(epoll_wait())拆分为两个操作,解决了select缺点【③】;
  • epoll内核事件表中维护一个就绪队列,用户程序该就绪队列即可获取就绪事件,时间复杂度为O(1),解决了select缺点【④】;

1. 设计思想

在内核中维护一个事件表epollevent(需要使用一个额外的文件描述符来唯一标识内核中的事件表),只有在创建事件时才需要将事件从用户空间拷贝到内核空间,后续事件的监听都是基于内核的事件表epollevent(不需要每次调用都重复传入事件集合)。

epollevent中会维护一个双向链表rdlist用于保存就绪的socket,维护一个红黑树rbr用于保存所有监听的事件,当rdlist>0时表示已有socket就绪,用户程序通过内存共享获取rdlist即可获取就绪的socket

为什么使用双向链表保存就绪socket?

保存就绪socket的数据结构应该有较高的插入效率,如果删除监听的socket,而该socket已经在就绪队列中,也应该将它删除,所以保存就绪socket的数据结构需要插入/删除效率高,因此选择双向链表(插入/删除时间复杂度为O(1))。

为什么使用红黑树保存监听的socket?

保存所有监听socket的数据结构应该要有较好的插入/删除效率,还要便于查找(在插入的时候需要查找,避免从重复添加),所以选择红黑树(插入/删除/查找的时间复杂度为O(logn))。

通过内存映射mmap()减少复制开销。

3. 函数定义

epoll_create():创建事件表文件描述符(内核会创建一个eventpoll对象),该文件描述符将作为其他epoll系统调用的第一个参数,用来指定要访问的内核事件表。

/**
 * @Description :创建事件表
 * @return 返回的文件描述符
 */
int epoll_create(int size);

epoll_ctl():操作内核事件表。在注册新的事件时会将该fd拷贝进内核,在epoll_wait()时只需要访问内核的事件表,而不需要重复拷贝。

/**
 * @Description :操作事件表
 * op:操作类型(EPOLL_CTL_ADD|往事件表中注册fd上的事件,EPOLL_CTL_MOD|修改fd上的注册事件,EPOLL_CTL_DEL|删除fd上的注册事件)
 * fd:要操作的文件描述符
 * event:指定事件,epoll_event结构体
 * @return :0|成功,-1|失败
 */
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll_wait():在一段超时时间内等待一组文件描述符上的事件。epoll_wait()如果检测到事件,就将所有就绪的事件从内核事件表epfd中复制到events指向的数组中。

/**
 * @Description :在一段超时时间内等待一组文件描述符上的事件
 * maxevents:最多监听事件总数
 * timeout:超时时间
 * @return :返回就绪的文件描述符个数
 */
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
  • epoll_create()场景: 大学开学第一周,你作为班长需要帮全班同学领取相关物品,你在学生处告诉工作人员,我是xx学院xx专业xx班的班长,这时工作人员确定你的身份并且给了你凭证,后面办的事情都需要用到(也就是调用epoll_create()向内核申请了epfd结构,内核返回了epfd句柄给你使用)。
  • epoll_ctl()场景: 你拿着凭证在办事大厅开始办事,分拣办公室工作人员说班长你把所有需要办理事情的同学的学生册和需要办理的事情都记录下来吧,于是班长开始在每个学生手册单独写对应需要办的事情:李明需要开实验室权限、孙大熊需要办游泳卡......就这样班长一股脑写完并交给了工作人员(也就是告诉内核哪些fd需要做哪些操作)。
  • epoll_wait()场景: 你拿着凭证在领取办公室门前等着,这时候广播喊xx班长你们班孙大熊的游泳卡办好了速来领取、李明实验室权限卡办好了速来取....还有同学的事情没办好,所以班长只能继续(也就是调用epoll_wait()等待内核反馈的可读写事件发生并处理)。

4. epoll监听过程

① 进程调用epoll_create()创建内核事件表(eventpoll对象),内核事件表会占用一个文件描述符;

② 可调用epoll_ctl()增/删/改监听事件,会将监听的事件添加到红黑树结构rbr中,并且将eventpoll对象添加到监听socket的等待队列;

③ 当进程调用epoll_wait()时,会将该进程放入eventpoll对象的等待队列中,阻塞等待事件就绪;

④ 当socket接收到数据时,中断程序会在eventpoll对象维护的就绪队列rdlist中添加该socket的引用,然后唤醒eventpoll对象等待队列中的进程;

⑥ 进程进入运行状态,通过内存共享获取rdlist即可获取就绪的socket

5. 触发方式

LT/ET模式是epoll对文件描述符的2种操作模式,可在epoll_event结构体中配置采用何种模式。

LT模式

  • 默认工作模式。采用LT模式的文件描述符,当epoll_wait()检测到有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件,当应用程序下一次调用epoll_wait()时,epoll_wait()还会再次向应用程序通知此事件,直到该事件被处理。

  • 触发时机

    • 对于读操作,只要缓冲内容不为空,LT模式返回读就绪。
    • 对于写操作,只要缓冲区还不满,LT模式会返回写就绪。

当被监听的文件描述符上有可读/写事件发生时,epoll_wait()会通知处理程序去读/写。如果这次没有把数据一次性全部读/写完,那么下次调用epoll_wait()时,它还会通知在上次没读/写完的文件描述符上继续读/写。如果一直不去读/写,LT模式下会一直通知。如果系统中有大量不需要读/写的就绪文件描述符,而每次调用epoll_wait()都会返回,这样会大大降低处理程序查找自己关心的就绪文件描述符的效率。

ET模式

  • 采用ET模式的文件描述符,当epoll_wait()检测到有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,后续的epoll_wait()将不再向应用程序通知这个事件。

  • 触发时机

    • 对于读操作

      • 当缓冲区由不可读变为可读时,即缓冲区由空变为不空时;

      • 当有新数据到达时,即缓冲区中的待读数据变多时;

      • 当缓冲区有数据可读,且应用进程对相应的描述符进行EPOLL_CTL_MOD修改EPOLLIN事件时;

    • 对于写操作

      • 当缓冲区由不可写变为可写时;

      • 当有旧数据被发送走,即缓冲区中的内容变少时;

      • 当缓冲区有空间可写,且应用进程对相应的描述符进行EPOLL_CTL_MOD修改EPOLLOUT事件时;

当被监听的文件描述符上有可读/写事件发生时,epoll_wait()会通知应用程序去进行/写。如果这次没有把数据全部读/写完,那么下次调用epoll_wait()时,将不会再次通知(也就是只会通知一次,对于可读数据变少的情况不会再次通知应用程序),直到该文件描述符上出现第二次可读/写事件才会通知应用程序。这种模式比水平触发效率高,系统不会充斥大量不关心的就绪文件描述符。

为什么需要ET模式?ET模式降低了同一个epoll事件被重复触发,因此效率比LT模式高。ET模式要求一直读写,直到返回EAGAIN,否则会遗漏事件。即使使用ET模式,并发时一个socket上的某个事件也可能被触发多次

6. EPOLLONESHOT事件

可在epoll_event结构体中配置是否注册文件描述符上的EPOLLONESHOT事件。

作用

实现在任一时刻一个socket连接只被一个线程处理。

一个线程在读取完某个socket上的数据后开始处理数据,在处理数据的过程中这个socket上又有新数据可读,此时另一个线程被唤醒读取这些新数据,出现两个线程同时操作一个socket的情况。

实现

注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读/可写/异常事件,且仅触发一次,当一个线程在处理某个socket时,其他线程不能再操作该socket。当这个socket一旦被某个线程处理完毕,该线程应该立即重置这个socket上的EPOLLONESHOT事件,确保这个socket下一次可读时还能被触发,让其它线程还有机会继续处理这个socket

EPOLLONESHOT事件是否相当于是给socket“加锁”?当线程A在处理某个就绪socket时给该socket“加锁”,其他线程“未获得锁”所以无法处理该socket,当线程A处理完socket后“释放锁”,此时如果该socket再次就绪,其他线程也能够“竞争锁”。

7. epoll应用

  • Java NIO
  • Redis使用了epoll的LT模式;
  • NettyNginx使用了epoll的ET模式;

总结:比较3个I/O多路复用函数

  • 维护监听事件

    • selectfd_set没有将文件描述符与事件绑定,仅是一个文件描述符集合,因此select需要提供3个类型的参数来区分传入的可读、可写、异常事件,所以select不能处理更多类型的事件。内核会修改fd_set,因此下次调用select需要重置这3个fd_set集合。

    • poll:文件描述符和事件都定义在pollfd中,任何事件都被统一处理,内核每次修改的是pollfd中的revents,不会修改event,因此下次调用poll不需要重置pollfd事件集参数。

    • epoll:在内核维护一个事件表,提供一个独立的系统调用epoll_ctl()来操作添加、修改、删除事件,每次epoll_wait()直接从内核事件表中取得用户注册的事件,无须每次从用户空间读入事件到内核空间中。

  • 可监听描述符数量

    • pollepoll可通过参数指定最多监听多少个文件描述符和事件,且这两个数值能够达到系统允许打开的最大文件描述符数量(65535),select允许监听的最大文件描述符数量为1024。
  • 触发方式

    • selectpoll只支持LT模式。
    • epoll支持LT模式和ET模式,epoll还支持EPOLLONESHOT事件,减少事件被触发的次数。
  • 检测就绪事件时间复杂度

    • selectpoll采用轮询的方式检测就绪事件,每次调用都要扫描整个监听文件描述符集合,并将其中就绪的文件描述符返回给用户程序,所以检测就绪事件的时间复杂度为O(n)

    • epoll采用回调的方式,内核检测到就绪事件时将触发回调函数,回调函数将该文件描述符上对应的事件插入事件表就绪事件队列中,所以epoll不需要轮询检测哪些事件已就绪,时间复杂度为O(1)

  • 获取就绪事件时间复杂度

    • selectpoll每次调用都返回全部用户注册的事件集合(包括已就绪和未就绪的),所以应用程序需要遍历整个集合获取就绪的文件描述符,时间复杂度为O(n)

    • epollepoll_wait()events参数仅用来返回就绪的事件,所以应用程序获取已就绪事件的时间复杂度为O(1)

  • 适用场景

    • epoll适用于连接数量多,但活动连接较少的情况,因为活动连接多的时候回调函数将会被频繁触发。
    • selectpoll适合在连接数少且连接都十分活跃的情况。