Unix环境高级编程学习笔记(九) 高级IO

fcntl 记录锁

很多时候,当我们有多个进程要访问同一个文件的时候,为了防止多进程访问导致的不一致,我们就要考虑进程间的同步问题了。fcntl是一个非常强大的函数,在这里我们可以使用它来给文件的某一个部分上锁。先来看一下它的声明:
int fcntl(int filedes, int cmd, ... /* struct flock *flockptr */ );

很显然,这是一个拥有可变参数的函数声明,filedes自然是要操作的文件描述符,对与记录锁相关的操作,cmd只能是F_GETLK, F_SETLK, 或者 F_SETLKW,而第三个参数则必须是一个指向flock结构体的指针,来看一下该结构体的内部结构:
struct flock {
	short l_type;/*F_RDLCK, F_WRLCK, or F_UNLCK */
	off_t l_start;/*offset in bytes, relative to l_whence */
	short l_whence;/*SEEK_SET, SEEK_CUR, or SEEK_END */
	off_t l_len;/*length, in bytes; 0 means lock to EOF */
	pid_t l_pid;/*returned with F_GETLK */
};

第一个成员是加锁的类型:只读锁,读写锁,或是解锁。l_start和l_whence用来指明加锁部分的开始位置,l_len是加锁的长度,l_pid是加锁进程的进程id。比如说,我们现在需要把一个文件的前三个字节加读锁,则该结构体的l_type=F_RDLCK, l_start=0, l_whence=SEEK_SET, l_len=3,l_pid不需要指定,然后调用fcntl函数时,cmd参数使用F_SETLK.

加锁的兼容规则可以参考我上一篇文章中关于读写锁的讨论,如果我们试图对文件中的一段上锁,而根据兼容规则是不允许的操作话,fcntl将直接返回,并且设置errno变量为EACCES 或是 EAGAIN。这个函数也可以用来进行解锁,只要把l_type设置为F_UNLCK就可以了。

如果一个进程已经获得了一个文件上某一段的锁,再此对这个文件的同一段尝试获得锁将替换掉原先的锁,比如,原先是读写锁,替换后变成只读锁等等。当然,要获得一个文件的只读锁,在打开文件描述符的时候需要有读模式,对于读写锁,也是如此。

如果把cmd设置成F_SETLKW,这个是F_SETLK的阻塞版本,功能和它一样,不过当不能获得锁时会阻塞住知道获得锁为止,或是被信号终端。

如果要检测文件中的某段是否被其它进程锁住,可以设置cmd为F_GETLK。第三个参数所指向的flock结构体可以用来设置我们想要上锁的地方和类型,如果该地方已有的锁阻止我们想要上的锁的类型,则该函数返回时,该结构体将被设置为已存在的锁的类型,l_pid将被设置为持有锁的进程ID,否则,该结构体不会发生变化,除了l_type将被设置为F_GETLK。因为它的这种特性,所以,我们无法使用该方法来检测某文件是否已经被我们自己上锁。

当一个进程结束时,所有被该进程持有的锁都将解锁,另外,当一个文件描述符被关闭时,该文件上的被该进程持有的锁也会解锁。因为这种记录锁使用来在进程间同步的,所以,当进程使用fork创建子进程时,锁是不会继承给子进程的。不过,当进程使用exec系列函数时,记录锁可以被延续下去,当然,前提是close-on-exec flag没有被设置。

从以上这些讲述可以看出,锁是文件的属性,而并非被哪个进程锁持有。以下是记录锁在FreeBSD上的实现方式:

Unix环境高级编程学习笔记(九) 高级IO_第1张图片



建议锁与强制锁

Linux系统上的文件锁主要分为建议锁(advisory lock)和强制锁(mandatory lock)。在Linux上使用的文件锁大部分为建议锁,而且使用强制锁的时候也要检查系统是否支持强制锁(http://www.ibm.com/developerworks/cn/linux/l-cn-filelock/index.html,这里有份代码传说可以检查是否支持强制锁)。根据查看的资料显示, OpenSuse11.1, CentOS5.3等系统不支持强制文件锁。

1. 建议锁又称协同锁。对于这种类型的锁,内核只是提供加减锁以及检测是否加锁的操作,但是不提供锁的控制与协调工作。也就是说,如果应用程序对某个文件进行操作时,没有检测是否加锁或者无视加锁而直接向文件写入数据,内核是不会加以阻拦控制的。因此,建议锁,不能阻止进程对文件的操作,而只能依赖于大家自觉的去检测是否加锁然后约束自己的行为。

2. 强制锁,是OS内核的文件锁。每个对文件操作时,例如执行open、read、write等操作时,OS内部检测该文件是否被加了强制锁,如果加锁导致这些文件操作失败。也就是内核强制应用程序来遵守游戏规则。

流(STREAM)

仅有理论而无实例只是纸上谈兵,unix的流机制是很妙的机制,但是unix流究竟是如何实现以及如何使用的呢,虽然unix流已经提出了很久很久,但是时至今日它也没有普遍被使用,出了solaris和windows等操作系统外,几乎没有什么系统在使用它,当今世上操作系统无非也就几家独大,按照不失一般性的分类,首先就是windows,然后是linux以及几家的unix,如solaris和各种bsd以及darvin,然后就是各家小诸侯了,不足挂齿,在几家大的中,独有windows和solaris吸取了unix的流思想,其余的好像都是比拼内功的结果,丝毫不在乎整体架构,只是在细节上略胜一筹,比如通读linux源码就会发现,里面有十分糟糕的算法,也有十分美妙的,显然不是一伙人所为,各个开发者都在各行其是,于是便失去了整体的美感,结果就是效率的提升,这也许就是GNU的绝美的地方吧(虽solaris10以及微软的开发框架也开源,但却是在不同license下的开源,和linux不可同日而语)。

在unix规范中,流机制只是一个可选的机制,它在用户进程和设备驱动之间提供了一条全双工通路,如下结构:

Unix环境高级编程学习笔记(九) 高级IO_第2张图片

这里的处理模块有点类似于过滤器,可以有任意多个。流中流通的数据都是以消息的形式,它包含可选的控制信息以及可选择的数据信息,后两者都由strbuf结构体所示:

struct strbuf {
	int maxlen;/* size of buffer */
	int len;/* number of bytes currently in buffer */
	char *buf;/* pointer to buffer */
};

大约有25种消息类型,不过只有有限的几种可以用在用户进程和流首之间,它们是:

1. M_DATA :用于IO的用户数据

2. M_PROTO :协议控制信息

3. M_PCPROTO :高优先级控制信息 

当然,也会有一些别的消息类型,例如,如果流首收到了一个来自下方的 M_SIG 消息,它就会产生一个信号。

我们要想往流中写入流消息,可以使用 putmsg 或是 putpmsg 函数,它们的不同之处仅在于后者允许决定消息的优先级。关于这两个函数的原型以及优先级的问题这里就不多做讨论了。也可以直接使用 write 函数向流中写入数据,这等价于使用 putmsg 函数,但不放入任何的控制信息。

接收消息使用 getmsg 或是 getpmsg ,当然,也可以直接读。

关于流的内容,这里不再详细多作讨论。

I/O复用

复用是个伟大的概念呀!什么是I/O复用(I/O multiplexing)呢?具体点就是当你编写的程序需要同时处理多个描数字(socket或file或device),你又不知道什么时候应该(比方说有数据可以读了)去操作(读/写)哪个描数字。这时候I/O复用就需要登场了。UNPv1给出了定义。I/O复用是一种让进程预先“警告”内核能力,使得内核一旦发现进程预先告知时指定的一个或多个I/O条件(就是描述符)就绪(可以读/写了),内核就通知进程。linux有4个调用可实现I/O复用:select、poll继承自Unix系统。pselect是select到Posix版。epoll是linux2.6内核特有的。

首先还是来看select函数:

int select(int maxfdp1, fd_set *restrict readfds, fd_set *restrict writefds,
	fd_set *restrict exceptfds, struct timeval *restrict tvptr);
int pselect(int maxfdp1, fd_set *restrict readfds, fd_set *restrict writefds,
	fd_set *restrict exceptfds, const struct timespec *restrict tsptr, 
	const sigset_t *restrict sigmask);

首先数亿select中间的三个参数,它们的类型都是fd_set,是文件描述符集,同之前博客中我讲过的信号集一样,不用去关心它的内部实现,只需要知道它是表示文件描述符的集合,对它的操作需要借助以下几个函数:

int FD_ISSET(int fd, fd_set *fdset);//test whether a given bit is turned on in the set
void FD_CLR(int fd, fd_set *fdset);//clear a single bit
void FD_SET(int fd, fd_set *fdset);//To turn on a single bit in a set
void FD_ZERO(fd_set *fdset);//zero the set

对于这几个函数的功能,注释中已经说的很清楚了,我们还是把注意力放回到select函数上来,slect函数的功能简单的说实际上就是等待我们所要求的对应文件描述符做好准备。使用中间的这三个集合参数分别来指定读集合,写集合,以及异常集合,比如说我们在读集合中指定了3个文件描述符,然后调用函数,进程陷入沉睡,那么当这三个文件描述符中的任意一个做好准备(有数据到来),该函数就会正确返回,我们就可以对那个文件描述符执行读操作,而不会再陷入阻塞(因为数据已经准备好了嘛)。对于其它集合也是一样的,我们可以同时对这几个集合进行指定。当函数正确返回时,这三个参数如果非NULL,那么它们都会被复写,以告诉我们哪些文件描述符已经准备好了,留在集合中的就是已经准备好了的。

第一个参数实际上是为了执行效率,当调用该函数时,系统会从0文件描述符开始检查他们在集合中是否被指定,但在大多数情况下,我们所使用的文件描述符的大小都不会超过10,所以对后面文件描述符的检查实际上是没有必要的,于是,在调用该函数时,我们可以利用第一个参数来指定我们在这些集合中指定的最大文件描述符的大小+1的值,从而可以避免对后面值的检查,以提供效率。

最后一个参数是用来指定超时的,因为调用该函数是会发生阻塞的,这个参数就可以来指定我们愿意等待的时间,如果vptr->tv_sec == 0 && tvptr->tv_usec == 0 的话,那么该函数就不会等待,它仅仅检测当前是否已有指定的文件描述符准备好而已,而如果该参数赋值为NULL,则表示不设置超时时间,它可能永远等待下去。

最好说一下该函数的返回值,当因超时而返回时,返回0,出现错误返回-1,其它情况下,该函数将返回指定的集合中已准备好的文件描述符的数量。

对于文件描述符上的读操作,如果有数据到来,当然这算它准备好了,而如果是文件结尾了,这也算该描述符准备好了。

对于 pselect 函数,它主要多了sigmask参数,从命名上就可以看出来,这是信号屏蔽字,它可以用来指定调用函数时应该额外被阻塞的信号有哪些。

提供同样功能的另一个函数是poll:

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

要理解这个函数,首先得了解fdarray结构体:

struct pollfd {
	int fd;/* file descriptor to check, or <0 to ignore */
	short events;/* events of interest on fd */
	short revents;/* events that occurred on fd */
};

与 select 不同,poll 函数不是每个状态(可读性,可写性,异常状态)构造一个集合,而是构造一个fdarray数组,每一个元素指定一个文件描述符,以及对该文件描述符关心的状态,nfds则是该结构体的长度,timeout是超时设置,其单位是毫秒。该函数的返回值情况与 select 相同。

pollfd 结构体中,event 是我们想要关心的状态,revent 则是实际发生的状态。event 可以被设置成以下表格中的前7个值:

Unix环境高级编程学习笔记(九) 高级IO_第3张图片

最后三行的值在 event 中设置无效。当函数返回时,如果有 event 中期待的事件发生,或是有异常发生,则 revent 会被设置成对应的值。

对于刚刚描述的这几个函数,当中断发生时,大部分实现都不会自动重启,即使是SA_RESTART flag已经被设置了。

异步I/O

unix 的信号机制提供了以异步形式通知某种事件已发生的方法,由BSD和 System V 提供了派生的所有系统提供了使用一个信号的异步I/O方法,该信号通知进程某个文件描述符已经发生了所关心的某个事件。

该类异步I/O的限制是,每个进程只有一个信号,无法对多个描述符进行的异步I/O进行标识。实际上,unix 规格说明书提供了一个可选择的通用异步I/O机制,这里暂不做讨论。

readv 和 writev 函数

先来看他们的声明:

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

readv 函数可以从文件中读取数据然后顺序放到多个缓存区中,而 writev 则是将多个缓存区中的数据顺序写入到指定文件中。每一个缓存区由 iovec 结构体描述,iovcnt 则描述了 iov 数组的长度,结构体成员如下:

struct iovec {
	void *iov_base;/* starting address of buffer */
	size_t iov_len;
};

iov_base 是混存取首地址,iov_len是缓存区长度。writev 函数返回所有输出的字节数,该返回值应该等于所有缓存区长度之和。readv 函数则返回读取的字节数。

内存映射I/O

可以通过 mmap 函数将文件中的某一段之间和内存中指定的缓存区建立映射关系,这样,对该缓存区的操作就会自动映射到对应的文件上。该函数的声明如下:

void *mmap(void *addr, size_t len, int prot, int flag, int filedes, off_t off );

成功时,返回映射的首地址,失败,返回MAP_FAILED。

addr 参数可以由我们来指定(默认情况下只是建议而已)混存取的首地址,如果赋值为NULL,则标识缓存区由系统自己来指定。port 参数指定混存取的保护模式,它可以是PROT_NONE 或是后面这些宏的或值: PROT_READ, PROT_WRITE, 以及 PROT_EXEC。缓存区上的保护模式不可以拥有比文件上的保护模式更高的权限。len 参数指定要映射的字节数。off 参数指定文件中开始映射的位置。最后是 flag 参数,它影响这映射缓存区的属性,它的取值如下:

a) MAP_FIXED 返回值一定等于 addr 参数值,这个标志是不被鼓励的,因为它降低了可移植性。

