2

非阻塞I/O

在最前面,我们讲过IO分成带缓冲的IO和不带缓冲的IO,但是实际上,这个区别并不是很大,因为缓冲区并没有影响到实际的读写。我们知道,系统调用实际上分成两种,高速的系统调用和低速的系统调用,换句话说,低速的调用会导致系统永久性阻塞,但是需要注意的是,并不是磁盘IO都是低速调用。比如open、read、write函数,如果这些操作不能完成就会立刻出错返回,并不会导致系统阻塞。在前面的时候我们也学到过,如果在open的时刻,指定O_NONBLOCK,或者在一个已打开的文件描述符上调用fcntl函数,附加上O_NONBLOCK参数。实际上虽然指定了参数,但是在某些情况下很有可能丢失信息。在大量传输信息的时候容易出现系统调用大量失败的情况。

记录锁

在很多情况下,我们需要面对多方一起操作文件的情况,这就是一个典型的资源竞争冲突,为了保证文件的正确读写,Unix系统提供了文件记录锁的机制,也就是上文中提到过的文件记录锁。为了提供这个功能,各个系统都自行实现了API,其中,POSIX1.x标准规定的是fcntl方法,而BSD系列则是规定flock方法,SystemV在fcntl方法的基础上构建了lockf函数

fcntl函数

int fcntl(int fildes, int cmd, ...);

The commands available for advisory record locking are as follows:

F_GETLK    Get the first lock that blocks the lock description pointed to by the third argument, arg, taken as a pointer to a struct flock (see above).  The information retrieved overwrites the information passed to fcntl in the flock structure.  If no lock is found that would prevent this lock from being created, the structure is left unchanged by this function call except for the lock type which is set to F_UNLCK.

F_SETLK    Set or clear a file segment lock according to the lock description pointed to by the third argument, arg, taken as a pointer to a struct flock (see above).  F_SETLK is used to establish shared (or read) locks (F_RDLCK) or exclusive (or write) locks, (F_WRLCK), as well as remove either type of lock (F_UNLCK).  If a shared or exclusive lock cannot be set, fcntl returns immediately with EAGAIN.

F_SETLKW   This command is the same as F_SETLK except that if a shared or exclusive lock is blocked by other locks, the process waits until the request can be satisfied.  If a signal that is to be caught is received while fcntl is waiting for a region, the fcntl will be interrupted if the signal han-dler has not specified the SA_RESTART (see sigaction(2)).

前面也介绍过这个函数,不过这次会讲解记录锁的内容,对于记录所来说,cmd参数是F_GETLKF_SETLK或者FSETLKW,第三个参数是一个纸箱flock结构体的指针

struct flock {
    off_t       l_start;    /* starting offset */
    off_t       l_len;      /* len = 0 means until end of file */
    pid_t       l_pid;      /* lock owner */
    short       l_type;     /* lock type: read/write, etc. */
    short       l_whence;   /* type of l_start */
};

基本上也不用讲解了,注释早已说明一切。这个结构体就是通过指定文件区域和锁的类型等参数锁定文件。不过需要注意的是,l_type实际上是取值SEEK_SETSEEK_CUR、或SEEK_END。并且上面提到的类型只有两种:共享读锁和独占写锁,实际上就是读写锁。

  • F_GETLK参数判断flockptr参数所描述的锁是否会被另一把锁排斥

  • F_SETLK参数设置由flockptr所描述的锁

  • F_SETLKW这是F_SETLK的阻塞版本

很容易想到,在开发中肯定是先用F_GETLK参数测试是否能建立一把锁,而后使用F_SETLK或者F_SETLKW建立锁,但是这两者并不是原子操作,前面已经讲过,非原子操作很容易导致操作冲突。
在设置释放锁的时候,内核是根据字节数维持锁的范围的,也就是说,实际上内核只是维护了一个flock结构体的链表,然后每次的锁更改都会导致链表被遍历并且合并。
对于记录锁的自动继承和释放有3条规则:

  1. 锁和进程、文件相关联,换言之,一个进程结束的时候,所有的锁全部释放,这实际上是exit函数做清理的,第二就是文件描述符关闭的时候,该文件所有的锁都会关闭

  2. fork产生的子进程不继承父进程的锁。因为锁是用于限制多个进程读写同一个文件的,如果fork能继承锁,那就起不到约束作用了

  3. 执行exec后,新程序继承原执行程序的锁,但是close_on_exec则会不一样。

