I have an std::packaged_task
containing a lambda that captures a variable by copy. When this std::packaged_task
is deleted, I would expect the variable living inside the lambda to be destructed, but I noticed that if I get the associated std::future
for this std::packaged_task
, the future
object extends the lifetime of the variable inside the lambda.
For example:
#include <iostream>
#include <future>
class Dummy
{
public:
Dummy() {std::cout << this << ": default constructed;" << std::endl;}
Dummy(const Dummy&) {std::cout << this << ": copy constructed;" << std::endl;}
Dummy(Dummy&&) {std::cout << this << ": move constructed;" << std::endl;}
~Dummy() {std::cout << this << ": destructed;" << std::endl;}
};
int main()
{
std::packaged_task<void()>* p_task;
{
Dummy ScopedDummy;
p_task = new std::packaged_task<void()>([ScopedDummy](){std::cout << "lambda call with: " << &ScopedDummy << std::endl;});
std::cout << "p_task completed" << std::endl;
}
{
std::future<void> future_result;
{
future_result = p_task->get_future();
(*p_task)();
delete p_task;
}
std::cout << "after p_task has been deleted, the scope of future_result determines the scope of the dummy inside p_task" << std::endl;
}
std::cout << "p_task cleans up when future_result dies" << std::endl;
}
A possible output is:
0x7fff9cf873fe: default constructed;
0x7fff9cf873ff: copy constructed;
0x1904b38: move constructed;
0x7fff9cf873ff: destructed;
0x7fff9cf873fe: destructed;
lambda call with: 0x1904b38
after p_task has been deleted, the scope of future_result determines the scope of the dummy inside p_task
0x1904b38: destructed;
p_task cleans up when future_result dies
So the object inside the lambda has its lifetime extended by the scope of future_result
.
If we comment out the line future_result = p_task->get_future();
, a possible output is:
0x7fff57087896: default constructed;
0x7fff57087897: copy constructed;
0x197cb38: move constructed;
0x7fff57087897: destructed;
0x7fff57087896: destructed;
lambda call with: 0x197cb38
0x197cb38: destructed;
after p_task has been deleted, the scope of future_result determines the scope of the dummy inside p_task
p_task cleans up when future_result dies
I have been wondering what mechanism comes into play here, does std::future
contains some link that keeps associated objects alive?
The class template std::packaged_task wraps any Callable target (function, lambda expression, bind expression, or another function object) so that it can be invoked asynchronously. Its return value or exception thrown is stored in a shared state which can be accessed through std::future objects.
(since C++11) The class template std::future provides a mechanism to access the result of asynchronous operations: An asynchronous operation (created via std::async, std::packaged_task, or std::promise) can provide a std::future object to the creator of that asynchronous operation.
looking at gcc7.2.0 packaged_task sources, we read:
packaged_task(allocator_arg_t, const _Alloc &__a, _Fn &&__fn)
: _M_state(__create_task_state<_Res(_ArgTypes...)>(std::forward<_Fn>(__fn), __a)){}
~packaged_task()
{
if (static_cast<bool>(_M_state) && !_M_state.unique())
_M_state->_M_break_promise(std::move(_M_state->_M_result));
}
where _M_state
is a shared_ptr to the internal packaged_task shared state. So, it turns out that gcc stores the callable as part of the packaged_task shared state, hence binding the callable lifetime to whom among packaged_task,future,shared_future dies last.
in comparison, clang does not, destroying the callable when the packaged task gets destroyed ( in fact, my copy of clang will store the callable as a proper member ).
Who's right ? the standard is not very clear about the stored task lifetime; from one side, we have
[[futures.task]]
packaged_task defines a type for wrapping a function or callable object so that the return value of the function or callable object is stored in a future when it is invoked.
packaged_task(F&& f)[...]Constructs a new packaged_task object with a shared state and initializes the object’s stored task with std::forward(f).
packaged_task(packaged_task&& rhs)[...]Moves the stored task from rhs to *this.
reset()[...]Effects: As if *this = packaged_task(std::move(f)), where f is the task stored in *this.
that suggests the callable is owned by the packaged_task, but we also have
[[futures.state]]
-Many of the classes introduced in this subclause use some state to communicate results. This shared state consists of some state information and some (possibly not yet evaluated) result, which can be a (possibly void) value or an exception. [ Note: Futures, promises, and tasks defined in this clause reference such shared state. —endnote]
-[ Note: The result can be any kind of object including a function to compute that result, as by async [...]]
and
[futures.task.members]
-packaged_task(F&& f);[...]Invoking a copy of f shall behave the same as invoking f[...] -~packaged_task(); Effects: Abandons any shared state
suggesting that a callable can be stored in the shared state and that one should not rely on any callable per-instance behaviour ( this may be interpreted to include the callable end of lifetime side-effects; by the way, this also implies that your callable is not strictly valid, because it behaves differently from its copy ); moreover, nothing is mentioned about the stored task in the dtor.
All in all, I think clang follows the wording more consistently, although nothing seems explicitly forbidding gcc behavior. That said, I agree this should be better documented because it may result in surprising bugs otherwise ...
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With