网络编程-五种IO模型

阻塞IO

阻塞IO即Blocking IO
针对阻塞IO执行的系统调用可能因为无法立即完成而被操作系统挂起,直到等待的事件发生。系统调用某个函数,一直检查这个函数有没有返回,必须等返回才能进行下一步动作。
socket基础API中,可能被阻塞的系统调用包括accept, send, recv, connect。
注意阻塞和非阻塞的概念是应用于文件描述符(socket, fd)而非函数。

Created with Raphaël 2.2.0 进程 read系统调用(阻塞) 有数据? 处理数据 yes no

进程执行read(是一次系统调用),当内核数据未准备好,进程会阻塞,一直等待数据。
数据准备好后开始拷贝数据,从内核空间拷贝到用户空间。
拷贝完成后,进程才能执行read。

阻塞原理

操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态。运行状态是进程获得cpu使用权,正在执行代码的状态;等待状态是阻塞状态,比如程序运行到recv时,程序会从运行状态变为等待状态,接收到数据后又变回运行状态。操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务。

当某进程A执行socket通信过程中的创建socket时,操作系统会创建一个socket对象,包含发送缓冲区,接收缓冲区,等待队列。当程序执行到recv时,操作系统会将进程A从工作队列移动到等待队列,cpu会继续执行工作队列中的进程,而进程A会被阻塞(不会往下执行代码,也不会占用cpu资源)。只有当接受数据后,操作系统才重新将进程A放回工作队列继续执行。

非阻塞IO

Non-Blocking IO

非阻塞等待,每隔一段时间就去检测IO事件是否就绪。没有就绪就可以做其他事。

非阻塞I/O执行系统调用总是立即返回,不管事件是否已经发生,若事件没有发生,则返回-1,此时可以根据 errno 区分这两种情况,对于accept,recv 和 send,事件未发生时,errno 通常被设置成 EAGAIN。

显然只有在事件已经发生的情况下操作非阻塞IO,才能提高程序的效率
因此非阻塞IO通常与其他IO通知机制一起使用,比如IO复用和SIGNO信号

IO多路复用

IO multiplexing
IO复用是最常使用的IO通知机制。它指的是应用程序通过IO复用函数向内核注册一组事件,内核通过IO复用函数把其中就绪的事件通知给应用程序。

IO复用函数本身也是阻塞的,但是能同时监听多个IO事件(同时阻塞多个IO操作),因此能提高程序效率

Linux下常用的三种IO复用函数是select/poll/epoll

IO复用流程可用以下图来表示:
在这里插入图片描述
程序阻塞于IO复用系统调用,等待套接字变成可读。当数据准备好,返回可读的文件描述符。然后用read读取数据,IO本身的读写操作是非阻塞的,读取数据还是需要从内核空间拷贝到用户空间。

select

主旨思想

  1. 首先构造一个关于文件描述符的列表fds(数组),将要监听的文件描述符添加到该列表中
  2. 调用一个系统函数select,监听该列表中的文件描述符,直到这些文件描述符中的一个或多个进行IO操作,函数返回,中断程序唤醒进程(将进程从等待队列移到工作队列)。
    a. 函数是阻塞的
    b. 函数对文件描述符的检测操作由内核完成
  3. 返回时,告诉进程有多少文件描述符要进行IO操作。
  4. 遍历fds,通过FD_ISSET判断具体收到数据的socket。

select 流程伪代码

int s = socket(AF_INET, SOCK_STREAM, 0);  
bind(s, ...)
listen(s, ...)

int fds[] =  存放需要监听的socket

while(1){
    
    
    int n = select(..., fds, ...)
    for(int i=0; i < fds.size; i++){
    
    
        if(FD_ISSET(fds[i], ...)){
    
    
            //fds[i]的数据处理
        }
    }
}

select API

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

  • 参数
    • nfds:委托内核检测的最大文件描述符值+1(因为文件描述符是从0开始计数)

    • readfds:要检测的文件描述符的读的集合,对应的是对方发过来的数据,检测的是读缓冲区

      是一个传入传出参数,传入要检测的(FD_SET将标志位置为1),根据FD_ISSET判断标志位是否为1,传出检测到的(用FD_SET置为1,无数据用FD_CLR置为0)

    • writefds:要检测的文件描述符的写的集合,检测内核写缓冲区是否有写空间,一般不检测

    • exceptfds:检测异常文件描述符的集合

    • timeout:设置的超时时间

  • 返回值:
    • -1:失败
    • >0(n):检测的集合中有n个文件描述符发生变化

select描述补充

  • readfds、writefds、exceptfds参数分别指向可读、可写、异常事件对应的文件描述符集合。应用程序调用select函数时,通过这3个参数传入自己感兴趣的文件描述符。select返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。

  • fd_set是一个结构体,仅包含一个整型数组,数组的每一位标记一个文件描述符

位操作API(宏)

void FD_CLR(int fd, fd_set *set);

FD_CLR:将参数文件描述符fd对应的标志位设置为0

int FD_ISSET(int fd, fd_set *set);

FD_ISSET:判断fd对应的标志位是0还是1

void FD_SET(int fd, fd_set *set);

FD_SET:将参数文件描述符fd对应的标志位设置为1

缺点

  1. 每次调用select,都需要把fd集合从用户态拷贝到内核态,将进程加入到等待队列,每次唤醒都需要从队列中移除
  2. 同时每次调用select,都需要在内核遍历传递进来的所有fd
  3. 支持的fd数量太小,默认1024(只能支持1024个客户端)(因为fd_set是128字节,即1024位,是整型数组,每一位是一个标志位,对应一个文件描述符)
  4. fd集合不能重用,每次都需要重置

