在附录 A,File I/O Essentials中,我们介绍了应用开发人员如何利用可用的 glibc 库 API 以及执行文件 I/O(打开、读取、写入和关闭)的典型系统调用。 当然,虽然它们起作用了,但实际情况是性能并没有真正优化。 在本章中,我们将重点介绍更高级的文件 I/O 技术,以及开发人员如何利用更新和更好的 API 来获得性能。
通常,人们会对 CPU 及其性能感到压力。 虽然很重要,但在许多(如果不是大多数)实际应用工作负载中,拖累性能的不是 CPU,而是 I/O 代码路径。 这是可以理解的;回想一下,在第 2 章,虚拟内存中,我们发现与 RAM 相比,磁盘速度慢了几个数量级。 网络 I/O 的情况与此类似,因此,由于大量持续的磁盘和网络 I/O,自然会出现真正的性能瓶颈。
在本章中,读者将学习提高 I/O 性能的几种方法;一般而言,这些方法包括以下几种:
- 充分利用内核页面缓存
- 向内核提供有关文件使用模式的提示和建议
- 使用分散聚集(矢量化)I/O
- 利用内存映射进行文件 I/O
- 了解和使用复杂的 DIO 和 AIO 技术
- 了解 I/O 调度程序
- 用于监视、分析和控制 I/O 带宽的实用程序/工具/API/cgroup
执行 I/O 时的另一个关键点是意识到底层存储(磁盘)硬件比 RAM 慢得多。 因此,设计策略以最大限度地减少对磁盘的访问,并利用内存进行更多工作总是有帮助的。 事实上,库层(我们已经相当详细地讨论了工作室的缓冲功能)和操作系统(通过页面缓存和块 I/O 层中的其他功能,事实上,甚至在现代硬件中)都将执行大量工作来确保这一点。 对于(系统)应用开发人员,下面提出一些需要考虑的建议。
如果可行,在对文件执行 I/O 操作时使用较大的缓冲区(用于保存读取或写入的数据),但有多大呢? 一个不错的经验法则是对本地缓冲区使用与文件所在文件系统的 I/O 块大小相同的大小(实际上,此字段在内部记录为文件系统 I/O 的块大小)。 要查询它很简单:在您想要执行 I/O 的文件上发出stat(1)
命令。举个例子,假设在 Ubuntu 18.04 系统上,我们想要读入当前运行的内核配置文件的内容:
$ uname -r
4.15.0-23-generic
$ ls -l /boot/config-4.15.0-23-generic
-rw-r--r-- 1 root root 216807 May 23 22:24 /boot/config-4.15.0-23-generic
$ stat /boot/config-4.15.0-23-generic
File: /boot/config-4.15.0-23-generic
Size: 216807 Blocks: 424 IO Block: 4096 regular file
Device: 801h/2049d Inode: 398628 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2018-07-30 12:42:09.789005000 +0530
Modify: 2018-05-23 22:24:55.000000000 +0530
Change: 2018-06-17 12:36:34.259614987 +0530
Birth: -
$
从代码中可以看出,stat(1)
揭示了内核中文件索引节点数据结构的几个文件特征(或属性),其中包括 I/O 块大小。
在内部,stat(1)
命令实用程序发出stat(2)
命令系统调用,该系统调用解析底层文件的 inode 命令,并将所有详细信息提供给用户空间。 因此,当以编程方式需要时,请使用[f]stat(2)
和 API。
此外,如果内存不是限制,为什么不分配一个中等到非常大的缓冲区并通过它执行 I/O;这会有所帮助。 确定有多大需要在目标平台上进行一些调查;让您了解一下,在早期,管道 I/O 使用一页大小的内核缓冲区;在现代 Linux 内核上,管道 I/O 缓冲区大小默认增加到一兆字节。
正如我们从附录 A、File I/O Essentials中了解到的,当进程(或线程)通过例如使用fread(3)
或fwrite(3)
库层 API 来执行文件 I/O 时,它们最终通过read(2)
和write(2)
系统调用被发布到底层操作系统。 这些系统调用让内核执行 I/O;虽然看起来很直观,但实际情况是读写系统调用并不同步;也就是说,它们可能会在实际 I/O 完成之前返回。 (显然,对文件的写入就是这种情况;同步读取必须将读取的数据返回到用户空间内存缓冲区;在此之前,读取块。 然而,使用异步 I/O(AIO),甚至可以进行异步读取。)
事实上,在内核中,每个单文件 I/O 操作都缓存在称为页缓存的全局内核缓存中。 因此,当进程将数据写入文件时,数据缓冲区不会立即刷新到底层块设备(磁盘或闪存),而是缓存在页面缓存中。 类似地,当进程从底层块设备读取数据时,数据缓冲区不会立即复制到用户空间进程内存缓冲区;不,您猜对了,它首先存储在页面缓存中(进程实际上将从那里接收数据)。 再次参考附录 A,文件 I/O 要点,图 3:更多详细信息-APP 到 Stdio,I/O 从缓冲区到内核页面缓存,查看这一点。
为什么内核页面缓存中的这种缓存是有帮助的? 简单:通过利用缓存的关键属性,即缓存内存区域(RAM)和缓存区域(块设备)之间的速度差异,我们可以获得极高的性能。 页面缓存位于 RAM 中,因此,当应用对文件数据执行读取时,(尽可能地)保持所有文件 I/O 的内容被缓存几乎可以保证对缓存的命中;从 RAM 读取要比从存储设备读取快得多。 类似地,内核不是缓慢而同步地将应用数据缓冲区直接写入块设备,而是将写数据缓冲区缓存在页面缓存中。 显然,将写入的数据刷新到底层块设备以及页面缓存内存本身的管理工作完全在 Linux 内核的工作范围内(我们在这里不讨论这些内部细节)。
程序员总是可以显式地将文件数据刷新到底层存储设备;我们已经在附录 A、File I/O Essentials中介绍了相关 API 及其用法。
我们现在了解到内核将所有文件 I/O 缓存在其页面缓存中;这对性能有好处。 考虑一个例子很有用:一个应用设置一个非常大的视频文件并对其执行流式读取(在某个应用窗口中向用户显示它;我们将假设特定的视频文件是第一次被访问)。 很容易理解,一般来说,在从磁盘读取文件时对其进行缓存会有所帮助,但在这里,在这种特殊情况下,它不会有太大帮助,因为第一次,我们仍然需要先去磁盘并将其读入。 因此,我们耸耸肩,继续以通常的方式对其进行编码,顺序读取视频数据块(通过其底层编解码器),并将其传递给呈现代码。
我们能做得更好吗? 是的,确实是这样:Linux 提供了完整的posix_fadvise(2)
系统调用,允许应用进程通过名为advice
的参数向内核提供有关其文件数据访问模式的提示。 与我们的示例相关的是,我们可以将通知作为值POSIX_FADV_SEQUENTIAL
、POSIX_FADV_WILLNEED
传递,以通知内核我们希望按顺序读取文件数据,并且我们希望在不久的将来需要访问文件数据。 此建议会导致内核按顺序(从低到高的文件偏移量)在内核页面缓存中启动积极的文件数据预读。 这将极大地帮助提高性能。
posix_fadvise(2)
系统调用的签名如下:
#include <fcntl.h>
int posix_fadvise(int fd, off_t offset, off_t len, int advice);
显然,第一个参数fd
表示文件描述符(我们请读者参阅附录 A,File I/O Essentials),第二个和第三个参数offset
和len
指定文件的一个区域,我们通过第四个参数advice
在该区域上传递提示或建议。 (长度实际上向上舍入为页面粒度。)
不仅如此,应用在完成对视频数据块的处理后,甚至可以通过调用posix_fadvise(2)
并将建议设置为值POSIX_FADV_DONTNEED
来向 OS 指定它将不再需要该特定的内存块;这将是对内核的一个提示,即它可以释放保存该数据的页面缓存的页面,从而为重要的传入数据(以及可能仍然有用的已经缓存的数据)创建空间。
有一些需要注意的事项。 首先,重要的是开发人员要认识到,这个建议实际上只是对操作系统的一个提示和建议;它可能会得到尊重,也可能不会得到尊重。 接下来,同样,即使目标文件的页面被读入页面缓存,它们也可能因为各种原因而被逐出,内存压力就是典型的原因。 不过,尝试一下也没什么坏处;内核通常会考虑这些建议,而且它确实可以提高性能。 (可以像往常一样在与此 API 相关的手册页中查找更多建议值。)
Interestingly, and now understandably, cat(1)
uses the posix_fadvise(2)
system call to inform the kernel that it intends to perform sequential reads until EOF. Using the powerful strace(1)
utility on cat(1)
reveals the following: ...fadvise64(3, 0, 0, POSIX_FADV_SEQUENTIAL) = 0
Don't get stressed with the fadvise64; it's just the underlying system call implementation on Linux for the posix_fadvise(2)
system call. Clearly, cat(1)
has invoked this on the file (descriptor 3), offset 0 and length 0—implying until EOF, and with the advice parameter set to POSIX_FADV_SEQUENTIAL
.
特定于 Linux(GNU)的readahead(2)
系统调用在执行主动文件预读方面实现了与我们刚才看到的posix_fadvise(2)
类似的结果。 其签名如下:
include <fcntl.h>
ssize_t readahead(int fd, off64_t offset, size_t count);
在由fd
指定的目标文件上执行预读,从文件offset
开始,最大为count
字节(四舍五入到页面粒度)。
Though not normally required, what if you want to explicitly empty (clean) the contents of the Linux kernel's page cache? If required, do this as the root user:
# sync && echo 1 > /proc/sys/vm/drop_caches
Don't miss the sync(1)
first, or you risk losing data. Again, we stress that flushing the kernel page cache should not be done in the normal course, as this could actually hurt I/O performance. A collection of useful command -line interface (CLI) wrapper utilities called linux-ftools is available on GitHub here: https://github.com/david415/linux-ftools. It provides the fincore(1)
(that's read as f-in-core), fadvise(1)
, and fallocate(1)
utilities; it's very educational to check out their GitHub README, read their man pages, and try them out.
回想一下我们在附录 A、File I/O Essentials、和中看到的read(2)
和write(2
)系统调用,它们构成了对文件执行 I/O 的基础。 您还会记得,在使用这些 API 时,操作系统将隐式更新底层文件和偏移量。 例如,如果进程打开一个文件(通过open(2)
),然后执行 512 字节的read(2)
,则文件的偏移量(或所谓的寻道位置)现在将为 512。 如果它现在写入(比方说)200 字节,则写入将从位置 512 发生到位置 712,从而将新的寻道位置或偏移量设置为该数字。
那又怎么样? 我们的观点很简单,当多线程应用有多个线程同时在同一底层文件上执行 I/O 时,隐式设置文件的偏移量会导致问题。 但是请稍等,我们之前已经提到过这一点:需要锁定文件,然后再对其进行操作。 但是,锁定会造成主要的性能瓶颈。 如果你设计了一个 MT 应用,它的线程并行地处理同一文件的不同部分,会怎么样? 这听起来不错,只是文件的偏移量会不断变化,从而破坏我们的并行性,从而破坏性能(您还记得我们在附录 A,File I/O Essentials中的讨论,简单地使用lseek(2)
来设置文件的查找位置可能会导致危险的竞争)。
那么,你是做什么的? Linux 为此提供了pread(2)
和pwrite(2)
系统调用(p 用于定位 I/O);使用这些 API,可以指定(或重新定位)执行 I/O 的文件偏移量,并且操作系统不会更改实际的底层文件偏移量。 他们的签名如下:
#include <unistd.h>
ssize_t pread(int fd, void *buf, size_t count, off_t offset);
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);
pread(2)
/pwrite(2)
和通常的read(2)
/write(2)
系统调用之间的区别在于,以前的 API 采用额外的第四个参数-执行读或写 I/O 操作的文件偏移量,而不修改它。 这使我们能够实现我们想要的:通过让多个线程并行读写文件的不同部分,让 MT 应用执行高性能 I/O。 (我们将尝试这一任务作为一项有趣的练习留给读者。)
需要注意的几点:首先,就像read(2)
和write(2)
、pread(2)
、pread(2)
和pwrite(2)
一样,也可以在没有传输所有请求的字节的情况下返回;程序员有责任在循环中检查和调用 API,直到没有剩余的字节要传输(重新访问附录 A,File I/O Essentials)。 正确使用读/写 API(解决此类问题的位置)。 其次,当使用指定的O_APPEND
标志打开文件时,Linux 的pwrite(2)
系统调用总是将数据附加到 EOF,而不考虑当前的偏移量值;这违反了 POSIX 标准,该标准规定O_APPEND
标志不应影响写入发生的起始位置。 第三,非常明显(但我们必须声明),被操作的文件必须能够被查找(即,支持fseek(3)
或lseek(2)
API)。 常规文件始终支持查找操作,但管道和某些类型的设备不支持)。
为了帮助解释此主题,假设我们被委托将数据写入文件,以便写入三个不连续的数据区域 A、B 和 C(分别填充 AS、B 和 C);下面的图表显示了这一点:
+------+-----------+---------+-----------+------+-----------+
| | ... A ... | | ... B ... | | ... C ... |
+------+-----------+---------+-----------+------+-----------+
|A_HOLE| A_LEN | B_HOLE | B_LEN |C_HOLE| C_LEN |
+------+-----------+---------+-----------+------+-----------+
^ ^ ^
A_START_OFF B_START_OFF C_START_OFF
The discontiguous data file
注意文件是如何有洞的-不包含任何数据内容的区域;这可以通过常规文件来实现(主要是洞的文件被称为稀疏文件)。 你是如何创造这个洞的? 简单:只需执行一个测试lseek(2)
,然后执行write(2)
数据;向前搜索的长度决定文件中孔的大小。
那么,我们如何才能实现如图所示的数据文件布局呢? 我们将展示两种方法-一种是传统方式,另一种是更优化的性能方法。 让我们从传统方法开始吧。
这似乎很简单:首先查找到所需的起始偏移量,然后写入所需长度的数据内容;这可以通过一对lseek(2)
和write(2)
系统调用来完成。 当然,我们必须调用这对系统调用三次。 因此,我们编写一些代码来实际执行此任务;请参见此处的代码(相关代码片段)(ch18/sgio_simple.c
):
For readability, only key parts of the source code are displayed; to view the complete source code, build, and run it, the entire tree is available for cloning from GitHub here: https://github.com/PacktPublishing/Hands-on-System-Programming-with-Linux.
#define A_HOLE_LEN 10
#define A_START_OFF A_HOLE_LEN
#define A_LEN 20
#define B_HOLE_LEN 100
#define B_START_OFF (A_HOLE_LEN+A_LEN+B_HOLE_LEN)
#define B_LEN 30
#define C_HOLE_LEN 20
#define C_START_OFF (A_HOLE_LEN+A_LEN+B_HOLE_LEN+B_LEN+C_HOLE_LEN)
#define C_LEN 42
...
static int wr_discontig_the_normal_way(int fd)
{ ...
/* A: {seek_to A_START_OFF, write gbufA for A_LEN bytes} */
if (lseek(fd, A_START_OFF, SEEK_SET) < 0)
FATAL("lseek A failed\n");
if (write(fd, gbufA, A_LEN) < 0)
FATAL("write A failed\n");
/* B: {seek_to B_START_OFF, write gbufB for B_LEN bytes} */
if (lseek(fd, B_START_OFF, SEEK_SET) < 0)
FATAL("lseek B failed\n");
if (write(fd, gbufB, B_LEN) < 0)
FATAL("write B failed\n");
/* C: {seek_to C_START_OFF, write gbufC for C_LEN bytes} */
if (lseek(fd, C_START_OFF, SEEK_SET) < 0)
FATAL("lseek C failed\n");
if (write(fd, gbufC, C_LEN) < 0)
FATAL("write C failed\n");
return 0;
}
请注意,我们是如何编写代码来连续三次使用一对{lseek, write}
系统调用的;让我们试一试:
$ ./sgio_simple
Usage: ./sgio_simple use-method-option
0 = traditional lseek/write method
1 = better SG IO method
$ ./sgio_simple 0
In setup_buffers_goto()
In wr_discontig_the_normal_way()
$ ls -l tmptest
-rw-rw-r--. 1 kai kai 222 Oct 16 08:45 tmptest
$ hexdump -x tmptest
0000000 0000 0000 0000 0000 0000 4141 4141 4141
0000010 4141 4141 4141 4141 4141 4141 4141 0000
0000020 0000 0000 0000 0000 0000 0000 0000 0000
*
0000080 0000 4242 4242 4242 4242 4242 4242 4242
0000090 4242 4242 4242 4242 4242 4242 4242 4242
00000a0 0000 0000 0000 0000 0000 0000 0000 0000
00000b0 0000 0000 4343 4343 4343 4343 4343 4343
00000c0 4343 4343 4343 4343 4343 4343 4343 4343
00000d0 4343 4343 4343 4343 4343 4343 4343
00000de
$
它起作用了;我们创建的文件tmptest
(我们在这里没有显示创建文件、分配和初始化缓冲区等的代码;请通过本书的 GitHub 存储库查找)的长度为 222 字节,尽管实际数据内容(AS、BS 和 Cs)的长度为 20+30+42=92 字节。 剩下的(222-92)130 个字节是文件中的三个洞(长度为 10+100+20 个字节;请参阅在代码中定义这些的宏)。 命令hexdump(1)
实用程序可以方便地转储文件内容;0x41 是 A,0x42 是 B,0x43 是 C。这些洞显然是我们想要的长度的空填充区域。
当然,连续三次使用{lseek, write}
对系统调用的传统方法是有效的,但会带来相当大的性能损失;事实是,发出系统调用被认为是非常昂贵的。 一种在性能方面要优越得多的方法称为分散聚集 I/O(SG-I/O,或向量化 I/O)。 相关的系统调用是readv(2)
和writev(2)
;这是它们的签名:
#include <sys/uio.h>
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
这些系统调用允许您指定一组段以一次读取或写入;每个段通过名为iovec
的结构描述单个 I/O 操作:
struct iovec {
void *iov_base; /* Starting address */
size_t iov_len; /* Number of bytes to transfer */
};
程序员可以传递描述要执行的 I/O 操作的段数组;这正是第二个参数-指向 struct iovecs 数组的指针;第三个参数是要处理的段数。 第一个参数很明显-文件描述符表示要对其执行集中读取或分散写入的文件。
因此,想想看:您可以将来自给定文件的不连续读取聚集到您通过 I/O 向量指针指定的缓冲区(及其大小)中,并且可以从您通过 I/O 向量指针指定的缓冲区(及其大小)分散对给定文件的不连续写入;因此,这些类型的多个不连续 I/O 操作被称为分散聚集 I/O! 这是真正酷的部分:系统调用保证以数组顺序和原子方式执行这些 I/O 操作;也就是说,只有当所有操作都完成时,它们才会返回。 不过,同样要小心:readv(2)
或writev(2)
的返回值是读取或写入的实际字节数,失败时返回值为-1
。 I/O 操作执行的数量总是有可能低于请求的数量;这不是故障,应该由开发人员进行检查。
现在,对于我们前面的数据文件示例,让我们看一下通过writev(2)
设置和执行不连续的分散有序原子写入的代码:
static int wr_discontig_the_better_SGIO_way(int fd)
{
struct iovec iov[6];
int i=0;
/* We don't want to call lseek of course; so we emulate the seek
* by introducing segments that are just "holes" in the file. */
/* A: {seek_to A_START_OFF, write gbufA for A_LEN bytes} */
iov[i].iov_base = gbuf_hole;
iov[i].iov_len = A_HOLE_LEN;
i ++;
iov[i].iov_base = gbufA;
iov[i].iov_len = A_LEN;
/* B: {seek_to B_START_OFF, write gbufB for B_LEN bytes} */
i ++;
iov[i].iov_base = gbuf_hole;
iov[i].iov_len = B_HOLE_LEN;
i ++;
iov[i].iov_base = gbufB;
iov[i].iov_len = B_LEN;
/* C: {seek_to C_START_OFF, write gbufC for C_LEN bytes} */
i ++;
iov[i].iov_base = gbuf_hole;
iov[i].iov_len = C_HOLE_LEN;
i ++;
iov[i].iov_base = gbufC;
iov[i].iov_len = C_LEN;
i ++;
/* Perform all six discontiguous writes in order and atomically! */
if (writev(fd, iov, i) < 0)
return -1;
/* Do note! As mentioned in Ch 19:
* "the return value from readv(2) or writev(2) is the actual number
* of bytes read or written, and -1 on failure. It's always possible
* that an I/O operation performs less than the amount requested; this
* is not a failure, and it's up to the developer to check."
* Above, we have _not_ checked; we leave it as an exercise to the
* interested reader to modify this code to check for and read/write
* any remaining bytes (similar to this example: ch7/simpcp2.c).
*/
return 0;
}
最终结果与传统方法完全相同;我们留给读者去尝试和观察。 这是关键点:传统方法要求我们至少发出 6 个系统调用(3 x{lseek, write}
对)来执行不连续的数据写入文件,而 SG-I/O 代码只使用一个系统调用执行完全相同的不连续的数据写入。 这会带来显著的性能提升,特别是对于 I/O 工作负载繁重的应用。
The interested reader, delving into the full source code of the previous example program (ch18/sgio_simple.c
) will notice something that perhaps seems peculiar (or even just wrong): the blatant use of the controversial goto
statement! The fact, though, is that the goto
can be very useful in error handling—performing the code cleanup required when exiting a deep-nested path within a function due to failure. Please check out the links provided in the Further reading section on the GitHub repository for more. The Linux kernel community has been quite happily using the goto
for a long while now; we urge developers to look into appropriate usage of the same.
回想一下MT 应用文件 I/O 和扩展,pwrite API 和部分,我们可以使用pread(2)
和pwrite(2)
系统调用通过多线程(在多线程应用中)有效地并行执行文件 I/O。 同样,Linux 提供了preadv(2)
和pwritev(2)
系统调用;正如您可以猜到的那样,它们提供了readv(2)
和writev(2)
的功能,并添加了第四个参数:Offset;就像readv(2)
和writev(2)
一样,可以指定要执行 SG-IO 的文件偏移量,并且不能更改它(同样,可能对 MT 应用很有用)。 preadv(2)
和pwritev(2)
的签名如下所示:
#include <sys/uio.h>
ssize_t preadv(int fd, const struct iovec *iov, int iovcnt,
off_t offset);
ssize_t pwritev(int fd, const struct iovec *iov, int iovcnt,
off_t offset);
最近的 Linux 内核(有些是 4.6 版以上)还提供了 API 的进一步变体:preadv2(2)
和pwritev2(2)
系统调用。 与以前的 API 的不同之处在于,它们采用额外的第五个参数和标志,允许开发人员通过能够指定 SG-I/O 操作是同步(通过RWF_DSYNC 和RWF_SYNC 标志)、高优先级(通过RWF_HIPRI 标志)还是非阻塞(通过RWF_NOWIT 标志)来更好地控制 SG-I/O 操作的行为。 有关详细信息,请读者参阅preadv2(2)
/pwritev2(2)
上的手册页。
无论是在附录 A、File I/O Essentials中,还是在本章中,我们都多次提到 Linux 内核的页面缓存如何通过缓存其中的文件内容来帮助极大地提高性能(减少了每次访问非常慢的存储设备而只读或写 RAM 中的数据块的需要)。 然而,尽管我们通过页面缓存获得了性能,但同时使用传统的read(2)
、write(2)
API 甚至更快的 SG-I/O([p][read|write][v][2](2)
)API 仍然存在一个隐藏的问题。
要了解问题所在,我们首先必须更深入地了解 I/O 代码路径的实际工作方式;下图概括了相关要点:
Figure 1: Page cache populated with disk data The reader should realize that though this diagram seems quite detailed, we're actually seeing a rather simplistic view of the entire Linux I/O code path (or I/O stack), only what is relevant to this discussion. For a more detailed overview (and diagram), please see the link provided in the *Further reading *section on the GitHub repository.
假设进程 P1打算从它打开的目标文件中读取大约 12KB 的数据(通过open(2)
系统调用);我们设想它将通过通常的方式完成此操作:
- 通过
malloc(3)
接口分配 12KB 的堆缓冲区(3 页=12,288 字节)。 - 发出
read(2)
系统调用,将数据从文件读入堆缓冲区。read(2)
系统调用在操作系统中执行工作;当读取完成后,它返回(希望是值12,288
;记住,程序员的工作是检查这一点,不做任何假设)。
这听起来很简单,但在引擎盖下发生的事情还有很多,更深入地挖掘一下符合我们的利益。 以下是所发生情况的更详细视图(上一图中的数字点1、2和3在圆圈中显示;以下为):
-
进程 P1通过
malloc(3)
API(len=12KB=12,288 字节)分配 12KB 的堆缓冲区。 -
接下来,它发出一个
read(2)
系统调用,将数据从文件(由.fd 指定)读取到刚才分配的 buf 的堆缓冲区中,长度为 12KB。 -
因为
read(2)
是一个系统调用,所以进程(或线程)现在切换到内核模式(还记得我们在前面的第 1 章,Linux 系统架构中介绍的单片设计); 它进入 Linux 内核的通用文件系统层(称为虚拟文件系统交换机(VFS),在那里它将被自动分流到其相应的底层文件系统驱动程序(可能是 ext4fs),之后 Linux 内核将首先检查:所需文件数据的这些页面是否已缓存在我们的页面缓存中?如果已缓存,则该工作完成(我们短路至步骤 7)。 假设我们得到一个缓存未命中--所需的文件数据页不在页面缓存中。 -
因此,内核首先为页面缓存分配足够的 RAM(页帧)(在我们的示例中,三个帧显示为页面缓存内存区域内的粉红色正方形)。 然后,它向请求文件数据的底层发出适当的 I/O 请求。
-
请求最终到达块(存储)驱动程序;我们假设它知道自己的工作,并从底层存储设备控制器(可能是磁盘或闪存控制器芯片)读取所需的数据块。 然后(这里是有趣的一点),它被赋予一个目标地址来写入文件数据;它是页面缓存中分配的页帧的地址(步骤 4);因此,块驱动程序总是将文件数据写入内核的页面缓存中,并且永远不会直接返回到用户模式进程缓冲区。
-
块驱动程序已成功地将数据块从存储设备(或其他设备)复制到内核页面缓存中先前分配的帧中。 (实际上,这些数据传输通过一种称为直接存储器访问(DMA)的高级存储器传输技术进行了高度优化),其中,驱动程序本质上利用硬件直接在设备和系统存储器之间传输数据,而无需 CPU 干预。 显然,这些主题远远超出了本书的范围。)
-
现在,内核将刚刚填充的内核页面缓存帧复制到用户空间堆缓冲区中。
-
(阻塞)
read(2)
系统调用现在终止,返回值 12,288,表示所有三页文件数据确实已经传输(同样,作为应用开发人员,您应该检查该返回值,不做任何假设)。
一切看起来都很棒,是吗? 其实并非如此;仔细考虑一下:虽然read(2)
(或pread[v][2](2)
)API 确实成功了,但这一成功是要付出相当大的代价的:内核必须分配 RAM(页帧)以在其页面缓存中保存文件数据(步骤 4),一旦数据传输完成(步骤 6),然后将该内容复制到用户空间堆内存中(步骤 7)。 因此,通过保留额外的数据副本,我们使用了两倍以上的 RAM。这是非常浪费的,显然,在块驱动程序到内核页高速缓存,然后内核页高速缓存到用户空间堆缓冲区之间多次复制数据缓冲区,也会降低性能(更不用说 CPU 高速缓存会不必要地受到所有这些垃圾内容的影响)。 使用前面的代码模式,不等待慢速存储设备的问题得到了解决(通过页面缓存效率),但其他一切都很糟糕-我们实际上将所需的内存使用量增加了一倍,并且在进行复制时 CPU 缓存被(不必要的)文件数据覆盖。
以下是这些问题的解决方案:通过进程mmap(2)
系统调用进行内存映射。Linux 提供了非常强大的进程mmap(2)
系统调用;它使开发人员能够将任何内容直接映射到进程中的虚拟地址空间(VAS)。 此内容包括文件数据、硬件设备(适配器)存储区域或仅通用存储区域。 在本章中,我们将只关注使用mmap(2)
将常规文件的内容映射到进程 VAS 中。 在进入mmap(2)
如何成为我们刚才讨论的内存浪费问题的解决方案之前,我们首先需要更多地了解如何使用mmap(2)
系统调用本身。
mmap(2)
系统调用的签名如下所示:
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
我们希望将文件的给定区域(从给定的offset
字节和length
字节)映射到我们的流程 VAS 中;下图描述了我们要实现的简单视图:
Figure 2: Memory mapping a file region into process VAS
要实现此文件到进程 VAS 的映射,我们使用mmap(2)
系统调用。 看一下它的签名,很明显我们首先需要做的是:通过open(2)
打开要映射的文件(以适当的模式打开:只读或读写,取决于您想要做什么),从而获得一个文件描述符;将此描述符作为第五个参数传递给文件mmap(2)
。 要映射到过程 VAS 的文件区域可以分别通过第六个和第二个参数指定-映射应从其开始的文件offset
和length
(以字节为单位)。
第一个参数是addr
,它提示内核在进程 VAS 中的哪个位置应该创建映射;建议在这里传递0
(NULL),允许操作系统决定新映射的位置。 这是使用mmap(2)
的正确便携方式;但是,有些应用(是的,还有一些恶意的安全攻击!)。 使用此参数可以尝试预测映射发生的位置。 在任何情况下,在流程 VAS 中创建映射的实际(虚拟)地址都是从函数mmap(2)
返回的值;NULL 返回表示失败,必须进行检查。
Here is an interesting technique to fix the location of the mapping: first perform a malloc(3)
of the required mapping size and pass the return value from this malloc(3)
to the mmap(2)
's first parameter (also set the flags parameter to include the MAP_FIXED bit)! This will probably work if the length is above MMAP_THRESHOLD (128 KB by default) and the size is a multiple of the system page size. Note, again, this technique is not portable and may or may not work.
另一点需要注意的是,大多数映射(总是文件映射)都是按照页面粒度(即页面大小的倍数)执行的;因此,返回地址通常是页面对齐的。
mmap(2)
的第三个参数是整数位掩码prot
-给定区域的内存保护(回想一下,我们已经在第章和动态内存分配一节的内存保护部分中遇到过内存保护)。 参数prot
是位掩码,它可以只是第一个PROT_NONE
位(表示没有权限),也可以是余数的逐位或;此表列举了位及其含义:
| 保护位 | 含义 |
| PROT_NONE
| 不允许访问该页面 |
| PROT_READ
| 页面上允许的读取数 |
| PROT_WRITE
| 页面上允许的写入 |
| PROT_EXEC
| 执行页面上允许的访问权限 |
mmap(2) protection bits
当然,页面保护必须与文件的open(2)
相匹配。 还要注意,在较旧的 x86 系统上,可写内存用于表示可读内存(即PROT_WRITE => PROT_READ
)。 现在不再是这种情况;您必须显式指定映射的页面是否可读(对于可执行页面也是如此:必须指定,文本段是典型的示例)。 为什么要使用 PROT_NONE 呢? Stack Guard 页是一个现实的例子(回忆一下第 14 章,使用 PthreadsPart I-Essentials中的Stack Guard 和部分)。
下一点需要理解的是,大致有两种类型的映射:文件映射区域或匿名区域。 文件映射区域非常明显地映射文件的(全部或部分)内容(如上图所示)。 我们认为该区域由文件支持;也就是说,如果操作系统内存不足,并决定回收一些文件映射的页面,则不需要将它们写入交换分区-它们已经在映射的文件中可用。 另一方面,匿名映射是内容是动态的映射;数据段(初始化数据、BSS、堆)、库映射的数据段、进程(或线程)栈是匿名映射的优秀示例。 可以认为它们不是文件备份的;因此,如果内存不足,操作系统可能确实会将它们的页面写入交换。 此外,回想一下我们在第 4 章和动态内存分配中了解到的关于malloc(3)
的内容;事实是,glibcmalloc(3)
引擎仅在分配的量很小-小于 MMAP_THRESHOLD(缺省值为 128KB)时才使用堆段来为分配提供服务。 高于该值的任何malloc(3)
都将导致内部调用mmap(2)
来设置所需大小的匿名内存区域(映射!)。 这些映射(或段)将位于堆顶部和 Main 堆栈之间的可用虚拟地址空间中。
回到mmap(2)
:第四个参数是位掩码,称为flags
;有几个标志,它们影响映射的许多属性。 其中,两个标志决定映射的私密性,并且是互斥的(一次只能使用其中任何一个):
- MAP_SHARED:映射是共享的;其他进程可能同时处理相同的映射(实际上,这是实现通用 IPC 机制-共享内存-的通用方式)。 在文件映射的情况下,如果内存区域被写入,则更新底层文件! (您可以使用
msync(2)
控制将内存中的写入刷新到底层文件。) - MAP_PRIVATE:这将设置一个私有映射;如果它是可写的,则意味着 COW 语义(导致最佳内存使用,如第 10 章,进程创建中所述)。 私有的文件映射区域将不会执行对底层文件的写入。 实际上,私有文件映射在 Linux 上非常常见:这正是在开始执行进程时,加载器(请参见信息框)引入二进制可执行文件的文本和数据以及进程使用的所有共享库的文本和数据的方式。
The reality is that when a process runs, control first goes to a program embedded into your a.out
binary executable—the loader (ld.so
or ld-linux[-*].so
). It performs the key work of setting up the C runtime environment: it memory maps (via the mmap(2)
) the text (code) and initialized data segments from the binary executable file into the process, thereby creating the segments in the VAS that we have been talking about since Chapter 2, Virtual Memory. Further, it sets up the initialized data segment, the BSS, the heap, and the stack (of main()
), and then it looks for and memory maps all shared libraries into the process VAS.
Try performing a strace(1)
on a program; you will see (early in the execution) all the mmap(2)
system calls setting up the process VAS! The mmap(2)
is critical to Linux: in effect, the entire setup of the process VAS, the segments or mappings—both at process startup as well as later—are all done via the mmap(2)
system call.
为了帮助弄清楚这些重要事实,我们显示了在ls(1)
上运行strace(1)
的一些(截断)输出; (例如)查看如何在 glibc 上执行open(2)
,返回文件描述符 3,然后由mmap(2)
使用它在进程 VAS!中创建 glibc 代码的私有文件映射只读映射(我们可以看到第一个mmap
中的偏移量是0
)(详细信息:open(2)
成为内核中的openat(2)
函数;忽略这一点,就像在 Linux 上经常发生的那样,mmap(2)
变成openat(2)
。 strace(1)
(截断)输出如下:
$ strace -e trace=openat,mmap ls > /dev/null
...
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
mmap(NULL, 4131552, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f963d8a5000
mmap(0x7f963dc8c000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1e7000) = 0x7f963dc8c000
...
The kernel maintains a data structure called the virtual memory area (VMA) for each such mapping per process; the proc filesystem reveals all mappings to us in user space via /proc/PID/maps
. Do take a look; you will literally see the virtual memory map of the process user space. (Try sudo cat /proc/self/maps
to see the map of the cat process itself.) The man page on proc(5)
explains in detail how to interpret this map; please take a look.
现在我们了解了如何使用mmap(2)
系统调用,我们再回顾一下前面的讨论:回想一下,使用read(2)
/write(2)
甚至 SG-I/O 类型的 API([p]readv|writev[2](2)
)会导致双重复制;内存浪费(加上 CPU 缓存也会被丢弃)。
实现mmap(2)
如此有效地解决这个严重问题的关键在于:mmap(2)
通过在内部将包含文件数据(从存储设备读入)的页面直接映射到进程虚拟地址空间来建立文件映射。 此图(图 3)透视了这一点(并使其不言自明):
Figure 3: Page cache populated with disk data
映射不是拷贝;因此,基于mmap(2)
的文件 I/O 被称为非零拷贝技术:一种在 I/O 缓冲区上执行工作的方法,其中内核在其页缓存中只维护一个拷贝;不需要更多拷贝。
The fact is that the device driver authors look to optimize their data path using zero-copy techniques, of which the mmap(2)
is certainly a candidate. See more on this interesting advanced topic within links provided in the Further reading section on the GitHub repository.
mmap(2)
在设置映射(第一次)时确实会产生很大的开销,但一旦完成,I/O 就会非常快,因为它基本上是在内存中执行的。 想想看:要查找文件中的某个位置并在那里执行 I/O,只需使用常规的‘C’代码从mmap(2)
返回值(它只是一个指针偏移量)移动到给定位置,并在内存本身中执行 I/O 工作(通过memcpy(3)
、s[n]printf(3)
或任何您喜欢的);根本不需要lseek(2)
、read(2)
/write(2)
或 SG-I/O 系统调用开销。 对于非常少量的 I/O 工作,使用mmap(2)
可能不是最佳的;当指示大量且持续的 I/O 工作负载时,建议使用它。
为了帮助读者使用mmap(2)
进行文件 I/O,我们提供了一个简单应用的代码;它通过mmap(2)
和十六进制(使用略有增强的开源hexdump
函数)将指定的内存区域映射到stdout
上,从而将给定的文件(文件的路径名、起始偏移量和长度作为参数提供)映射到stdout
。 我们敦促读者查阅代码、构建并试用它。
The complete source code for this book is available for cloning from GitHub here: https://github.com/PacktPublishing/Hands-on-System-Programming-with-Linux. The aforementioned program is here within the source tree: ch18/mmap_file_simple.c
.
以下是几个附加要点的快速总结,以结束内存映射讨论:
mmap(2)
的第四个参数是flags
,它可以采用其他几个(相当有趣的)值;我们让读者参考mmap(2)
上的手册页来浏览它们:http://man7.org/linux/man-pages/man2/mmap.2.html。- 直接类似于如何使用
posix_fadvise(2)
API 向内核提供有关内核页面缓存页面的提示或建议,您可以通过posix_madvise(3)
库 API 向内核提供关于给定内存范围(起始地址、提供的长度)的内存使用模式的类似提示或建议。 建议值包括能够说我们希望随机访问数据(从而通过POSIX_MADV_RANDOM
位减少预读),或者我们希望很快访问指定范围内的数据(通过POSIX_MADV_WILLNEED
位,从而产生更多的预读和映射)。 此例程调用 Linux 上的底层系统调用madvise(2)
。 - 假设我们已经将文件的一个区域映射到我们的进程地址空间;我们如何知道映射的哪些页面当前驻留在内核页面(或缓冲区)缓存中? 准确地说,这可以通过
mincore(2)
系统调用(读作“m-in-core”)来确定。 - 程序员可以通过
msync(2)
系统调用对同步(刷新)文件映射区域(返回到文件)进行显式(和微调)控制。 - 一旦完成,内存映射应该通过
munmap(2)
系统调用取消映射;参数是映射的基地址(来自mmap(2)
的返回值)和长度。 如果进程终止,映射将隐式取消映射。 - 在
fork(2)
上,子进程继承内存映射。 - 如果一个巨大的文件被内存映射,并且在运行时分配页帧来保存进程 VAS 中的映射(回想一下我们在第 4 章,动态内存分配中关于按需分页的讨论),系统会耗尽内存(剧烈的,但也有可能发生);在这种情况下,进程将收到错误的
SIGSEGV
信号(因此取决于应用的信号处理能力来正常终止)。
同时使用阻塞函数[p]read[v](2)
/[p]write[v](2)
API 和函数mmap(2)
(实际上在使用mmap
时更是如此)的一个显著缺点是:它们依赖于内核页面缓存总是由文件的页面(它正在处理或映射)填充。 如果不是这种情况-当数据存储远远大于 RAM 大小(即,文件可能非常大)时会发生这种情况-它将导致内核内存管理(mm)代码进行大量的元工作,以将页面从磁盘引入到页面缓存、分配帧、为它们缝合页面表项,等等。 因此,当 RAM 与存储的比率尽可能接近 1:1 时,mmap
技术效果最好。 当存储大小远远大于 RAM 时(通常是数据库、大规模云虚拟化等企业级软件的情况),它可能会受到所有元工作造成的延迟,以及大量内存将用于分页元数据的事实。
两种 I/O 技术-DIO 和 AIO-缓解了这些问题(以复杂性为代价);接下来我们将简要介绍它们。 (由于篇幅所限,我们将重点放在这些主题的概念性方面;因此,学习使用相关 API 是一项相对容易的任务。 请务必参考有关 GitHub 存储库的进一步阅读部分。)
一种有趣的 I/O 技术是Direct I/O(DIO);要使用它,请在通过open(2)
系统调用打开文件时指定O_DIRECT
标志。
有了 DIO,内核页面缓存几乎完全被绕过了,从而立即带来了好处,即mmap
技术可能面临的所有问题现在都消失了。 另一方面,这确实意味着整个缓存和管理将完全由用户空间应用处理(数据库等大型项目肯定需要缓存!)。对于没有特殊 I/O 要求的常规小应用,使用 DIO 可能会降低性能;请小心,在压力下测试您的工作负载,并确定是使用 DIO 还是跳过它。
传统上,内核处理哪些 I/O 片段(I/O 请求)在什么时候得到服务-换句话说,I/O 调度(这与 I/O 调度没有直接关系,但也请参阅I/O 调度器一节)。 使用 DIO(以及下面介绍的 AIO),应用开发人员基本上可以通过确定何时执行 I/O 来接管 I/O 调度。这可能是好事,也可能是坏事:它为(成熟的)应用开发人员提供了设计和实现 I/O 调度的灵活性,但这并不是一件小事;像往常一样,这是一种权衡。
此外,您应该意识到,尽管我们直接调用 I/O 路径,但它不能保证写操作立即刷新到底层存储介质;这是一个单独的功能,可以通过将O_SYNC
标志指定给open(2)
或者当然是显式刷新(通过[f]sync(2)
系统调用)来请求。
异步 I/O(AIO)是 Linux 实现的一种现代高性能异步非阻塞 I/O 技术。 想想看:非阻塞和异步意味着应用线程可以发出读取(针对文件或网络数据);usermode API 立即返回;I/O 在内核中排队;应用线程可以继续处理 CPU 限制的内容;一旦 I/O 请求完成,内核通知线程读取准备就绪;然后线程实际执行读取。 这是高性能-应用不会在 I/O 上保持阻塞状态,而是可以在处理 I/O 请求时执行有用的工作;不仅如此,当 I/O 工作完成时,它还会收到异步通知。 (另一方面,像select(2)
、poll(2)
和epoll(7)
这样的多路复用 API 是异步的-您可以发出系统调用并立即返回-但它们实际上仍然是阻塞操作,因为线程必须检查 I/O 是否完成-例如,在返回时使用poll(2)
和read(2)
系统调用-这仍然是阻塞操作。)
使用 AIO,线程可以同时启动多个 I/O 传输;每个传输都需要一个上下文-称为*[a]iocb*-[Async]I/O 控制块数据结构(Linux 将该结构称为 iocb,POSIX AIO 框架(包装库)将其称为 iaiocb)。 [a]iocb 结构包含文件描述符、数据缓冲区、异步事件通知结构sigevent
等。 警觉的读者会记得,我们已经在创建和使用 POSIX(间隔)计时器部分的第 13 章,计时器中使用了这个强大的sigevent
结构。 实际上正是通过这个sigevent
结构实现了异步通知机制(我们在第 13 章和Timers中使用了它,以异步通知我们的计时器超时;这是通过将sigevent.sigev_notify
设置为值SIGEV_SIGNAL
来实现的,从而在计时器超时时接收信号)。-Linux 公开了五个系统调用,供应用开发人员利用 AIO; 它们如下:io_setup(2)
、io_submit(2)
、io_cancel(2)
、io_getevents(2)
和io_destroy(2)
。
AIO 包装器 API 由两个库提供-libaio 和 iLibrt API(与 glibc 一起发布);当然,您可以使用它们的包装器,它们最终将调用系统调用。 还有一些 POSIX AIO 包装器;有关使用它的概述以及示例代码,请参阅aio(7)
上的手册页。 (有关更多详细信息和示例代码,请参阅中有关 GitHub 存储库的进一步阅读一节中的文章。)
下表提供了我们已经看到的四到五种 Linux I/O 技术之间一些比较突出的比较点,即:阻塞read(2)
/write(2)
(以及 SG-I/O/定位在[p]read[v](2)
/[p]write[v](2)
)、内存映射、非阻塞(主要是同步)DIO 和非阻塞异步 AIO:
| I/O 类型 | 接口 | 专业 | CONS |
| 阻塞
(常规和 SG-IO/定位) | [p]read[v](2)
/[p]write[v](2)
| 易用 | 缓慢;数据缓冲区的双拷贝 |
| 内存映射 | mmap(2)
| (相对)易于使用;快速(在内存 I/O 中);数据的单一副本(零拷贝技术);
在 RAM:STORAGE::~1:1 时工作得最好 | 当 RAM:存储比为 1:N(N>>1)时,MMU 密集型(高页表开销、元工作) |
| DIO
(非阻塞,主要是同步) | open(2)
带有O_DIRECT
标志 | 零拷贝技术;对页面缓存没有影响;对缓存的控制;对 I/O 调度的一些控制 | 设置和使用相当复杂:应用必须执行自己的缓存 |
| AIO 接口
(非阻塞、异步) | io_*(2)等> | 真正的异步和非阻塞-高性能应用所需;零拷贝技术;不影响页面缓存;完全控制缓存、I/O 和线程调度 | 设置和使用起来很复杂 |
Linux I/O technologies—a quick comparison
在关于 GitHub 存储库的进一步阅读一节中,我们提供了两篇博客文章的链接(来自两个现实世界的产品:现代高性能分布式 No SQL 数据存储库 Scylla 和现代高性能 Web 服务器 Nginx),这两篇文章深入讨论了这些替代的强大 I/O 技术(AIO、线程池)如何在(各自的)现实产品中使用;请一定要看一看。
您经常听说强大的多路复用 I/OAPI-select(2)
、poll(2)
,以及最近 Linux 强大的epoll(7)
框架。 这些 API,例如select(2)
、poll(2)
和/或epoll(7)
,提供了所谓的异步阻塞 I/O。它们可以很好地处理在 I/O 上保持阻塞状态的描述符;例如套接字(Unix 和 Internet 域)以及管道(包括未命名管道和命名管道(FIFO))。
这些 I/O 技术是异步的(您可以发出系统调用并立即返回),但它们实际上在本质上仍然是阻塞操作,因为线程必须检查 I/O 是否完成,例如,通过将poll(2)
与read(2)
系统调用配合使用,这仍然是一个阻塞操作。
这些 API 对于网络 I/O 操作确实非常有用,典型的例子是监控数百(甚至数千)个连接的繁忙(Web)服务器。 首先,由套接字描述符表示的每个连接使得使用select(2)
或poll(2)
系统调用很有吸引力。 然而,事实是select(2)
是旧的且受限的(最多 1,024 个描述符;不够);其次,select(2)
和poll(2)
的内部实现的算法时间复杂度都是 O(N),这使得它们不可伸缩。epoll(7)
的实现没有(理论)描述符限制,并且使用 O(1)算法和所谓的边缘触发通知。此表总结了以下几点:
| 加入时间:清华大学 2007 年 01 月 25 日下午 3:33 | 算法时间复杂度 | 最大客户端数量 |
| select(2)
| O(N) | FD_SETSIZE(1024) |
| poll(2)
| O(N) | (理论上)无限的 |
| epoll(7)
接口 | O(1) | (理论上)无限的 |
Linux asynchronous blocking APIs
因此,这些特性使得epoll(7)
组 API(epoll_create(2)
、epoll_ctl(2)
、epoll_wait(2)
和epoll_pwait(2)
)成为在需要非常高可伸缩性的网络应用上实现非阻塞 I/O 的首选。 (请参阅关于 GitHub 存储库的进一步阅读部分中的一篇博客文章的链接,该文章提供了有关在 Linux 上使用多路复用的 I/O(包括 EPOLL)的更多详细信息。)
下面是本章要完善的其他几个主题。
虽然这些多路复用 API 非常适合网络 I/O,但是这些多路复用 API 虽然在理论上可以用于监视常规文件描述符,但它们只会报告它们始终处于就绪状态(用于读取、写入或出现错误情况),从而降低了它们的有用性(在常规文件上使用时)。
也许 Linux 的 iinotify 框架,一种监视文件系统事件(包括单个文件上的事件)的方法,可能就是您正在寻找的。 Inotify 框架提供了以下系统调用来帮助开发人员监控文件:http://man7.org/linux/man-pages/man7/inotify.7.html`inotify_init(2)`、`inotify_add_watch(2)`(随后可以是`read(2)`),然后是`inotify_rm_watch(2)`。有关更多详细信息,请查看`inotify(7)`上的手册页:[http://man7.org/linux/man-pages/man7/inotify.7.html](http://man7.org/linux/man-pages/man7/inotify.7.html)。
Linux I/O 堆栈中的一个重要特性是内核块层的一部分,称为 I/O 调度器。 这里要解决的问题基本上是这样的:内核或多或少不断地发出 I/O 请求(由于应用想要执行各种文件数据/代码读写);这导致连续的 I/O 请求流最终由块驱动程序接收和处理。 内核人员知道,I/O 降低性能的主要原因之一是典型 SCSI 磁盘的物理寻道速度非常慢(与硅片速度相比;是的,当然,SSD(固态设备)正使这一点如今变得更受欢迎)。
因此,如果我们能够使用某种智能来对块 I/O 请求进行排序,使其在底层物理介质方面最有意义,这将有助于提高性能。 想一想大楼里的电梯:它使用一种排序算法,在穿过不同的楼层时,最佳地让人们上下楼。 这就是 OS I/O 调度器试图做的事情;事实上,第一个实现被称为 Linus 的升降机。
存在各种 I/O 调度器算法(Deadline,完全公平队列(CFQ),NOOP,Predictive Scheduler:这些算法现在被认为是遗留的;截至撰写本文时,最新的 I/O 调度器似乎是 MQ-Deadline 和预算公平队列(BFQ)和 I/O 调度器,BFQ 对于重或轻的 I/O 工作负载看起来非常有希望(BFQ 是一个。 Linux 操作系统中存在的 I/O 调度器是一个内核特性;您可以检查哪些是它们,哪些正在使用;请看我的 Ubuntu 18.04x86_64 机器上正在做的事情:
$ cat /sys/block/sda/queue/scheduler
noop deadline [cfq]
$
这里,bfq
是我的 Fedora 28 系统(具有更新的内核)上使用的 I/O 调度器:
$ cat /sys/block/sda/queue/scheduler
mq-deadline [bfq] none
$
此处的默认 I/O 调度器为bfq
。 有趣的是:用户实际上可以在 I/O 调度器之间进行选择,运行他们的 I/O 压力工作负载和/或基准测试,并查看哪个产生最大的好处! 如何选择 I/O 调度器?要在引导时选择 I/O 调度器,请传递内核参数(通过 Bootloader,在基于 x86 的笔记本电脑、台式机或服务器系统上通常为 GRUB,在嵌入式 Linux 上为 U-Boot);有问题的参数作为elevator=<iosched-name>
传递;例如,要将 I/O 调度器设置为 noop(可能对具有 SSD 的系统有用),请将参数作为elevator=noop
传递给内核。
有一种更简单的方法可以在运行时立即更改 I/O 调度器;只需将您想要的 I/O 调度器echo(1)
更改到伪文件中;例如,要将 I/O 调度器更改为mq-deadline
,请执行以下操作:
# echo mq-deadline > /sys/block/sda/queue/scheduler
# cat /sys/block/sda/queue/scheduler
[mq-deadline] bfq none
#
现在,您可以(对)不同 I/O 调度器上的 I/O 工作负载进行(压力)测试,从而决定哪个 I/O 调度器能为您的工作负载带来最佳性能。
Linux 提供了posix_fallocate(3)
API;它的工作是保证特定于给定文件的给定范围有足够的磁盘空间可用。 这实际上意味着,只要应用在该范围内写入该文件,就可以保证写入不会因为磁盘空间不足而失败(如果确实失败,errno
将被设置为 ENOSPC;这种情况不会发生)。 签名如下:
#include <fcntl.h>
int posix_fallocate(int fd, off_t offset, off_t len);
以下是关于此接口的一些快速注意事项:
- 该文件是由描述符
fd
引用的文件。 - 范围是从 0
offset
到 0len
个字节;实际上,这是为文件保留的磁盘空间。 - 如果当前文件大小小于范围要求的大小(即,
offset
+len
),则文件将增长到此大小;否则,文件的大小保持不变。 posix_fallocate(3)
是底层系统调用fallocate(2)
上的可移植包装器。- 要使此 API 成功,底层文件系统必须支持
fallocate
;如果不支持,则对其进行仿真(但有很多警告和问题;有关更多信息,请参阅手册页)。 - 此外,还存在一个名为
fallocate(1)
的 CLI 实用程序,用于从(比方说)shell 脚本执行相同的任务。
These APIs and tools may come in very useful for software such as backup, cloud provisioning, digitization, and so on, guaranteeing sufficient disk space is available before a long I/O operation begins.
此表汇总了各种实用程序、API、工具,甚至 cgroup blkio 控制器;事实证明,这些工具/功能在监视、分析(以查明 I/O 瓶颈)和分配 I/O 带宽(通过ioprio_set(2)
和功能强大的 cgroup blkio 控制器)方面非常有用。
| 实用程序名称 | 它的作用 |
| iostat(1)
| 监视 I/O 并显示有关设备和存储设备分区的 I/O 统计信息。 从iostat(1)
上的手册页:iostat
命令用于通过观察设备相对于其平均传输速率的活动时间来监视系统输入/输出设备负载。 iostat
命令生成可用于更改系统配置的报告,以更好地平衡物理磁盘之间的输入/输出负载。 |
| iotop(1)
| 在top(1)
样式(针对 CPU)中,iotop 持续显示按 I/O 使用情况排序的线程。 必须以超级用户身份运行。 |
| ioprio_[get|set](2)
| 查询和设置给定线程的 I/O 调度类和优先级的系统调用;有关详细信息,请参阅手册页:http://man7.org/linux/man-pages/man2/ioprio_set.2.html;也请参阅其包装器实用程序ionice(1)
。 |
| 性能-工具 | 在这些工具(来自 B Gregg)中,iosnoop-perf(1)
和iolatecy-perf(1)
分别用于窥探 I/O 事务和观察 I/O 延迟。 从他们的 GitHub 资源库安装这些工具:https://github.com/brendangregg/perf-tools。 |
| Cgroup blkio 控制器 | 使用功能强大的 Linux cgroup 的 blkio 控制器,以任何所需的方式限制一个或一组进程的 I/O 带宽(在云环境中大量使用,包括 Docker);请参阅 GitHub 存储库的进一步阅读部分中的相关链接。 |
Tools/utilities/APIs/cgroups for I/O monitoring, analysis, and bandwidth control
注意:Linux 系统默认情况下可能不会安装上面提到的实用程序;(显然)请安装它们以试用它们。
Do also check out Brendan Gregg's superb Linux Performance blog pages and tools (which include perf-tools, iosnoop, and iosnoop latency heat maps); please find the relevant links in the Further reading section on the GitHub repository.
在本章中,我们学习了处理文件的一个关键方面的强大方法:确保 I/O 性能保持尽可能高,因为 I/O 确实是许多实际工作负载中耗尽性能的瓶颈。 这些技术包括传递给操作系统的文件访问模式建议、SG-I/O 技术和 API、文件 I/O 的内存映射、DIO、AIO 等。
The next chapter in the book is a brief look at daemon processes; what they are and how to set them up. Kindly take a look at this chapter here: https://www.packtpub.com/sites/default/files/downloads/Daemon_Processes.pdf.