其实锁对数据库这种大量读写IO的程序才是最有用的,所以基本上锁就可以直接考虑数据库的环境,如果数据库的客户端库使用的是同一套锁机制,那就能保证文件的共享访问,但是建议性锁无法保证其他有权限存取数据库文件的进程读写此文件。而强制性锁则会让进程检查每一个open、read和write函数,验证调用进程是否违背了正在访问文件的锁,这就是强制性锁和建议性锁的区别。

IO多路转接

前面谈到过,对于内核来说,IO只有两种方式:阻塞和非阻塞,阻塞IO会导致CPU等待IO从而浪费等待时间,所以系统提供了非阻塞IO,但是非阻塞IO带来的问题就是完整IO没有完成,为了获取完整的数据,应用程序需要重复调用IO操作来确认是否完成,也就是轮询。
当从一个文件描述符读,然后又写到另一个描述符时,通常会写出以下代码

while ((n = read(STDIN_FILENO, buf, BUFSIZE)) > 0)
    if (write(STDOUT_FILENO, buf, n) != n)
        err_sys("write error");

这种循环获取的形式就是轮询,非常简单,但是消耗了CPU资源,并且如果需要有更高的要求,比如必须从两个文件描述符读取。
典型的应用就是网络守护进程,例如Nginx和Telnet,这里直接拿原著中的Telnet讲解,telnet由于存在两个输入两个输出,所以不能使用阻塞式的IO函数,开发者的第一反应,应该是fork函数,使用两个进程,每个进程都负责一条读写通道,但是这就需要进程同步,而多线程编程也同样是这样的问题。
另一个方法就是使用一个进程,但是使用非阻塞IO读取数据。其基本思想很简单,两个描述符都读取,但是一直处于循环,每次循环都查询一次两个文件描述符,如果没有就立刻返回不阻塞,这种循环就是典型的轮询,这是种非常常见的技术,实际上却是非常浪费CPU资源的技术,所以目前,基本开发以及不能也不推荐了。
还有几种技术就是异步IO,这种技术实质上就是类似通知,当描述符准备完毕后,进程通知内核,但是实际上目前原生API并不能做到移植,所以,目前大部分的开发,包括Node.js等在内的网络服务,基本都是使用第三方或者自己实现线程池。不过,目前Linux系统已经有了名为AIO的原生异步IO。
现在目前大部分的使用方式就是IO多路转接,系统构造一张链表,里面存储所有的文件描述符,然后调用函数侦听,知道其中一个已经准备完毕的时候返回。poll、pselect和select三个函数就是这样执行的。

select和pselect函数

这连个函数是POSIX规定的

int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict errorfds, struct timeval *restrict timeout);
int pselect(int nfds, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict errorfds, const struct timespec *restrict timeout, const sigset_t *restrict sigmask);

第一个参数nfds的意思就是“最大文件描述符编号值+1”,因为文件描述符都是从0开始的,从后面readfds、writefds、errorfds中找出最大描述符编号值并+1就是这个参数的值,中间三个参数是指向描述符集的指针,使用fd_set数据结构表示,实际上有下列五个函数

void FD_CLR(fd, fd_set *fdset);
void FD_COPY(fd_set *fdset_orig, fd_set *fdset_copy);
void FD_ISSET(fd, fd_set *fdset);
void FD_SET(fd, fd_set *fdset);
void FD_ZERO(fd_set *fdset);

是不是发现比原著多了一个FD_COPY函数,实际上就是复制用的,无关紧要。最后一个参数就是制定愿意等待的时间长度,使用timeval结构体,也就是可以指定秒和微妙单位。

  1. timeout == NULL,永远等待

  2. timeout->tv_sec == 0 && timeout->tv_usec == 0,不等待

  3. timeout->tv_sec != 0 || timeout->tv_usec != 0,等待指定时间

