前言
由于前段时间项目需要对高速数据处理,并且实时存储,所以研究了使用C++或者Windows API做数据存储,以及如何发挥系统的IO性能。
注:本次测试采用的是Intel® Xeon® CPU E5-2643 v4 @ 3.40GHz(2处理器),64G内存,运行在Windows Server 2016系统上,硬盘为NVME Samsung 970 EVO x 4,使用RAID卡组为RAID0。
研究历程
作为C++软件工程师,研究阶段所使用的代码均为C++ 开发。我们主要是要理解数据从应用层到硬盘都经历了什么,这对于我们提升写入速度是非常重要的。
一,初始阶段
之前的项目我们并没有对数据存储的速度有所要求,也可以说是基本的固态硬盘(300MB/s ~ 400MB/s)已经可以满足我们的需求了,因此并没有关注过应用层软件将数据写入内存缓冲中后,系统会如何处理这些数据。我们在开发软件的工程中,所使用的就是流处理的方式。
写文件方式:
size_t fwrite(
const void *buffer,
size_t size,
size_t count,
FILE *stream);
读文件方式
size_t fread(void *buffer,
size_t size,
size_t count,
FILE *stream );
这种方式也是大家普遍存文件的方式,我们将数据写入缓存中,由Windows文件系统操作Windows内核和硬盘内核来完成一次读写操作,这种读写方式也被称作同步IO调用(Synchronous I/O),比如,我们在线程中调用一次fwrite或者fread,OS发起IO请求之后便一直处于等待挂起的状态,直到OS将IO结果返回给这个线程。
同步IO也被称作阻塞IO,顾名思义,我们的IO并没有发挥其最大的性能。同步IO调用如果不加任何参数的话一般是操作系统提供的默认调用方式,也是一般应用程序首选的IO调用方式。IO请求发送到OS内核之后到内核将IO请求对应的数据读取或者写入完成这段时间会贡献为OS内的IOWait指标增高,IOWait指标一旦升高到高于60%左右的百分比,那么就需要考虑后端存储系统所提供的性能是否已经不能满足要求了,而在同步IO调用的情况下,我们想要提升IO的性能只能选择多线程同步IO的方式,但这并不能很好的提供更好的性能,只能增加我们内存的负担。
以下是我在使用流处理方式测试写速度的代码:
#include <stdio.h>
#include <iostream>
#include <string>
#include <process.h>
#include <Windows.h>
#include <vector>
using namespace std;
#define BUFF_SIZE (1024)
char buff[BUFF_SIZE] = {0};
char vBuf[1024 * 1024 * 100] = {0};
FILE * file = NULL;
FILE * closeFile = NULL;
const unsigned long long FileSize = (unsigned long long)(1024 * 1024) * (unsigned long long)(1024 * 100);
const unsigned long long FileLen = (unsigned long long)(1 * 1024) * (unsigned long long)(1024 * 1024);
LARGE_INTEGER beat, startTime, stopTime;
string strFolderpath = "";
void WriteFileThread(void * p);
void CloseFileThread(void * p);
void CreateFileTHread(void * p);
vector<FILE *>m_File;
unsigned int FileNum = FileSize / FileLen;
bool bWriteFlg = false;
int main()
{
string strPan = "";
cout << "请输入要写入文件磁盘(例如:C):" << endl;
getline(cin, strPan);
strFolderpath = strPan + ":\\test";
CreateDirectory(strFolderpath.c_str(), NULL);
QueryPerformanceFrequency(&beat);
int nParam = 0;
_beginthread(CreateFileTHread, NULL, &nParam);
//_beginthread(CloseFileThread, NULL, &nParam);
//_beginthread(WriteFileThread, NULL, &nParam);
while (1)
{
Sleep(1);
}
fclose(file);
}
void CreateFileTHread(void * p)
{
unsigned int i = 0;
char FilePath[MAX_PATH] = { 0 };
FILE * file = NULL;
int nParam = 0;
int nCnt = 0;
while (i < FileNum)
{
sprintf_s(FilePath, "%s\\%d", strFolderpath.c_str(), i);
file = _fsopen(FilePath, "wb+", _SH_DENYNO);
if (file != NULL)
{
m_File.push_back(file);
i++;
}
}
bWriteFlg = true;
_beginthread(CloseFileThread, NULL, &nParam);
_beginthread(WriteFileThread, NULL, &nParam);
}
void WriteFileThread(void * p)
{
unsigned int nWriteNum = FileLen / BUFF_SIZE;
unsigned int WriteCnt = 0;
unsigned int i = 0;
FILE * file = NULL;
memset(buff, 1, BUFF_SIZE);
QueryPerformanceCounter(&startTime);
while(i < FileNum)
{
_fseeki64(m_File[i], 0, SEEK_SET);
while (WriteCnt < nWriteNum)
{
fwrite(buff, BUFF_SIZE, 1, m_File[i]);
//fflush(file);
WriteCnt++;
}
closeFile = m_File[i];
WriteCnt = 0;
i++;
}
QueryPerformanceCounter(&stopTime);
double nTime = (double)stopTime.QuadPart - (double)startTime.QuadPart;
double SpanTime = nTime / (double)beat.QuadPart;
// printf("SpanTime = %.2f ms.\n", SpanTime);
double dSpeed = FileSize / SpanTime / 1024 / 1024 / 1024;
printf("SpanTime = %.2f, Speed = %.2f GB\n", SpanTime, dSpeed);
}
void CloseFileThread(void * p)
{
while (1)
{
if (closeFile != NULL)
{
fclose(closeFile);
}
else
Sleep(1);
}
}
这种写100G数据下的测试结果:
测试次数 | 时间(s) | 速度(GB/s) |
---|---|---|
1 | 35.64 | 2.81 |
2 | 35.68 | 2.80 |
3 | 35.56 | 2.81 |
4 | 35.55 | 2.81 |
5 | 35.88 | 2.79 |
6 | 35.77 | 2.80 |
可以看出来,在写文件的过程中,我们的内存压力是很大的,并且一旦内存的缓冲超过60%,OS处理就会降低,优先写缓存中的数据。这只是单线程写文件的情况,如果在项目中用多线程同步IO的方式,后果不堪设想。 |
二,寻找出路
从上面的测试中我们可以看出,写文件的速度已经到了瓶颈,主要的原因就是我们能够使用的IO太少,每次IOWait的时间过长,究其根本的原因,是因为OS为了保护我们写入的数据安全,所使用的文件系统缓存和写文件的调度方式。那有什么方法可以跳过文件系统的缓存?答案就是使用异步IO调用(Asynchronous I/O)。
首先我们看一下IO属性对性能的影响:
大类 | 小类 | 性质 | 典型场景 | 整体相对性能 |
读/写IO | 读IO | 将数据从存储设备中读出 | 连续情况下一般优于写,随机情况下可能会低于写 | |
写IO | 将数据向存储设备中写入 | 连续情况下一般低于读,随机情况下可能会优于读 | ||
大/小块IO | 大块IO | 每个IO的目标地址段比较长 | 视频编辑播放、读写大文件 | 贡献为带宽吞吐量 |
小块IO | 每个IO的目标地址段比较短 | 读写小文件 | 贡献为IOPS | |
连续/随机IO | 连续IO | 单位时间内所发生的IO其目标地址相对前一个IO为相邻或跳跃很小 | 视频编辑播放 | 最优的IO方式 |
随机IO | 单位时间内所发生的IO其目标地址相对前一个IO跳跃很大 | 没有索引的数据条目搜索 | 最差的IO方式 | |
顺序/并发IO | 顺序IO | 同步阻塞IO方式,只能等当前的IO完成后才发起下一个IO | 单线程同步调用的应用程序 | 最差的IO方式 |
并发IO | 异步非阻塞IO方式,一批IO可接连发出 | 异步调用的应用程序 | 最优的IO方式 | |
持续/间断IO | 持续IO | IO持续地被发起 | 视频播放 | |
间断IO | IO进行一段时间后停顿一段时间再发起 | 科学计算 | ||
稳定/突发IO | 稳定IO | IO吞吐量或IOPS在单位时间内的值趋于稳定 | 视频播放 | |
突发IO | 某时刻突然发起大量的IO请求 | 科学计算 | 影响存储系统处理能力,控制器随时保存一定量的Buffer来应付 | |
实/虚IO | 实IO | 读写实体数据内容的IO请求 | 贡献为实际吞吐量 | |
虚IO | 读或者更改文件属性或SCSI/ATA协议中其他非实体数据操作的IO请求比如设备控制请求等 | 贡献为虚吞吐量,由于系统总吞吐量和IOPS为定值,所以影响实际吞吐量 |
从上面的表格可以很清楚的看到,我们要实现告诉实时存储,就要使用小块连续并发IO的模式。如何在代码中实现,Windows API也为我们提供了接口:
HANDLE CreateFileA(
LPCSTR lpFileName,
DWORD dwDesiredAccess,
DWORD dwShareMode,
LPSECURITY_ATTRIBUTES lpSecurityAttributes,
DWORD dwCreationDisposition,
DWORD dwFlagsAndAttributes,
HANDLE hTemplateFile
);
其他的参数不再赘述,这里我们需要关注的是dwFlagsAndAttributes参数,用来设置文件或者设备的属性,我们看一下它拥有的属性:
属性 | 含义 |
---|---|
FILE_ATTRIBUTE_ARCHIVE (0x20) | 应归档的文件。应用程序使用这个属性来标记文件备份或删除。 |
FILE_ATTRIBUTE_ENCRYPTED (0x4000) | 加密的文件或目录。对一个文件来说,这意味着所有的数据文件是加密的。对于一个目录,这意味着加密是默认为新创建的文件和子目录。 |
FILE_ATTRIBUTE_HIDDEN (0x2) | 文件是隐藏的。不包括在一个普通的目录清单。 |
FILE_ATTRIBUTE_NORMAL (0x80) | 这个文件不包含其他属性。只有当它单独使用时,这个属性值才是合法的 |
FILE_ATTRIBUTE_OFFLINE (0x1000) | 文件的数据不会立即可用。该属性表明文件数据在物理上转移到离线存储。这个属性是使用远程存储、层次化存储管理软件。应用程序不应该随意更改此属性。 |
FILE_ATTRIBUTE_READONLY (0x1) | 该文件是只读的。应用程序可以读取文件,但不能写入或删除它。 |
FILE_ATTRIBUTE_SYSTEM (0x4) | 文件是操作系统的的一部分或者专门为操作系统所使用。 |
FILE_ATTRIBUTE_TEMPORARY (0x100) | 文件被用于临时存储。 |
标识 | 含义 |
---|---|
FILE_FLAG_BACKUP_SEMANTICS | 文件被打开或创建用于一个备份或恢复操作。 |
FILE_FLAG_DELETE_ON_CLOSE | 在所有与此文件相关的句柄被关闭后,文件马上会被删除 |
FILE_FLAG_NO_BUFFERING | 文件或设备被以读写无缓冲区的方式打开。此标记位对于硬盘或者内存映射文件无效。 |
FILE_FLAG_OPEN_NO_RECALL | 这个标志是使用远程存储系统 |
FILE_FLAG_OPEN_REPARSE_POINT | 尝试打开重新解析点,不会对其做任何操作 |
FILE_FLAG_OVERLAPPED | 文件或设备设置使用异步IO方式打开或者创建 |
FILE_FLAG_POSIX_SEMANTICS | 根据POSIX规则访问 |
FILE_FLAG_SESSION_AWARE | 文件或设备带 session awareness 打开 |
FILE_FLAG_SEQUENTIAL_SCAN | 文件访问为随机访问 |
FILE_FLAG_WRITE_THROUGH | 写操作不会经过任何中间缓存,直接写向磁盘 |
这里我们需要关注的是FILE_FLAG_NO_BUFFERING、FILE_FLAG_OVERLAPPED、FILE_FLAG_WRITE_THROUGH三个参数。其中使用FILE_FLAG_NO_BUFFERING参数可以既享受文件系统管理文件的便利同时不适用文件系统的缓存,这种方式也被称为DIO,即Direct IO模式。
FILE_FLAG_OVERLAPPED参数表示我们使用的是异步IO的方式读写文件。但是要注意,当我们使用异步IO模式,也意味着我们需要自己管理文件的读写偏移,这一点在下面会介绍。
FILE_FLAG_WRITE_THROUGH参数代表写操作不会经过任何中间缓存,这个参数和FILE_FLAG_NO_BUFFERING参数有什么区别呢?
FILE_FLAG_WRITE_THROUGH模式也被称作Write Through,在这种模式下,数据依然首先进入操作系统内核缓存,只不过内核保证数据写入磁盘之后才返回成功而不是像BufferedIO模式下只要数据进入内核缓存便立即返回成功。而在Direct IO模式下,数据根本不会进入内核缓存,直接由底层驱动从用户程序自身缓存取走数据从而写入底层存储介质。
此外,需要注意,并不是所有的固态硬盘都支持Direct IO模式。
接下来我们看一下使用异步IO模式,直接写硬盘的代码:
#include <stdio.h>
#include <iostream>
#include <string.h>
#include <windows.h>
#include <process.h>
#include <vector>
using namespace std;
const unsigned int File_Num = 500;
const unsigned long long File_Size = (unsigned long long)(1 * 1024) * (unsigned long long)(1024 * 1024);
const unsigned int Write_Size = 1024 * 1024;
vector<HANDLE> hFile(File_Num);
vector<OVERLAPPED> overLapped(File_Num);
void WriteFileThread(LPVOID p);
void CloseFileThread(LPVOID p);
LARGE_INTEGER beat;
int main()
{
char FilePath[MAX_PATH] = { 0 };
string strName = "E:\\test";
CreateDirectory(strName.c_str(), NULL);
for (unsigned int i = 0; i < File_Num; i++)
{
sprintf_s(FilePath, "%s\\%d.bin", strName.c_str(), i);
hFile[i] = CreateFile(FilePath, GENERIC_WRITE | GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, CREATE_ALWAYS, FILE_FLAG_SEQUENTIAL_SCAN | FILE_FLAG_NO_BUFFERING | FILE_FLAG_OVERLAPPED | FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile[i] == NULL)
{
printf("创建文件%d失败\n", i + 1);
}
overLapped[i].hEvent = CreateEvent(NULL, false, false, NULL);
}
QueryPerformanceFrequency(&beat);
int nParam = 0;
_beginthread(WriteFileThread, NULL, &nParam);
_beginthread(CloseFileThread, NULL, &nParam);
while (1)
{
Sleep(1000);
}
return 0;
}
void WriteFileThread(LPVOID p)
{
unsigned int WriteNum = File_Size / Write_Size;
unsigned int WriteCnt = 0;
unsigned int i = 0;
char buff[Write_Size] = { 0 };
DWORD dwWriteByte = 0;
LARGE_INTEGER start_Time, stop_Time;
LARGE_INTEGER FilePos;
const unsigned long long W32_MAX_SIZE = (unsigned long long)(4 * 1024) * (unsigned long long)(1024 * 1024);
memset(buff, 1, Write_Size);
QueryPerformanceCounter(&start_Time);
while (i < File_Num)
{
FilePos.LowPart = 0;
FilePos.HighPart = 0;
while (WriteCnt < WriteNum)
{
//WriteFileEx(hFile[i], buff, Write_Size, &dwWriteByte, &overLapped[i]);
WriteFile(hFile[i], buff, Write_Size, &dwWriteByte, &overLapped[i]);
FilePos.LowPart += Write_Size;
if (FilePos.LowPart % W32_MAX_SIZE == 0)
{
FilePos.HighPart++;
FilePos.LowPart = 0;
}
overLapped[i].OffsetHigh = FilePos.HighPart;
overLapped[i].Offset = FilePos.LowPart;
//overLapped[i].Offset = SetFilePointer(hFile[i], 0, NULL, FILE_END);
WriteCnt++;
}
//CloseHandle(hFile[i]);
i++;
WriteCnt = 0;
}
QueryPerformanceCounter(&stop_Time);
printf("写文件完成\n");
double SpanTime = ((double)stop_Time.QuadPart - (double)start_Time.QuadPart) / (double)beat.QuadPart;
double speed = (double)(File_Size / 1024 / 1024 / 1024 )* File_Num / SpanTime;
printf("spanTime = %.2f s, speed = %.2f GB/s \n", SpanTime, speed);
}
void CloseFileThread(LPVOID p)
{
unsigned int i = 0;
DWORD dwWriteSize = 0;
while (i < File_Num)
{
dwWriteSize = GetFileSize(hFile[i], NULL);
if (dwWriteSize >= File_Size)
{
CloseHandle(hFile[i]);
hFile[i] = NULL;
i++;
}
else
{
Sleep(10);
}
}
}
这里我们使用了OVERLAPPED 结构体来管理我们写入文件的偏移地址:
typedef struct _OVERLAPPED {
ULONG_PTR Internal;
ULONG_PTR InternalHigh;
union {
struct {
DWORD Offset;
DWORD OffsetHigh;
} DUMMYSTRUCTNAME;
PVOID Pointer;
} DUMMYUNIONNAME;
HANDLE hEvent;
} OVERLAPPED, *LPOVERLAPPED;
我们看一下测试的结果:
在单线程写文件的情况下,我们的写入速度已经接近4.0GB/s,如果我们使用双线程同时写文件呢?看一下测试结果:
接近7.0GB/s的写入速度,还是在我们CPU主频只有3.40GHz的情况下达到的,因此还是有上升的空间。
总结
至此,已经基本达到了项目的需求速度。至于更深层次的研究还是在继续,由于本人水平有限,作为初入存储行业的菜鸟,还有很多的知识需要学习,希望能与各位多交流经验。
此外,需要《大话存储 – 存储系统底层架构原理极限剖析》这本书的帮助,博客中很多知识均引用于本书,有兴趣的各位可以去钻研一下这本书,非常有意义。