《Unix环境高级编程》学习笔记——高级IO

一、引言

高级IO包含很多内容,如非阻塞IO、记录锁、IO多路转接(select和poll函数)、异步IO、readv和writev函数以及存储映射IO(mmap)等。

二、非阻塞IO

非阻塞IO使我们可以发出open、read和write这样的I/O操作,并使这些操作不会永远阻塞。

它的特点是:

  1. 进程轮询(重复)调用,消耗CPU资源。(阻塞式IO被阻塞时会挂起,不会消耗CPU资源)
  2. 实现难度低,开发应用相对阻塞IO模式较难。
  3. 适用并发量较小,且不需要及时响应的网络应用开发。

对于一个给定的文件描述符,有两种为其指定非阻塞IO的方法:

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

非阻塞IO模型:

《Unix环境高级编程》学习笔记——高级IO_第1张图片

三、记录锁

在大多Unix系统中,当两个人同时编辑一个文件时,该文件最后的状态取决于写该文件的最后一个进程。但大多数时候我们并不希望看到这样的结果,例如在数据库中。为了解决这个问题,Unix提供提供了记录锁机制。

记录锁(record locking)的功能是:当一个进程正在读或修改文件的某个部分时,使用记录锁可以阻止其他进程修改同一文件区。记录锁也可以理解为字节范围锁,因为它锁定的只是文件中的一个区域。

3.1 记录锁的实现

POSIX.1使用fcntl函数来进行记录锁相关操作,其函数原型为:

#include
int fcntl(int fd, int cmd, .../* struct flock *flockptr */);
//若成功,依赖于cmd,否则,返回-1

可选参数flockptr是一个flock结构的指针,其结构如下:

struct flock{
    //锁类型 可选值是: 共享读锁:F_RDLCK 独占写锁:F_WRLCK 解锁一个区域F_UNLCK
    short l_type;
    //起始位置 文件头:SEEK_SET 当前位置:SEEK_CUR 文件尾:SEEK_END
    short l_whence;
    //相对于l_whence的偏移量,单位是byte
    off_t l_start;
    //区域的字节长度
    off_t l_len;
    //进程ID
    pid_t l_pid;
}

关于该结构需要注意的是:

  1. 锁可以在当前文件尾端处开始或者越过尾端处开始,但是不能在文件起始位置之前开始。
  2. 如果l_len为0,则表示锁的范围可以扩展到最大可能偏移量,不管向该文件中追加多少数据,都处于锁的范围内,而且起始位置可以是文件中的任意一个位置。
  3. 为了对整个文件加锁,我们设置l_start和l_whence指向文件的起始位置,指定l_len为0.
  4. 多个进程访问同一文件,只有读锁+读锁可以兼容,但单个进程对同一区间重复加锁的结果是后面的锁覆盖前面的锁。

cmd参数决定函数fcntl的行为,其可选值如下:

  1. F_GETLK:判断是否可以对文件加锁。(很少用)
  2. F_SETLK:加锁。出错则立即返回,非阻塞。
  3. F_SETLKW:F_SETLK的阻塞版本,当不能加锁时进程会休眠等待,直到锁可用或收到中断信号时被唤醒。

注意F_GETLK与FSETLK不是原子操作,在两个函数调用之间可能有新的进程进行了加锁操作。在设置或释放文件上的一把锁时,系统会按要求组合或分裂相邻区。

记录锁在使用过程中也可能会造成死锁,需要当心。

3.2 锁的隐含继承和释放

记录锁的自动继承和释放有3条规则:

3.2.1 锁与进程和文件两者相关联

  1. 锁与进程的联系:当一个进程终止时,它所建立的锁全部释放。
  2. 锁与文件的联系:无论一个描述符何时关闭,该进程通过这一描述符引用的文件上的任何一把锁都会释放。

3.2.2 由fork产生的子进程不继承父进程的锁

若一个进程得到一把锁,然后调用fork,那么对于父进程获得的锁而言,子进程被视为另一个进程。

3.2.3 在执行exec后,新程序可以继承原执行程序的锁

p397页有图文详解。

3.3 记录锁应用实例

在守护进程章节,我们知道守护进程可用一把文件锁来保证只有该守护进程的唯一副本在运行,下面展示了一种实现:

#include
#include

int lockfile(int fd)
{
    struct flock fl;

    fl.l_type = F_WRLCK;
    fl.l_whence = SEEK_SET;
    fl.l_start = 0;
    fl.l_len = 0;
    return (fcntl(fd,F_SETLK, &fl));
}

四、IO多路转接