poll

poll系统调用和select类似,在指定时间内轮询一定数量的文件描述符,是select的改进(解决了select缺点3、4)

select用128字节的文件描述符集合readfds标志要检测的文件描述符,在poll中换成了结构体数组,标志要委托检测的和实际发生的分开,使得文件描述符集合可以重用,并且没有128字节限制

API

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

  • 参数:
    • fds:是一个struct pollfd结构体数组,是一个需要检测的文件描述符的集合
    • nfds:指定被监听事件集合fds的大小,是第一个参数数组中最后一个有效元素的下标+1
    • timeout:阻塞时长
  • 返回值:
    • -1:失败,永远阻塞,直到某事件发生
    • 0:立即返回
    • >0(n):n表示检测到集合中有n个文件描述符发生变化

pollfd结构体:

struct pollfd{
      int fd;        //委托内核检测的文件描述符
      short events;  //注册文件描述符的事件
      short revents; //文件描述符实际发生的事件
};

epoll

select低效的原因是将“维护等待队列”和“阻塞进程”两个步骤合二为一,每次调用select都需要执行这两步。epoll把这两步分开,先用epoll_ctl维护等待队列,再调用epoll_wait阻塞进程

epoll原理

创建一个新的eventpoll对象。

int epfd = int epoll_create(int size);

epoll_create创建一个eventpoll对象,epfd文件描述符操作这个对象。参数size无意义。
这个对象中有两个比较重要的数据(还有一个等待列表存放等待的进程)

  • 一个是需要检测的文件描述符的信息(红黑树)
  • 一个是就绪列表,存放检测到数据发生改变的文件描述符信息,对应就绪的socket(双向链表)。当进程被唤醒后,只要获取就绪列表的内容,就能知道哪些socket收到数据

伪代码:

struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = lfd;

struct eventpoll{
    
    
  ...
  struct rb_root rbr;
  struct list_head rdlist;
  ...
};

int lfd = socket(AF_INET, SOCK_STREAM, 0);   
bind(lfd , ...);
listen(lfd , ...);
int cfd = accept(lfd, ...);

int epfd = epoll_create(...);
epoll_ctl(epfd, EPOLL_CTL_ADD,...); //将所有需要监听的socket添加到epfd中

while(1){
    
    
    int ret = epoll_wait(epfd,...);
    for(接收到数据的socket){
    
    
        //处理
        read(...)/recv(...)
    }
}
  • 创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。以添加socket为例,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。
  • 当某个socket收到数据后,中断程序会给eventpoll的“就绪列表rdlist”添加该socket引用。
    eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。
  • 假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。内核会将进程A放入eventpoll的等待队列中,阻塞进程。
  • 当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态。进程A只需要不断循环遍历rdlist,从而获取就绪的socket。
  • 当程序执行到epoll_wait时,其实就是去遍历 rdlist。如果rdlist已经引用了socket,那么epoll_wait直接返回;如果rdlist为空,阻塞进程。

总结

首先epoll初始化用epoll_create创建一个event_poll对象,这个对象有就绪列表,红黑树,等待列表。就绪列表存放就绪的socket,红黑树存放所有正在监听的socket引用,等待列表放正在等待的进程。

每次accept到一个新连接,调用中断在文件系统中创建fd,这个fd里有接收缓存区,发送缓存区,等待列表,(ps:如果已有这个fd,则直接将这个socket加入这个fd的等待列表中,这里理解为fd对应的端口,计算机有65535个端口,所以epoll最多支持65535,而一个端口可能会有许多的连接,因为socket是IP:端口,IP不同,端口一样也是不同的socket,但是对应的端口fd是一个),同时在中断系统里注册一个监听回调函数。一旦某个socket发生了读写操作,中断程序会调用这个socket的回调函数,将这个socket的引用加入到event_poll的就绪队列中,在while程序里,一直都会有epoll_wait(A进程),一旦A进程会一直轮询就绪队列,一旦就绪队列非空,A进程获得其中的socket数据进入系统运行队列,由等待列表的下一个进程继续使用epoll_wait来轮询。

等待列表中的进程,系统每次有空闲进程,就将其放入等待列表中阻塞,epoll_wait有一个time_out参数,在这个等待时间time里,如果就绪队列有socket需要处理,就调用阻塞的进程运行,如果一直为空,当计时器到了,进程变为非阻塞继续去干活。

epoll_ctl()

epoll_ctl添加或删除所要监听的socket。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

  • 参数
    • epfd:epoll实例对应的文件描述符
    • op:要进行的操作
      • EPOLL_CTL_ADD 添加
      • EPOLL_CTL_MOD 修改
      • EPOLL_CTL_DEL 删除
    • fd:要检测的文件描述符
    • event:要检测的事件

epoll_wait()

等待数据

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)

  • 参数
    • epfd:epoll实例对应的文件描述符
    • events:保存发生变化的文件描述符的信息
    • maxevents:第二个参数结构体数组的大小
    • timeout:阻塞时间
      • 0:不阻塞
      • -1:阻塞,直到检测到fd数据发送变化,解除阻塞
      • >0:阻塞的时长
  • 返回值
    • -1:失败
    • >0:发生变化的文件描述符个数

两种工作模式

水平触发LT

边缘触发ET

信号驱动

Linux 用套接口进行信号驱动 IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO事件就绪,进程收到SIGIO 信号,然后处理 IO 事件。
在这里插入图片描述

异步(待补充)

猜你喜欢

转载自blog.csdn.net/MinutkiBegut/article/details/114765623