背景
最近有在做 RocketMQ 社區(qū)的 Node.js SDK友瘤,是基于 RocketMQ 的 C SDK 封裝的 Addon翠肘,而 C 的 SDK 則是基于 C++ SDK 進行的封裝。
然而辫秧,卻出現(xiàn)了一個詭異的問題束倍,就是當我在消費信息的時候,發(fā)現(xiàn)在 macOS 下得到的消息居然是亂碼盟戏,也就是說 Linux 下居然是正常的绪妹。
重現(xiàn)
首先我們要知道一個函數(shù)是
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()
);
并且很詭異的是,當我在調(diào)試第三種寫法的時候蝇摸,我發(fā)現(xiàn)在 const char* orig = GetMessageTopic(msg);
這一部的時候 orig
的值是正確的婶肩。而一步步單步運行下去,一直到 memcpy
執(zhí)行結束的時候貌夕,orig
內(nèi)存塊里面的字符串居然被莫名其妙修改成亂碼了律歼。
參考如下:
這就不能忍了。
當我鍥而不舍的時候啡专,發(fā)現(xiàn)當我改成這樣之后险毁,返回的值就對了:
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
還是會變成一堆亂碼畔况。當前返回能正確的原因是因為我在它變成亂碼之前,用可以“不觸發(fā)”變成亂碼的操作先把 orig
的字符串給賦值到另一個字符數(shù)組中慧库,最后返回那個新的數(shù)組跷跪。
問題看似解決了,但是這種詭異完沪、危險的行為始終是我心中的一顆喪門釘域庇,不處理總之是慌的嵌戈。
RocketMQ C++ SDK 源碼查看
在排查的過程中,我去看了 RocketMQ 的 C++ 和 C SDK 的實現(xiàn)听皿,我把重要的內(nèi)容摘出來:
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 字符串尉姨,然后調(diào)用它的 c_str()
返回 const char*
庵朝。一切看起來是那么美好,沒有問題又厉。
但我后來在多次調(diào)試的時候發(fā)現(xiàn)九府,對于同一個 msg
進行調(diào)用 GetMessageTopic
得到的指針居然不一樣!我是不是發(fā)現(xiàn)了什么新大陸覆致?
誠然侄旬,msg->getTopic()
返回了一個字符串對象公条,并且是通過拷貝構造從 m_topic
那邊來的帖汞。依稀記得大學時候看的 STL 源碼解析窟扑,根據(jù) STL 字符串的 Copy-On-Write 來說猎荠,我沒做任何改變的情況下,它們不應該是同源的嗎摹芙?
事實證明嵌莉,我當時的這個“想當然”就差點讓我查不出問題來了苛茂。
柳暗花明
在我捉雞了好久之后一直毫無頭緒之后之宿,在參考資料 1 中獲得了靈感族操,我開始打開腦洞(請原諒我這個坑還找了很久,畢竟我主手武器還是 Node.js)比被,會不會現(xiàn)在的 String 都不是 Copy-On-Write 了色难?但是 Linux 下又是正常的哇。
后來我在網(wǎng)上找是不是有人跟我遇到一樣的問題等缀,最后還是找到了端倪莱预。
不同的 stl 標準庫實現(xiàn)不同, 比如 CentOS 6.5 默認的 stl::string 實現(xiàn)就是 『Copy-On-Write』项滑, 而 macOS(10.10.5)實現(xiàn)就是『Eager-Copy』。
說得白話一點就是涯贞,不同庫實現(xiàn)不一樣枪狂。Linux 用的是 libstdc++,而 macOS 則是 libc++宋渔。而 libc++ 的 String 實現(xiàn)中州疾,是不寫時拷貝的,一開始賦值就采用深拷貝皇拣。也就是說就算是兩個一樣的字符串严蓖,在不同的兩個 String 對象中也不會是同源薄嫡。
其實深挖的話內(nèi)容還有很多的,例如《Effective STL》中的第 15 條也有提及 String 實現(xiàn)有多樣性颗胡;以及大多數(shù)的現(xiàn)代編譯器中 String 也都有了 Short String Optimization 的特性毫深;等等。
回到亂碼 Bug
得到了上面的結論之后毒姨,這個 Bug 的原因就知道了哑蔫。
((MQMessageExt *) msg)->getTopic()
得到了一個函數(shù)中的棧內(nèi)存字符串變量。
- 在 Linux 中弧呐,就算是棧內(nèi)存變量闸迷,但是它的
c_str()
還是源字符串指向的指針,所以函數(shù)聲明周期結束俘枫,這個棧內(nèi)存中的字符串被釋放腥沽,c_str()
指向的內(nèi)存還堅挺著; - 在 macOS 下鸠蚪,由于字符串是棧內(nèi)存分配的今阳,字符串又是深拷貝,所以
c_str()
的生命周期是跟著字符串本身來的邓嘹,一旦函數(shù)調(diào)用結束酣栈,該字符串就被釋放了,相應地c_str()
對應內(nèi)存中的內(nèi)容也被釋放汹押。
綜上所述矿筝,在 macOS 下,我通過 GetMessageTopic()
得到的內(nèi)容其實是一個已經(jīng)被釋放內(nèi)存的地址棚贾。雖然通過 for
可以趁它的內(nèi)存塊被復制之前趕緊搶救出來窖维,但是這種操作一塊已經(jīng)被釋放的內(nèi)存行為總歸是危險的,因為它的內(nèi)存塊隨時可能被覆蓋妙痹,這也就是之前亂碼的本質(zhì)了铸史。
更小 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 下執(zhí)行則輸出的是兩個不一樣的指針。
小結
在語言耿芹、庫的使用中崭篡,我們不能去使用一個沒有明確在文檔中定義的行為的“特性”。例如文檔中沒跟你說它用的是 Copy-On-Write 技術吧秕,也就說明它可能在未來任何時候不通知你就去改掉琉闪,而你也不容易去發(fā)現(xiàn)它。你就去用已經(jīng)定義好的行為即可砸彬,就是說 c_str()
返回的是字符串的一個真實內(nèi)容颠毙,我們就要認為它是跟隨著 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 第三版對于一個對象的定義中涵防,鍵名在對象中的順序也是未定義的闹伪,當時就不能討巧地看哪個瀏覽器是怎么樣一個順序來進行輸出,畢竟對于未定義的行為壮池,瀏覽器隨時改了你也不能聲討它什么偏瓤。
好久沒寫文了,碼字能力變?nèi)趿恕?/p>
以上椰憋。
參考資料
- 《Why does calling c_str() on a function that returns a string not work?》
- 《Why a new C++ Standard Library for C++11?》
- 《Effective STL》第 15 條:注意 String 實現(xiàn)的多樣性
- 《C++ 之 stl::string 寫時拷貝導致的問題》
- 《C++ 再探 String 之eager-copy厅克、COW 和 SSO 方案》
- 《C++ Short String Optimization stackoverflow 回答集錦以及我的思考》