首页 > 解决方案 > 当从另一个线程调用它时,我可以防止存储的 c++ lambda 中的“this”实例指针的失效/破坏吗?

问题描述

这个问题是对这个问题的一种扩展,因为它是一个类似的问题,但并没有像我想要的那样完全回答我的问题(更不用说那个问题中的问题不涉及线程/多线程)。

问题

就像标题所暗示的那样,我遇到了一个关于 lambdas 和std::threads 的特定问题,我的背景知识无法帮助我。我实际上是在尝试将std::function包含成员函数的std::thread. 问题是这个成员函数需要通过引用this的实例指针,因为这个成员函数修改了同一个类的数据成员,但是通过引用传递给 lambda 的指针在成员函数被调用时被无效/销毁,这导致了什么似乎是未定义的行为。这个问题实际上在提案文档P0018R3中有所讨论,其中讨论了 lambdas 和并发性。this

半最小工作示例

//Function class
struct FuncWrapper {
    //random data members
    std::string name;
    std::string description;
    //I still have this problem even if this member is const
    std::function<void()> f;
};
class FuncHolder {
public:
    //For the FuncWrapper argument, I've tried pass by reference, const-reference, rvalue, etc. doesn't make a difference in terms of the problem (as I expect)
    //add a FuncWrapper object to the static holder
    static void add(FuncWrapper f) {
        return const_cast<std::vector<FuncWrapper>&>(funcs).push_back(f);
    }
    //Same here with the return
    //get a static FuncWrapper object by index (with bounds checking)
    static const std::function<void()>& get(size_t index) {
        return funcs.at(index);
    }
private:
    //C++17 inline static definition; If I weren't using it I would do it the non-inline way
    const static inline std::vector<FuncWrapper> funcs;
};
//Data member class
struct Base {
    //This is meant to be like a constexpr; its unique for each derived type
    inline virtual const char* const getName() const = 0;
protected:
    //This will be important for a solution I tried
    const size_t storedIdx = 0;
};
class Derived : public Base {
public:
    Derived(){
        FuncHolder::add({"add5", "adds 5", 
            [&](){increment(5);} //This is the problem-causing statement
        });
        FuncHolder::add({"add1", "adds 1", 
            std::bind(&FuncHolder::increment, 1) //This syntax also causes the same problem
        }); 
    }
    inline const char* const getName() const override {
        return "Derived";
    }
    void increment(int amount){
         //Do some stuff...
         privMember += amount;
         //Do some other stuff..
    }
private:
    int privMember = 0;
};

//Class to hold the static instances of the different derived type
class BaseHolder {
public:
    //make a new object instace in the static vector
    template<class DerivedTy>
    static void init(const DerivedTy& d){
        static_assert(std::is_base_of_v<Base, DerivedTy>); //make sure it's actually derived
        size_t idx = baseVec.size(); //get the index of the object to be added
        const_cast<std::vector<std::unique_ptr<Base>>&>(baseVec).emplace_back(std::make_unique<DerivedTy>(d)); //forward a new unique_ptr object of derived type
        const_cast<size_t&>(baseVec.at(idx)->storedIdx) = idx; //store the object's index in the object itself
    }
    ///This function is used later for one of the solutions I tried; it goes unused for now
    ///So, assume the passed size_t is always correct in this example
    ///There's probably a better way of doing this, but ignore it for the purposes of the example
    //get a casted reference to the correct base pointer
    template<class DerivedTy>
    static DerivedTy& getInstance(size_t derivedIdx){
        return *(static_cast<DerivedTy*>(baseVec.at(derivedIdx).get()));
    }
private:
    //C++17 inline static again
    const static inline std::vector<std::unique_ptr<Base>> baseVec{};
};
int main() {
    BaseHolder::init(Derived()); //add a new object to the static holder
    //Do stuff...
    std::thread runFunc([&](){
        FuncHolder::Get(0)(); //Undefined behavior invoked here; *this pointer used in the function being called is already destroyed
    });
    //Main thread stuff...
    runFunc.join();
    return 0;
}