select实际上和描述符本身阻塞无关,它只是简化了我们监听一堆文件描述符的繁琐操作,除了select以外,上面还有一个select的变体pselect,pselect和select很像,但是select得超时值用timeval结构体定义,pselect使用timespec结构,pselect可使用可选信号屏蔽字,如果sigmask为null,则两者一样,但是sigmask指向屏蔽字的时候,将以原子操作形式安装屏蔽字。

poll函数

除了select以外,大家应该还见过poll函数

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

看起来poll函数相对于select更加简洁易懂,select函数对三种类型都指定了参数用于构造描述符集,但是poll函数使用的则是pollfd结构体数组,pollfd结构体如下

struct pollfd {
    int    fd;       /* file descriptor */
    short  events;   /* events to look for */
    short  revents;  /* events returned */
};

nfds参数指定了fds数组的大小,从上面的注释中应该也看得出来结构体究竟是怎么构造的,events是我们关心fd的事件,而revents则是内核设置,返回的时候用于说明每个描述符发生了哪些事件。

     The event bitmasks in events and revents have the following bits:

     POLLERR        An exceptional condition has occurred on the device or socket.  This flag is output only, and ignored if present in the input events bitmask.

     POLLHUP        The device or socket has been disconnected.  This flag is output only, and ignored if present in the input events bitmask.  Note that POLLHUP
                    and POLLOUT are mutually exclusive and should never be present in the revents bitmask at the same time.

     POLLIN         Data other than high priority data may be read without blocking.  This is equivalent to ( POLLRDNORM | POLLRDBAND ).

     POLLNVAL       The file descriptor is not open.  This flag is output only, and ignored if present in the input events bitmask.

     POLLOUT        Normal data may be written without blocking.  This is equivalent to POLLWRNORM.

     POLLPRI        High priority data may be read without blocking.

     POLLRDBAND     Priority data may be read without blocking.

     POLLRDNORM     Normal data may be read without blocking.

     POLLWRBAND     Priority data may be written without blocking.

     POLLWRNORM     Normal data may be written without blocking.

上面是两个参数可取的值,每个系统实现可能存在偏差,所以需要自行尝试。

异步I/O

前面讲过,非阻塞IO带来的就是轮询,前面内容包括前面的章节整合一下,可以归纳出以下主流轮询技术:

  1. read,最原始,性能最低的一种,重复检查IO状态来完成完整数据的读取,也就是前面一小节的开头代码

  2. select,在read基础上改进的方案,通过对文件描述符上的事件状态判断

  3. poll,使用链表作为文件描述符的存储方式,和select类似

  4. epoll,目前Linux下最高效的IO事件通知机制,进入轮询时候如果没有检查到IO事件就会休眠,直到事件将其唤醒

  5. queue,和epoll类似,不过是FreeBSD下的

虽然轮询满足了非阻塞IO获取完整数据的需求,但是依旧是同步的,也需要花费CPU用于便利文件描述符或者休眠等待事件发生。所以就有了异步IO,目前据笔者所知,只有Linux下有AIO技术算是真正原生提供的API。
但是,实际上,是有模拟方式的,信号机构提供了异步形式通知事件发生的方法,使用一个信号通知进程,但是,由于信号是有限的,如果使用一个信号,则进程不知道是哪个文件描述符发生的事件,如果用多个信号,文件描述符的数量可能远远超出信号的数量。
实际上,最容易想到的办法就是多线程。让部分线程进行阻塞IO或者非阻塞IO加轮询技术来完成数据获取,让另一个线程进行计算,而后通过线程间通信将IO得到的数据进行传递,就能轻松实现异步IO。

SystemV异步IO

SystemV中异步IO是归属给STREAMS系统的,他只能用于STREAMS设备和管道,异步IO信号是SIGPOLL。实际上由于这种机制本身的限制,目前已经找不到Unix环境会去采用它了,所以这里也不需要再讲解了。

BSD异步IO

对于BSD系列的系统来说,异步IO信号是SIGIO和SIGURG信号的组合,SIGIO是通用异步IO的信号,SIGURG则是通知网络连接的数据已经到达。

POSIX异步IO

POSIX标准对不同类型文件异步IO提供了可移植的模型,异步IO使用AIO控制块来描述IO操作。

struct aiocb {
        int             aio_fildes;             /* File descriptor */
        off_t           aio_offset;             /* File offset */
        volatile void   *aio_buf;               /* Location of buffer */
        size_t          aio_nbytes;             /* Length of transfer */
        int             aio_reqprio;            /* Request priority offset */
        struct sigevent aio_sigevent;           /* Signal number and value */
        int             aio_lio_opcode;         /* Operation to be performed */
};

上面是苹果系统下的AIO控制块实现,实际上和POSIX规定几乎一样,它是继承于FreeBSD3.0的AIO实现,
从上面可以看出,每个字段究竟的意义,aio_fildes就是文件描述符,读写操作从aio_offset指定的偏移量位置开始,对于读操作,会将数据复制到aio_buf的缓冲区内,对于写操作,会从这个缓冲区写入磁盘,aio_nbytes字段指定了读写的字节数。
除了上面4个字段以外,aio_reqprio就是异步IO请求的顺序,aio_sigevent就是IO事件完成后如何通知,而aio_lio_opcode就是执行的操作。

struct sigevent {
        int                             sigev_notify;                           /* Notification type */
        int                             sigev_signo;                            /* Signal number */
        union sigval    sigev_value;                            /* Signal value */
        void                    (*sigev_notify_function)(union sigval);   /* Notification function */
        pthread_attr_t  *sigev_notify_attributes;       /* Notification attributes */
};

sigevent结构体是归属于signal信号机制模型中的数据结构,其中sigev_notify字段是通知类型

  • SIGEV_NONE 不通知进程

  • SIGEV_SIGNAL 异步IO完成后,产生sigev_signo指定的信号,

  • SIGEV_THREAD 异步请求完成后,由sigev_notify_function指定的函数被调用

int aio_read(struct aiocb *aiocbp);
int aio_write(struct aiocb *aiocbp);

在异步IO之前需要先初始化AIO控制块,当函数返回成功时候,异步IO请求就已经被放在了等待处理队列中。这些返回值与实际IO擦做的结果没有任何关系,如果想要强制所有等待中的异步操作不等待直接写入存储,则调用aio_fsync函数
当然,好像aio_fsync函数并不是非常广泛,所以在使用的时候记得运行时检查。
为了获取一个异步读写的完成状态,可以调用aio_error函数

int aio_error(const struct aiocb *aiocbp);

返回如下:

  1. 返回值为0,异步操作成功,使用aio_return函数获得返回值

  2. 返回值为-1,对aio_error操作失败

  3. 返回值为EINPROGRESS,读写操作仍处于等待状态

ssize_t aio_return(struct aiocb *aiocbp);

记住在aio_error检查已经成功之前,不要调用aio_return函数,而且需要当心每个异步操作只能调用一次aio_return函数。
如果在其他操作完成之后,异步操作还未完成,那可以使用

int aio_suspend(const struct aiocb *const list[], int nent, const struct timespec *timeout);

aio_suspend函数会阻塞当前进程直到操作完成,一般情况下很少会使用。
如果我们想要取消已经处于进行中的异步操作,可以使用如下函数

int aio_cancel(int fildes, struct aiocb *aiocbp);

这个函数会返回4个返回值:

  1. AIO_ALLDONE,所有操作已经完成

  2. AIO_CANCELED,所有操作已经取消

  3. AIO_NOtCANCELED,至少有一个请求没有取消

  4. -1,函数本身失败

除了上述函数以外,还有一个函数也被包含在异步请求函数中,但是实际上很少见到,所以这里就不多做讲解。

readv和writev函数

ssize_t readv(int d, const struct iovec *iov, int iovcnt);
ssize_t writev(int fildes, const struct iovec *iov, int iovcnt);

这两个函数用于在一次读写中读写多个非连续的缓冲区,也就是说可以将传统的多个函数读写调用压缩到一个,这连个函数第二个参数就是一个指向iovec结构体的指针,实际上是一个指向数组的指针

struct iovec {
    char   *iov_base;  /* Base address. */
    size_t iov_len;    /* Length. */
};

