写在前面
高速相机的实时采集存储和网络中的大数量传输有共同之处,核心部分就是利用生产者消费者模型完成高速数据传输或存储。关于图像数据的获取,由于高速相机提供的sdk利用缓冲区事件进行回调去调用专用线程,基本思路就是在专用线程中完成存储功能。
目标帧率 | 500FPS |
---|---|
C写入bmp文件耗时 | 4ms左右 |
基于以上目标,平均需要在2ms左右完成一张图像的存储。因此需要对C写文件的过程进行拆分(创建文件,写入数据,关闭文件)。将创建文件和写入数据交给生产者线程完成,关闭文件句柄交给消费者线程完成。其中涉及到FIFO队列的设计,在这里整理一下。
FIFO队列——环形缓冲区RingBuffer
FIFO队列,从本质上来说是一个队列,满足先进先出的特点。在大数据量高并发的应用场景中,利用FIFO作为缓冲队列是很常见的应用。与一般的队列相比,RingBuffer的实际物理地址在初始化后就不再改变,进行循环使用,即循环队列。对于生产者消费者线程来说,FIFO也就是临界资源,需要我们去进行互斥的访问,即对于同一个内存单元,生产者和消费者不可以同时访问。
FIFO数据结构的基本构成如下所示。
struct ringbuffer{
char* buffer; //缓冲内存
int size; //缓冲区大小
int front; //读者索引,即消费者索引
int rear; //写者索引,即生产者线程
std::mutex mt; //互斥量
condition_varaible cond; //条件变量
};
由于生产者线程和消费者线程在速率上往往存在差异,而两者处于一个队列通道内,需要保证对于同一个内存单元的读写操作的互斥进行。从逻辑上来说,对于队列,发生互斥的访问的情况有两种:
1、缓冲区为空,消费者线程追上了生产者线程
2、缓冲区为满,生产者线程追上了消费者线程
即我们常说的读写者问题(单生产者消费者模型)。为了内存操作的连续性,我们利用顺序存储来模拟环形队列,利用指针索引的取模运算来循环使用初始化后的缓冲内存。
为了避免只有一个元素时,队头和队尾的重合,引入front指针指向对头元素,rear指针指向队尾元素的下一个位置(待生产位置)。因此从逻辑上来说,当front==rear
时,队列为空。
当rear指针到达物理内存尾部时,此时如何继续入队,会出现“假溢出”现象(为什么说假?别忘了,另一个线程在向后移动front指针,从队列头部到front之间的缓冲区已经时空的了)。因此,将rear移动到队列头部,重新开始计数,这里的操作就是进行取模运算。同理,当front到达尾部时,也一样处理。
然而,问题又来了,如果出现互斥2的情形,此时队列为满,但是front==rear
,与空队列重合了。解决办法很简答,有两种:
A、设置一个标志变量,flag,当front==rear && flag==true
时,队列为空,当front==rear && flag==false
时,队列为满
B、在环形队列中,保留一个元素空间,即当队列为满时,数组中还有一个空闲单元
常用的方法是B。由于rear和front的大小关系会随buffer的工作而改变,且环形队列在逻辑上被认为首尾相接,因此,当队列满时,虽然从物理上看,两者差距为1,逻辑上相差了整整一圈。可以得出,队列满的条件为(reat+1)%size==front
。
FILE文件队列的有锁队列设计
FIFO的朴素实现方法就是利用生产者-消费者模型中典型的互斥量+条件变量实现两个线程的同步,即只允许一个线程占有资源锁。在FILE-RingBuffer的设计中,本文将创建文件和数据写入作为生产者线程,文件句柄关闭作为消费者线程。在仿真实验中设定缓冲区大小为64,连续存储1000张2304*1720大小的bmp图像。
生产者线程
当生产者获取到数据后,利用unique_lock
对互斥量上锁,如果此时缓冲区为满,则等待消费者线程释放锁资源。获取到锁资源后,开始文件的创建和写入,之后更新生产者线程的索引,通过条件变量去通知消费者线程,释放锁资源。
void OpenWrite() {//开文件、写文件线程
unsigned char* buf = NULL;
printf("Start Open and Write %d frames...\n", N);
for (int i = 0; i < N; ++i) {
buf = img->imageData;
std::unique_lock<std::mutex> lk(diskThread.mt);
while ((diskThread.openPos + 1) % BUFFER_NUM == diskThread.closePos) {
std::cout << "Resourse is full" << std::endl;
diskThread.not_full.wait(lk);
}
diskThread.fileBuffer[diskThread.openPos] = creatFile(("D:\\disktest\\test" + std::to_string(i) + ".bmp").c_str());
FILE* pfile = diskThread.fileBuffer[diskThread.openPos];
fwrite(&fileType, sizeof(unsigned short), 1, pfile);
//文件头信息写入
fwrite(&bmpFileHeader, sizeof(BmpFileHeader), 1, pfile);
//位图头信息写入
fwrite(&bmpInfoHeader, sizeof(BmpInfoHeader), 1, pfile);
//调色板写入
fwrite(quad, sizeof(RgbQuad), 256, pfile);
//数据写入
fwrite(img->imageData, sizeof(unsigned char), picSize, pfile);
diskThread.openPos = (diskThread.openPos + 1) % BUFFER_NUM;
diskThread.not_empty.notify_all();
std::cout << "Open.." << i << "..file" << std::endl;
}
diskThread.not_empty.notify_all();
}
消费者线程
逻辑和生产者线程其实相同,当文件队列为空,等着呗,等着资源被生产者释放呗。关闭文件后,更新索引,同样通过条件变量通知生产者。
void Close() {//关文件线程
bool exit = false;
while (1) {
if (diskThread.count < N) {
std::unique_lock<std::mutex> lk(diskThread.mt);
while (diskThread.closePos == diskThread.openPos) {
std::cout << "Resource is empty" << std::endl;
diskThread.not_empty.wait(lk);
}
FILE* pfile = diskThread.fileBuffer[diskThread.closePos];
fclose(pfile);
diskThread.closePos = (diskThread.closePos + 1) % BUFFER_NUM;
++(diskThread.count);
diskThread.not_full.notify_all();
std::cout << "Close File..." << diskThread.count << std::endl;
}
else
exit = true;
if (exit) break;
}
std::cout << "Exit closing thread: " << std::endl;
}
FILE文件队列的无锁队列设计
对于单生产者单消费者模型来说,并不一定需要加锁才能保证。在环形队列中,真正互斥的就是front和rear指针不能重合,即队列满时不能进行生产,队列空时不能进行消费。因此,只要保证队列满时生产者线程不进行写入操作,等待队列中出现空位,队列空时消费者线程不进行读出等待队列中出现新的数据即可。
class myRingBuffer {
public:
myRingBuffer(int len) : front(0), rear(0), length(len) {
filePtr = (FILE**)malloc(sizeof(FILE*) * len);
}
~myRingBuffer() {
free((void*)filePtr);
}
bool isEmpty() {
return front == rear;
}
bool isFull() {
return (rear + 1) % length == front;
}
int size() {
return this->length;
}
FILE** filePtr;
int front;
int rear;
private:
int length;
};
无锁生产者
void FileInput() {
int index = 0;
unsigned char* buf = NULL;
printf("Start Open and Write %d frames...\n", N);
while(1) {
if (filebuffer.isFull()) {
std::cout << "Buffer is full" << std::endl;
continue;
}
buf = img->imageData;
filebuffer.filePtr[filebuffer.rear] = creatFile(("D:\\disktest\\test" + std::to_string(index) + ".bmp").c_str());
FILE* pfile = filebuffer.filePtr[filebuffer.rear];
fwrite(&fileType, sizeof(unsigned short), 1, pfile);
//文件头信息写入
fwrite(&bmpFileHeader, sizeof(BmpFileHeader), 1, pfile);
//位图头信息写入
fwrite(&bmpInfoHeader, sizeof(BmpInfoHeader), 1, pfile);
//调色板写入
fwrite(quad, sizeof(RgbQuad), 256, pfile);
//数据写入
fwrite(img->imageData, sizeof(unsigned char), picSize, pfile);
filebuffer.rear = (filebuffer.rear + 1) % BUFFER_NUM;
std::cout << "Open.." << index++ << "..file" << std::endl;
if (index == N)
break;
}
printf("Stop storing\n");
}
无锁消费者
void FileClose() {
int index = 0;
bool exit = false;
while (!exit) {
if (filebuffer.isEmpty()) {
std::cout << "Buffer is empty" << std::endl;
continue;
}
FILE* pfile = filebuffer.filePtr[filebuffer.front];
fclose(pfile);
filebuffer.front = (filebuffer.front + 1) % BUFFER_NUM;
++index;
if (index == N)
exit = true;
}
std::cout << "Exit closing thread: " << std::endl;
}
仿真实验结果
利用两种队列对1000张图像进行写入存储测试,计算NVME硬盘(Intel 750 Serials 1.2TB)的写入速度。
写入方式 | 速度(GB/s) |
---|---|
单线程串行 | 0.8777 |
有锁队列 | 0.7325 |
无锁队列 | 1.0481 |
高速相机250FPS下实测
利用高速相机在250FPS下对单个硬盘进行存储测试,分别测试采集10s、30s、60s。
采集时间/s | 图像数量 |
---|---|
10 | 2498 |
30 | 7497 |
60 | 14996(不稳定,有时候会丢帧比较多) |