一文讲完发送文件那些事

150 阅读8分钟

写文件那点事

当我们打算往一个文件写点东西的时候,大概步骤如下:

  1. 先调用 open() 打开文件;
  2. 然后调用 write() 将用户输入写入文件。

那在操作系统会发生什么呢?

首先,操作系统会将用户输入拷贝到内核缓冲区 os cache,而内核缓冲区会定期进行刷盘。

当内核缓存区的成功刷盘之后,文件才算是真正的写入成功。一次写操作涉及到两次拷贝。

为什么需要内核缓冲区呢?

为什么不能将用户输入直接写入磁盘,而是需要使用缓冲的形式?

我们知道,写文件是随机写操作,需要进行寻址、寻道操作,非常耗时。

假使每一次的写操作都直接写磁盘,那么 IO 的效率就会非常慢。

而缓冲区的存在是为了数据整流,将写批量进行刷盘,以此来提高 IO 的效率。

什么时候需要用户缓冲区?

我们可以看到有时候我们对文件的写操作会使用到带有缓冲区的文件输入流。

这个缓冲区处于用户态,被称作用户缓冲区。

为什么要使用用户缓冲区呢?其实原理跟内核缓冲区的原理一样,写批量提高 IO 速率。

这里提高的是用户态到内核态的 IO 速率,将用户态数据拷贝到内存缓冲区需要执行系统调用。

系统调用需要切换进程,这里就设置到了进程间切换,需要保存现场、清空寄存器、恢复现场操作。另外系统调用还需要对用户进行鉴权,这些操作都提高了系统调用的代价。

为了避免频繁的系统调用,所以引入了用户缓冲区。

当不是每一种场景都适合使用用户缓冲区的,当我们能够一次性将用户输入都写入的时候就不需要缓冲区了,因为有没有缓冲都是一次性写操作。假如写入的数据很多,操作系统也会在执行置页时自动刷盘交换虚拟空间。

读操作那点事

当我们打算往一个文件读点东西的时候,大概步骤如下:

  1. 先调用 open() 打开文件;
  2. 然后调用 read() 将文件数据读出。

那在操作系统会发生什么呢?

首先,操作系统会将文件从磁盘拷贝到内核缓冲区 os cache,而内核缓冲区在拷贝到用户态。一次读操作涉及到两次拷贝。

为什么需要内核缓冲区?

通常,文件在磁盘上都是按页存储的,读取的时候会读取一整页的数据,就算我们只读其中一行也是如此,这是为了减少磁盘 IO 的次数。所以,读出来的这一页数据就会放在缓冲区里,我们需要在遍历文件时,需要读完这一页的数据才会加载下一页的数据。

什么时候需要用户缓冲区?

跟写操作一样的是,用户缓冲区可以减少系统调用的次数,一次性加载较多的数据。

跟写操作不一样的是,建议都使用用户缓冲区进行读取。这可以避免文件过大时,一次性加载导致内存溢出的状况出现。

发送文件那些事

现在我们知道了文件读写在操作系统下的底层操作,那么这和发送文件有什么关系呢?

传统的文件发送大概是这样的:

读取文件、处理数据、发送文件,其中发送文件的操作跟写文件类似,只不过对象有些差异。

发送文件通常是往套接字 Socket 中写入数据,Socket 内部有一个类似内核缓冲区的 Socket 缓冲区,Socket 缓冲区刷盘的对象也不再是磁盘,而是网卡。网卡会负责将数据打包成数据帧然后发送到目的主机。

这一通操作下来,发送一个文件竟然要进行 4 次拷贝,这无疑拖垮了我们发送文件的性能。现在我们思考哪些步骤是可以优化的,优化后可以减少拷贝的次数,并且提高整体的速率。

第一次拷贝:读操作是否可以没有内核缓冲区?

假如没有内核缓冲区,意味着磁盘数据会直接拷贝到用户态上。

看起来是没有问题的,不过这里涉及到一次内核态到用户态的切换。

第二次拷贝:读操作是否可以不拷贝到用户态?

假如不拷贝到用户态,那么就会让用户态访问内核态数据。

用户态访问内核数据只要限定好了权限和地址边界其实不会出现问题。

而且不拷贝到用户态意味着不需要进行系统调用。

第三次拷贝:写操作什么情况下可以不经过用户态?

假如我们只发送文件,不会对文件进行任何的操作,那么是不是告知操作系统我们需要发哪个文件就可以了?

这就可以避免了用户态到内核态的拷贝。

第四次拷贝:写操作是否可以没有 Socket 缓冲区?

在写操作不经过用户态的前提下,我们是不是只需要告诉 Socket,数据的地址、数据的长度、类型是什么,数据的偏移量是什么,就可以让操作系统直接将这些数据发送出去,而不需要经过 Sokcet 缓冲区

优化思路

看起来四次拷贝都有优化的空间,但是硬件之间是不能直接相互拷贝的,一定要通过内存协助。所以至少存在两次的拷贝。

读操作 一定会涉及到磁盘到内存的一次 IO,从上面的分析可以看出,因为第二次拷贝涉及到一次的系统调用,而且将这些复杂的操作封装到内核可以减轻用户代码的复杂度。另外,假如我们需要将文件发送出去,那么还可以减少一次用户态到内核态的拷贝。所以我们可以保留第一次的拷贝,优化第二次的拷贝。

而写操作能不能优化呢? 首先写操作也会设计到一次内存到网卡的 IO。

  1. 假如我们需要对文件处理之后发送处理结果,比如对文件的单词进行计数,发送计数的结果,这个时候就无法避免 write() 的调用。必须将用户态数据拷贝到 socket 缓冲区,再拷贝到网卡中。

  2. 但是假使我们不需要对文件进行任何的操作,只是单纯的发一个文件。那么第三四次拷贝都可以优化。直接将文件加载到内核缓冲区,然后再将内核缓存区的数据拷贝到网卡进行发送。

这就是 mmap + writesendfile 两个针对不同场景的系统调用函数的原理。

mmap + write

如何让用户态安全的访问内核态数据呢? 首先不管是内核态数据还是用户态数据,他们都是在相同的物理介质上的,通过程序的方式将他们相互隔离开来。一般而言,用户程序只能访问操作系统分配给他们的地址空间,访问文件也不是直接访问文件的地址空间,而是将文件数据读取到自己的堆栈上,然后访问自己的内存。

访问内核态数据的方式也很简单,只需要将内核的某块地址空间放开给程序进行访问就可以了。

这就是共享内存的原理,它通过内存映射将内核地址空间映射到用户的内存空间上,使得用户态访问内核态数据不需要进行系统调用,就像访问自己的内存一样简单。

sendfile

sendfile 可以运行用户直接发送磁盘文件,他不会经过用户态,而是只以内核缓存区作为中介将磁盘数据拷贝到网卡上。但是文件描述符,文件偏移量等还是需要拷贝到 socket 缓冲区 告知 socket 需要发送那些文件的。

DMA 直接访问器

通过 mmap + writesendfile,我们已经减少了很多次的拷贝了,但是剩下的几次拷贝仍需要 CPU 参与,CPU 会将数据复制到暂存器,然后再拷贝到目的地址。这个操作很简单,我们可以使用 DMA 来协助 CPU 完成这样的操作。这样就可以减少 CPU 在发送文件时的参与度,空闲出来的 CPU 资源就可以利用到业务上。