这可能不是一个超级简单的例子,但我想强调重要的细节(例如函数的存储方式和调用它们的类),以便清楚问题是如何产生的。
还有一些可能不相关但重要的设计部分需要指出。

  1. 它旨在有许多派生类的类/类型Base(即Derived1Derived2等),但每个派生类只有一个实例;因此,为什么类的所有成员BaseHolder都是静态的。因此,如果确实需要重新设计此设计,请记住这一点(尽管老实说,这可能会以比现在更好的方式实现,但这可能与问题无关)。
  2. BaseHolder将类模板化并在编译时将我希望它保留的类/类型传递给它的模板可能是本能的(因此使用 atuple而不是 a 之类的东西vector),但我不是故意这样做的因为我可能需要在运行时稍后添加更多派生类型。
  3. 我无法真正更改f(the std::function<>) 的模板类型,因为我可能需要使用它们自己的返回类型和参数传递不同的函数(这就是为什么我有时会使用 lambda,有时std::bind我只是希望可调用的 a 是返回类型为 void 的函数)。为了做到这一点,我只是把它变成std::function<void()>
  4. 这种设计的总体目标是静态调用和调用一个函数(就好像它是由一个事件触发的),该函数在被调用之前已经构建并且能够修改给定的类(特别是它所构建的类 -Derived在这个案子)。

问题根源

研究这个问题,我知道this在 lambda 中通过引用捕获的指针可能会在 lambda 在不同线程中运行时失效。使用我的调试器,我似乎在构造函数中构造 lambda 时this指针已被破坏,这与我以前的知识背道而驰,所以我不能 100% 确定这是怎么回事;调试器显示 的整个实例被垃圾值填充或不可读DerivedthisDerived

Derived(){
    FuncHolder::add({"add5", "adds 5", //`this` pointer is fine here
       [&](){increment(5);} //`this` pointer is filled with junk and pointing to a different random address
    });
    //...
}

尽管由于其看似被破坏的指针this实例,当调用/运行 lambda/函数时,我更确定未定义的行为。Derived每次我从不同的文件中得到不同的异常,有时只是得到一个完全的访问读取访问冲突,有时不是。this调试器在调用它时也无法读取 lambda 指针的内存;所有看起来都像是被破坏的指针的迹象。
我之前在 lambdas 中也处理过此类问题,并且知道在std::thread不涉及 s 时该怎么做,但是线程似乎使事情变得复杂(稍后我将对此进行更多解释)。

我试过的

按价值捕获

最简单的解决方案是让 lambda按值捕获this指针(如上述问题提案文档 P0018R3的答案中所述),因为我使用的是 C++17。提案文件甚至提到了如何按值捕获对于并发应用程序(例如线程)是必要的:Derivedthis

Derived(){
    FuncHolder::add({"add5", "adds 5", 
        [&, *this](){increment(5);} //Capture *this by value (C++17); it's thread-safe now
    });
    //...
}

问题是,就像我说的,传入/捕获的函数需要修改类的数据成员;如果我按值捕获this,则该函数只是修改派生实例的副本,而不是预期的实例。

使用静态实例

好的,如果每个派生类只应该有一个静态实例,并且派生类有一个静态持有者,为什么不直接在 lambda 中使用静态实例并直接修改该实例?:

Derived(){
    FuncHolder::add({"add5", "adds 5", 
        [=](){BaseHolder::getInstance(storedIdx)::increment(5);} //use static instance in lambda; Again assume the passed index is always correct for this example
    });
    //...
}

这在纸上可能看起来不错,但问题是getInstance()在使用构造函数创建实际实例之前,在构造函数中调用了。具体来说,首先尝试在vector中创建实例的地方调用了Derived()构造函数;但是,向量是在之前调用的构造函数中访问的。BaseHolder::init(Derived())initDerived()init

将静态实例传递给成员函数

上述问题中的另一个答案是更改 lambda 中的函数,使其具有一个接受其类实例的参数。在我们的示例中,它看起来像这样:

class Derived : public Base {
public:
    Derived(){
        FuncHolder::add({"add5", "adds 5", 
            [&](){increment(BaseHolder::getInstance(storedIdx), 5);} //Pass the static instance to the actual function
        });
        //...
    }
    //rest of the class...
    void increment(Derived& instance, int amount){
         //Do some stuff...
         instance.privMember += amount;
         //Do some other stuff..
    }
private:
    int privMember = 0;
};

但这与之前尝试的解决方案(使用 lambda 中的静态实例)相同的问题:尚未创建静态实例,因为它正在调用访问实例的构造函数来创建它。

shared_ptrthis直接)的

在上述问题中不止一次提到的解决方案是制作和使用一个shared_ptr(或任何智能指针)this来延长其生命周期以及什么不是(尽管答案没有深入探讨如何实现它)。快速而肮脏的方法是直接:

Derived(){
    FuncHolder::add({"add5", "adds 5", 
        [self=std::shared_ptr<Derived>()](){self->increment(5);} //pass a shared_ptr of *this; syntax can differ
    });
    //...
}

这样做的问题是你得到一个 std::bad_weak_ptr 异常,因为这样做可能是不正确的(或者至少我假设)。

shared_ptr( this)std::enable_shared_from_this<T>

这篇博文中的解决方案,以及我在不涉及线程时通常使用的解决方案,是利用std::enable_shared_from_this<T>::shared_from_this捕获一个属性shared_ptrthis

class Derived : public Base, public std::enable_shared_from_this<Derived> {
    Derived(){
        FuncHolder::add({"add5", "adds 5", 
            [self=shared_from_this()](){self->increment(5);} //pass a shared_ptr of *this
        });
        //...
    }
    //rest of class...
}

这在纸面上看起来不错,并没有真正引起任何异常,但它似乎并没有改变任何东西;问题仍然存在,它似乎与this通常通过引用捕获没有什么不同。

结论

在另一个线程中调用它时,我可以防止thislambda 中派生类的指针被破坏/失效吗?如果不是,那么做我想要实现的目标的正确方法是什么?即,我怎样才能重新设计设计,使其正常运行,同时仍然保留我的设计原则?

标签: c++multithreadingc++11lambdathis

解决方案


我认为有很多解决方案可以在这里工作,但这不是一个“短”的问题来描述。这是我对两种方法的看法:

1 - 促进意图

看起来您需要在对象和 lambda 之间共享一些状态。覆盖生命周期很复杂,所以按照您的意愿去做,并将公共状态提升为共享指针:

class Derived // : Base classes here
{
  std::shared_ptr<DerivedImpl> _impl;

public:
  /*
  Public interface delegates to _impl;
  */
};

这使得能够与共享状态进行两种交互:

// 1. Keep the shared state alive from the lambda:
func = [impl = _impl]() { /* your shared pointer is valid */ };

// 2. Only use the shared state if the original holder is alive:
func = [impl = std::weak_ptr(_impl)]() {
  if (auto spt = impl.lock())
  {
    // Use the shared state
  }
};

毕竟这种enable_shared_from_this方法对你不起作用,因为你一开始就没有分享状态。通过在内部存储共享状态,您可以保留原始设计的值语义,并促进生命周期管理变得复杂的使用。

2 - 将状态放在安全的地方

保证某个状态在某个时间点“活着”的最安全方法是将其置于更高的范围:

  1. 静态存储/全局范围
  2. 比用户、派生类和 lambda 更高(或更低,取决于您的内存心智模型)的堆栈帧。

拥有这样的“存储”将允许您对对象进行“放置新”,以在不使用空闲存储(堆)的情况下构造它们。然后将此功能与引用计数配对,以便引用派生对象的最后一个将是调用析构函数的那个​​。

这个方案实现起来并不简单,只有在这个层次结构中有主要的性能要求时,你才应该为它而努力。如果你决定走这条路,你也可以考虑内存池,它提供这种终身操作,并提前解决了许多相关的难题。


推荐阅读