Linux --- 高级IO

Posted Moua

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux --- 高级IO相关的知识,希望对你有一定的参考价值。

1、举例理解五种IO模型

在IO模型中,一般将IO操作分为两步,第一步就是等待数据准备好(记为“等”),第二步就是将数据从内核拷贝到用户区(记为“拷贝”)。

钓鱼例子:钓鱼我们认为有两步,第一步就是等鱼上钩(记为“等”),第二步就是钓鱼(记为“掉”)。

阻塞IO:在内核将数据准备好之前,系统调用会一直等待。所有的套接字默认都是阻塞方式的。

钓鱼例子之张三钓鱼:张三是一个非常认真的人,他在钓鱼时只顾钓鱼,完全不受外界任何事情的影响。在等鱼上钩的过程中,他就静静的坐在凳子上看着鱼鳔,即使有人找他他也不会搭理。当鱼上钩时,将鱼钓上来再继续下一的等。

在阻塞IO中,内核如果没有准备好数据,系统调用就会像张三一样,静静的等待数据准备。数据准备好后,系统调用将数据从内核拷贝到用户缓冲区,完成一次IO操作。

非阻塞IO:如果内核没有将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码。

钓鱼例子之李四钓鱼:李四是一个活泼好动的人,他在钓鱼时不同于张三。李四在钓鱼时并不会一直在等鱼上钩,鱼没上钩他就去找张三说话,但是张三完全不会搭理他的话,李四感到自讨无趣就继续回去看他的鱼竿是否有鱼上钩,如果有鱼上钩天涯就 钓鱼,如果没有他就继续取自讨无趣找张三说话(实际上是在自言自语,张三并不会搭理他)。就这样轮询的取找张三说话,在过去看是否有鱼上钩。

在非阻塞IO中,系统调用并不会因为内核中数据为准备好一直等待,如果内核中数据没有准备好他就直接返回。需要注意的是:在非阻塞IO往往需要程序员循环的方式反复读写文件描述符,这个过程称为轮询,对CPU来说是一个较大的浪费,只有特定场景下才会使用。

信号驱动IO:内核将数据准备好时,使用SIGIO通知应用进程拷贝数据。

钓鱼例子之王五钓鱼:王五是一个爱学习的人,他在钓鱼时都会带一本书,同时,它给鱼竿上放一个铃铛,当鱼上钩时铃铛就会响。他在钓鱼时,将鱼竿放入水中之后他就开始专心看书,当听到铃铛响他就知道有鱼上钩了,就会放下手中的书将鱼钓上来。

在信号驱动IO中,应用进程发现内核数据并未准备好,会继续执行。当内核数据准备好后,给进城发送SIGIO信号,进程再去读取数据。这样,解决了阻塞IO和非阻塞IO中的效率问题。

IO多路转接:虽然从流程图上看起来和阻塞IO类似. 实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态.
钓鱼例子之赵六钓鱼:赵六是一个土豪,别人每次钓鱼拿一个鱼竿钓鱼,它每次直接拉一车鱼竿钓鱼。将所有鱼竿放进水中,他就开始在拿转悠看那个鱼竿有鱼上钩了,将上钩的鱼钓上来。他虽然和张三一样,都是静静的在哪等鱼上钩,但是不同的是赵六等的是一车鱼竿而张三只等一个鱼竿。
 在IO多路转接中,应用进程一次可以阻塞的等待多个文件描述符,那个文件描述符的数据准备好了应用进程就将内核数据拷贝到该进程的用户区中。

 异步IO:由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)。

钓鱼例子之田七钓鱼:田七一个名副其实的土豪,他和他的司机开车拿着一个鱼竿去钓鱼,到河边后田七告诉司机,你去拿着鱼竿钓鱼,我去打会麻将,等鱼掉好了你给我打电话我来接你,晚上一起烤鱼。

异步IO他最主要的特点就是,用户进程只需要告诉内核需要进行IO操作,等内核将数据准备好并拷贝到用户区后给用户进程发送信号,告诉用户进程数据准备好了。用户进程在处理数据。

 总结:任何IO过程中,都包含两个步骤:等和拷贝,而在实际的应用中,等待消耗的时间往往都远远高于拷贝消耗的时间,让IO更高效最核心的办法就是让等待的时间尽量减少。

2、理解同步和异步、阻塞和非阻塞

