一次 macOS 下 C++ 的 STL 踩坑記錄
背景
最近有在做 RocketMQ 社群的 Node.js SDK,是基於 RocketMQ 的 C SDK 封裝的 Addon,而 C 的 SDK 則是基於 C++ SDK 進行的封裝。
然而,卻出現了一個詭異的問題,就是當我在消費資訊的時候,發現在 macOS 下得到的訊息居然是亂碼,也就是說 Linux 下居然是正常的。
重現
首先我們要知道一個函式是
const char* GetMessageTopic(CMessageExt* msg)
,用於從一個msg
指標中獲取它的 Topic 資訊。
亂碼的程式碼可以有好幾個版本,是我在排查的時候做的各種改變:
// 往 JavaScript 的 `object` 物件中插入鍵名為 `topic` 的值為 `GetMessageTopic` // 第一種寫法:亂碼 Nan::Set( object, // v8 中的 JavaScript 層物件 Nan::New("topic").ToLocalChecked(), Nan::New(GetMessageTopic(msg)).ToLocalChecked() ); // 另一種寫法:亂碼 const char* temp = GetMessageTopic(msg); Nan::Set( object, // v8 中的 JavaScript 層物件 Nan::New("topic").ToLocalChecked(), Nan::New(temp).ToLocalChecked() ); // 第三種寫法:亂碼 string GetMessageColumn(CMessageExt* msg, char* name) { // ... const char* orig = GetMessageTopic(msg); int len = strlen(orig); char temp[len + 1]; memcpy(temp, orig, sizeof(char) * (len + 1)); return temp; } const char* temp = GetMessageColumn(msg, "topic"); Nan::Set( object, // v8 中的 JavaScript 層物件 Nan::New("topic").ToLocalChecked(), Nan::New(temp).ToLocalChecked() );
並且很詭異的是,當我在除錯第三種寫法的時候,我發現在 const char* orig = GetMessageTopic(msg);
這一部的時候 orig
的值是正確的。而一步步單步執行下去,一直到 memcpy
執行結束的時候,orig
記憶體塊裡面的字串居然被莫名其妙修改成亂碼了。
參考如下:
這就不能忍了。
當我鍥而不捨的時候,發現當我改成這樣之後,返回的值就對了:
string GetMessageColumn(CMessageExt* msg, char* name) { // ... const char* orig = GetMessageTopic(msg); int len = strlen(orig); int i; char temp[len + 1]; for(i = 0; i < len + 1; i++) { temp[i] = orig[i]; } // 做一些其它操作 return temp; } const char* temp = GetMessageColumn(msg, "topic"); Nan::Set( object, // v8 中的 JavaScript 層物件 Nan::New("topic").ToLocalChecked(), Nan::New(temp).ToLocalChecked() );
但問題在於,在“其它操作”中,orig
還是會變成一堆亂碼。當前返回能正確的原因是因為我在它變成亂碼之前,用可以“不觸發”變成亂碼的操作先把 orig
的字串給賦值到另一個字元陣列中,最後返回那個新的陣列。
問題看似解決了,但是這種詭異、危險的行為始終是我心中的一顆喪門釘,不處理總之是慌的。
RocketMQ C++ SDK 原始碼檢視
在排查的過程中,我去看了 RocketMQ 的 C++ 和 C SDK 的實現,我把重要的內容摘出來:
class MQMessage { public: string::string getTopic() const { return m_topic; } ... private: string m_topic; ... } // MQMessageExt 是繼承自 MQMessage const char* GetMessageTopic(CMessageExt *msg) { ... return ((MQMessageExt *) msg)->getTopic().c_str(); }
我們閱讀一下這段程式碼,在 GetMessageTopic
中,先得到了一個 getTopic
的 STL 字串,然後呼叫它的 c_str()
返回 const char*
。一切看起來是那麼美好,沒有問題。
但我後來在多次除錯的時候發現,對於同一個 msg
進行呼叫 GetMessageTopic
得到的指標居然不一樣!我是不是發現了什麼新大陸?
誠然,msg->getTopic()
返回了一個字串物件,並且是通過拷貝構造從 m_topic
那邊來的。依稀記得大學時候看的 STL 原始碼解析,根據 STL 字串的 Copy-On-Write 來說,我沒做任何改變的情況下,它們不應該是同源的嗎?
事實證明,我當時的這個“想當然”就差點讓我查不出問題來了。
柳暗花明
在我捉雞了好久之後一直毫無頭緒之後,在參考資料 1 中獲得了靈感,我開始開啟腦洞(請原諒我這個坑還找了很久,畢竟我主手武器還是 Node.js),會不會現在的 String 都不是 Copy-On-Write 了?但是 Linux 下又是正常的哇。
後來我在網上找是不是有人跟我遇到一樣的問題,最後還是找到了端倪。
不同的 stl 標準庫實現不同, 比如 CentOS 6.5 預設的 stl::string 實現就是 『Copy-On-Write』, 而 macOS(10.10.5)實現就是『Eager-Copy』。
說得白話一點就是,不同庫實現不一樣。Linux 用的是 libstdc++,而 macOS 則是 libc++。而 libc++ 的 String 實現中,是不寫時拷貝的,一開始賦值就採用深拷貝。也就是說就算是兩個一樣的字串,在不同的兩個 String 物件中也不會是同源。
其實深挖的話內容還有很多的,例如《Effective STL》中的第 15 條也有提及 String 實現有多樣性;以及大多數的現代編譯器中 String 也都有了 Short String Optimization 的特性;等等。
回到亂碼 Bug
得到了上面的結論之後,這個 Bug 的原因就知道了。
((MQMessageExt *) msg)->getTopic()
得到了一個函式中的棧記憶體字串變數。
- 在 Linux 中,就算是棧記憶體變數,但是它的
c_str()
還是源字串指向的指標,所以函式宣告週期結束,這個棧記憶體中的字串被釋放,c_str()
指向的記憶體還堅挺著; - 在 macOS 下,由於字串是棧記憶體分配的,字串又是深拷貝,所以
c_str()
的生命週期是跟著字串本身來的,一旦函式呼叫結束,該字串就被釋放了,相應地c_str()
對應記憶體中的內容也被釋放。
綜上所述,在 macOS 下,我通過 GetMessageTopic()
得到的內容其實是一個已經被釋放記憶體的地址。雖然通過 for
可以趁它的記憶體塊被複制之前趕緊搶救出來,但是這種操作一塊已經被釋放的記憶體行為總歸是危險的,因為它的記憶體塊隨時可能被覆蓋,這也就是之前亂碼的本質了。
更小 Demo 驗證
對於 STL 在這兩個平臺上不同的行為,我也抽出了一個最小化的 Demo,各位看官可以在自己的電腦上試試看:
#include <stdio.h>
#include <string>
using namespace std;
string a = "123";
string func1()
{
return a;
}
int main()
{
printf("0x%.8X 0x%.8X\n", a.c_str(), func1().c_str());
return 0;
}
上面的程式碼在 Linux 下(如 Ubuntu 14.04)執行會輸出兩個一樣的指標地址,而在 macOS 下執行則輸出的是兩個不一樣的指標。
小結
在語言、庫的使用中,我們不能去使用一個沒有明確在文件中定義的行為的“特性”。例如文件中沒跟你說它用的是 Copy-On-Write 技術,也就說明它可能在未來任何時候不通知你就去改掉,而你也不容易去發現它。你就去用已經定義好的行為即可,就是說 c_str()
返回的是字串的一個真實內容,我們就要認為它是跟隨著 String 的生命週期,哪怕它其中有黑科技。
畢竟,下面這個才是 C++ reference 中提到的定義,我們不能臆想人家一定是 COW 行為:
Returns a pointer to a null-terminated character array with data equivalent to those stored in the string.
The pointer is such that the range
[c_str(); c_str() + size()]
is valid and the values in it correspond to the values stored in the string with an additional null character after the last position.
這一樣可以引申到 JavaScript 上來,例如較早的 ECMAScript 262 第三版對於一個物件的定義中,鍵名在物件中的順序也是未定義的,當時就不能討巧地看哪個瀏覽器是怎麼樣一個順序來進行輸出,畢竟對於未定義的行為,瀏覽器隨時改了你也不能聲討它什麼。
好久沒寫文了,碼字能力變弱了。
以上。