【问题标题】:Avoid memory allocation with std::function and member function避免使用 std::function 和成员函数分配内存
【发布时间】:2017-09-11 20:06:35
【问题描述】:

此代码仅用于说明问题。

#include <functional>
struct MyCallBack {
    void Fire() {
    }
};

int main()
{
    MyCallBack cb;
    std::function<void(void)> func = std::bind(&MyCallBack::Fire, &cb);
}

valgrind 的实验表明,分配给func 的行在 linux 上使用 gcc 7.1.1 动态分配了大约 24 个字节。

在实际代码中,我有几个不同的结构,它们都有一个 void(void) 成员函数,存储在大约 1000 万个 std::function&lt;void(void)&gt; 中。

有什么办法可以避免在执行 std::function&lt;void(void)&gt; func = std::bind(&amp;MyCallBack::Fire, &amp;cb); 时动态分配内存? (或者以其他方式将这些成员函数分配给std::function

【问题讨论】:

  • @BeyelerStudios,对函数的分配器支持已从 2017 C++ 中删除。
  • @BeyelerStudios 强制使用 lambda 是一件好事。
  • @BeyelerStudios 类型擦除总是有运行时成本。如果您想避免这种情况,您可以将使用 std::function 的函数重写为采用任意可调用类型的模板。编译时工作和运行时工作之间的这种权衡一直存在于 C++ 中。
  • 即使超出这个特定情况,如果给定的答案是正确的,应该只是在 C++14 及更高版本中几乎完全避免绑定。可能在某些极端情况下仍然可以,但在 99.9% 的情况下,您应该使用 lambda。
  • 问题是,即使提出了使用 lambda 解决直接问题的解决方案,lambda/std::function 仍然存储指向结构的指针,因此数据仍然会超出-线。所以如果你说vector&lt;function&lt;void(void)&gt;&gt;,你仍然会疯狂地触发缓存未命中并且性能很差。您将只有一个间接而不是双重间接。如果您的各种结构都具有相似的大小,您可以做得更多、更好、更好。他们是吗?你有几个?

标签: c++ gcc


【解决方案1】:

很遗憾,std::function 的分配器已在 C++17 中删除。

现在,避免在 std::function 中进行动态分配的公认解决方案是使用 lambdas 而不是 std::bind。这确实有效,至少在 GCC 中 - 它有足够的静态空间来存储您的情况下的 lambda,但没有足够的空间来存储活页夹对象。

std::function<void()> func = [&cb]{ cb.Fire(); };
    // sizeof lambda is sizeof(MyCallBack*), which is small enough

作为一般规则,对于大多数实现以及仅捕获单个指针(或引用)的 lambda,您将避免使用此技术在 std::function 内进行动态分配(与其他答案一样,它通常也是更好的方法建议)。

请记住,要使其工作,您需要保证此 lambda 将比 std::function 寿命更长。显然,这并不总是可能的,有时您必须通过(大)副本来捕获状态。如果发生这种情况,目前没有办法消除函数中的动态分配,除了自己修补 STL(显然,一般情况下不推荐,但在某些特定情况下可以这样做)。

【讨论】:

  • 这是因为std::function 有一个优化,可以在堆栈上分配对象内部的内存,前提是函数对象的大小足够小。在此处使用lambda 将产生一个指针大小的对象,应该触发小函数优化并在std::function内分配它
  • @AnT,嗯...我的回答明确规定了 OP 如何实现他们的目标(获得一个不在 GCC 上分配的std::function&lt;&gt;)以及为什么没有其他方法。这怎么不相关?
  • function 在 C++17 中放弃分配器“支持”并不是“不幸的”;这是非常幸运的,因为它可以避免任何人尝试使用它并发现没有库供应商曾经实现过它(因为这是不可能的)。有点像 C++11 如何放弃对export 模板的“支持”。 :)
  • OP 也可能对std::function 的第三方替代品感兴趣,其内存使用可以得到保证(而不是在 libc++ 和 libstdc++ 之间以不同的阈值动态分配)。要搜索的关键字是inplace_function,如sg14::inplace_function&lt;void(), 24&gt;。然后,如果您发现需要存储更大的 lambda 并且仍然不想堆分配,您可以将该模板参数从 24 提升到 40 或其他任何值,然后重新编译。
  • @Quuxplusone 这样一个可调用对象的真正好处在于,与堆栈存储字符串/向量等不同,当您在容器中存储太大的东西时,它很容易在编译时捕获。
