天天看點

一次 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 回答集錦以及我的思考