b) MAP_PRIVATE 如果该标志被设置,则映射操作将导致该文件被复制,在缓存去中的一切操作都将映射到副本上,而非源文件中。

c) MAP_SHARED 文件不会被复制。

需要注意的是,不可能通过对映射缓存区的操作而扩增文件的长度,要想如此,必须首先自己扩增文件的长度。

因为映射缓存区在地址空间中,所以通过 fork 函数创建子进程,子进程将继承父进程的映射关系,但对与 exec 函数而言,这种映射关系则不会延续下去。

通过 mprotect 函数可以在已经建立好映射关系后在改变映射缓存区的保护模式:

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

而通过 msync 函数则可以将缓存区中的改变立刻映射到实际的文件中去,否则,则是由对应的守护进程自动在合适的时候来完成这个工作,声明如下:

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

该函数中的 flags 参数必须被指定,它的值可以是 MS_ASYNC 或者 MS_SYNC ,后者代表同步,即,只有当数据被真正写如文件后,该函数才会返回。

对于以上所有的内存映射操作,addr 参数都必须是按页对齐的。

当一个进程终止时,内存缓存区会自动被接触映射关系,当然,也可以通过调用 munmap 函数去做。

int munmap(caddr_t addr, size_t len);

对 munmap 函数的调用并不会首先刷新缓存区。

和i啊游一点需要注意的是:关闭文件描述符不会自动解除映射关系。

参考文献

《Linux 文件锁学习笔记 》 http://blog.csdn.net/jiang1013nan/article/details/5721675

《unix流架构到底是个什么东西》 http://blog.csdn.net/dog250/article/details/5712160

《Linux——I/O复用》 http://blog.sina.com.cn/s/blog_7530db6f0100ovos.html

你可能感兴趣的:(Unix,&,Linux)