C++ lambda 引用捕獲臨時物件引發 coredump 的案例

烛秋發表於2024-08-31

今天覆習前幾年在專案過程中積累的各類技術案例,有一個小的 coredump 案例,當時小組裡幾位較資深的同事都沒看出來,後面是我週末查了兩三個小時解決掉的,今天再做一次系統的總結,給出一個復現的案例程式碼,案例程式碼比較簡單,便於學習理解。

1. 簡介

原則:臨時物件不應該被 lambda 引用捕獲,因為臨時物件在它所在的語句結束就會被析構掉,只能採用值捕獲。
當臨時物件比較隱蔽時,我們就可能犯這個低階錯誤。本文介紹一類case:以基類智慧指標物件的 const 引用為函式形參,並在函式內對該引數做引用捕獲,然後進行跨執行緒非同步使用。當函式呼叫者使用派生類智慧指標作為實參時,此時派生類智慧指標物件會向上轉換為基類智慧指標物件,這個轉換是隱式的,產生的物件是臨時物件,然後被 lambda 引用捕獲,後續跨執行緒使用引發“野引用” core。

2. 案例

下面寫一個簡單的 demo 程式碼來模擬這個案例。案例涉及的程式碼流程,如下圖所示:

其中,基類 BaseTask,派生類 DerivedTask,main 函式將 lambda 閉包拋到工作執行緒中非同步執行。
詳細示例程式碼如下:

/**
 * @brief 關鍵字:lambda、多執行緒、std::shared_ptr 隱式向上轉換
 * g++ main.cc -std=c++17 -O3 -lpthread
 */

#include <atomic>
#include <chrono>
#include <functional>
#include <iostream>
#include <memory>
#include <mutex>
#include <queue>
#include <string>
#include <thread>

using namespace std::chrono_literals;

/// 簡易執行緒池
template <typename Func>
class ThreadPool {
 public:
  ~ThreadPool() {
    stop_ = true;
    for (auto& item : workers_) {
      item.join();
    }
  }

  void Run() {
    static constexpr uint32_t kThreadNum = 2;

    uint32_t idx = 0;
    for (uint32_t idx = 0; idx != kThreadNum; ++idx) {
      workers_.emplace_back(std::thread([this, idx] { ThreadFunc(idx); }));
      mutexs_.emplace_back(std::make_shared<std::mutex>());
    }

    job_queues_.resize(kThreadNum);
  }

  void Stop() { stop_ = true; }

  bool Post(Func&& f) {
    if (!stop_) {
      uint32_t index = ++job_cnt_ % job_queues_.size();
      auto& queue = job_queues_[index];
      std::lock_guard<std::mutex> locker(*mutexs_[index]);
      queue.push(std::move(f));
      return true;
    }

    return false;
  }

  void ThreadFunc(uint32_t idx) {
    auto& queue = job_queues_[idx];
    auto& mutex = *mutexs_[idx];
    // 退出前清空任務佇列
    while (true) {
      if (!queue.empty()) {
        std::lock_guard<std::mutex> locker(mutex);
        const auto& job_func = queue.front();
        job_func();
        queue.pop();
      } else if (!stop_) {
        std::this_thread::sleep_for(10ms);
      } else {
        break;
      }
    }
  }

 private:
  /// 工作執行緒池
  std::vector<std::thread> workers_;
  /// 任務佇列,每個工作執行緒一個佇列
  std::vector<std::queue<Func>> job_queues_;
  /// 任務佇列的讀防寫鎖,每個工作執行緒一個鎖
  std::vector<std::shared_ptr<std::mutex>> mutexs_;
  /// 是否停止工作
  bool stop_ = false;
  /// 任務計數,用於將任務均衡分配給多執行緒佇列
  std::atomic<uint32_t> job_cnt_ = 0;
};
using MyThreadPool = ThreadPool<std::function<void()>>;

/// 基類task
class BaseTask {
 public:
  virtual ~BaseTask() = default;
  virtual void DoSomething() = 0;
};
using BaseTaskPtr = std::shared_ptr<BaseTask>;

/// 派生task
class DeriveTask : public BaseTask {
 public:
  void DoSomething() override {
    std::cout << "derive task do someting" << std::endl;
  }
};
using DeriveTaskPtr = std::shared_ptr<DeriveTask>;

/// 示例使用者
class User {
 public:
  User() { thread_pool_.Run(); }
  ~User() { thread_pool_.Stop(); }

  void DoJobAsync(const BaseTaskPtr& task) {
    // task 是 user->DoJob 呼叫產生的臨時物件,捕獲它的引用會變成也指標
    thread_pool_.Post([&task] { task->DoSomething(); });
  }

 private:
  MyThreadPool thread_pool_;
};
using UserPtr = std::shared_ptr<User>;

/// 測試執行出 core
int main() {
  auto user = std::make_shared<User>();
  DeriveTaskPtr derive_task1 = std::make_shared<DeriveTask>();
  // derive_task 會隱式轉換為 BaseTask 智慧指標物件,
  // 該物件是臨時物件,在 DoJob 執行完之後生命週期結束。
  user->DoJobAsync(derive_task1);

  DeriveTaskPtr derive_task3 = std::make_shared<DeriveTask>();
  user->DoJobAsync(derive_task3);

  std::this_thread::sleep_for(std::chrono::seconds(3));
  return 0;
}

上面這個例子程式碼,會出現 coredump,或者是沒有執行派生類的 DoSomething,總之是不符合預期。不符合預期的原因如下:這份程式碼往一個執行緒裡 post lambda 函式,lambda 函式引用捕獲智慧指標物件,這是一個臨時物件,其離開使用域之後會被析構掉,導致 lambda 函式在非同步執行緒執行時,訪問到一個"野引用"出錯。而之所以捕獲的智慧指標是臨時物件,是因為呼叫 User.DoJobAsync 時發生了型別的向上轉換。

上述的例子還比較容易看出來問題點,但當我們的專案程式碼層次較深時,這類錯誤就非常難看出來,也因此之前團隊裡的資深同事也都無法發現問題所在。

這類問題有多種解決辦法:
(1)方法1:避免出現隱式轉換,消除臨時物件;
(2)方法2:函式和 lambda 捕獲都修改為裸指標,消除臨時物件;引用本質上是指標,需要關注生命週期,既然採用引用引數就表示呼叫者需要保障物件的生命週期,智慧指標的引用在用法上跟指標無異,那麼這裡不如用裸指標,讓呼叫者更清楚自己需要保障物件的生命週期;
(3)方法3:非同步執行時採用值捕獲/值傳遞,不採用引用捕獲,但值捕獲可能導致效能浪費,具體到本文的例子,這裡的效能開銷是一個智慧指標物件的構造,效能損耗不大,是可接受的。

3. 其他

臨時物件的生命週期可以參考這篇文件:https://en.cppreference.com/w/cpp/language/reference_initialization#Lifetime_of_a_temporary

相關文章