谈谈系统级I/O

对于I/O每个程序员并不陌生,但从菜鸟入门阶再到进阶,每个阶段对I/O都会有新的认识见解,从最开始的printf函数到Linux中的管道再到多线程使用中读写I/O,网络I/O的概念,每个地方都感觉有I/O的身影,但模模糊糊,含义好像都不太一样,困惑也渐渐增多…

1. Unix I/O

1.1 输入/输出

输入/输出(I/O)是在主存(DRAM)和外部设备(例如磁盘驱动器、终端、网络)之间复制数据的过程。输入操作是I/O设备复制数据到主存,而输出操作是从主存复制数据到I/O设备。

图稍微配的有点糙,但是能很清晰的说明输入上段文字中所描述的关系,复制数据的过程在I/O总线中进行。

在UNIX操作系统中,所有的外围设备(上图中显示的)都被看作是文件系统中的文件,一个Linux文件就是m个字节的序列:BO,B1,B2...。因此,所有的输入/输出都要通过对应的读文件或写文件完成。这种将设备优雅地映射为文件的方法,允许Linux内核引出一个简单、低级的应用接口口,称为为Unix I/O。也就是说,通过一个单一的接口就可以处理外围设备和程序之间的所有通信。

文本文件(比如txt文件)与文件(上述的文件含义)要区分开

主存(L3高速缓存)的速度远远大于本地磁盘(本地二级存储)和web服务器(远程二级存储),由于这种巨大的速度差,我们在多线程中总是提到I/O瓶颈,I/O时间观,例如从SSD读1M连续数据真实延迟是1毫秒,但对CPU的感觉来说是一个月的漫长时间!而这个等待时间我们不能让CPU白白浪费,要搞点别的事,然后就是I/O模型的各种进化版,最后到异步的操作了。

1.2 操作文件的方式

打开文件:因为大多数的输入/输出是通过键盘和显示器实现的,为了方便,UNIX做了特别的安排。一个应用程序通过要求内核打开相应的文件,内核将返回一个非负整数,称为描述符,记录打开文件的所有信息:标准输入(描述符0)、标准输出(描述符1)、标准错误(描述符2)。

改变当前文件位置:内核保持一个文件的位置k,初始为0,表示从文件开始处偏移的字节数。通过seek操作。

读写文件:读操作就是从文件拷贝n个字节到存储器,如果是从k处开始,就是拷贝k+n为止。文件的大小为m,如果k≥m就会触发(EOF),所有就不需要明确的EOF字符了。写操作就是从存储器拷贝n个字节到文件当前位置k处。

关闭文件:通知内核关闭文件。内核释放文件打开时创建的数据结构,将描述符恢复到可以的描述符池中。无论一个进程因为哪种原因终止,内核都会关闭所有打开的文件并释放他们的内存资源。

2. 标准I/O函数

有了上面的一些概念,我们可以看看C语言中一些常见的容易混淆的函数。各种I/O函数都存放在stdio.h头文件中

2.1 格式化输出

1
2
3
4
5
6
7
#include <stdio.h>
// 这里在显示器打印`hello,world`
int main(){
printf("hello,world\n");
// 等同于 fprintf(stdout,"hello,world\n");
return 0;
}

printf(...)函数其实是fprint的带默认参数版,等价于fprint(stdout,...)
int printf(const char *format,...) 对比int fprintf(FILE *stream, const char *foramt,...)
因为所有外围设备(这里指显示器)都是文件,我们只要把后者函数的stream参数默认为标准输出stdout即可。所以我们也可以把这个stream参数用一个FILE *参数来代替,那就直接格式化输出到文件里,这个我们后面会继续讲到。

流(stream)是与磁盘或者其他外围设备关联的数据的源或者目的地,UNIX中文本流和二进制流是相同的。打开一个流将把该流与一个文件或设备连接起来,关闭流将断开这种连接。打开一个文件将返回一个指向FILE类型对象的指针,该指针记录了控制该流的所有必要信息。我们可以将文件指针等同
A very important concept in C is the stream. In C, the stream is a common, logical interface to the various devices that comprise the computer.In its most common form, a stream is a logical interface to a file. As C defines the term “file”, it can refer to a disk file, the screen, the keyboard, a port, a file on tape, and so on.Although files differ in form and capabilities, all streams are the same. The stream provides a consistent interface and to the programmer one hardware device will look much like another.
https://www.cquestions.com/2009/01/what-is-stream-in-c-programming.html

此外,还有个int sprintf(char *s, const char *foramt,...)函数,与printf函数基本相同,但其输出将被写入到字符串s中,具体可以看C语言sprintf()函数:将格式化的数据写入字符串。为什么会有这个函数,反正把内存中的数据写到文本,显示器等文件中,当然我也可以直接不变位置还是写到内存中的另一个地方,所有才有了这个char *s

2.2 格式化输入

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
int main()
{
float num;
printf("Enter a number: ");
// %f 匹配浮点型数据
scanf("%f",&num);
// 等同于 fscanf(stdin,"%f",&num)
printf("Value = %.2f", num);
return 0;
}

格式化输入这边的原理跟输出,不展开

2.3 字符输入输出

