背景
最近有在做 RocketMQ 社群的 Node.js SDK,是基于 RocketMQ 的 C SDK 封裝的 Addon,而 C 的 SDK 則是基于 C++ SDK 進行的封裝。
然而,卻出現了一個詭異的問題,就是當我在消費資訊的時候,發現在 macOS 下得到的消息居然是亂碼,也就是說 Linux 下居然是正常的。
重制
首先我們要知道一個函數是,用于從一個
const char* GetMessageTopic(CMessageExt* msg)
指針中擷取它的 Topic 資訊。
msg
亂碼的代碼可以有好幾個版本,是我在排查的時候做的各種改變:
// 往 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
is valid and the values in it correspond to the values stored in the string with an additional null character after the last position.
[c_str(); c_str() + size()]
這一樣可以引申到 JavaScript 上來,例如較早的 ECMAScript 262 第三版對于一個對象的定義中,鍵名在對象中的順序也是未定義的,當時就不能讨巧地看哪個浏覽器是怎麼樣一個順序來進行輸出,畢竟對于未定義的行為,浏覽器随時改了你也不能聲讨它什麼。
好久沒寫文了,碼字能力變弱了。
以上。