本文首發於 BriFuture 的 個人博客
在我的前一篇文章 使用 Qt 獲取 UDP 數據並顯示成圖片 中,我講了如何用 Python 模擬發送數據,如何在 Qt 中高效的接收 UDP 數據包並將數據解析出來。然而此前的文章在分別顯示 RGB 通道、R 通道、G 通道、B 通道這四組通道的圖片時仍然會出現處理速度過慢的問題。
前面說過編寫的程序至少會用到 3 個線程來分別處理 UI、socket 數據、數據解析,因為不這樣做沒法在時限內處理完接收到的數據,寫第一篇博客的時候,我以為是單純的使用 new 在堆中分配內存導致程序運行效率低,后來確實通過預分配對象內存解決了部分問題,但是還有一些會影響程序運行速度的問題沒有解決,也沒有深究,今天重新編寫代碼的時候,為了分配數據到 4 幅圖片上(分別是 RGB 通道、R 通道、G 通道、B 通道),發現運行速度還是不夠,影響運行速度的原因有幾個:
- 運行程序的模式(Debug 和 Release 兩種模式)
- Qt 的事件循環機制(之前反復懷疑過,不過最后還是發現短時間內大量調用信號很容易導致處理速度過慢)
- 低效的內存復制操作(如 QByteArray 的 assign 賦值操作和過多、過於復雜的程序流程)
接下來看看這幾個導致程序運行速度不夠的原因:
1. Qt Debug 模式和 Release 模式的差異
在 QtCreator 中運行程序,如果是以 Debug 模式運行的話,速度是要比 Release 模式低一些的。以前編寫 Qt 程序,數據量一般不大,對於性能都沒有要求,即使程序代碼不夠優化,但在用戶使用過程中一般不會感受到運行卡頓,所以一直都沒發現 Debug 模式和 Release 模式的性能有差異。
不過其實也能猜到性能有差異的大概原因:Debug 模式下會在最終生成的代碼里面插入很多額外的代碼用於調試,但是 Release 生成的代碼是不會插入這些調試用的代碼的,最明顯的差異就是 Debug 模式生成的可執行文件比 Release 模式生成的可執行文件要大得多。
Debug 模式下運行程序,實際 FPS 和期望的 FPS 有 6 幀的差距,差距產生的原因是處理速度不夠,導致最終生成圖片的速度慢了。
Release 模式下即使是原始數據包的期望 FPS 到了 77 幀,實際的 FPS 也可以達到 77 幀,也就是說在處理過程中沒有出現處理速度跟不上接受數據的速度。
2. Qt 的事件循環機制
當我們使用 Qt 程序的時候,經常會在主函數 main 里寫出類似下面這樣的代碼:
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
MainWindow mw;
mw.show();
return a.exec();
}
這樣我們的程序應用的生命周期就是 QApplication 所定義的,當我們使用 QObject::moveToThread 方法將某個 QObject (子類)對象移到其他的子線程中的時候,子線程也有獨立於主線程的相應的事件派發機制。
QObject 的多線程使用方法很巧妙,利用信號&槽機制或者是 QMetaObject::invokeMethod 方法就可以讓要執行的耗時函數在子線程中執行,但是如果是直接調用耗時函數,那么就會在當前的線程中執行耗時操作,導致線程阻塞。
在線程之間傳遞數據,如果是用信號&槽的機制,那么可能你都不需要考慮線程間的數據同步問題,但信號&槽機制是要依靠 Qt 的事件循環機制的,如果事件不能正常分發(dispatch),那么子線程中的槽函數就不會被調用。
關於 Qt 的事件循環機制和線程機制,推薦看一看官方 wiki,《線程、事件與QObject》 或者也有對應的英文原文 Threads Events QObjects。
如果頻繁的調用信號,在 Qt 的事件循環中,因為前一次耗時的任務沒有完成,導致對應的槽函數無法執行,最終導致處理速度跟不上。
因此對於實時性要求高的程序,Qt 的事件循環機制可能不會是你的首選,你更有可能去做的是在 Qt 的一個子線程中運行循環代碼,忽略掉該子線程中的事件循環以提高程序的性能。
3. 低效的內存復制操作
在接收到原始字節數據之后,最重要也是最麻煩的就是解析數據。包括識別自定義協議數據的頭部信息,將數據包中的圖像數據復制到緩沖區,並將緩沖區中的數據以圖片的形式顯示出來。接下來分享幾個高效處理數據的幾個小技巧:
- 使用 QByteArray 存儲原始數據包時,先調用 resize 預分配內存,然后使用 memcpy 直接對內存數據進行操作,這樣做效率是最高的,但它也是比較繁瑣的。
QByteArray data;
data.resize(PacketSize); // PacketSize 是預先定義好的數據字節數
// 可以簡單的認為 rawData 就是從 UDP 端口中接收到的數據,
// ValidDataSize 也是預先定義好的數據字段的長度
memcpy(rawData.data(), data.data(), ValidDataSize);
// 上面的代碼要比直接使用賦值操作 = 高效
data = rawData;
- 如果接收到的數據可以明確是有序的,可以用數據分別表示相應的序號,再從數組中取數據,我最開始存儲
LineDataObj
(用於表示圖片的一行數據)的時候,用 QMap 存儲行號和指針,利用 QMap 的查找功能減少了查找或排序的時間,但是缺點是 QMap 會隨着其內部的數據量增大變得緩慢,如果只需要緩存數據,建議直接使用數組存取,這樣的運行效率最高。
// 在類中聲明一個 map
QMap<int line, LineDataObj *> map;
// 在方法體中使用 map 查找是否有對應的行數據
if(map.contains(line)) {
// 如果有對應的行數據對象,直接將數據寫入到行對象數據上
...
} else {
// 如果沒有,則插入一條記錄
map.insert(line, lineDataObj);
}
// 處理完一行數據后,可以將該行數據從 map 中移除掉
map.remove(line);
可以發現,map 就是用來判斷是否有對應行數據對象,然后處理結束后移除保存的行號,這並沒有達到緩存數據的目的,反而再插入和移除的過程中浪費了過多時間。但如果用一個數組當做緩存區就會快很多,因為我們減少了查找和移除記錄的時間:
QVector<LineDataObj *> linePool;
// LinePoolSize 是預定義的池大小
linePool.reserve(LinePoolSize);
for(int i = 0; i < LinePoolSize; i++) {
linePool.append(new LineDataObj());
}
// 數組大小是有限的,行號卻是不斷增加的,因此要設置一個起始行,保證在長時間執行程序后不會出現數組越界的問題
int diffLine = line - startLine;
// 進行處理
linePool[line].setData(...);
- 盡量保持清晰而且簡單的結構。我之前寫代碼總想着考慮到所有情況,最終卻總是沒法盡善盡美只有根據情況放低預期,我覺得不必一開始就非要把代碼的層次結構划分的特別詳細,根據實際情況使用合理的程序結構(當然每個人可能有不同的看法,但
少即是多
的原則確實給了我很大的啟發)。
我之前編寫程序時,除了有一個 LineDataObj
用來表示行對象,還有一個 RawDataObj
表示原始的數據包對象。處理的流程多:1. 接受原始數據包 => 2. 將數據包填充到 RawDataObj 中並解析數據包的行號,RGB 類型 => 3. 根據 RawDataObj 的屬性確定對應的 LineDataObj => 4. 當 LineDataObj 存儲到一定數目時生成圖像。
這個流程很直觀也很容易想到,但是 RawDataObj 這個數據對象其實沒必要使用,因為它增加了一次不必要的內存數據復制。這完全可以給 LineDataObj 類增加幾個靜態方法,判斷出數據包的行號和 RGB 類型,然后將數據部分寫入到 LineDataObj 的數據字段中。這樣做不僅可以減少內存讀寫的次數,而且可以在一個對象中申請大段內存,保存整行的數據,最后寫入到圖片時,只用將這個區域賦值到圖片中即可。
4. 高效地顯示圖片
最后分享一下如何在 Qt 中高效的顯示圖片。一般用 Qt 顯示圖片可以用 QLabel:
QLabel label;
QImage image;
// 執行一些讀取圖片的操作,再顯示在 QLabel 上
label.setPixmap(QPixmap::fromImage(image));
但是用 QPixmap::fromImage 會從 image 的內存區域中復制一份數據到 Pixmap 中,這樣的操作並不高效。我們可以使用 QImage::scanLine 方法獲取它對應的內存區域,直接對內存進行操作,顯示的時候不用 QPixmap::fromImage,我們要直接將內存中的修改顯示到界面中,這樣我們要定義一個類(不妨讓它繼承 QLabel
),重寫 paintEvent 方法:
void PictureImage::paintEvent(QPaintEvent *event)
{
Q_UNUSED(event);
if(m_index == uchar(-1)) {
return;
}
// this->painter.drawImage(target, *m_image);
QPainter p(this);
// target 在構造函數中定義:
// target = QRectF(0.0, 0.0, PictureImage::ImageWidth, PictureImage::ImageHeight);
p.drawImage(target, *m_images[m_index]);
}
像 p.drawImage(target, image)
這樣就可以將圖片更新到界面中,並且它會被 QPixmap 的 fromImage 方法要高效。
用 Python 發送模擬數據遇到的問題
之前說過,模擬數據是用 Python 代碼編寫的,這個代碼發送模擬數據的效率可以高達 100M/s,下面的截圖是我在自己的筆記本(i5 8200U@1.8G)上運行的結果:
但是令我感到特別奇怪的是,有一段時間同樣的代碼在我的 amd ryzen 1500x@3.5G 台式機上只能達到 50M/s 的速度。我一度懷疑是英特爾和 AMD 的處理器單核性能有差異,但按道理不應該有這么大的速度差異。而且最近幾天它又在我的台式機上能夠跑到 100M/s 的速度。