Qt 這個圖形介面的開發套件(官網),基本上算是目前數一數二的跨平台圖形介面開發方案了。
在 Qt 的架構中,要在不同的物件之間傳遞資訊、觸發事件,所使用的是其創造的「Signals & Slots」架構(官方文件)。
而目前除了 Qt 之外,還有許多函式庫,也有提供 Signals & Slots 的功能,像是 Boost C++ Libraries 裡面,就有 boost::signal 和 boost::signal2 這兩個函式庫可以使用;其中,Heresy 也有針對 boost::signal2 做過介紹(參考)。
在 Heresy 來看,Qt 的 signal & slot 的架構比較麻煩的地方,是他必須要使用許多非標準的語法,所撰寫的程式還需要經過 Qt me
不過,這兩者個效能怎麼樣呢?這邊 Heresy 就是打算來做個簡單的效能評估。
這邊 Heresy 的基本設想,是有兩個類別 Caller 和 Reciver,當使用者呼叫 Caller 的 run() 的函式時,要能去自動執行 Reciver 的 impl() 這個函式。
Qt Signal & Slot
以 Qt 標準的 Signal & Slot 寫法,Caller 的 class 大概會寫成下面的樣子:
class QCaller : public QObject { Q_OBJECT public: void run(int iVal) { emit invoke(iVal); } signals: void invoke(int iVal); };
而 Reciver 則會是:
class QReciver : public QObject { Q_OBJECT public slots: void impl(int iVal); };
可以看到,不管是 Caller 或 Reciver,在程式中都需要加上一些關鍵字(紅字的部分)、讓 moc 在編譯前先去處理才行。
在使用時,則是要呼叫 QObject::connect() 這個函式,把 Signal 和 slot 做連結。
QCaller mCaller; QReciver mReciver; QObject::connect(&mCaller, &QCaller::invoke, &mReciver, &QReciver::impl);
如此一來,之後執行 mCaller.run() 的時候,就會自動去呼叫 mReciver.impl() 了。
而在 Qt5 推出時,新的 signal & slot 架構也同時允許 slot 是一般的函式(參考),而不一定需要是繼承 QObject 物件的 slot 函式;如此一來,他的通用性會更好一些,至少一般非 Qt 的 class 也可以拿來當作 reciver 用了。下面就是一個範例:
class BasicReciver { public: void impl(int iVal);
};
連接時,則可以寫成:
QCaller mCaller; BasicReciver mReciver; QObject::connect(&mCaller, &QCaller::invoke, [&mReciver](int iVal) { mReciver.impl(iVal); });
不過,這樣做的缺點,就是當 mReciver 物件死亡時,他沒有辦法「自動斷線」了。(並非完全無解就是了)
另外,Qt 的 signal & slot 在連線時,其實也還有幾種不同的連線方法可以選擇,算是彈性相對大的。(參考)
Boost Signal2
如果是以 Boost::Signal2 來寫的話,Caller 大概可以寫成:
class BSigCaller { public: void run(int iVal) { invoke(iVal); } boost::signals2::signal<void(int)> invoke; };
而 Reciver 的部分,由於 Boost::Signal2 是設計用來連接一般的 callable object,所以基本上可以沿用前面的 BasicReciver。
在使用時,則可以寫成:
BSigCaller mCaller; BasicReciver mReciver; mCaller.invoke.connect([&mReciver](int iVal) { mReciver.impl(iVal); });
而如果希望他可以在 mReciver 消失時自己斷線的話,則可以搭配 shared_ptr 來使用(參考)。
std::function
而除了上面兩個方法外,其實還有許多方法,都可以實作出類似的功能;例如 C++11 的 std::function<> 就是一個可以拿來實作簡易型的 callback 的機制。
(這邊不提 function pointer 是因為他要處理 class 的 member function 非常麻煩…)
在考慮到可能要觸發多個 reciver 的情況下,這邊的 Caller 可以簡單寫成:
class CfuncCaller { public: void run(int iVal) { for( auto& rFunc: m_vReciver) rFunc(iVal); } std::vector<std::function<void(int)>> m_vReciver; };
Reciver 的部分,這邊一樣是沿用前面的 BasicReciver。
而要使用時,則只要把 reciver 的函式用 lambda 包起來、丟到 vector 裡面就可以了。
CfuncCaller mCaller; BasicReciver mReciver; mCaller.m_vReciver.push_back( [&mReciver](int iVal) { mReciver.impl(iVal); } );
這樣的實作由於都是使用標準函式庫的東西,所以其實相當簡單。
不過相對的,他個功能也更為簡單,基本上也沒有什麼自動斷線之類的功能了。
當作成員物件、直接呼叫
另外,根據之前《C++ 幾種函式傳遞方法的效能比較》的經驗,std::function<> 的效率其實並不好!所以這邊就另外設計了一個更單純的機制,就是把 reciver 的指標直接存在 caller 內、直接去呼叫,理論上這樣的效能應該會是接近最佳的。其寫法如下:
class MemCaller { public: void run(int iVal) { for (auto pRec : m_vReciver) pRec->impl(iVal); } std::vector<BasicReciver*> m_vReciver; };
Reciver 的部分,這邊一樣是沿用前面的 BasicReciver。
使用實則是:
MemCaller mCaller; BasicReciver mReciver; mCaller.m_vReciver.push_back( &mReciver );
而實際上,它的功能也相當少,如果能接受不同的 reciver,實際上就需要透過繼承 BasicReciver、並將 impl() 改成 virtual function、並 override 掉了;而這部分,實際上也是會對效能造成影響的。
效能測試
這邊完整的程式碼,Heresy 都放在 GitHub 上(連結)了。由於主要是要測試 Boost 和 Qt,所以這邊就要用 qmake 了。
Heresy 自己在 Visual C++ 2015 和 g++ 4.8.4 都做了測試,執行次數是 100,000,000、實際執行的都是簡單的累加計算;在設定上,基本上都只有使用預設的最佳化參數,並沒有很認真地去排除編譯器的最佳化的不同。
在 VC2015 的環境時,其結果如下(數字單位為 ms):
Member data
|
std::function
|
Boost signal2
|
Qt5 Signal & Slot
|
|||
non virtual
|
virtual
|
Qt Slot
|
simple function
|
|||
1 slot
|
87
|
780
|
1201
|
16845
|
7587
|
7636
|
5 slot
|
215
|
3663
|
5558
|
42771
|
24281
|
24723
|
10 slot
|
606
|
7732
|
11464
|
75637
|
45266
|
45874
|
如果把各種方法的效能,都和第一項做比較的話,其結果則是(數字代表倍數):
Member data
|
std::function
|
Boost signal2
|
Qt5 Signal & Slot
|
|||
non virtual
|
virtual
|
Qt Slot
|
simple function
|
|||
1 slot
|
1.00
|
8.97
|
13.80
|
193.62
|
87.21
|
87.77
|
5 slot
|
1.00
|
17.04
|
25.85
|
198.93
|
112.93
|
114.99
|
10 slot
|
1.00
|
12.76
|
18.92
|
124.81
|
74.70
|
75.70
|
可以看到,當有使用 virtual 後,所需要的時間就已經馬上增加了快九倍以上,而使用 std::function 也需要超過 10 倍、甚至 20 倍的時間。
而 Qt 的 Signal & slot 大概會需要 75 倍以上的時間、Boost 的 Signal2 則更需要到 125 倍以上…感覺上,這邊額外的效能負擔,如果有需要大量使用的話,似乎已經超過可以忽視的範圍了?
而如果是看單一 signal 所對應 slot 越來越多時造成的影響呢?下面則是把 1 slot 當作比較基準的結果(數字代表倍數):
Member data
|
std::function
|
Boost signal2
|
Qt5 Signal & Slot
|
|||
non virtual
|
virtual
|
Qt Slot
|
simple function
|
|||
1 slot
|
1.00
|
1.00
|
1.00
|
1.00
|
1.00
|
1.00
|
5 slot
|
2.47
|
4.70
|
4.63
|
2.54
|
3.20
|
3.24
|
10 slot
|
6.97
|
9.91
|
9.55
|
4.49
|
5.97
|
6.01
|
可以看到,Boost::signal2 在 slot 增加時,所增加的時間算是相對少的;但是由於他一開始就吃太多了,所以感覺還是不是很划算啊…
而如果是使用 g++ 4.8.4 的話,測試的結果如下:
Member data
|
std::function
|
Boost signal2
|
Qt5 Signal & Slot
|
|||
non virtual
|
virtual
|
Qt Slot
|
simple function
|
|||
1 slot
|
602
|
747
|
745
|
13209
|
6586
|
6639
|
5 slot
|
3041
|
3726
|
5045
|
30680
|
20017
|
20034
|
10 slot
|
6047
|
7873
|
10478
|
51717
|
37558
|
37990
|
這邊比較有趣的,是在 g++ 中,non virtual 的類別的效率雖然還是比有 virtual 的類別來的好,但是快的並不多;這點,應該是編譯器最佳化造成的問題吧?
去掉這點的話,其他的效能算是差不多。
如果把各種方法的效能,都和第一項做比較的話,其結果則是(數字代表倍數):
Member data
|
std::function
|
Boost signal2
|
Qt5 Signal & Slot
|
|||
no virtual
|
virtual
|
Qt Slot
|
simple function
|
|||
1 slot
|
1.00
|
1.24
|
1.24
|
21.94
|
10.94
|
11.03
|
5 slot
|
1.00
|
1.23
|
1.66
|
10.09
|
6.58
|
6.59
|
10 slot
|
1.00
|
1.30
|
1.73
|
8.55
|
6.21
|
6.28
|
下面則是把 1 slot 當作比較基準的結果(數字代表倍數):
Member data
|
std::function
|
Boost signal2
|
Qt5 Signal & Slot
|
|||
no virtual
|
virtual
|
Qt Slot
|
simple function
|
|||
1 slot
|
1.00
|
1.00
|
1.00
|
1.00
|
1.00
|
1.00
|
5 slot
|
5.05
|
4.99
|
6.77
|
2.32
|
3.04
|
3.02
|
10 slot
|
10.04
|
10.54
|
14.06
|
3.92
|
5.70
|
5.72
|
最後,以結論來說的話…看來如果是要大量使用的話,可能還是得避開 Boost 或 Qt 的 signal & slot 吧…畢竟,他們的 overhead 算是大到不可忽略的了…
所以,如果是要連動的物件數量很多、而且呼叫很頻繁的話,還是想辦法自己用簡單的方法來時做吧;如果在需求單純的情況下,就算是簡單地用 std::function 來實作,都可以獲得好上數倍的效能。
當然,自己實作會欠缺很多功能,安全性上或許也不是那麼好,但是那些功能、額外的檢查,也都可能會是產生效能瓶頸的地方啊…
這也難怪會有人說,真的在乎效能的話,就把這些寫的大的函式庫丟掉了。(苦笑
(有興趣可以參考《SG14 (the GameDev & low latency ISO C++ working group) – Guy Davidson – Meeting C++ 2016》)
其他參考: