【问题标题】:Strange behavior when calling std::invoke(std::forward(...)) with address-sanitization in a std::thread with a std::ref在带有 std::ref 的 std::thread 中使用地址清理调用 std::invoke(std::forward(...)) 时的奇怪行为
【发布时间】:2020-10-04 10:23:38
【问题描述】:

问题

我正在尝试将 lambda-closure 传递给 std::thread,它使用任意封闭参数调用任意封闭函数。

template< class Function, class... Args > 
std::thread timed_thread(Function&& f, Args&&... args) {
  // Regarding capturing perfectly-forwarded variables in lambda, see [1]
  auto thread_thunk = ([&] {
    std::cout << "Start thread timer" << std::endl;
    // Regarding std::invoke(_decay_copy(...), ...), see (3) of [2].
    // Assume no exception can be thrown from copying.
    std::invoke(_decay_copy(std::forward<Function>(f)),
                _decay_copy(std::forward<Args>(args)...));
  }
}

int main() {
  int i = 3;
  std::thread t = timed_thread(&print_int_ref, std::ref(i));
  t.join()
  return 0;
}

/*
[1]: https://stackoverflow.com/questions/26831382/capturing-perfectly-forwarded-variable-in-lambda
[2]: https://en.cppreference.com/w/cpp/thread/thread/thread
*/
  • 我使用std::forward 以便转发(正确发送)右值引用和左值引用。
  • 由于 std::invoke 和 lambda 创建临时数据结构,调用者必须将引用包装在 std::ref 中。

代码似乎可以工作,但会导致stack-use-after-scope 进行地址清理。这是我的主要困惑。

嫌疑人

我认为这可能与this error 有关,但我没有看到这种关系,因为我没有返回参考;对i 的引用应该在main 的堆栈帧期间有效,因为main 加入了它,所以它应该比线程更持久。引用通过副本 (std::reference_wrapper) 传递到 thread_thunk

我怀疑args...不能被引用捕获,那么应该如何捕获呢?

第二个困惑:将{std::thread t = timed_thread(blah); t.join();}(强制析构函数的大括号)更改为timed_thread(blah).join(); 不会产生这样的问题,尽管在我看来它们是等价的。

小例子

#include <functional>
#include <iostream>
#include <thread>

template <class T>
std::decay_t<T> _decay_copy(T&& v) { return std::forward<T>(v); }

template< class Function, class... Args > 
std::thread timed_thread(Function&& f, Args&&... args) {
  // Regarding capturing perfectly-forwarded variables in lambda, see [1]
  auto thread_thunk = ([&] {
    std::cout << "Start thread timer" << std::endl;
    // Regarding std::invoke(_decay_copy(...), ...), see (3) of [2].
    // Assume no exception can be thrown from copying.
    std::invoke(_decay_copy(std::forward<Function>(f)),
                _decay_copy(std::forward<Args>(args)...));
    std::cout << "End thread timer" << std::endl;
  });

  /* The single-threaded version code works perfectly */
  // thread_thunk();
  // return std::thread{[]{}};

  /* multithreaded version appears to work
     but triggers "stack-use-after-scope" with ASAN */
  return std::thread{thread_thunk};
}

void print_int_ref(int& i) { std::cout << i << std::endl; }

int main() {
  int i = 3;

  /* This code appears to work
     but triggers "stack-use-after-scope" with ASAN */
  // {
  //   std::thread t = timed_thread(&print_int_ref, std::ref(i));
  //   t.join();
  // }

  /* This code works perfectly */
  timed_thread(&print_int_ref, std::ref(i)).join();
  return 0;
}

编译器命令:clang++ -pthread -std=c++17 -Wall -Wextra -fsanitize=address test.cpp &amp;&amp; ./a.out。 Remvoe address 看看它的工作原理。

ASAN backtrace

【问题讨论】:

    标签: c++ multithreading c++17 move-semantics


    【解决方案1】:

    这两个版本似乎都是未定义的行为。未定义的行为是否会被消毒剂捕获是便饭。如果程序重新运行足够多的次数,即使是所谓的工作版本也很可能会触发消毒剂。错误在这里:

    std::thread timed_thread(Function&& f, Args&&... args) {
      // Regarding capturing perfectly-forwarded variables in lambda, see [1]
       auto thread_thunk = ([&] {
    

    闭包使用捕获的args引用

    如您所知,timed_thread 的参数超出范围并在timed_thread 返回时被销毁。那是他们的范围。这就是 C++ 的工作原理。

    但是你不能保证,无论如何,这个闭包会被新的执行线程执行并引用捕获的,通过引用 em>,所有的args...,在他们在这里消失之前:

    return std::thread{thread_thunk};
    

    除非这个新线程设法执行 thread_hunk 内的代码,该代码引用捕获的 通过引用 args...,否则它将最终在此函数之后访问返回,这会导致未定义的行为。

    【讨论】:

    • 我很困惑为什么这不是 [this question(stackoverflow.com/questions/26831382/…) 中未定义的行为,它说要通过引用捕获。
    • @charmoniumQ:在那个问题中,lambda 正在函数内部执行。使用std::thread,lambda 正在某个未知时间点执行。
    • 因为它不是一个新的执行线程,并且lambda()在函数返回之前返回并完成了它的工作,所有的函数参数都超出了作用域并被销毁。
    • @charmoniumQ:值得注意的是,这就是为什么std::thread 的构造函数在启动新线程之前 对函数参数执行 DECAY_COPY。尽管您可以使用 std::ref 覆盖它,但它不需要任何引用。
    • 你不能。不是在这种情况下,这涉及到一个新的执行线程。在 stackoverflow.com 上有很多关于尝试将参数传递给新的执行线程而不进行复制的问题。这只有在父执行线程本身直接启动执行线程时才有可能,没有任何中间函数调用或像这里显示的那样的包装器并等待执行线程使用按引用捕获的参数。这是 C++ 的基础。
    【解决方案2】:

    在其生命周期之后使用的对象是 std::ref(i)。按照参考。该函数通过引用获取 std::ref,lambda 通过引用捕获,lambda 被复制到新创建的线程中,该线程将引用复制到 std::ref(i)。

    工作版本正在工作,因为 std::ref(i) 的生命周期以分号结束,并且线程在此之前加入。

    【讨论】:

    • 问题中所有提及的工作版本都已被删除,请参阅编辑历史记录。
    • 我将最小示例移回 SO 帖子以供后代使用。
    猜你喜欢
    • 1970-01-01
    • 2017-09-07
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-10-03
    • 1970-01-01
    • 2016-03-21
    • 1970-01-01
    相关资源
    最近更新 更多