一次 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 第三版對於一個對象的定義中,鍵名在對象中的順序也是未定義的,當時就不能討巧地看哪個瀏覽器是怎麼樣一個順序來進行輸出,畢竟對於未定義的行爲,瀏覽器隨時改了你也不能聲討它什麼。

好久沒寫文了,碼字能力變弱了。

以上。

參考資料

  1. Why does calling c_str() on a function that returns a string not work?
  2. Why a new C++ Standard Library for C++11?
  3. 《Effective STL》第 15 條:注意 String 實現的多樣性
  4. C++ 之 stl::string 寫時拷貝導致的問題
  5. C++ 再探 String 之eager-copy、COW 和 SSO 方案
  6. C++ Short String Optimization stackoverflow 回答集錦以及我的思考
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章