对于多任务系统,传统的阻塞IO、非阻塞IO以及异步IO都存在这样那样的局限性,不能很好地拓展和使用。

  1. 阻塞IO会阻塞当前进程,无法处理多个请求。
  2. 非阻塞IO使用轮询,非常浪费CPU时间,在多任务系统应当避免使用这种方法。
  3. 异步IO受限于信号的数量以及可移植问题,不能最大限度地利用系统资源。

一种比较好的技术是使用IO多路转接(IO multiplexing)。大致过程是,先构造一张描述符表,然后调用一个函数,直到这些描述符中的一个已准备好进行IO时,该函数返回。select,pselect,poll和epoll这4个函数使我们能够执行IO多路转接。

4.1 函数select

传给select函数的参数告诉核心:

  • 我们所关心的描述符
  • 对于每个描述符我们所关心的条件(是否想从一个给定的描述符读、写,是否关心异常条件)
  • 愿意等待多长时间(可以永远等待、等待一个固定时间或者根本不等待)

从select返回时,内核告诉我们:

  • 已准备好的描述符的总数量
  • 对于读、写或异常者3个条件中的每一个,哪些描述符已经准备好了

有了这些信息,就可以调用相应的IO函数,并且确定该函数不会阻塞。

select函数原型:

#include
int select(int maxfdpl, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict exceptfds,struct timeval *restrict tvptr);
//返回值:准备就绪的描述符数目;若超时,返回0;若出错,返回-1

参数说明如下(从最后一个开始):

4.1.1 tvptr参数

tvptr是一个timeval结构,指定愿意等待的时间长度,单位为秒和微妙。有以下3中情况:

  • vptr == null.永远等待。如果捕捉到一个信号则中断此无限等待。
  • tvptr->tv_sec == 0 && tvptr->tv_usec == 0.不等待,测试所有指定的描述符并立即返回。
  • tvptr->tv_sec != 0 || tvptr->tv_usec != 0.等待指定的秒数和微妙数。

4.1.2 readfds、writefds和exceptfds

中间三个参数readfds、writefds和exceptfds是指向描述符集的指针。这3个描述符集说明了我们关心的可读、可写或处于异常条件的描述符集合。每个描述符集存储在一个fd_set结构中,可以将该结构看作一个字节数组,每一位代表一个可能的描述符。

该结构的可用操作是:

#include
int FD_ISSET(int fd, fd_set *fdset); //测试在fdset中,fd所在的位是否处于打开状态。
void FD_CLR(int fd, fd_set *fdset); //清除fdset中fd所代表的位。
void FD_SET(int fd, fd_set *fdset); //开启fdset中代表fd的位
void FD_ZERO(fd_set *fdset); //将fdset全部设置为0

如果这3个参数都为NULL,则select提供比sleep更精确的定时器(微妙)。

4.1.3 maxfdpl

该参数的意思是调用者设置的readfds、writefds和exceptfds3个结构中最大描述符的编号再加1。该参数的作用是帮助内核缩小描述符扫描范围,内核只需要在maxfdpl范围扫描描述符即可。

4.1.4 select的限制

select最多只能监听1024个文件描述符,这是由于fd_set类型在内核中限定为最大1024.

4.2 函数pselect

pselect是select的一个变体,它提供纳秒级别的时间控制和额外的信号屏蔽功能。

#include 

int pselect(int maxfdpl, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict exceptfds, const struct timespec *restrict tsptr, const sigset_t *restrict sigmask);
//返回值:准备就绪的描述符数目;若超时,返回0;若出错,返回-1

4.3 函数poll

与select不同,poll不是为每个条件(可读性、可写性和异常条件)构造一个描述符集,而是构造一个pollfd结构的数组,每个数组元素指定一个描述符编号以及我们对该描述符该兴趣的条件。

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

struct pollfd{
    int fd;   //要检查的文件描述符,如果小于0则忽略
    short events;  //在fd上感兴趣的事件
    short revents; //在fd上发生的事件
};

fdarray数组中的元素数量由nfds指定。

events和revents用于告诉内核我们关心的是每个描述符的哪些事件,可能的事件有可以不阻塞地读/写普通/高优先级的数据,具体可参考P409.返回时,revents成员由内核设置,用于说明每个描述符发生了哪些事件。

timeout参数指定等待事件,如同select一样,有3种不同的情形。

  • timeout == -1 。永远等待,当描述符准备好或捕捉到一个信号后返回。
  • timeout == 0 。不等待,测试所有描述符并立即返回。
  • timeout > 0 。等待timeout毫秒,当指定的描述符之一准备好,或者timeout到期时返回。

4.3.1 poll的限制

原理和Select相似,没有数量限制,但IO数量大扫描线性性能下降(随着数组元素的增多,数组的扫描性能会线性下降);

4.3 Epoll

