准备

要求:

限制内存的情况下,假定我们每次写入4k的数据,如何保证kill -9不丢数据的情况下,仍然稳定的跑满磁盘的IO?因为需要保证kill -9不丢数据,所以fwrite()就不在我们的考虑范围之内了. 又因为限制内存,所以直观的想法是直接Direct IO, 但Direct IO能否跑满磁盘IO呢?

机器配置:

CPU: 64核 Intel(R) Xeon(R) CPU E5-2682 v4 @ 2.50GHz

磁盘: Intel Optane SSD

测试磁盘IO性能:

官方称读/写带宽是2400/2000 MB/s, 我们利用fio来进行实测:

顺序读性能:

1
sudo fio --filename=test -iodepth=64 -ioengine=libaio --direct=1 --rw=read --bs=2m --size=2g --numjobs=4 --runtime=10 --group_reporting --name=test-read

结果:

1
READ: bw=2566MiB/s (2691MB/s), 2566MiB/s-2566MiB/s (2691MB/s-2691MB/s), io=8192MiB (8590MB), run=3192-3192msec

顺序写性能:

1
sudo fio --filename=test -iodepth=64 -ioengine=libaio -direct=1 -rw=write -bs=1m -size=2g -numjobs=4 -runtime=20 -group_reporting -name=test-write

结果:

1
WRITE: bw=2181MiB/s (2287MB/s), 2181MiB/s-2181MiB/s (2287MB/s-2287MB/s), io=8192MiB (8590MB), run=3756-3756msec

实测读写带宽: 2566/2181 MB/s

实验一: Buffer IO写入

因为是限制内存,所以Buffer IO不在我们的考虑范围内,但是我们先来测试一下Buffer IO的具体性能到底如何? 我们使用最简单的方法,因为我们的CPU核数是64,所以直接64线程单次4K字节Buffer IO写入, 即通过操作系统的Page Cache的策略来缓存,刷盘:

代码片段: 完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static char data[4096] attribute((aligned(4096))) = {'a'}; 

void writer(int index) {
std::string fname = "data" + std::to_string(index);
int data_fd = ::open(fname.c_str(), O_RDWR | O_CREAT | O_APPEND, 0645);
for (int32_t i = 0; i < 1000000; i++) {
::write(data_fd, data, 4096);
}
close(data_fd);
}

int main() {
std::vectorstd::thread threads;
for(int i = 0; i < 64; i++) {
std::thread worker(writer, i);
threads.push_back(std::move(worker));
}
for (int i = 0; i < 64; i++) {
threads[i].join();
}
return 0;
}

我们通过O_APPEND单次4k追加写入,之后通过vmstat来保留120s的写入带宽:

1
vmstat 1 120 > buffer_io

经过最后的测试数据整理,我们发现Buffer IO的性能基本能稳定跑满带宽, 其中只有一次I/O抖动:

buffer_io

实验二: 4K单次Direct IO写入

Buffer IO利用Page Cache帮助我们缓存了大量的数据,其实必然提高了写入带宽,但假如在限制内存的情况下,Buffer IO就不是正确的解决方案了,这次我们绕过Page Cache, 直接Direct IO单次4K写入:

代码片段: 完整代码

唯一需要修改的地方就是在open()中加入O_DIRECT标志:

1
int data_fd = ::open(fname.c_str(), O_RDWR | O_CREAT | O_APPEND | O_DIRECT, 0645);

通过vmstat获取写入带宽数据, 整理如下:

direct_io

通过数据我们发现,单次4k的Direct IO写入无法跑满磁盘的I/O带宽,仅仅只有800MB/S

实验三: mmap写入

通过前面这两个实验我们发现,Buffer IO是可以跑满磁盘I/O的,那我们可以尝试模拟Buffer IO的写入方式,使用较少的内存来达到Buffer IO的写入效果.

我们使用mmap来实现Buffer IO写入,通过限定的Buffer Block来模拟Page Cache的聚合效果, 实验中我们使用memcpy来完成数据拷贝,Buffer Block我们设定为4K * 4, 与Direct IO的不同,我们这次限定即16KB的单次写入:

代码片段: 完整代码

main()函数不变,修改线程的writer()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
static char data[4096] attribute((aligned(4096))) = {'a'};
static int32_t map_size = 4096 * 4;