第三个参数就是数组的长度。iov数组中的元素最大值就是IOV_MAX。

存储映射IO

存储映射IO能将一个磁盘文件映射到存储空间中的一个缓冲区上,于是,当从缓冲区中读取数据的时候,就等同于读取文件。Unix系统提供了此类函数

void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);

addr指定映射存储区的起始地址。通常为0,也就是系统自动分配区域。fd参数指定被映射文件的文件描述符,也就代表必须先打开这个文件。prot参数指定了映射存储区的保护要求如下:

prot 说明
PROT_READ 存储区可读
PROT_WRITE 存储区可写
PROT_EXEC 存储区可执行
PROT_NONE 存储区不可访问

当然,这个参数的指定必然是基于文件描述符的打开方式的,很容易明白,因为存储映射IO技术本质上还是基于文件描述符的,所以不可能绕过文件描述符的限制读写。
flag参数影响映射存储区的多种属性,如下就是可选值:

     MAP_ANONYMOUS     Synonym for MAP_ANON.

     MAP_ANON          Map anonymous memory not associated with any specific file.  The offset argument is ignored.  Mac OS X specific: the file descriptor used
                       for creating MAP_ANON regions can be used to pass some Mach VM flags, and can be specified as -1 if no such flags are associated with the
                       region.  Mach VM flags are defined in <mach/vm_statistics.h> and the ones that currently apply to mmap are:

                       VM_FLAGS_PURGABLE   to create Mach purgable (i.e. volatile) memory

                       VM_MAKE_TAG(tag)    to associate an 8-bit tag with the region
                       <mach/vm_statistics.h> defines some preset tags (with a VM_MEMORY_ prefix).  Users are encouraged to use tags between 240 and 255.  Tags
                       are used by tools such as vmmap(1) to help identify specific memory regions.

                       VM_FLAGS_SUPERPAGE_SIZE_*     to use superpages for the allocation.  See <mach/vm_statistics.h> for supported architectures and sizes (or
                       use VM_FLAGS_SUPERPAGE_SIZE_ANY to have the kernel choose a size).  The specified size must be divisible by the superpage size (except for
                       VM_FLAGS_SUPERPAGE_SIZE_ANY), and if you use MAP_FIXED, the specified address must be properly aligned. If the system cannot satisfy the
                       request with superpages, the call will fail. Note that currently, superpages are always wired and not inherited by children of the process.

     MAP_FILE          Mapped from a regular file.  (This is the default mapping type, and need not be specified.)

     MAP_FIXED         Do not permit the system to select a different address than the one specified.  If the specified address cannot be used, mmap() will fail.
                       If MAP_FIXED is specified, addr must be a multiple of the pagesize.  If a MAP_FIXED request is successful, the mapping established by
                       mmap() replaces any previous mappings for the process' pages in the range from addr to addr + len.  Use of this option is discouraged.

     MAP_HASSEMAPHORE  Notify the kernel that the region may contain semaphores and that special handling may be necessary.
     
          MAP_PRIVATE       Modifications are private (copy-on-write).

     MAP_SHARED        Modifications are shared.

     MAP_NOCACHE       Pages in this mapping are not retained in the kernel's memory cache.  If the system runs low on memory, pages in MAP_NOCACHE mappings will
                       be among the first to be reclaimed.  This flag is intended for mappings that have little locality and provides a hint to the kernel that
                       pages in this mapping are unlikely to be needed again in the near future.

这就不讲解了,原著上已经讲解的足够清楚了。
调用mprotect可以更改现有映射的权限

int mprotect(void *addr, size_t len, int prot);

也就是一个修改映射区域权限的函数,当页已经修改完毕,可以调用msync函数冲洗到被映射的文件中。

int msync(void *addr, size_t len, int flags);

基本就和fsync函数差不多,也不多说了,基本上都在Unix手册上
当进程终止的之后,自然会自动解除存储区的映射,或者可以调用munmap函数解除

int munmap(void *addr, size_t len);

munmap函数删除了指定地址的映射,如果继续对其进行读写会导致无效内存引用。并且这个函数不会冲洗缓冲区内容到文件,所以需要小心使用。


山河永寂
2.4k 声望159 粉丝