epoll是针对select/poll的不足而设计的改良方式,它的IO效率不随FD数目增加而线性下降。Epoll只会对"活跃"的socket进行操作—这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有"活跃"的socket才会主动的去调用 callback函数,其他idle状态socket则不会。

同时,epoll还使用存储映射IO(mmap)来加速内核与用户空间的消息传递。

4.4 有关epoll的操作

关于epoll有以下操作:

 #include 

//创建一个新的epoll实例,从linux2.6.8开始,size参数被忽略,但必须大于0.
//成功,其返回值是一个文件描述符,该描述指向新的epoll实例。失败,返回-1.
int epoll_create(int size);

//类似于epoll_create,但可以为epoll设置运行时关闭标志(close-on-exec)。
int epoll_create1(int flags);

//用于添加、修改或移除epoll实例中的兴趣列表中的条目。
//即它要求epfd所代表的epoll实例对fd执行操作op。
//具体信息参考手册:http://man7.org/linux/man-pages/man2/epoll_ctl.2.html
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

//等待epoll实例中的特定事件发生。
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

//该函数与epoll_wait的区别于select和pselect的区别类似。
//epoll_pwait可以设置信号屏蔽字,屏蔽某些信号,防止函数被某些信号中断。
int epoll_pwait(int epfd, struct epoll_event *events,int maxevents, int timeout,const sigset_t *sigmask);


4.4 级别触发与边缘触发(level triggered、edge-triggered)

LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表.

ET (edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once),不过在TCP协议中,ET模式的加速效用仍需要更多的benchmark确认。

4.4 IO多路复用总结

Linux中IO复用的实现方式主要有select、poll和epoll:

Select:注册IO、阻塞扫描,监听的IO最大连接数不能多于FD_SIZE;

Poll:原理和Select相似,没有数量限制,但IO数量大扫描线性性能下降;

Epoll :事件驱动不阻塞,mmap实现内核与用户空间的消息传递,数量很大,Linux2.6后内核支持;

五、异步IO

进程发起一个IO操作,进程返回(不阻塞);内核把整个IO处理完后,会通知进程结果。进程和内核间通过一个指定的缓冲区进行沟通,在异步读中,内核会向缓冲区中写数据;在异步写中,内核会从缓冲区中读数据。

POSIX为异步IO定义了一套统一的接口,现在所有的平台都被要求支持这些接口。

5.1 异步IO相关数据结构

POSIX中的异步IO接口使用AIO控制块来描述IO操作,aiocb结构定义了AIO控制块:

struct aiocb{
    int             aio_fildes;  //被打开用来读或写的文件描述符
    off_t           aio_offset;  //多一些操作开始处的偏移量
    volatile void   *aio_buf;    //IO缓冲区
    size_t          aio_nbytes;  //要读或写的字节数
    int             aio_reqprio; //为异步IO请求提示顺序,系统不一定采用
    struct sigevent aio_sigevent; //决定IO事件完成后,如何通知应用程序。sigevent结构下面介绍
    int             aio_lio_opcode; //对list IO的操作编码
    //......
};

注意,异步IO操作必须显示地指定偏移量。异步IO接口并不影响操作系统维护的文件偏移量,只要不在同一个进程里把异步IO函数和传统IO函数混在一起用在同一个文件上,就不会导致什么问题。

sigevent结构:

struct sigevent{
    int sigev_notify;  //通知类型。如不通知、信号通知或函数调用
    int sigev_signo;   //信号编号
    union sigval sigev_value; //函数sigev_nofity_function的唯一参数
    void (*sigev_notify_function)(union sigval); //通知函数
    pthread_attr_t *sigev_notify_attributes;  //通知属性
};

sigev_notify字段控制通知的类型,取值可能是以下3个中的一个:

  • SIGEV_NONE。异步IO完成后不通知进程。
  • SIGEV_SIGNAL。异步IO完成后,产生由sigev_signo指定的信号。
  • SIGEV_HTREAD。异步IO完成后,由sigev_notify_function字段指定的函数被调用。sigev_value作为唯一参数被传入。

5.2 异步IO相关操作

#include 

//异步读
int aio_read(struct aiocb *aiocb);

//异步写
int aio_write(struct aiocb *aiocb);

//强制所有等待中的异步操作不等待而写入持久化存储中
//当op等于O_DSYNC,则类似于调用了fdatasync。当op为O_SYNC,那么操作类似于调用了fsync一样。
int aio_fsync(int op, struct aiocb *aiocb);

//获取一个异步读、写或者同步操作的完成状态
//返回值 0:异步操作完成  -1:对aio_error调用失败
//EINPROGRESS:异步读、写或同步操作仍在等待;其他情况:失败错误的返回码
aio_error(const struct aiocb *aiocb);

