【問題:崩潰】記憶體位置 0x1f0fe214 處的 std::bad_alloc
久別重逢的 std::bad_alloc
久別重逢是說,自從在教科書上見過它一面之後,這才是第二次碰面。也就是說,在這些年的程式設計經歷中,從來沒有遇到過吧——至少在我印象中是這樣的。以至於我都開始懷疑在“平常的”程式中,它是否真正存在了。記憶體分配,C 裡的 malloc (或者配套的函式) ,如果分配失敗了會返回地址 0 ,所以,“作為良好的程式設計習慣,每次申請記憶體之後,應該檢查一下返回值是不是 NULL ”,這樣的“良好習慣”也許剛開始寫幾個程式的時候還能堅持,到後來就完全不管了——因為從來沒有遇到過 malloc 返回 0 的情況,申請記憶體怎麼會失敗呢?如果連記憶體都申請失敗了,那接下去估計也沒有什麼好做的了,估計系統已經處於崩潰邊緣了,與其每次都費力去檢查,還不如讓它自生自滅好了——反正之後如果嘗試去訪問這個 0 地址,肯定會碰到段錯誤 (segment fault) 而掛掉的,當然,一個不好的地方可能就是這個掛掉的位置和最初申請記憶體失敗的位置已經相差了十萬八千里,可能追蹤起來會比較麻煩。
至於 C++ 裡,就更簡單了,new 的時候如果申請不到那麼多記憶體的話,會丟擲 std::bad_alloc 異常,如果沒把這個異常接住,讓它一直跳到最頂層的話,程式會立即掛掉。比 C 更加“人性化”——當場掛掉,而不是在某個未知的其他地方 segment fault 。如此一來,就更加熟視無睹了。
總而言之,漸漸地有了這樣一種印象:像記憶體申請失敗之類的情況,大概只有在嵌入式裝置等非常極端的資源匱乏的平臺上程式設計的時候才會碰到吧。結果這次卻在一個記憶體很大很大(256G 實體記憶體)的環境裡遇到了。果然是和車禍之類的類似,越是在看上去很太平的路段,越會讓駕駛員掉以輕心呀。
情況是這樣的,在跑的是一個很大的聚類程式,聚類開始之前先要把資料從 MongoDB 讀出來,由於記憶體很大,所有的後續操作都是在記憶體中進行的。不過,第二天來看狀態的時候,卻發現一堆的 std::bad_alloc 輸出。因為程式的整個框架裡用了 worker ,在裡面把所有的異常都接住了,所以程式沒有掛掉,而是繼續很歡地跑,不過,從滿屏的 bad_alloc 來看,後續的許多許多次記憶體分配的嘗試都失敗了——至少有兩千個 exception 的 LOG 吧,因為 tmux 的 history buffer 被設成了那麼多,所以沒法看到更早的結果。
這件事情重新整理了我的兩個認識:第一,原來世界上真的有“記憶體申請失敗”這種玩意啊;第二,原來記憶體申請失敗之後程式還是可以繼續“正常”執行的啊。第一點果然還是因為記憶體比較大,所以就當白菜一樣用了,殊不知白菜也有吃光的時候啊。第二點是我之前一直覺得如果系統連記憶體這種基本資源都已經給不出來了,那肯定已經是日薄西山氣息奄奄了,卻忽略了一個引數——申請失敗的時候想要申請的那塊記憶體的大小。比如,如果我要申請 1T 的記憶體,系統給不出來,不能因此直接斷定系統已經記憶體耗盡,就等 panic 了,說不定 1T 雖然給不出,但是 500G 還遊刃有餘呢。
不過,碰到這個問題,和寒仔商量了下,都覺得大概是因為我當時一次開了好幾個程式在跑的緣故吧,其他幾個程式雖然沒有這麼吃記憶體,但是加起來也許就有點吃不消了。於是我第二天再跑了一遍,其他無關的程式儘量不開起來。順便還把 tmux 的 history limit 設成了一百萬行 =.= 。又過了很久很久,跑去看結果,發現總共有超過 16 萬行的 std::bad_alloc 輸出。
總之還是 bad_alloc 了。看來還是程式自己的問題啊,可恨的是 C++ 的 exception 沒有 stack trace ,就看到一個 bad_alloc ,卻不知道具體是在哪個位置丟擲來的。因為我和寒仔討論的結論是,一是程式程式碼哪裡有 bug ,比如用 int (32 位) 來計算 size ,結果給溢位了變成一個負數了,於是在 new 那裡被轉成 size_t (64 位)的時候,成為了一個超級大超級大的數,自然要 bad_alloc 了;第二種可能性就是程式碼本身是沒有問題的,但是程式佔用的記憶體確實太多了,以至於系統無法提供那麼多記憶體。
寒仔比較傾向於第一種可能,因為我們大致估算了一下總的資料量,由於使用了抽樣,並沒有取出所有的資料,所以總量是在 100G 以下的。不過我比較傾向於第二種可能,因為相關的程式碼就那麼一點點,兩人仔細看了一遍程式碼,雖然修正了一個可能會造成剛才那種溢位的隱患,但是程式出現 bad_alloc 的時候還沒有執行到那裡呢,即便是個 bug ,那都只能是另一個 bug 了…… -,-|| 看來看去,也只有 std::vector::insert 的呼叫那裡最可疑了。
因為我印象中 STL 的 vector 在插入元素的時候,容量增長是翻倍的。比如 vector 分配了足夠容納 128 個元素的記憶體空間,如果插入了 128 個元素之後再插入更多的元素,它就會重新分配一塊容納得下 256 (=128*2) 個元素的記憶體塊。這樣會導致分配的記憶體空間以 2 的指數級別增長,看上去很可怕,實際卻比較好用,因為頻繁地釋放原來的記憶體塊再重新分配記憶體塊的操作實際上是不太好的,這種增長方式可以有效地減少重新分配的次數。不過可能出現的情況就是:雖然實際資料沒有那麼多,但是佔用的記憶體可能會多近一倍。比如,257 個元素,實際會佔用 512 個元素那麼多的空間。
為了確認問題到底出在哪裡,我又把程式跑了第三遍,這次去掉了 exception 的 catch ,並關閉了 shell 的 core dump 的 ulimit 限制。又過了很久很久,它如期被 abort 了,dump 出來一個 65G 的 core 。在 gdb 裡把 core 載入進來,看了一下 backtrace
#0 0x00007f9c72b87165 in *__GI_raise (sig=<value optimized out>) at ../nptl/sysdeps/unix/sysv/linux/raise.c:64 #1 0x00007f9c72b89f70 in *__GI_abort () at abort.c:92 #2 0x00007f9c7341adc5 in __gnu_cxx::__verbose_terminate_handler() () from /usr/lib/libstdc++.so.6 #3 0x00007f9c73419166 in ?? () from /usr/lib/libstdc++.so.6 #4 0x00007f9c73419193 in std::terminate() () from /usr/lib/libstdc++.so.6 #5 0x00007f9c7341928e in __cxa_throw () from /usr/lib/libstdc++.so.6 #6 0x00007f9c7341971d in operator new(unsigned long) () from /usr/lib/libstdc++.so.6 #7 0x000000000041f1aa in __gnu_cxx::new_allocator<float>::allocate (this=0x7fff7c709608, __position=..., __first=0x7f9c60017c34, __last=0xffffffffffffffff) at /usr/include/c++/4.4/ext/new_allocator.h:89 #8 std::_Vector_base<float, std::allocator<float> >::_M_allocate (this=0x7fff7c709608, __position=..., __first=0x7f9c60017c34, __last=0xffffffffffffffff) at /usr/include/c++/4.4/bits/stl_vector.h:140 #9 std::vector<float, std::allocator<float> >::_M_range_insert<float*> (this=0x7fff7c709608, __position=..., __first=0x7f9c60017c34, __last=0xffffffffffffffff) at /usr/include/c++/4.4/bits/vector.tcc:521 #10 0x0000000000428bca in _M_insert_dispatch<float*> (this=0x7fff7c709500, obj=<value optimized out>) at /usr/include/c++/4.4/bits/stl_vector.h:1102 #11 insert<float*> (this=0x7fff7c709500, obj=<value optimized out>) at /usr/include/c++/4.4/bits/stl_vector.h:874 ...
果然是在 vector::insert 那裡掛掉了。切換到 vector 所在的那個 frame ,左右看了一下,好多變數都被優化沒了,沒法看。vector::size() 也被優化成了 inline 函式,沒法在 gdb 裡呼叫,結果連 vector 的大小都看不了,真是不方便呀。就除錯 STL 來說,gdb 和 Visual Studio 相比還是不夠人性化啊。索性把整個 vector 列印一下:
{<std::_Vector_base<float, std::allocator<float> >> = { _M_impl = {<std::allocator<float>> = {<__gnu_cxx::new_allocator<float>> = {<No data fields>}, <No data fields>}, _M_start = 0x7f7c5fff1010, _M_finish = 0x7f8c5fff1010, _M_end_of_storage = 0x7f8c5fff1010}}, <No data fields>}
這裡總算可以推算出 vector 的大小了,猜測一下,_M_start 和 _M_finish 應該是使用的記憶體區段了,算了一下:
(0x7f8c5fff1010 - 0x7f7c5fff1010)/1024/1024/1024
剛好等於 64 ,也就是說 vector 已經用了 64G 的記憶體了。其實從 core 檔案的大小應該也大概可以猜到了。也就是說,下一步要翻倍為 128G 的時候掛掉了?雖然我聽說 STL 的 vector 的空間分配是按照翻倍的方式,但是這似乎是從某本書上看到的,不排除是比較學院派的程式碼庫裡的做法,到底是不是在 industrial 裡用的呢,我還不是很清楚呢,索性開啟剛才 backtrace 裡的 /usr/include/c++/4.4/bits/vector.tcc:521 去看一下,彷彿聽到一個聲音在喊:歡迎來到一堆模版和下劃線組成的世界:
const size_type __len = _M_check_len(__n, "vector::_M_range_insert"); pointer __new_start(this->_M_allocate(__len)); pointer __new_finish(__new_start);
看來 __len 就是我要找的那個新的 size 了,於是接下來去找 _M_check_len 這個函式,沒有 IDE 在一個裸的 editor 裡找這種函式的定義還真是一件麻煩的事情(我又不知何故非常抵制 ctags),不過這樣也並不是全無好處。在 look around 之後,我發現這個 vector.tcc (副檔名就比較奇怪了)是 export 的模版定義,也就是那個號稱幾乎沒有任何編譯器支援(至少在我學 C++ 的那個年代)的 C++ 特性:不把模版定義放在標頭檔案裡,而是單獨放在另一個地方。總之 vector 的本體是在 stl_vector.h 裡面:
size_type _M_check_len(size_type __n, const char* __s) const { if (max_size() - size() < __n) __throw_length_error(__N(__s)); const size_type __len = size() + std::max(size(), __n); return (__len < size() || __len > max_size()) ? max_size() : __len; }
可以看到 size 確實是(至少)翻一倍的。不過我比較好奇這個 max_size() 是什麼,如果大於這個 max_size() 的話,還是會被截斷的。於是再找到 max_size() 的定義:
size_type max_size() const { return _M_get_Tp_allocator().max_size(); }
唔,好吧,呼叫了某個 allocator 的 max_size() ,那麼這個 allocator 是什麼?於是去找 _M_get_Tp_allocator 的定義,發現 vector 實際上是繼承自一個叫做 _Vector_base 的東西:
template<typename _Tp, typename _Alloc = std::allocator<_Tp> > class vector : protected _Vector_base<_Tp, _Alloc> { // Concept requirements. typedef typename _Alloc::value_type _Alloc_value_type; __glibcxx_class_requires(_Tp, _SGIAssignableConcept) __glibcxx_class_requires2(_Tp, _Alloc_value_type, _SameTypeConcept) typedef _Vector_base<_Tp, _Alloc> _Base; typedef typename _Base::_Tp_alloc_type _Tp_alloc_type; public: typedef _Tp value_type; typedef typename _Tp_alloc_type::pointer pointer; typedef typename _Tp_alloc_type::const_pointer const_pointer; typedef typename _Tp_alloc_type::reference reference; typedef typename _Tp_alloc_type::const_reference const_reference; typedef __gnu_cxx::__normal_iterator<pointer, vector> iterator; typedef __gnu_cxx::__normal_iterator<const_pointer, vector> const_iterator; typedef std::reverse_iterator<const_iterator> const_reverse_iterator; typedef std::reverse_iterator<iterator> reverse_iterator; typedef size_t size_type; typedef ptrdiff_t difference_type; typedef _Alloc allocator_type; protected: using _Base::_M_allocate; using _Base::_M_deallocate; using _Base::_M_impl; using _Base::_M_get_Tp_allocator;
那裡的 using _Base::_M_get_Tp_allocator (還第一次見到這樣用 using 的),看來這是 _Base 裡的一個函式,而 _Base 是
_Vector_base<_Tp, _Alloc>
這麼一個東西的 typedef 。於是再去看 _Vector_base :
template<typename _Tp, typename _Alloc> struct _Vector_base { typedef typename _Alloc::template rebind<_Tp>::other _Tp_alloc_type; struct _Vector_impl : public _Tp_alloc_type { typename _Tp_alloc_type::pointer _M_start; typename _Tp_alloc_type::pointer _M_finish; typename _Tp_alloc_type::pointer _M_end_of_storage; _Vector_impl() : _Tp_alloc_type(), _M_start(0), _M_finish(0), _M_end_of_storage(0) { } _Vector_impl(_Tp_alloc_type const& __a) : _Tp_alloc_type(__a), _M_start(0), _M_finish(0), _M_end_of_storage(0) { } }; public: typedef _Alloc allocator_type; // <omitted snippet>... _Tp_alloc_type& _M_get_Tp_allocator() { return *static_cast<_Tp_alloc_type*>(&this->_M_impl); } const _Tp_alloc_type& _M_get_Tp_allocator() const { return *static_cast<const _Tp_alloc_type*>(&this->_M_impl); }
可以看到這傢伙把 _M_impl 型別轉換為一個 _Tp_alloc_type 的東西返回了。_M_impl 是一個 _Vector_impl 型別的成員變數,這個傢伙繼承自 _Tp_alloc_type 型別,其實就是加了幾個 typedef 和建構函式,所以型別轉換一下其實就是原來那個東西。
只是我有一點不太明白的地方是,它有一個 allocator_type (也就是模版引數 _Alloc),建構函式也是接受的這個型別,並用它來初始化的 _M_impl :
_Vector_base() : _M_impl() { } _Vector_base(const allocator_type& __a) : _M_impl(__a) { } _Vector_base(size_t __n, const allocator_type& __a) : _M_impl(__a) { this->_M_impl._M_start = this->_M_allocate(__n); this->_M_impl._M_finish = this->_M_impl._M_start; this->_M_impl._M_end_of_storage = this->_M_impl._M_start + __n; }
可是為什麼又搞出一個 _Tp_alloc_type 來?根據這個關係,allocator_type 型別的物件似乎是可以轉換為 _Tp_alloc_type 型別的物件,或者用來構造一個後者的物件。並且這個型別的定義也比較奇怪:
typedef typename _Alloc::template rebind<_Tp>::other _Tp_alloc_type;
剛看到的時候差點以為 rebind 不會是 C++11 新加入的某關鍵字吧?果斷 google ,發現不是,不過找到了一個 C++ 模版的 FAQ 裡講何時要用 template 何時要用 typename 的,舉了一個例子居然就是類似這裡的。明白它不是個關鍵字之後(C++11 好像引入了好多新功能啊,好想嘗試啊!!),再看這個 typedef 就彷彿如紙老虎一般了,其實開啟 allocator.h 一看便明白:
template<typename _Tp> class allocator: public __glibcxx_base_allocator<_Tp> { public: typedef size_t size_type; typedef ptrdiff_t difference_type; typedef _Tp* pointer; typedef const _Tp* const_pointer; typedef _Tp& reference; typedef const _Tp& const_reference; typedef _Tp value_type; template<typename _Tp1> struct rebind { typedef allocator<_Tp1> other; }; allocator() throw() { } allocator(const allocator& __a) throw() : __glibcxx_base_allocator<_Tp>(__a) { } template<typename _Tp1> allocator(const allocator<_Tp1>&) throw() { } ~allocator() throw() { } // Inherit everything else. };
所謂 rebind 其實是一個模版 trick ,因為 C++ 這該死的型別系統最大的麻煩之處應該屬於寫出某個型別的名字吧,特別是使用模版程式設計的時候(每當這個時候就會想起 Haskell 來)。這裡這個 rebind 顧名思義,其實就是從
FooBarAllocator::rebind::other
(我就不寫 template 、typename 什麼的了……)得到
FooBarAllocator
這個型別。為什麼要這麼波折呢?因為有時候
FooBarAllocator
這個型別本身是通過(模版)引數傳進來的,你並不事先知道它是什麼型別,所以沒法寫出
FooBarAllocator
來。這倒是一個有趣的 trick ,想起來好像幾天前我也遇到一個比較類似的問題,面對一堆 T ,想要拿到一個和 T 相關的型別,但是由於不知道 T 是什麼,顯得非常無力。
回到剛才的程式碼,結果這個 _Tp_alloc_type 和 _Alloc 是“差不多”的型別,不過換了一下模版引數。也就是說
vector
實際上要得到
allocator
型別,不過難道構造的時候不就是
allocator
嗎?這樣的話 _Tp_alloc_type 和 _Alloc 實際上根本就是同一個型別了。亦或者也許可以用
allocator
之類的東西來構造,然後這個東西可以轉換或者構造一個 generic 的
allocator
。不過這一堆 allocator 相當複雜,比如對小尺寸物件應該有采用物件池等方式來優化記憶體使用減少碎片吧:
$ ls /usr/include/c++/4.4/ext/*alloc* /usr/include/c++/4.4/ext/array_allocator.h /usr/include/c++/4.4/ext/bitmap_allocator.h /usr/include/c++/4.4/ext/debug_allocator.h /usr/include/c++/4.4/ext/extptr_allocator.h /usr/include/c++/4.4/ext/malloc_allocator.h /usr/include/c++/4.4/ext/mt_allocator.h /usr/include/c++/4.4/ext/new_allocator.h /usr/include/c++/4.4/ext/pool_allocator.h /usr/include/c++/4.4/ext/throw_allocator.h
所以那些就先不管了,總之是個 allocator ,我現在要看它的 max_size() 怎麼定義的。不過在 allocator.h 裡並沒有定義,於是找了 ext/ 下的 new_allocator.h 和 malloc_allocator.h 來看,兩個差不多的:
size_type max_size() const throw() { return size_t(-1) / sizeof(_Tp); }
好啦,到這裡謎底終於揭曉啦!原來所謂的 max_size ,其實就是 size_t 能夠表示的最大值啊,和系統記憶體什麼的一點關係都沒有……-,-bb 費了我不少周折,不過想想其實也是正常的。那麼,所以說問題還是出在分配記憶體的時候 double 了一下 size 咯?64G 變成 128G 的時候掛了?按理系統的記憶體是夠的,其他的一些服務佔去了幾十 G,也還有不少呢,不過 MongoDB 這個記憶體大戶估計不好惹,但是按理說 MongoDB 應該是使用記憶體對映,記憶體不夠的時候作業系統應該可以自動幫他釋放一些的。那問題出在哪裡呢?
後來寒仔突然頓悟,說,因為有個時刻 64G 和 128G 是同時存在的啊!果然如此!看 new_allocator 裡的程式碼並沒有用特殊的記憶體操作,只是普通的 new 出一塊新的空間,把記憶體複製過去,然後再釋放原來的空間。這樣子一來,192G 的記憶體的話,好像確實有些吃不消了,因為系統還在跑一些其他的服務,合計起來佔去的記憶體也挺多,128G 應該沒有問題,但是 192G 就有問題了。
既然是在 size double 的問題上,倒是好解決的——因為實際記憶體佔用並不會超過實體記憶體,STL 提供了一個函式叫做 reserve ,告訴它需要預留多少空間,如果這個 size 估算得好的話,就不會出現剛才那樣的問題了。編譯、執行、等待………………耶,果然 OK 啦!
唔,到這裡為止,發現我果然大量篇幅都在跑題啊,也許是因為 N 年沒有用 C++ 了,突然又回頭開始用,心裡頭比較激動的緣故吧! 順便,C++11 的標準出來了,有點小熱血沸騰呢!其實應該大部分的標準在 draft 的時候 gcc 就已經支援了吧,可是 Debian stable 上的 gcc 版本真的好老好老啊。還有 Clang 也想嘗試一下的,因為聽說它的錯誤輸出可讀性非常好,前幾日在重構一坨依賴比較多的模版程式碼的時候,同數萬行(也許小誇張了下 ,不過那滿屏滿屏的陣勢,你懂的~) gcc 的模版編譯錯誤血拼了一個下午,深感噁心啊,這些年來似乎都沒啥改進呢,果然是塊難啃的肉啊。