1
2
3
4
5
6
#include<stdio.h>
void main( ){
int c;
while( (c=getchar()) != '\n' ) //从控制台流中读取字符,直到按回车键结束
printf ("%c", c); //输出读取内容
}

这个的getchar函数等同于getc(stdin),而相对的输出这边putchar函数等同于putc(c,stdout)

2.4 文件操作

上面都是常见的标准输出到显示器,从键盘标准输入,这些其实都是文件操作函数,只是带有默认输入输出的函数。
那么,究其根源,处理与文件有关的通用函数来了!

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int main()
{
FILE *fp ;
char *s = "hello\n";
fp = fopen("foo.txt", "w");
fwrite(s, sizeof(s) , 1, fp );
fprintf(fp, "call one number:%d\n", 520);
fputs("This is testing for fputs...\n", fp);
fclose(fp);
}

FILE *fopen(const char *filename, const char *mode)
fopen函数打开filename指定的文本文件,并返回一个与之相关联的流。如果打开操作失败,则返回NULL。访问模式mode就是常见的:rwa等,含义就不一一说了,网上很多

相对应的,int fclose(FILE *stream)函数讲所有未写入的数据写入流stream中,丢弃缓冲区中的所有未读输入数据,并释放自动分配的全部缓冲区,最后关闭流,若出错则返回EOF,否则返回0。

我们可以用fwrite直接把数据输出到流stream中,相应的是fread

fprintf是对输出进行格式化再写到stream流中,对应的是fscanf

fputs就是讲字符使出到流stream中,对应的是fgetc

顺便提一下int fflush(FILE *stream),对输出流来说,该函数将已写到缓冲区但尚未写入文件的所有数据写入到文件中。对输入流来说,其结果是未定义的。如果在写的过程中发生错误,则返回EOF,否则返回0。fflush(NULL)将清洗所有的输出流。

刚开始学python也遇到缓冲区,print函数中flush参数的问题,以及缓冲策略。参考Python File Bufferpython的print(flush=True)实现动态loading……效果

3. UNIX I/O函数


上面说的标准I/O函数其实算是叫高级别的I/O函数,在Linux系统中,这些高级I/O函数都是通过使用由内核提供的系统级Unix I/O函数(较低级)来实现的。大多数时候,高级别I/O函数工作良好,没必要直接使用Unix I/O。那什么还要讲呢,两点,一是能帮助理解其他的系统概念,二是又是你除了Unix I/O函数以外别无选择。

1
2
3
4
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(char *filename, int flags, mode_t mode);

进程是通过open函数来打开一个已存在的文件或者创建一个新文件,返回一个描述符数字。返回的描述符总是进程中当前没有打开的最小描述符

1
2
3
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t n);
ssize_t write(int fd, const void *buf, size_t n);

应用程序是通过分别调用readwrite函数来执行输入和输出的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main(void)
{
char c;
int fd;
fd = open("foo.txt",O_WRONLY,1);
while(read(STDOUT_FILENO, &c, 1) != 0){
write(fd, &c, 1);
}
exit(0);

}

这里写了个例子,打开foo.txt,从标准输入(即键盘输入)中获取字符只要read函数读到的字节数不为0就把字符写进foo.txt中。

4. 更健壮的RIO包

RIO包更强壮一点,带有无缓冲的输入输出函数以及带缓冲的输入函数,但这不是我这篇文章的重点,有兴趣可以去网上找找。

5. 我该使用哪些I/O函数

Unix I/O模型实在操作系统内核中实现的。应用程序可以通过诸如opencloselseekreadwritestat这样的函数来访问Unix I/O。较高级别的RIO和标准I/O函数都是基于Unix I/O函数来实现的。具体可以看第三节的那个图,一目了然

那么,选哪一个? 对我自己来说:只要有可能就使用标准I/O。对磁盘和终端设备来说,标准I/O函数是首选方法。大多数C程序员在其整个职业生涯只使用标准I/O!

6. I/O重定向

Linux shell提供了I/O重定向操作符,允许用户将磁盘文件和标准输入输出联系在一起。例如ls > foo.fxt,使用shell加载和执行ls程序,将标准输出重定向到磁盘文件foo.txt。一些更加复杂的重定向操作可以看Linux Shell 1>/dev/null 2>&1 含义

7. 总结

断断续续花了两天时间做了这篇总结,回头想想起因是啥,回头C程序设计语言这本圣经时,以前只是看到结构体那一章就盖上书了,结果这次不仅翻到了后一章,还把附录翻了一遍,刚好手头有本CSAPP,越翻又糊涂又明白,遂总结了一下。

其实写到后面,发现,有些低级函数作为一般程序员压根用不到,还是得用高级函数,但是在这学习的过程中,很多相关概念才是最让人解惑,让自己理解整个过程以及其他系统概念。其实平常常用的文本文件读写,Python相关的函数封装地更简单,直接两行代码搞定。然而,所有编程语言都基于操作系统,当总结了相关概念之后,无论啥语言, 关于I/O根本的东西都是一样的。而自己偏爱使用Python所谓高级语言后,简单便捷,但所有的东西经过封装之后隐藏了幕后的细节,只是把语法当成工具,或许,工具就够了,又或许,还不够。

当了解了系统I/O之后,接下去,回到过去,跟着历史和大佬们一起,开发Linux I/O模型!!