void MapRegion(int fd, uint64_t file_offset, char** base) {
void* ptr = mmap(nullptr, map_size, PROT_READ | PROT_WRITE,
MAP_SHARED,
fd,
file_offset);
if (unlikely(ptr == MAP_FAILED)) {
*base = nullptr;
return;
}
base = reinterpret_cast<char>(ptr);
}

void UnMapRegion(char* base) {
munmap(base, map_size);
}

void writer(int index) {
std::string fname = "data" + std::to_string(index);
char* base = nullptr;
char* cursor = nullptr;
uint64_t mmap_offset = 0, file_offset = 0;
int data_fd = ::open(fname.c_str(), O_RDWR | O_CREAT, 0645);
posix_fallocate(data_fd, 0, (4096UL * 1000000));
MapRegion(data_fd, 0, &base);
if (unlikely(base == nullptr)) {
return;
}
cursor = base;
file_offset += map_size;
for (int32_t i = 0; i < 1000000; i++) {
if (unlikely(mmap_offset >= map_size)) {
UnMapRegion(base);
MapRegion(data_fd, file_offset, &base);
if (unlikely(base == nullptr)) {
return;
}
cursor = base;
file_offset += map_size;
mmap_offset = 0;
}
memcpy(cursor, data, 4096);
cursor += 4096;
mmap_offset += 4096;
}
UnMapRegion(base);
close(data_fd);
}

我们通过vmstat来获取写入带宽数据,我们发现mmap16K写入可以跑满磁盘带宽,但I/O抖动较大,无法类似于Buffer IO稳定的写入.

mmap_io

我们通过perf生成火焰图分析:

mmap_perf

通过pref生成分析瓶颈时发现,写入writer()时触发了大量的Page Fault, 即缺页中断,而mmap()本身的调用也有一定的消耗(关于mmap()的源码分析,我们在后面的文章会详细分析),我们实验三的思路是: 首先fallocate一个大文件,然后mmap()内存映射16k的Block, memcpy()写满之后,游标右移重新mmap(),以此循环.

实验四: 改进的mmap写入

为了避免mmap()的开销,我们使用临时文件在写入之前mmap()映射,之后循环利用这16K的Block, 避免mmap()的巨大开销:

代码片段: 完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
void MapRegion(int fd, uint64_t file_offset, char** base) { 
void* ptr = mmap(nullptr, map_size, PROT_READ | PROT_WRITE,
MAP_SHARED,
fd,
file_offset);
if (unlikely(ptr == MAP_FAILED)) {
*base = nullptr;
return;
}

base = reinterpret_cast<char>(ptr);
}

void UnMapRegion(char* base) {
munmap(base, map_size);
}

void writer(int index) {
std::string fname = "data" + std::to_string(index);
std::string batch = "batch" + std::to_string(index);
char* base = nullptr;
char* cursor = nullptr;
uint64_t mmap_offset = 0, file_offset = 0;
int data_fd = ::open(fname.c_str(), O_RDWR | O_CREAT, 0645);
int batch_fd = ::open(batch.c_str(), O_RDWR | O_CREAT, 0645);
posix_fallocate(data_fd, 0, (4096UL * 1000000));
posix_fallocate(batch_fd, 0, map_size);
MapRegion(batch_fd, 0, &base);
if (unlikely(base == nullptr)) {
return;
}
cursor = base;
file_offset += map_size;
for (int32_t i = 0; i < 1000000; i++) {
if (unlikely(mmap_offset >= map_size)) {
pwrite64(data_fd, base, map_size, file_offset);
cursor = base;
file_offset += map_size;
mmap_offset = 0;
}
memcpy(cursor, data, 4096);
cursor += 4096;
mmap_offset += 4096;
}
UnMapRegion(base);
close(data_fd);
close(batch_fd);
}

使用vmstat来获取写入速度的数据, 整理如下:

mmap_batch_io

这次避免了mmap()的开销,写入速度可以稳定保持在2180 MB/S左右,且没有I/O抖动.

内存使用也仅仅只有18000KB, 大约18M:

memory_usage

结论:

下面是四种方式的写入速度对比:

summary
在限制内存,且需要kill -9不丢数据的情况下,我们可以使用mmap()来模拟Buffer IO,但为了避免频繁mmap()的开销,我们需要临时文件来做我们的内存映射. 这种方法可以保证我们的写入速度稳定且kill -9不至于丢失数据.