【解决方案2】:

作为已经存在的正确答案的附录,请考虑以下内容:

MyCallBack cb;
std::cerr << sizeof(std::bind(&MyCallBack::Fire, &cb)) << "\n";
auto a = [&] { cb.Fire(); };
std::cerr << sizeof(a);

这个程序用 gcc 和 clang 为我打印 24 和 8。我不完全知道 bind 在这里做什么(我的理解是它是一个极其复杂的野兽),但正如你所见,与 lambda 相比,它的效率几乎低得荒谬。

碰巧的是,std::function 保证不会分配,如果从函数指针构造,它也是一个字大小。因此,从这种 lambda 构造一个std::function,它只需要捕获一个指向对象的指针并且也应该是一个单词,实际上应该永远不会分配。

【讨论】:

  • 我希望bind 存储指向成员的指针 - 16 个字节 + 指向对象的指针 - 8 个字节。在这里你有你的 24 岁。
  • 看起来 lambda 捕获的状态仅包含对 cb 的引用。因此,8 个字节。
  • @SergeyA 啊,我忘记了指向成员的指针是 16 个字节,这就是为什么我很难计算 24 个字节。我讨厌关于指向成员的指针的所有内容。
  • 我不认为 lambda 对象与函数指针具有相同大小的事实是相关的。如果你从一个指针构造你的 std::function,那么它只需要那个 8 字节的内部状态。如果您从 lambda 创建它,则该对象必须存储 lambda 的副本和一个函数指针(由于类型 Erasure)。话虽如此,您关于大多数实现不会为如此小的对象进行分配的说法是正确的。
  • @MikeMB:当我上次检查字符串的 libstdc++ 实现时,它还具有“短字符串优化”,sizeof(string) 是 24 个字节,但只能内联存储 15 个(+尾随 NUL) (即使愚蠢设法存储 23+NUL 内联)。所以sizeof确实是一个上限,不太可能满足。
【解决方案3】:

运行这个小技巧,它可能会打印出你可以在不分配内存的情况下捕获的字节数:

#include <iostream>
#include <functional>
#include <cstring>

void h(std::function<void(void*)>&& f, void* g)
{
  f(g);
}

template<size_t number_of_size_t>
void do_test()
{
  size_t a[number_of_size_t];
  std::memset(a, 0, sizeof(a));
  a[0] = sizeof(a);

  std::function<void(void*)> g = [a](void* ptr) {
    if (&a != ptr)
      std::cout << "malloc was called when capturing " << a[0] << " bytes." << std::endl;
    else
      std::cout << "No allocation took place when capturing " << a[0] << " bytes." << std::endl;
  };

  h(std::move(g), &g);
}

int main()
{
  do_test<1>();
  do_test<2>();
  do_test<3>();
  do_test<4>();
}

gcc version 8.3.0 打印出来

捕获 8 个字节时未进行分配。
捕获 16 个字节时未进行分配。
捕获 24 个字节时调用 malloc。
捕获 32 个字节时调用了 malloc。

【讨论】:

  • 整洁。顺便说一句,您可以使用size_t a[number_of_size_t]{}; 来执行zero initialization,而不是调用std::memset。或者只是不初始化数组。您只读取第一个元素,因此这是唯一需要初始化的元素。
【解决方案4】:

许多 std::function 实现将避免分配并使用函数类本身内部的空间,而不是在它包装的回调“足够小”并且具有微不足道的复制时进行分配。但是,该标准并不要求这样做,只是建议这样做。

在 g++ 上,函数对象上的重要复制构造函数或超过 16 个字节的数据足以使其分配。但是如果您的函数对象没有数据并使用内置的复制构造函数,则 std::function 将不会分配。 另外,如果你使用函数指针或成员函数指针,它不会分配。

虽然不是您问题的直接部分,但它是您示例的一部分。 不要使用 std::bind。几乎在所有情况下,lambda 都更好:更小、更好的内联、可以避免分配、更好的错误消息、更快的编译,等等。如果要避免分配,还必须避免绑定。