同步通信和异步通信

  • 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回. 但是一旦调用返回,就得到返回值了; 换句话说,就是由调用者主动等待这个调用的结果;
  • 异步则是相反, 调用在发出之后,这个调用就直接返回了,所以没有返回结果; 换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果; 而是在调用发出后, 被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。

需要注意的是,这里的同步和进程间通信的同步是完全不同的概念。

  • 进程/线程同步也是进程/线程之间直接的制约关系
  • 是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调他们的工作次序而等待、传递信息所产生的制约关系. 尤其是在访问临界资源的时候。

阻塞和非阻塞

阻塞和非阻塞关注的是程序在在等待调用结果时的状态。

  • 阻塞调用是指调用结果返回之前,当前线程会被挂起. 调用线程只有在得到结果之后才会返回
  • 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。

3、非阻塞IO

fcntl

int fcntl(int fd, int cmd, ... /* arg */ );

根据传入的cmd参数的不同,fcntl有以下五种功能(一般我们使用第三个):

  •  复制一个现有的描述符(cmd=F_DUPFD) .
  • 获得/设置文件描述符标记(cmd=F_GETFD或F_SETFD).
  • 获得/设置文件状态标记(cmd=F_GETFL或F_SETFL).
  • 获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN).
  • 获得/设置记录锁(cmd=F_GETLK,F_SETLK或F_SETLKW)

实现setNonBlock函数

基于fcntl函数,实现一个将文件描述符设置为非阻塞的函数。

void SetNoBlock(int fd) 
{
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0) 
    {
        perror("fcntl");
        return;
    }
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}

前边说过高效IO最主要的就是减少等的时间,因此下面要讲的几种IO多路转接模型,最主要的就是减少等的时间(提高等的效率,同一时间等更多的文件描述符)。

4、IO多路转接 --- select

1)select概念

select是一个系统调用,用来实现多路复用输入输出模型。

  • select系统调用的作用是让程序等待多个文件描述符的状态变化
  • 程序会在select这里等待,直到有一个或多个文件描述符的状态发生改变。

函数原型如下:

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

nfds:表示需要监视的最大的文件描述符+1(select需要等待多个文件描述符的,文件描述符是被保存在fd_array数组中,那么select要监视多个肯定需要遍历fd_array数组,因此只需要给出最大文件描述符+1就可以进行遍历。)

readfds writefds exceptfds:输出型参数,分别表示可读文件描述符的集合、可写文件描述符的集合、异常文件描述符的集合。

timeout:是一个结构体,用来设置select的等待时间。当timeout为NULL时select会一直被阻塞,直到某个文件描述符上发生了时间;为0时仅检测文件描述符集合的状态,然后立即返回,并不等待外部事件的发生;指定时间,如果在该时间内没有发生时间select将超时返回。

 fd_set结构:

其实fd_set就是一个位图,用对应的比特位表示要监视的文件描述符。

操作系统提供了一些系统调用接口,来操作位图:

void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位

struct timeval结构: 

 timeval结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为0。

select函数返回值:

  • 执行成功则返回文件描述符状态已经改变的个数
  • 如果返回值为0则表示在timeout时间结束都没有文件描述符状态发生改变
  • 出错返回-1

2)select执行过程

  • fd_set  set;将set清空:FD_ZERO(&set)
  • 使用FD_SET将需要等待的文件描述符置为1(假设等待的是fd = 1 fd = 2  fd = 5)
  • 执行select阻塞等待(select(6,&set,0,0,0))
  • select返回,输出型参数fd_set等待到的文件描述符不变,没有等到的会被置为0.(最终set的5文件描述符会被置为0)

3)socket就绪条件

读就绪:

为了保证效率socket并不是说缓冲区有数据就立刻通知应用进程拷贝数据,而是有一个低水位线,接收缓冲区的字节数大于等于低水位线时,可以无阻塞的读并且select函数的返回值大于0

socket TCP通信中, 对端关闭连接, 此时对该socket读, 则返回0;

写就绪:

socket内核中, 发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小), 大于等于低水位标记
SO_SNDLOWAT, 此时可以无阻塞的写, 并且返回值大于0;
socket的写操作被关闭(close或者shutdown). 对一个写操作被关闭的socket进行写操作, 会触发SIGPIPE信号

3)select的特点和缺点

特点:

  • 可监控的文件描述符个数取决于sizeof(fd_set)的值。
  • 将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd(一是用于再select 返回后, array作为源数据和fd_set进行FD_ISSET判断。
    二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第一个参数)

缺点:

  • 每次调用select都需要手动设置fd_Set集合
  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  • 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  • select支持的文件描述符数量太小

 简单实现

#include "Sock.hpp"
#include <sys/time.h>

#define DFL_PORT 8080
#define MAX_FD_SET sizeof(fd_set)

class Select
{
private:
    int lsock;
    int port; 
    int fd_arry[MAX_FD_SET] = {-1};
public:
    Select(int _port = DFL_PORT):port(_port)
    {}

    void InitServer()
    {
        lsock = Sock::Socket();
        Sock::SetSockOpt(lsock);
        Sock::Bind(lsock,port);
        Sock::Listen(lsock);

        //一开始只有一个文件描述符:将lsock加入到fd_array中
        fd_arry[0] = lsock;
    }
     void AddFd2Array(int sock)
     {
          size_t i = 0;
          for( ; i < MAX_FD_SET; i++ )
          {
              if(fd_arry[i] == -1)
              {
                  break;
              }
          }
          if(i >= MAX_FD_SET)
          {
              //fd_Set设置满了
              cerr << "fd array is full, close sock" << endl;
              close(sock);
          }
          else
          {
              fd_arry[i] = sock;
              cout << "fd: " << sock << " add to select ..." << endl;
          }
     }

    void DefFdFromArray(size_t index)
    {
        if(index >=0 && index < MAX_FD_SET)
        {
            fd_arry[index] = -1;
        }
    }
    void HandlerEvent(fd_set* rfd)
    {
        //遍历rfd
        for(size_t i = 0;i < MAX_FD_SET;i++)
        {
           if(fd_arry[i] == -1)
              continue;
          
           if(FD_ISSET(fd_arry[i],rfd))
           {
              if(fd_arry[i] == lsock)
              {
                  int sk = Sock::Accept(lsock);
                  if(sk > 0)
                  {
                      cout<<"get a new link..."<<endl;
                      AddFd2Array(sk); 
                  }
              }
              else 
              {
                  char buf[10240];
                  ssize_t s= recv(fd_arry[i], buf, sizeof(buf), 0);
                  if(s>0)
                  {
                      buf[s] = 0;
                      cout << "client# " << buf << endl;
                  }
                  else if(s == 0)
                  {
                      cout << "clien quit" << endl;
                      close(fd_arry[i]);
                      DefFdFromArray(i);
                  }
              }
           }
        }
    }

    void SatrtServer()
    {
        int maxfd = -1;
        for(;;)
        {
            fd_set rfd;
            FD_ZERO(&rfd); 
            //将fd_srray设置仅rfd
            for(size_t i = 0;i < MAX_FD_SET;i++)
            {
                if(fd_arry[i] > maxfd)
                    maxfd = fd_arry[i];
                FD_SET(fd_arry[i],&rfd);
            }

            switch(select(maxfd+1,&rfd,nullptr,nullptr,nullptr))
            {
                case 0:
                  cout<<"timeout ..."<<endl;
                  break;
                case -1:
                  cout<<"wait error..."<<endl;
                  break;
                default:
                  HandlerEvent(&rfd);
                  break;
            }
        }
    }
};

5、IO多路转接 --- poll

1)相关接口

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd结构
struct pollfd {
        int fd; /* file descriptor */
        short events; /* requested events */
        short revents; /* returned events */
};

参数说明:

  • fds是一个poll函数监听的结构列表. 每一个元素中, 包含了三部分内容: 文件描述符, 监听的事件集合, 返回的事件集合.
  • nfds表示fds数组的长度.
  • timeout表示poll函数的超时时间, 单位是毫秒(ms)

events和revents的取值

  • POLLIN 读数据

  • POLLPRI There is urgent data to read (e.g., out-of-band data on TCP socket; pseudoterminal master in packet mode has seen state change in slave).

  •  POLLOUT Writing now will not block.

  • POLLRDHUP (since Linux 2.6.17)  Stream  socket  peer  closed  connection,  or  shut  down writing half of connection.  The _GNU_SOURCE feature test macro must be defined (before
    including any header files) in order to obtain this definition.

  • POLLERR  Error condition (output only).

  • POLLHUP   Hang up (output only).

  • POLLNVAL  Invalid request: fd not open (output only).When compiling with _XOPEN_SOURCE defined, one also has the following, which convey no further information beyond the bits listed above:

  • POLLRDNORM  Equivalent to POLLIN.

  • POLLRDBAND  Priority band data can be read (generally unused on Linux).

  • POLLWRNORM  Equivalent to POLLOUT.

  • POLLWRBAND Priority data may be written.

返回值:

  • 返回值小于0, 表示出错;
  • 返回值等于0, 表示poll函数等待超时;
  • 返回值大于0, 表示poll由于监听的文件描述符就绪而返回

2)poll的优点和缺点

优点

  • 不同与select使用三个位图来表示三个fdset的方式, poll使用一个pollfd的指针实现.
  • pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式. 接口使用比select更方便
  • poll并没有最大数量限制

缺点

  • poll中监听的文件描述符数目增多时
  • 和select函数一样, poll返回后,需要轮询pollfd来获取就绪的描述符.
  • 每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中.
  • 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增长, 其效率也会线性下降
int main()
{
    struct pollfd rfds[1];
    rfds[0].fd = 1;
    rfds[0].events = POLLOUT;
    rfds[0].revents = 0;

    char buf[1024] = {0};

    cout << "poll begin..." << endl;
    while(true){
        switch(poll(rfds, 1, 1000)){
            case 0:
                cout << "time out ..." << endl;
                break;
            case -1:
                cout << "poll error" << endl;
                break;
            default:
                cout << "events happen!" << endl;
                //HandlerEvents(rfds);
                if(rfds[0].fd == 1 && (rfds[0].revents & POLLOUT)){
                    printf("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
                    //cout << "hello world";
                   // ssize_t s = read(0, buf, sizeof(buf));
                   // buf[s] = 0;
                   // cout <<"echo# "<< buf <<endl;
                }
                break;
        }
    }
    return 0;
}

6、IO多路转接 --- epoll

1)相关接口

创建句柄:int epoll_create(int size);

  • 注意:epool_create的的参数不需要设置,随便给值即可,一般给成64或者128
  • epoll_create的返回值实际上是一个文件描述符,使用结束后需要close关闭。

事件注册:int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

  • epfd是epoll_create的返回值
  • op表示要进行的操作,用三个宏EPOLL_CTL_MOD EPOLL_CTL_ADD EPOLL_CTL_DEL表示修改、增加和删除。
  • 第三个参数fd表示要设置的文件描述符
  • 第四个参数event表示要监听的事件
  • 总结:epoll_ctl函数的功能就是告诉内核要对那个文件描述符上的额什么操作进行什么事件

收集epoll监控的事件中已经发生的事件:int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

 

2)工作原理

3)select、poll和epoll的对比

select最主要的缺点就是,每次调用select需要手动设置fd_set、当fd_set中保存的文件描述符过多时必然会产生较大的系统到内核内核到系统的数据拷贝开销并且在内核内部需要阻塞的关注这些文件描述符的状态,因此操作系统就需要遍历这些文件描述符,这个开销在文件描述符过多时也是给常大的,还有一个问题就是fd_set的大小是有限(我电脑上是128字节)的,也就是说select只能关注有限个文件描述符的状态;poll模型使用一个结构体保存文件描述符,不同于select使用三个位图表示,其次最主要的就是poll可以设置的文件描述符的大小并没有限制,但是使用poll每次也需要将数据从内核拷贝到用户从用户拷贝到内核,其次也需要操作系统轮询检测这些文件描述符,同时select和poll都需要使用一个数组将设置的文件描述符保存,一是为了方便对比(跟fd_Set上返回的就绪时间对比,查看哪些事件就绪),二是select会将没有就绪的文件描述符置为0.只是poll每次将就绪的文件描述符保存在一个数组中,每次只需要遍历数组就可以取到就绪的文件描述符。

epoll解决了select和poll的所有缺点,epoll使用三个接口进行控制。用户调用epoll_sreate时操作系统底层会创建红黑树、创建回调机制、创建就绪队列,当用户调用epoll_ctl时,会将文件描述符保存在红黑树中,并未文件描述符创建回调函数、当文件描述符上的事件就绪就会调用回调函数将对应的红黑树节点插入到就绪队列,当调用epoll_wait时会会从就绪队列中将就绪的节点返回给用户。epoll不需要操作系统轮询遍历所有文件描述符,epoll不需要每次将所有文件描述符都拷贝给用户(select每调用陪你过一次,就需要将fd_set中的所有文件描述符拷贝一次),其次每次从红黑树中取就绪节点,时间复杂度为O(1)同时epoll支持大量的文件描述符。

