c语言 - socket编程(四)

c语言 - socket编程(四),Linux中的五种网络IO模型:阻塞IO非阻塞IO多路复用IO信号驱动式IO异步IO

IO模型

网络IO的本质是socket的操作,我们以recv为例:

每次调用recv,数据会先拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间

所以说,当一个recv操作发生时,它会经历两个阶段:

  • 第一阶段:等待数据准备
  • 第二阶段:将数据从内核拷贝到进程中

对于socket流而言:

  • 第一阶段:通常涉及等待网络上的数据分组到达,然后被复制到内核的某个缓冲区
  • 第二阶段:把数据从内核缓冲区复制到应用进程的缓冲区

网络IO模型

  • 同步IO(synchronous IO)
    • 阻塞IO(bloking IO)
    • 非阻塞IO(non-blocking IO)
    • 多路复用IO(multiplexing IO)
    • 信号驱动式IO(signal-driven IO)
  • 异步IO(asynchronous IO)

由于信号驱动式IO实际中并不常用,所以接下来只介绍其他四种IO模型

阻塞IO
应用程序调用一个IO函数,如recv:
当socket接收缓冲区中没有数据时,当前进程会被recv阻塞,一直会等待数据的到来;
当socket接收缓冲区中有数据时,recv将数据拷贝到进程空间的这个过程,也是阻塞的;

所以,对于阻塞IO,在这两个阶段都被阻塞了

非阻塞IO
将一个套接字设置为非阻塞时,就是告诉内核,当一个请求的IO操作无法立即完成时,不要让我等待,应立即给我返回一个错误,如recv:
当socket接收缓冲区中没有数据时,recv会立即返回一个错误,errno为EAGAIN,表示现在没有数据,等下再来吧;
当socket接收缓冲区中有数据时,recv将数据拷贝到进程空间的这个过程,也是阻塞的;

所以对于非阻塞IO,通常采取轮询polling的方式,循环往复的主动询问内核,当前是否有数据了

对于非阻塞IO,第一个阶段不会阻塞,但是第二个阶段依旧是阻塞的

IO多路复用
其实这种方式和第二种的非阻塞IO很相似,只不过优点是可以借助这几个特殊的系统调用(selectpollepoll),来同时轮询多个socket连接:
当调用select、poll、epoll函数时,如果所监控的socket中有部分socket可读、可写或其他事件发生时,就会返回,将其交给用户进程来处理,这个过程是阻塞的,只不过是因为select、poll、epoll系统调用而阻塞的;
当调用返回后,用户进程再调用recv,将数据从内核拷贝到进程空间中,这个过程也是阻塞的,因recv拷贝数据而阻塞;

实际上这种方式相比第二种还差一些,因为这里面包含了两个系统调用(select/poll/epoll、recv),而第二种只有一个系统调用recv;
不过IO多路复用的优点是可以处理更多的连接,当连接数大的时候,缺点就被优点给掩盖了

IO多路复用相比多进程/多线程 + 阻塞IO的系统开销小,因为系统不需要创建新的进程或线程,也不需要维护多个进程、线程的执行

对于多路复用IO,第一个阶段是因为select、poll、epoll而阻塞的,第二个阶段(实际IO操作)依旧是阻塞的

信号驱动式IO
首先我们允许socket进行信号驱动IO,并安装一个信号处理函数,进程继续运行并不阻塞;
当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用IO操作函数处理数据;

异步IO
对于前面四种IO模型,都是同步的,因为所有的实际IO操作(将数据从内核拷贝到进程空间的这个过程)都是阻塞的;
所谓同步IO,就是必须等待当前的IO操作完成之后,才能执行后面的指令,这个等待的过程中是不能进行其他操作的,也就是说,指令序列都是线性执行的;
而异步IO,当遇到IO操作时,直接把这个IO操作交给别人(内核、新的线程/进程等等)来做,而当前进程并不会因为这个IO操作而阻塞,可以立即执行别的操作,当IO操作完成后,进程会收到完成的通知(回调函数、信号等等)