//如果异步调用成功,可以调用aio_return函数来获取异步操作的返回值
//异步操作完成前调用此函数的结果是未定义的;
//每个异步操作只能调用以此aio_return,一旦调用了该函数,操作系统就可以释放掉包含了IO操作返回值的记录
ssize_t aio_return(const struct aiocb *aiocb);

//如果在完成了所有事务时,还有异步操作未完成时,可以调用aio_suspend函数来阻塞进程,直到操作完成
//可能返回三种情况。 被信号中断时返回-1,并将errorno设置为EINTR;阻塞时间超过timeout时返回-1,并将errono设置为EAGAIN。
//如果有任何IO操作完成,返回0.
int aio_suspend(const struct aiocb *const list[], int nent, const struct timespec *timeout);

//取消等待中的异步IO操作
//如果aiocb为null,则系统会尝试取消所有该文件上未完成的异步IO操作
//可能的返回值有4个。AIO_ALLDONE:所有异步操作都已经完成;AIOCANCELD:所有要求的操作已被取消;
//AIO_NOTCANCELED:至少有一个要求的操作没有被取消;-1:对aio_cancel调用失败,错误码存储在errorno中
int aio_cancel(int fd, struct aiocb *aiocb);

六、函数readv和writev

readv和writev函数用于在一次函数调用中读、写多个非连续缓冲区。有时也将这两个函数称为散布读(scatter read)和聚集写(gather write)。

#include
//将输入数据写到不连续的缓冲区中
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
//将不连续缓冲区中的数据输出
ssize_T writev(int fd, const struct iovec *iov, int iovcnt);
//返回值:已读或已写的字节数;若出错,返回-1

将两个缓冲区中的内容连续地写到一个文件中有3种方式:

  1. 调用两次write,每个缓冲区一次。
  2. 分配一个大到足以包含两个缓冲区的新缓冲区,将内容复制到新缓冲区,再写入。
  3. 调用writev输出两个缓冲区

在内存足够大的情况下,2的效率最高,而1的效率最低。但随着数据量的增大,要找到一块足够大的连续缓冲区的难度越来越高,因此writev的优势也越来越大。

七、存储映射IO(mmap)

存储映射IO(memory-mapped I/O)能将一个磁盘文件映射到存储空间中的一个缓冲区上,当从缓冲区上读数据时,就相当于读文件中的相应字节。将数据存入缓冲区时,相应字节也会在某一个时刻(系统选择)自动写入文件。这样,就可以在不适用read和write的情况下执行I/O。

7.1 性能对比

mmap性能并不一定比read、write效率高,但mmap在某些特殊场景下非常有用(如对帧缓冲设备的操作)。

7.2 相关操作

#include
//存储映射建立函数
//addr:映射存储区的起始地址,通常将其设置为0,这表示由系统选择该映射区起始地址。此函数的返回值是该映射区的起始地址。
//fd:要被映射文件的描述符,在映射前要先打开该文件
//len:映射的字节数; off:要映射字节在文件中的起始偏移量
//prot:指定了映射存储区的保护要求,包含可读、可写、可执行以及不可访问4种,可以相互组合,但保护要求不能超过文件open模式访问权限。可选值参考P423或linux手册
//flag:定义对映射相关操作的特性,详情见P423或linux手册
void *mmap(void *addr, size_t len, int prot, int flag, int fd, off_t off);

//更改一个现有映射的权限
int mprotect(void *addr, size_t len, int prot);

//将已经修改的页冲洗到被映射的文件中
//如果映射是私有的(mmap中flag参数),那么不修改被映射的文件。
//flags需要至少指定为MS_ASYNC和MS_SYNC中的一个。MS_ASYNC:指定安排更新,但调用立即返回;MS_SYNC:等待冲洗完成才返回。
int msync(void *addr, size_t len, int flags);

//解除映射
//munmap并不影响被映射的对象,即调用munmap并不会使映射区的内容写到磁盘文件上。在解除映射后,对MAP_SHARED区磁盘的更新,会在后面某个时刻由内核写入。对MAP_PRIVATE存储区的修改会被丢弃。
int munmap(void *addr, size_t len);

对于mmap,需要注意,off的值和addr的值,通常被要求是系统虚拟存储页长度的整数倍。子进程能通过fork继承存储映射区,但新程序不能通过exec继承。

当进程终止时,会自动解除存储映射区的映射,或者直接调用munmap函数手动解除。关闭被映射的文件描述符并不解除映射区。

参考文章

https://blog.csdn.net/tjiyu/article/details/52959418

你可能感兴趣的:(操作系统——Linux)