何为IO多路复用

网络编程概念

在网络当中,数据的传输是基于HTTP/TCP协议簇来实现的。TCP协议仅仅是把这些数据看做是一串二进制流来进行处理。
所以:客户端和服务器是通过在建立的连接上发送字节流来进行通信

一个连接由它两端的套接字地址唯一确定,结构为(客户端地址:客户端端口号,服务端地址:服务端端口号)。有了通信双方的连接地址信息后,就可以进行数据传输了。

在Unix系统中,实现了一套套接字接口来描述和规范双方通信的整个过程。
创建->连接->绑定->监听->响应

  • socket():创建一个套接字描述符
  • connect():客户端通过调用connect函数来建立和服务器的连接
  • bind():告诉内核将socket()创建的套接字与某个服务端地址与端口连接起来,后续会对这个地址和端口进行监听
  • listen():告诉内核,将这个套接字当成服务器这种被动实体来看待(服务器是等待客户端连接的被动实体,而内核认为socket()创建的套接字默认是主动实体,所以才需要listen()函数,告诉内核进行主动到被动实体的转换)
  • accept():等待客户端的连接请求并返回一个新的已连接描述符

最简单的单进程服务器

由于Unix的历史遗留问题,原始的套接字接口对地址和端口等数据封装并不简洁。
在最初的服务器中,一个服务器进程只能同时处理一个客户端连接与相关的读写操作。
在读写的过程中,整个进程被该客户端独占,当前服务器进程只能处理该客户端连接的读写操作,无法对其他客户端连接请求进行处理。

IO并发提升

多进程

如果去优化单进程?
一个进程不行,那就搞多个进程来同时处理不就得了。

由于一个客户端的connect对应着一个服务端的accept,那么每次客户端过来时,都使用fork()来进行accept的系统调用。

缺点:

  • 进程创建的数量随连接请求的增加而增加。
  • fork等系统调用会使得进程的上下文进行切换,效率很低。
  • 进程与进程之间的地址空间私有。使得进程之间的数据共享比较困难。

多线程

线程是运行在进程上下文的逻辑流。一个进程可以包含多个线程,多个线程运行在单一的进程上下文中,因此共享这个进程的地址空间的所有内容,解决了进程与进程之间通信难的问题。同时,由于一个线程的上下文要比一个进程的上下文小得多,所以线程的上下文切换,要比进程的上下文切换效率高得多。线程是轻量级的进程,解决了进程上下文切换效率低的问题。

基于单进程的IO多路复用

前面谈到的都是通过增加进程和线程的数量来同时处理多个套接字。而IO多路复用只需要一个进程就能够处理多个套接字。

其本质是:一个服务端进程可以同时处理多个套接字描述符

在之前的讲述中,一个服务端进程,只能同时处理一个连接。如果想同时处理多个客户端连接,需要多进程或者多线程的帮助,免不了上下文切换的开销。IO多路复用技术就解决了上下文切换的问题。IO多路复用技术的发展可以分为select->poll->epoll三个阶段。

IO多路复用的核心就是添加了一个套接字集合管理员,它可以同时监听多个套接字。由于客户端连接以及读写事件到来的随机性,我们需要这个管理员在单进程内部对多个套接字的事件进行合理的调度。

select

select()函数会在某个或某些套接字的状态从不可读变为可读、或不可写变为可写的时候通知服务器主进程。所以select()本身的调用是阻塞的。但是具体哪一个套接字或哪些套接字变为可读或可写我们是不知道的,所以我们需要遍历所有select()返回的套接字来判断哪些套接字可以进行处理了。

但是,select()函数本身的调用阻塞的。因为select()需要一直等到有状态变化的套接字之后(比如监听套接字或者连接套接字的状态由不可读变为可读),才能解除select()本身的阻塞,继续对读写就绪的套接字进行处理。虽然这里是阻塞的,但是它能够同时返回多个就绪的套接字,而不是之前单进程中只能够处理一个套接字,大大提升了效率

优点:

  • 实现了对多个套接字的同时、集中管理
  • 通过遍历所有的套接字集合,能够获取所有已就绪的套接字,对这些就绪的套接字进行操作不会阻塞

缺点:

  • select管理的套接字描述符们存在数量限制。在Unix中,一个进程最多同时监听1024个套接字描述符
  • select返回的时候,并不知道具体是哪个套接字描述符已经就绪,所以需要遍历所有套接字来判断哪个已经就绪,可以继续进行读写

poll

poll解决了select带来的套接字描述符的最大数量限制问题

poll的fds参数集合了select的read、write和exception套接字数组,合三为一。poll中的fds没有了1024个的数量限制。当有些描述符状态发生变化并就绪之后,poll同select一样会返回。但是遗憾的是,我们同样不知道具体是哪个或哪些套接字已经就绪,我们仍需要遍历套接字集合去判断究竟是哪个套接字已经就绪,这一点并没有解决刚才提到select的第二个问题。

epoll

epoll是最先进的套接字管理员,解决了上述select和poll中所存在的问题。它将一个阻塞的select、poll系统调用拆分成了三个步骤。一次select或poll可以看作是由一次 epoll_create、若干次 epoll_ctl、若干次 epoll_wait构成:

1
2
3
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);

  • epoll_create():创建一个epoll实例。后续操作会使用
  • epoll_ctl():对套接字描述符集合进行增删改操作,并告诉内核需要监听套接字描述符的什么事件
  • epoll_wait():等待监听列表中的连接事件(监听套接字描述符才会发生)或读写事件(连接套接字描述符才会发生)。如果有某个或某些套接字事件已经准备就绪,就会返回这些已就绪的套接字们

我们调用epoll_wait()等待连接或读写等事件,在某个套接字描述符上准备就绪。当有事件准备就绪之后,会存到第二个参数epoll_event结构体中。通过访问这个结构体就可以得到所有已经准备好事件的套接字描述符。这里就不用再像之前select和poll那样,遍历所有的套接字描述符之后才能知道究竟是哪个描述符已经准备就绪了,这样减少了一次O(n)的遍历,大大提高了效率。