【讨论】:

    【解决方案5】:

    我为您的特定用途提出了一个自定义类。

    虽然您确实不应该尝试重新实现现有的库功能,因为这些库的功能会经过更多测试和优化,但它也适用于一般情况。如果您遇到示例中的特殊情况,并且标准实现不适合您的需求,您可以探索实现针对您的特定用例量身定制的版本,您可以根据需要对其进行衡量和调整。

    所以我创建了一个类似于std::function&lt;void (void)&gt; 的类,它仅适用于方法并且所有存储都已到位(无动态分配)。

    我亲切地称它为Trigger(灵感来自您的Fire 方法名称)。如果你愿意,请给它一个更合适的名字。

    // helper alias for method
    // can be used in user code
    template <class T>
    using Trigger_method = auto (T::*)() -> void;
    
    namespace detail
    {
    
    // Polymorphic classes needed for type erasure
    struct Trigger_base
    {
        virtual ~Trigger_base() noexcept = default;
        virtual auto placement_clone(void* buffer) const noexcept -> Trigger_base* = 0;
    
        virtual auto call() -> void = 0;
    };
    
    template <class T>
    struct Trigger_actual : Trigger_base
    {
        T& obj;
        Trigger_method<T> method;
    
        Trigger_actual(T& obj, Trigger_method<T> method) noexcept : obj{obj}, method{method}
        {
        }
    
        auto placement_clone(void* buffer) const noexcept -> Trigger_base* override
        {
            return new (buffer) Trigger_actual{obj, method};
        }
    
        auto call() -> void override
        {
            return (obj.*method)();
        }
    };
    
    // in Trigger (bellow) we need to allocate enough storage
    // for any Trigger_actual template instantiation
    // since all templates basically contain 2 pointers
    // we assume (and test it with static_asserts)
    // that all will have the same size
    // we will use Trigger_actual<Trigger_test_size>
    // to determine the size of all Trigger_actual templates
    struct Trigger_test_size {};
    
    }
    
    struct Trigger
    {
        std::aligned_storage_t<sizeof(detail::Trigger_actual<detail::Trigger_test_size>)>
            trigger_actual_storage_;
    
        // vital. We cannot just cast `&trigger_actual_storage_` to `Trigger_base*`
        // because there is no guarantee by the standard that
        // the base pointer will point to the start of the derived object
        // so we need to store separately  the base pointer
        detail::Trigger_base* base_ptr = nullptr;
    
        template <class X>
        Trigger(X& x, Trigger_method<X> method) noexcept
        {
            static_assert(sizeof(trigger_actual_storage_) >= 
                             sizeof(detail::Trigger_actual<X>));
            static_assert(alignof(decltype(trigger_actual_storage_)) %
                             alignof(detail::Trigger_actual<X>) == 0);
    
            base_ptr = new (&trigger_actual_storage_) detail::Trigger_actual<X>{x, method};
        }
    
        Trigger(const Trigger& other) noexcept
        {
            if (other.base_ptr)
            {
                base_ptr = other.base_ptr->placement_clone(&trigger_actual_storage_);
            }
        }
    
        auto operator=(const Trigger& other) noexcept -> Trigger&
        {
            destroy_actual();
    
            if (other.base_ptr)
            {
                base_ptr = other.base_ptr->placement_clone(&trigger_actual_storage_);
            }
    
            return *this;
        }
    
        ~Trigger() noexcept
        {
            destroy_actual();
        }
    
        auto destroy_actual() noexcept -> void
        {
            if (base_ptr)
            {
                base_ptr->~Trigger_base();
                base_ptr = nullptr;
            }
        }
    
        auto operator()() const
        {
            if (!base_ptr)
            {
                // deal with this situation (error or just ignore and return)
            }
    
            base_ptr->call();
        }
    };
    

    用法:

    struct X
    {    
        auto foo() -> void;
    };
    
    
    auto test()
    {
        X x;
    
        Trigger f{x, &X::foo};
    
        f();
    }
    

    警告:仅测试编译错误。

    您需要彻底测试它的正确性。

    您需要对其进行分析,看看它是否比其他解决方案具有更好的性能。这样做的好处是因为它是自家制作的,您可以对实现进行调整以提高特定场景的性能。

    【讨论】:

    • 这段代码是正确的。但是,如果您将表达式 sizeof(detail::Trigger_actual&lt;detail::Trigger_test_size&gt;) 提取为模板非类型参数 size_t Size = that-expression 的默认值,并且还为 Alignment 提供了模板非类型参数和所需签名的类型参数(不是硬-编码void(void)),那么您将重新发明sg14::inplace_function,除非没有测试并且它仅适用于成员函数。 :) 我建议只使用inplace_function
    • @Quuxplusone 我不知道sg14::inplace_function。谢谢你的链接
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2015-11-24
    • 2023-03-31
    • 2012-10-29
    • 2015-03-04
    • 2012-08-22
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多