linux2.6之后的内核提供了AIO库,提供异步IO操作,但是实际上用的比较少,目前有很多流行的开源异步IO库:libevent、libev、libuv等

实例

迭代模式 + 阻塞IO

所谓的迭代模式,就是用while、for循环来不断接受新连接

server.c

client.c

多进程 + 阻塞IO

主进程调用accept()不断接收新连接,然后调用fork()创建一个子进程来处理新连接,即:来一个新连接就启动一个新进程

server.c

client.c

epoll多路复用IO

基本知识
epoll是在Linux 2.6内核中提出的,是之前的select和poll的增强版本;
相对于select和poll来说,epoll更加灵活,没有描述符限制;
epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中;

epoll接口
epoll操作过程需要三个接口,这三个函数都在头文件sys/epoll.h中:

int epoll_create(int size);:创建一个epoll文件描述符

  • size:输入参数,在内核版本 2.6.8 之后,这个参数被弃用了,不过传入的值必须大于0
  • 返回值:成功返回epoll实例的文件描述符fd,失败返回-1,并设置errno

int epoll_create1(int flags);:创建一个epoll文件描述符(新)

  • flags:输入参数,如果flags为0,则等价于epoll_create();
  • 返回值:成功返回epoll实例的文件描述符fd,失败返回-1,并设置errno

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);:epoll事件注册、修改、删除

  • epfd:输入参数,epoll文件描述符
  • op:输入参数,动作:EPOLL_CTL_ADD添加、EPOLL_CTL_MOD修改、EPOLL_CTL_DEL移除
  • fd:输入参数,被监听的文件描述符
  • event:输入参数,监听的epoll事件,events可以是以下几个宏的集合:
    EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
    EPOLLOUT:表示对应的文件描述符可以写;
    EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
    EPOLLERR:表示对应的文件描述符发生错误;
    EPOLLHUP:表示对应的文件描述符被挂断;
    EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的;
    EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里;
  • 返回值:成功返回0,失败返回-1,并设置errno

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);:等待epoll事件的发生

  • epfd:输入参数,epoll文件描述符
  • events:输出参数,一个数组,用来保存发生的事件的集合
  • maxevents:输入参数,events数组的长度
  • timeout:输入参数,超时时间(单位:毫秒),-1永久阻塞,0立即返回
  • 返回值:成功返回实际发生的事件的数量,返回0表示超时,失败返回-1,并设置errno

工作模式
epoll有两种工作模式:LT(level trigger)水平触发、ET(edge trigger)边缘触发;

默认工作在LT模式,LT模式与ET模式的区别:

  • LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件;下次调用epoll_wait时,会再次响应应用程序并通知此事件;
    LT模式同时支持阻塞、非阻塞的socket套接字;
  • ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件;如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件;只能等待该描述符的下次事件发生(也就是状态改变的时候)才会通知应用程序;
    ET模式只支持非阻塞的socket套接字;

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高;
epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/写操作把处理多个文件描述符的任务饿死;

所以,对于ET模式的epoll,必须在事件触发后,一次性把当前socket缓冲区的数据全部读完,把要发送的数据全部发完才能继续处理下一个事件,同时,对于connect和accept也要进行相应的处理

LT模式 - 实例
server.c

client.c

ET模式 - 实例
server.c

client.c

可以发现,ET模式所耗的时间比LT模式更长一些,不过这是因为echo回声程序的特性导致的;
因为ET模式中的recv/send结果判断、循环,无疑加重了cpu的负担,适得其反;
不过也可能是我的程序逻辑太辣鸡了(这是肯定的了(눈_눈));
所以说选对模型很重要,并不是所有的情况都适合用ET模式;

不过我们可以只将listenfd设置为ET模式,其他的新连接使用默认的LT模式,我们来看一下: