"高级I/O"

  "Unix环境高级编程"

Posted by Xu on April 15, 2018

高级I/O

非阻塞I/O

系统调用可以分为两类:“低速系统调用”和其他,低俗系统调用是可能使进程永远阻塞的一类系统调用,包括:

  • 某些文件类型(如读管道,终端设备和网络设备)的数据并不存在。读操作可能会使进程永远阻塞。
  • 如果数据不能被相同的文件类型立即接收(如管道中无空间,网络流控制),写操作可能会使的调用者永远阻塞。
  • 在某种条件发生之前打开某文件类型可能会发生阻塞
  • 对已经加上强制性记录锁的文件进行读写
  • 某些ioctl操作
  • 某些进程间通信函数

非阻塞i/o使我们可以发出open,read和write这样的i/o操作,并使得这些操作不会永远阻塞,如果这种操作不能完成,则调用立即出错返回。例如当调用写write时,不会等待用户的输入,而是通过轮询调用的方式,不断调用写操作,如果用户没有输入则出错返回

对于一个给定的描述符,有两种方式为其指定非阻塞i/o的方法,指定对该文件的i/o为非阻塞I/O:

  1. 用open获得描述符,指定O_NONBLOCK标志
  2. 对于一个已经打开的文件描述符,调用fcntl,用该函数打开O_NONBLOCK文件标志位。

记录锁

当一个进程正在读或写文件的某个部分时,使用记录锁,可以阻止其他进程修改同一文件区,对于unix而言,更适合的术语为:“字节范围锁”

使用fcntl加记录锁:

#include<fcntl.h>

int fcntl(int fd, int cmd,.../*struct flock *flockptr*/)

其中cmd为F_GETLK,F_SETTLK或F_SETLKW,第三个参数是一个指向flock结构的锁:

struct flock{
	short l_type;/*文件锁的类型:F_RDLCK(共享读锁),F_WRLCK(独占性写锁),F_UNLCK(解锁一个区域)*/
	short l_whence;/*SEEK_SET(文件首地址作为基址),SEEK_CUR(当前文件指针所指向的位置作为基址),SEEK_END(文件末端作为基址)*/
	off_t l_start;/*相对于l_whence的偏移量作为起始地址*/
	off_t l_len;/*要加锁的区域长度*/
	pid_t l_pid;/*阻挡当前进程加锁的进程ID,由F_GETLK返回*/
}
  • F_GETLK判断是否可以加一把flockptr指向的锁,如果被排斥,则将文件已添加的锁的信息重写到flockptr指向的flock。如果没有被排斥,则只需要将flock中l_type设置为F_UNLCK即可
  • F_SETTLK:设置并添加flock所描述的锁结构,如果不能添加则fcntl会出错返回,此时errno设置为EACCES或EAGAIN
  • F_SETTLKW:是F_SETTLK的阻塞版本,当不能加锁时,则阻塞等待可以加锁,而不是出错返回
  • 当l_len=0时,表示锁的范围可以扩展到最大可能偏移量,不管向该文件中追加了多少数据,都在锁的范围内
  • 一般对整个文件进行加锁,我们通常设置l_start(0),l_whence(SEEK_SET)指向文件首地址,并将l_len设为0

I/O多路转接

当从一个文件描述符读,然后写到另一个文件描述符,可以使用阻塞i/o实现, 然而当一个进程从多个文件描述符读,写到多个描述符中去,一个进程利用阻塞I/O则会严重降低读写效率(这些文件描述符中的读写操作不存在读写顺序的关系)。我们我们需要通过提高并发性的角度来提高读写效率。

  1. 方法1:一个文件描述符对应一个进程完成读写操作,所以需要利用fork来使用多个进程。但这会出现一个新的问题:什么时候终止进程,因为父进程停止后,会使的子进程也被停止

  2. 方法2: 使用线程,需要处理两个线程之间的同步

  3. 方法3:非阻塞i/o,类似于轮询,浪费cpu时间

  4. 方法4:异步i/o当描述符准备好后,用信号通知进程指向读写操作

  5. 方法5:i/o多路转接,使用这个技术,需要先构造一张我们感兴趣的描述符的列表,然后调用一个函数,直到这些描述符已经准备好进行i/o时该函数才返回。poll,select和pselect这三个函数可以实现多路转接。

函数select和pselect(就是一种非阻塞的方式监视socket,通常可以和while一起使用)

传给select的参数:

  1. 我们所关心的描述符
  2. 对于每个描述符我们所关注的条件(想读还是想写,是否关心异常情况?)
  3. 愿意等待多长时间
  4. 已经准备好的描述符的总数量
  5. 对于读,写或异常这三个条件中每一个,哪些描述符已经准备好。
int select(int maxfdpl,fd_set *restrict readfds,fd_set *restrict writfds,fd_set *restrict exceptfds,struct timeval *restrict tvptr);

struct timeval{
    long tv_sec;//秒数
    long tv_usec;//微秒数
}
  • maxfdp1表示三个文件描述符集合中的最大文件描述符+1(p1就是plus1,出于效率的目的)
  • readfds,writfds,exceptfds分别代表我们关心的可读,可写,处于异常的文件描述符集合,fd_set相当于一个位图。

      //对位图的操作
      void FD_ZERO(fd_set *fdset);//全部清0
      void FD_SET(int fd,fd_set *fdset);//指定位置位
      void FD_CLR(int fd,fd_set *fdset);//指定位清零
      void FD_ISSET(int fd,fd_set *fdset);//测试指定位
    
  • tvptr表示我们愿意等待的时间
    • tvptr == NULL,永远等待,直到描述符准备好,或捕捉到一个信号,select返回-1,errno设置为EINTR.
    • tvptr->tv_sec,tvptr->tv_usec。设置等待时长的秒和微秒数。
      • 都为0时,相当于轮询找到多个描述符的状态,并不阻塞select
      • 都大于0时,只要在超时时长内只要有一个文件描述符准备好,或超时时返回

select函数有三个返回值:

  • 返回-1:在三个描述符集合指定的描述符一个都没“准备好”之前就捕捉到一个信号,此种情况下,描述符集不用修改
  • 返回0:指定的描述符还没有一个准备好就已经超时了,所有描述符集都会置0
  • 返回正值:返回已经准备好的描述符数之和(不是数量),三个描述符集中只打开那些准备好的描述符位

“准备好”:读写操作时不会阻塞,如果阻塞只会阻塞select所设定的时长。

套接口四个准备好读的条件:

  1. 套接口接收缓冲区中的数据字节数大于等于套接口接收缓冲区低潮标记(low-water)的当前大小
  2. TCP连接的读这一半关闭,也就是接收到FIN的TCP客户端(服务器端不会再发送数据了,但客户端还可以继续发送数据),对这样的套接口进行读操作不会阻塞并会返回0
  3. 套接口为监听套接口(被动),且套接口中已完成的连接数大于0,accept准备好读,不会阻塞
  4. 套接口错误待处理,对该套接口的读会返回-1并将errno设置为具体的错误条件。

套接口四个准备好读的条件:

  1. 发送缓冲区大于等于低潮标记的当前大小,可以通过套接字选项SO_SNDLOWAT来设置低潮标记,TCP和UDP默认为2048
  2. 连接写这一半关闭,发送FIN的这一端,对这样的套接字进行写操作会产生SIGPIPE信号
  3. 该套接口早些使用非阻塞式connect来建立连接,并且连接已经异步建立,或connect已经失败(不懂)
  4. 套接字有错误产生,向该套接字写,不阻塞,直接返回-1,并将errno设置为具体错误条件。

socket_prepare

pselect的时长设置的更加精确,并且可以设置信号掩码,可以使得进程禁止递交某些信号,然后再测试这些描述符是否可读,可写或异常。

函数poll

类似于select,接口有所不同:

int poll(struct pollfd fdarray[],nfds_t nfds, int timeout);

struct pollfd{
	int fd;//文件描述符
	short events;//表示我们关心该文件的哪些事件
	short revents;//说明该文件上发生的事件
}

fdarray[]存储我们所关心的文件描述符,将三个文件描述符统一在一起,nfds表示这些文件描述符的数量,timeout表示等待时长(-1永远等待,=0轮询所有描述符的状态,>0表示等待的毫秒数)。

events和revents可能出现的常量值:

poll_events

select和poll的缺点

  1. select单个进程能够监视的文件描述符数量具有限制,通常是1024(poll没有这个限制),轮询方式,描述符数量越多,性能越差
  2. 需要维护一个用来存放大量fd的数据结构(位图),这样会使得用户空间和内核空间在传递该结构时复制开销大
  3. 需要在返回后,通过遍历文件描述符(返回之后还是需要遍历)来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
  4. 水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select和poll调用还是会将这些文件描述符通知进程

epoll

epoll的设计和实现与select完全不同,它将原select/poll操作分为三步调用:

  1. epoll_create()建立一个epoll对象,存放所有需要监听的文件描述符对象
  2. epoll_ctl()将需要监听的连接(100万)的socket添加到epoll中
  3. epoll_wait()收集发生事情的连接

epoll对象结构体如下(红黑树rbr存放这些要监控的文件描述符,用链表存放这些文件描述符中发生的事件,所以不用遍历,直接返回rdlist):

struct eventpoll{  
    ....  
    /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/  
    struct rb_root  rbr;  
    /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/  
    struct list_head rdlist;  
    ....  
};  

添加到该红黑树中的节点都会和设备(网卡)驱动程序建立回调关系,回调方法在内核中叫ep_poll_callback,当事件发生时会调用该回调方法,将发生的事件epitem添加到rdlist中。当调用epoll_wait()时会返回该链表。

其中epitem结构体如下:

struct epitem{  
    struct rb_node  rbn;//红黑树节点  
    struct list_head    rdllink;//双向链表节点  
    struct epoll_filefd  ffd;  //事件句柄信息  
    struct eventpoll *ep;    //指向其所属的eventpoll对象  
    struct epoll_event event; //期待发生的事件类型  
} 

epoll优点(类似于异步I/O,回调类似于通知机制)

  1. 利用红黑树,结合回调机制,构建epoll处理高并发的多路转接的高效
  2. 没有文件描述符的限制,因为可以在红黑树中随意添加删除
  3. 没有大量的内核到用户态的拷贝,因为只需要返回那些有事件发生的对象rdlist(所以比较适合有大量(100万)连接,但通常只有少量(成百上千)的连接是活跃的场景)
  4. 不需要轮询,epoll_wait()直接返回rdlist即可。
  5. 既可以水平触发(epoll默认,没有指行对应的i/o操作就持续触发),也可以边缘触发(文件描述符就绪后,只会触发一次)。