系统级I/O
输入/输出 是在主存和外部设备之间拷贝数据的过程。
外部设备可以是:磁盘驱动器、终端和网络。
输入和输出都是相对于主存而言的。
输入是从I/O设备拷贝数据到主存。输出时从主存拷贝数据到I/O设备。
所有语言运行时系统都提供执行I/O的较高级别的工具。
如ANSI C提供标准I/O库,包含像printf和scanf这样执行带缓冲区的I/O函数;
C++语言用它的重载运算符提供了类似的功能;
在Unix系统中,是通过使用由内核提供的Unix I/O函数来实现这些较高级别I/O函数的。
了解Unix I/O有什么用呢?
1)了解Unix I/O将帮助你理解其他的系统概念
I/O是系统操作不可或缺的一部分,经常与其他系统概念之间有循环依赖关系。我们需要深入理解I/O,来关闭这个循环。
2)有时候除了使用Unix I/O以外别无选择
标准I/O没有提供读取文件元数据的方式,I/O库还存在一些问题使得用它来进行网络编程非常冒险;所以有必要了解Unix I/O。
=====================================================
1、Unix I/O
首先要理解一个思想,就是任何文件其实都是一个m个字节的序列;
所有的I/O设备,如网络,磁盘和终端都被优雅地映射成文件。
那么对于Unix内核来说,它就可以引出一个简单的低级的应用接口,称为Unix I/O。
这使得所有的输入和输出都能以一种统一且一致的方式来执行。
打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。
内核会返回一个小的非负整数,叫作描述符。
这个描述符用于表示这个文件,应用程序后续对该文件的操作,都用描述符来标识。
应用程序只需记住这个描述符。
内核记录着有关这个打开文件的所有信息。
Unix外壳创建的每个进程一开始都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)、标准错误(描述符为2);
这三个文件是默认打开的,对任何进程而言。
改变当前的文件位置
对于每个打开的文件,内核保持着一个文件的位置k,初始为0;
这个文件位置是从文件开头起始的字节偏移量。
应用程序可以通过seek操作,显式地设置文件的当前位置为k。
读写文件
读操作就是从文件拷贝n>0字节到存储器。
从当前文件位置k开始,然后将k增加到k+n。
给定一个大小为m字节的文件,当k大于m时会触发一个称为EOF(end of file)的条件。
应用程序能够检测到这个条件。
在文件结尾并没有明确的EOF符号。
类似地写操作就是从存储器拷贝n>0个字节到一个文件。
从当前文件位置k开始,然后更新k。
关闭文件
当应用完成了对文件的访问之后,它就通知内核关闭这个文件。
作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的存储器资源。
=====================================================
2、打开和关闭文件
进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件;
int open(char *filename, int flags, mode_t mode); //若成功则返回文件描述符,若失败则返回-1
filename参数,被open函数转换为文件描述符;open函数返回文件描述符数字;
flags参数指明进程打算如何访问这个文件
O_RDONLY 只读
O_WRONLY 只写
O_RDWR 可读可写
比如
fd = Open("foo.txt",O_RDONLY,0);
flag参数也可以是一个或者更多位掩码的或,为写提供给一些额外的指示
O_CREAT 如果文件不存在,就创建它的一个截断的(空)文件
O_TRUNC 如果文件已存在,就截断它
O_APPEND 每次写操作前,设置文件位置到文件的结尾处
比如
fd = Open("foo.txt", O_WRONLDY|O_APPEND, 0);
mode_t 是访问权限位;
进程通过调用close函数关闭一个打开的文件。
int close(int fd); //若成功则为0,若出错则为-1
=====================================================
3、读和写文件
应用程序分别通过调用read和write函数来执行输入和输出的。
ssize_t read(int fd, void * buf, size_t n); //若成功则为读的字节数,若EOF则为0,若出错为-1
ssize_t write(int fd, const void * buf, size_t n); //若成功则为写的字节数,若出错则为-1
read函数从描述符为fd的当前文件位置拷贝最多n个字节到存储器位置buf。
返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
write函数从存储器位置buf拷贝至多n个字节到描述符fd的当前文件位置。
size_t 被定义为unsigned int
ssize_t 被定义为int,为的是返回-1;返回-1使得read的最大值减小了一半,从4GB减小到2GB;
不足值:read和write传送的字节比应用程序要求的要少。
读时遇到EOF:假设我们准备读一个文件,该文件从当前文件位置开始只有20个字节,而我们以50个字节的片进行读取。这样一来,下一个read的返回不足值为20,此后的read将通过返回不足值0来发出EOF信号;
从终端读文本行:如果打开文件是和终端相关联的,那么每个read函数将一次传送一个文本行,返回的不足值等于文本行的大小。
读和写网络套接字(socket):如果打开的文件对应于网络套接字,那么内部缓冲约束和较长的网络延迟会引起read和write返回不足值。
实际上除了EOF,在读磁盘文件时,将不会遇到不足值,而且在写磁盘文件时,也不会遇到不足值。
如果想创建健壮的注入Web服务器这样的网络应用,就必须通过反复调用read和write处理不足值,知道所有需要的字节都传送完毕。
=====================================================
4、用RIO包健壮地读写
RIO(Robust I/O)包会自动地为你处理上文中所述的不足值。
在想网络程序这样容易出现不足值的应用中,RIO包提供了方便、健壮和高效的I/O。
RIO提供了两类不同的函数:
无缓冲的输入和输出函数:
这些函数直接在存储器和文件之间传送数据。没有应用级缓冲。它们将对二进制数据读写到网络和从网络读写二进制数据尤其有用。
带缓冲的输入函数:
这些函数允许你高效地从文件中读取文本行和二进制数据。
这些文件的内容缓存在应用级缓冲区内,类似于printf这样的标准I/O函数提供的缓冲区。
带缓冲的RIO输入函数时线程安全的。
它在同一个描述符上可以被交错地调用。你可以从一个描述符读一些文本行。然后读取一些二进制数据,接着在多读取一些文本行。
4.1、RIO的无缓冲的输入输出函数
rio_readn函数和rio_writen函数,应用程序可以再存储器和文件之间直接传送数据。
ssize_t rio_readn(int fd, void *usrbuf, size_t n);
ssize_t rio_writen(int fd, void *usrbuf, size_t n);
//若成功则为传送的字节数,若EOF则为0(只对rio_readn而言),若出错则为-1;
4.2、RIO的带缓冲的输入函数
一个文本行就是一个由换行符结尾的ASCII码字符序列。
=====================================================
5、读取文件元数据
应用程序能够通过调用stat函数和fstat函数,检索到关于文件的信息。(有时候也成为文件的元数据)
int stat(const char * filename, struct stat *buf);
int fstat(int fd, struct stat *buf);
//若成功则为0,若出错则为-1
这个函数会填写stat数据结构中的成员。
struct stat {
dev_t st_dev; /* ID of device containing file -文件所在设备的ID*/
ino_t st_ino; /* inode number -inode节点号*/
mode_t st_mode; /* 文件的类型和存取的权限*/
nlink_t st_nlink; /* number of hard links -链向此文件的连接数(硬连接)*/
uid_t st_uid; /* user ID of owner -user id*/
gid_t st_gid; /* group ID of owner - group id*/
dev_t st_rdev; /* device ID (if special file) -设备号,针对设备文件*/
off_t st_size; /* total size, in bytes -文件大小,字节为单位*/
blksize_t st_blksize; /* blocksize for filesystem I/O -系统块的大小*/
blkcnt_t st_blocks; /* number of blocks allocated -文件所占块数*/
time_t st_atime; /* time of last access -最近存取时间*/
time_t st_mtime; /* time of last modification -最近修改时间*/
time_t st_ctime; /* time of last status change - */
};
st_mode 编码了文件访问许可位和文件类型。
文件类型包括:
普通文件:包括某种类型的二进制或文本数据;对于内核而言,文本文件和二进制文件毫无区别;
目录文件:包含关于其他文件的信息;
套接字:一种用来通过网络也其他进程通信的文件;
Unix提供的宏指令根据st_mode成员来确定文件的类型。
=====================================================
6、共享文件
除非你很清楚内核是如何表示打开的文件,否则文件共享的概念相当难懂。
内核用三个相关的数据结构来表示打开的文件;
描述符表
每个进程都有它独立的描述符表,它的表项是由进程打开的文件描述符来索引的。每个打开的描述符表项指向文件表中的一个表项。(每个进程一张表)
文件表
打开文件的集合是由一张文件表来表示的,所有的进程共享这张表。每个文件表的表项组成包括有:当前的文件位置、引用计数(即当前指向该表项的描述符表项数)、以及一个指向v-node表中对应表项的指针。关闭一个描述符会减少相应的文件表表项中的引用计数。内核不会删除这个文件表表项,直到它的引用计数为零。
v-node表
同文件表一样,所有的进程共享这张v-node表。每个表项包含stat结构中的大多数信息,包括st_mode和st_size成员。
子进程会继承父进程打开的文件;
=====================================================
7、I/O重定向
Unix I/O提供重定向操作符,允许用户将磁盘文件和标准输入输出联系起来。
例如,键入 unix> ls >foo.txt
使得外壳加载和执行ls程序,将标准输出重定向到磁盘文件foo.txt中。
当一个Web服务器代表客户端允许CGI程序时,它就执行一种相类似的重定向。
I/O重定向是如何工作的?
一种方式是使用dup2函数
int dup2(int oldfd, int newfd);
//若成功则为非负的描述符,若出错则为-1
dup2函数拷贝描述符表项oldfd到描述符表项newfd,覆盖描述符表项newfd以前的内容。
如果newfd已经打开了,dup会在拷贝oldfd之前关闭newfd。
以下这张图是调用dup2(4,1)之前
调用dup2(4,1)之后我们发现newfd fd1被重定向到了与oldfd fd4一样的文件B。而由于文件A的引用计数变成了0,所以文件A关闭。
=====================================================
8、标准I/O
ABSI C定义了一组高级输入输出函数,称为标准I/O库,为程序员提供Unix I/O的较高级别替代。
这个库libc 提供了:
打开和关闭文件的函数(fopen和fclose);
读和写字节的函数(fread和fwrite);
读和写字符串的函数(fgets和fputs);
以及复杂的格式化的I/O函数(scanf和printf);
标准I/O库将一个打开的文件模型化为一个流。
对于程序员而言,一个流就是一个指向FILE类型的结构的指针。
每个ANSI C程序开始时都有三个打开的流stdin、stdout和stderr。分别对应于标准输入、标准输出和标准错误。
类型为FILE的流是对文件描述符和流缓冲区的抽象。
流缓冲区和RIO读缓冲区的一样:就是使开销较高的Unix I/O系统调用的数量尽可能的小。
=====================================================
9、综合:我们该使用哪些I/O函数
标准I/O函数是磁盘和终端设备I/O之选。
大多数C程序员在他们的职业生涯只使用标准I/O,而从不涉及低级Unix I/O函数;
但是我们试图对网络输入和输出而使用标准I/O时,会产生一些问题。
Unix对网络的抽象是一种称为套接字的文件类型。
和任何Unix文件一样,套接字也是用文件描述符来引用的。在这种情况下,称为套接字描述符。
应用程序通过读写套接字描述符来与运行在其他计算机上的进程通信。
标准I/O流,从某种意义上来说是全双工的。即程序能够在同一流上执行输入和输出。
然而,对流的限制和对套接字的限制,有时候会互相冲突,而又极少有文档描述这些现象:
限制一:跟在输出函数之后的输入函数
如果中间没有插入对fflush、fseek、fsetpos或者rewind的调用,一个输入函数不能跟随在一个输出函数之后。fflush清空与流相关的缓冲区,后三个函数使用Unix I/O lseek函数来重置当前的文件位置。
限制二:跟在输入函数之后的输出函数
如果中间没有插入对fseek、fsetpos或者rewind的调用,一个输出函数不能跟在一个输入函数之后,除非该输入遇到了一个EOF。
由于上述限制给网络应用带来了一些麻烦。
因为对套接字使用lseek是非法的。
对流I/O的第一个限制可以用在每个输入操作之前刷新缓冲区来满足;
要满足第二个限制的唯一办法是,对同一个打开的套接字描述符打开两个流,一个用来读,一个用来写;
但是这样做也有问题,就是它要求每个流都要调用fclose,这样才能释放存储器资源,避免存储器泄漏。但是第二个close调用操作会失败,
对于顺序的程序来说这不是问题,但是对于一个线程化的程序中关闭一个已经关闭了的描述符是会导致灾难的。
建议在网络套接字上不要使用标准I/O函数来进行输入和输出。
而要使用健壮的RIO函数。
如果你需要格式化输出,使用sprintf函数在存储器中格式化一个字符串,然后用rio_writen把它发给套接口。
如果你需要格式化输入,使用rio_readlineb来读一个完整的文本行,然后用sscanf从文本行提取不同的字段。
=====================================================
10、小结
Unix 提供少量的系统级函数,它们允许应用程序打开、关闭、读和写文件,提取文件的元数据,以及执行I/O重定向。
Unix的读和写操作会出现不足值情况,应用程序必须正确地预计和处理这种情况。
应用程序不应该直接调用Unix I/O函数,而应该使用RIO包。
RIO包通过反复执行读写操作,知道传送完所有的请求数据,自动处理不足值。
Unix内核使用三个相关的数据结构来表示打开的文件。
描述符表
打开文件表
v-node表
理解这些结构就能理解文件共享和I/O重定向
标准I/O库是基于Unix I/O实现的。并提供一组强大额高级I/O例程。
对于大多数应用程序而言,标准I/O更简单,是由于Unix I/O的选择。
然而,因为对标准I/O和网络文件的一些相互不兼容的限制,
Unix I/O比标准I/O更加实用于网络应用程序。