4)epoll工作方式

  • 我们已经把一个tcp socket添加到epoll描述符
  • 这个时候socket的另一端被写入了2KB的数据
  • 调用epoll_wait,并且它会返回. 说明它已经准备好读取操作
  • 然后调用read, 只读取了1KB的数据
  • 继续调用epoll_wait

水平触发LT

epoll默认状态下是LT工作模式:

  • 当epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分.
  • 如上面的例子, 由于只读了1K数据, 缓冲区中还剩1K数据, 在第二次调用 epoll_wait 时, epoll_wait
  • 仍然会立刻返回并通知socket读事件就绪.
  • 直到缓冲区上所有的数据都被处理完, epoll_wait 才不会立刻返回.
  • 支持阻塞读写和非阻塞读写

边缘出发ET

如果我们在第1步将socket添加到epoll描述符的时候使用了EPOLLET标志, epoll进入ET工作模式.

  • 当epoll检测到socket上事件就绪时, 必须立刻处理.
  • 如上面的例子, 虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第二次调用 epoll_wait 的时候,
  • epoll_wait 不会再返回了.
  • 也就是说, ET模式下, 文件描述符上的事件就绪后, 只有一次处理机会.
  • ET的性能比LT性能更高( epoll_wait 返回的次数少了很多). nginx默认采用ET模式使用epoll.
  • 只支持非阻塞的读写

注意:select和poll其实也是在LT模式下工作的

LT是 epoll 的默认行为. 使用 ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完.相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话, 其实性能也是一样的.另一方面, ET 的代码复杂程度更高了。

理解ET模式和非阻塞文件描述符
使用 ET 模式的 epoll, 需要将文件描述设置为非阻塞. 这个不是接口上的要求, 而是 "工程实践" 上的要求.假设这样的场景: 服务器接受到一个10k的请求, 会向客户端返回一个应答数据. 如果客户端收不到应答, 不会发送第二个10k请求.如果服务端写的代码是阻塞式的read, 并且一次只 read 1k 数据的话(read不能保证一次就把所有的数据都读出来,参考 man 手册的说明, 可能被信号打断), 剩下的9k数据就会待在缓冲区中,此时由于 epoll 是ET模式, 并不会认为文件描述符读就绪. epoll_wait 就不会再次返回. 剩下的 9k 数据会一直在缓冲区中. 直到下一次客户端再给服务器写数据. epoll_wait 才能返回,但是问题来了.服务器只读到1k个数据, 要10k读完才会给客户端返回响应数据.客户端要读到服务器的响应, 才会发送下一个请求客户端发送了下一个请求, epoll_wait 才会返回, 才能去读缓冲区中剩余的数据.所以, 为了解决上述问题(阻塞read不一定能一下把完整的请求读完), 于是就可以使用非阻塞轮训的方式来读缓冲区,保证一定能把完整的请求都读出来.而如果是LT没这个问题. 只要缓冲区中的数据没读完, 就能够让 epoll_wait 返回文件描述符读就绪。

epoll的惊群问题
在多线程或者多进程环境下,有些人为了提高程序的稳定性(当有多个请求时,程序的效率可以得到保障),往往会让多个线程或者多个进程同时在epoll_wait监听的socket描述符。当一个新的链接请求进来时,操作系统不知道选派那个线程或者进程处理此事件,则干脆将其中几个线程或者进程给唤醒,而实际上只有其中一个进程或者线程能够成功处理accept事件,其他线程都将失败,且errno错误码为EAGAIN。这种现象称为惊群效应,结果是肯定的,惊群效应肯定会带来资源的消耗和性能的影响。

这种情况,不建议让多个线程同时在epoll_wait监听的socket,而是让其中一个线程epoll_wait监听的socket,当有新的链接请求进来之后,由epoll_wait的线程调用accept,建立新的连接,然后交给其他工作线程处理后续的数据读写请求,这样就可以避免了由于多线程环境下的epoll_wait惊群效应问题。

以上是关于Linux --- 高级IO的主要内容,如果未能解决你的问题,请参考以下文章

Linux高级IO

Linux 高级IO

Linux 高级IO

Linux --- 高级IO

Linux----高级IO(参考UNP)

Linux --- 高级IO