今天覆習前幾年在專案過程中積累的各類技術案例,有一個小的 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