坑爹的list容器size方法--為了splice居然把複雜度設計為O(N)?
最近在做一個性能要求較高的專案,有個伺服器需要處理每秒2萬個udp包,每個包內有40個元素(當然這是高峰期)。伺服器需要一個連結串列,演算法中有個邏輯要把每個元素新增到連結串列末尾(只是這個元素物件的指標,不存在物件複製的問題),再從連結串列中把這些元素取出(另一個時間點)。就是一個單執行緒在做這件事。
既然邏輯這麼簡單,我自然選用了C++的標準STL容器List(Linux GNU,sgi的實現),想來如此簡單的事情,不過是一次末尾插入,一次頭部取出而已,就用STL的List容器吧。沒有想到這是痛苦的開始。我預想中每秒處理80萬元素應該是很輕鬆寫意的,沒想到每秒一千個包時伺服器就頂不住了,處理演算法的執行緒佔用CPU達到百分之百,大量的包得不到及時處理而超時。由於演算法較為複雜,定位這問題耗了不少時間,終於感覺到LIST容器似乎有嚴重效能問題。
於是乾脆自己寫了個簡單的連結串列,替代了STL的容器後效能有了極大的提升。為此我特意寫了個簡單的程式,大致模仿我演算法中的場景,程式流程如下:
每3秒中向連結串列中插入N個元素(指標),再把這N個元素從連結串列中取出釋放。之後檢視耗時t,如果t小於3秒,就sleep(3-t)秒,並打印出睡眠的時間。
在我的測試機上,出現了差異很大的測試結果,大約每3秒測試2萬個元素時,使用STL LIST的壓力程式導致CPU已經達到70%了,而使用自己寫的簡單鏈表幾乎沒有感覺。直到每3秒測試2000萬個元素時,才導致CPU佔用80%。結果有一千倍的差距!這裡沒有物件的複製,我插入連結串列的都只是指標而已!
(下面附測試程式,這裡只是對比兩種list的效能,機器的引數並不重要。請大家注意71行程式碼
#include <list> #include <sys/time.h> #include <iostream> using namespace std; //待測試的物件,連結串列中的每個元素就是物件A的指標 class A {}; //每3秒鐘插入連結串列末尾/從連結串列首部取出的元素個數 int testPressureNum = 40000; //測試的STL連結串列 list<A*> testList; //自己寫的連結串列 typedef struct { A* p; void* prev; void* next; } SelfListElement; SelfListElement* myListHead; SelfListElement* myListTail; int myListSize; //向自己寫的連結串列首部新增元素 bool add(A* packet) { SelfListElement* ele = new SelfListElement; ele->p = packet; myListSize++; if (myListHead == NULL) { myListHead = myListTail = ele; ele->prev = NULL; ele->next = NULL; return true; } ele->next = myListHead; myListHead->prev = ele; ele->prev = NULL; myListHead = ele; return true; } // 從自己寫的連結串列尾部取出元素 SelfListElement* get() { if (myListTail == NULL) return NULL; myListSize--; SelfListElement* p = myListTail; if (myListTail->prev == NULL) { myListHead = myListTail = NULL; } else { myListTail = (SelfListElement*)myListTail->prev; myListTail->next = NULL; } return p; } //從STL連結串列中取出元素並刪除 void testDelete1() { while (testList.size() > 0)//這行語句有嚴重效能問題,size的複雜度不是常量級,而是O(N),請注意!就是這裡跳坑裡去了 { A* p = testList.back(); testList.pop_back(); delete p; p = NULL; } } //從簡單鏈表中取出元素並刪除 void testDelete2() { do { SelfListElement* packet = myListTail; if (packet == NULL) break; packet = get(); delete packet->p; delete packet; packet = NULL; } while (true); } //向Stl連結串列中新增元素 void testAdd1() { for (int i = 0; i < testPressureNum; i++) { A* p = new A(); testList.push_front(p); } } //向簡單鏈表中新增元素 void testAdd2() { for (int i = 0; i < testPressureNum; i++) { A* p = new A(); add(p); } } void printUsage(int argc, char**argv) { cout<<"usage: "<<argv[0]<<" [1|2] [oneRoundPressueNum]"<<endl <<"1 means STL, 2 means simple list\noneRoundPressueNum means in 3 seconds how many elements add/del in list"<<endl; } int main(int argc, char** argv) { //為方便測試可使用2個引數 if (argc < 2) { printUsage(argc, argv); return -1; } int type = atoi(argv[1]); if (type != 1 && type != 2) { printUsage(argc, argv); return -2; } if (argc >= 2) testPressureNum = atoi(argv[2]); cout<<"every 3 seconds add/del element number is "<<testPressureNum<<endl; struct timeval time1, time2; gettimeofday(&time1, NULL); while (true) { gettimeofday(&time1, NULL); if (type == 1) { testAdd1(); cout<<"stl list has "<<testList.size()<<" elements"<<endl; } else { testAdd2(); cout<<"my list has "<<myListSize<<" elements"<<endl; } //每3秒一個週期 gettimeofday(&time2, NULL); unsigned long interval = 1000000*(time2.tv_sec-time1.tv_sec)+ time2.tv_usec-time1.tv_usec; if (interval < 3000000) { cout<<"after add sleep "<<3000000-interval<<" usec"<<endl; usleep(3000000-interval); } else cout<<"add cost time too much"<<interval<<endl; gettimeofday(&time1, NULL); if (type == 1) { testDelete1(); cout<<"stl list has "<<testList.size()<<" elements"<<endl; } else { testDelete2(); cout<<"my list has "<<myListSize<<" elements"<<endl; } //每3秒一個週期 gettimeofday(&time2, NULL); interval = 1000000*(time2.tv_sec-time1.tv_sec)+ time2.tv_usec-time1.tv_usec; if (interval < 3000000) { cout<<"after delete sleep "<<3000000-interval<<" usec"<<endl; usleep(3000000-interval); } else cout<<"delete cost time too much"<<interval<<endl; } return 0; }
一千倍的效能差距太誇張了。究竟是什麼原因導致STL效能這麼差呢?之前對在一些效能要求高的場景下我沒怎麼用過STL容器,對它還不夠熟悉。這篇部落格發出後,陳碩幫忙指出原來是第71行的size()方法出了問題! 將size()方法改為 empty()方法後,list效能有了大幅度提高,當然與上面自己寫的連結串列相比還有差距,大概自寫連結串列效能比STL的list還要高出70%左右!
我很好奇究竟size()方法幹了些什麼?看看它的實現!(STL的程式碼我下的是sgi 3.3版本)
size_type size() const {
size_type __result = 0;
distance(begin(), end(), __result);
return __result;
}
原來這個size()方法並不像自己寫的連結串列那樣,用一個變數來儲存著連結串列的長度,而是去呼叫了distance方法來獲取長度。那麼這個distance方法又做了些什麼呢?
template <class _InputIterator, class _Distance>
inline void distance(_InputIterator __first,
_InputIterator __last, _Distance& __n)
{
__STL_REQUIRES(_InputIterator, _InputIterator);
__distance(__first, __last, __n, iterator_category(__first));
}
又封了一層__distance,看看它又做了什麼?
template <class _InputIterator>
inline typename iterator_traits<_InputIterator>::difference_type
__distance(_InputIterator __first, _InputIterator __last, input_iterator_tag)
{
typename iterator_traits<_InputIterator>::difference_type __n = 0;
while (__first != __last) {
++__first; ++__n;
}
return __n;
}
原來是遍歷!為什麼獲得連結串列長度必須要遍歷全部的連結串列元素才能獲得,而不是用一個變數來表示呢?sgi設計list的思路何以如此與眾不同呢(話說微軟的STL實現就沒有這個SIZE方法的效率問題)?
看看作者自己的解釋:http://home.roadrunner.com/~hinnant/On_list_size.html
開篇點題,原來作者是為了
splice(iterator position, list& x, iterator first, iterator last);
方法所取的折衷,為了它的實現而把size方法設計成了O(N)。
splice方法就是為了把連結串列A中的一些元素直接串聯到連結串列B中,如果size()設計為O(1)複雜度,那麼做splice時就需要遍歷first和last間的長度(然後把連結串列A儲存的連結串列長度減去first和last(待移動的元素)之間的長度)!於是作者考慮到size方法設計為O(N),就無需在splice方法執行時做遍歷了!
看看splice的實現:
void splice(iterator __position, list&, iterator __first, iterator __last) {
if (__first != __last)
this->transfer(__position, __first, __last);
}
再看看transfer幹了些什麼:
void transfer(iterator __position, iterator __first, iterator __last) {
if (__position != __last) {
// Remove [first, last) from its old position.
__last._M_node->_M_prev->_M_next = __position._M_node;
__first._M_node->_M_prev->_M_next = __last._M_node;
__position._M_node->_M_prev->_M_next = __first._M_node;
// Splice [first, last) into its new position.
_List_node_base* __tmp = __position._M_node->_M_prev;
__position._M_node->_M_prev = __last._M_node->_M_prev;
__last._M_node->_M_prev = __first._M_node->_M_prev;
__first._M_node->_M_prev = __tmp;
}
}
作者確實是考慮splice執行時,不用再做遍歷,而是僅僅移動幾個指標就可以了,因此犧牲了size的效率!
怎麼評價這種設計呢?作者的出發點是好的,但是,畢竟絕大多數程式設計師都會潛意識認為 size方法的複雜度是常量級的,同時size方法也是最常用的!這個確實是作者在給我們挖坑哈!
這個例子真是告訴我們,必須謹慎使用第三方軟體,特別是對它有較高的要求時,務必對將要使用它的所有方法都有足夠的瞭解,不是滿足於它能做什麼,還必須要知道它怎麼做到的,否則,還是老老實實用自己熟悉的工具吧。