Freigeben über


Perils Of Lambda Capture

One issue that consistently trips people up when they start using lambdas in asynchronous programming (particularly, using PPL), is the lifetime of the variables captured in the lambda expressions. More than once, I’ve fallen into this trap myself, lamenting my carelessness and the lack of a helpful warning from the compiler.

Here is the rule for writing safe PPL code that rule I settled on:

Never capture locals by reference into a task’s lambda.

The rest of this post explains the rationale behind this rule, and shows a few simple tricks I developed when I do need to pass the address of a local into a lambda.

Danger Ahead!

Let’s start with the basics. Consider the following code using PPL tasks:

 // Danger!
 void foo() {
     int n = 10;
     task<void> t([&n] () {
         printf("%d\n", n);
     });
 }

Now, quick, without compiling it, what will be printed from this function?

Here is what I got on my laptop – I tried a few times:

2947604
2292700
2554116

Unexpected? Yes! Here is what’s going on: the function finished before the task got a chance to execute, and by then, the address of the local n has become invalid, resulting in garbage printed from the task.

The right way to do it is to either capture the local variable by value or allocate it on the heap and capture the pointer to it by value:

 void foo() {
     int* pn = new int;
     *pn = 10;
     task<void> t([pn] () {
         printf("%d\n", *pn);
         delete pn;
     });
 }

This is safe, because while pn is a pointer, it points to the heap and I captured it into the lambda by value. The value of the variable will be copied into the lambda, and will remain valid even after the local will have gone out of scope. Just to make sure this sinks in: had I assigned pn the address of local variable, I would have been in trouble again even after capturing pn by value:

 // Bad!
 void foo() {
     int n = 10;
     int *pn = &n;
     task<void> t([pn] () {
         printf("%d\n", *pn);
         delete pn;
     });
 }

Therefore, let me refine the rule above as follows:

Never let the address of a local variable get into a task’s lambda.

An astute reader can point out that waiting on the task inside the function can make it safe. In fact, this is what makes it safe to pass the address of a local into the lambda of parallel_for or parallel_invoke. True, but when writing non-blocking code with PPL we almost never wait on the tasks – instead we schedule continuations.

It's also worth mentioning that C++ AMP by design allows capturing by reference types such as concurrency::array and concurrency::graphics::texture. There is more to say on the asynchronous programming model of C++ AMP, so stay tuned for future posts on this blog.

Proceed With Caution!

So you started using heap pointers, and now it’s time to master the technique. Imagine that you want to use the pointer in more than one continuation. Here is an example:

 void foo() {
     int* pn = new int;
     *pn = 10;
     task<void> t1([pn] () {
         printf("%d\n", *pn);
         delete pn;  // should I delete it here?
     });
     task<void> t2([pn] () {
         printf("%d\n", *pn);
         delete pn; // or here???
     });
 }

Where do you delete the pointer? You can obviously do it in a joined task, or introduce a reference counter – but let’s not reinvent the wheel. Somebody else already invented smart pointers for us:

 void foo() {
     auto pn = std::make_shared<int>(10);
     task<void> t1([pn] () {
         printf("%d\n", *pn);
         // no worries here
     });
     task<void> t2([pn] () {
         printf("%d\n", *pn);
         // or here
     });
 }

I mentioned that capturing the int by value would not have this problem, but it’s not always possible. Consider a case where one task produces a value that needs to be used in a subsequent continuation. This is a first attempt, and clearly bogus because it’s not even legal C++:

 void foo() {
     task<void> t([] () {
         int n = 10;
     });
     t.then([n] () {  // error
         printf("%d\n", n);
     });
 }

You cannot capture a variable from another scope. Here is another attempt – but still illegal:

 void foo() {
     int n;
     task<void> t([n] () {
         n = 10; // error
     });
     t.then([n] () {
         printf("%d\n", n);
     });
 }

This time the compiler will complain that “a by-value capture cannot be modified in a non-mutable lambda”. And don’t even try to make the lambda mutable – trust me, it won’t help.

Solution? Again, use a smart pointer:

 void foo() {
     auto pn = std::make_shared<int>(-1);
     task<void> t([pn] () {
         *pn = 10;
     });
     t.then([pn] () {
         printf("%d\n", *pn); // works now!
     });
 }

Sometimes you will want to pass more than one value from one continuation to another. For a pair of values, you can use the std::pair, like this:

 void foo() {
     auto p = std::make_shared<std::pair<int,char>>(-1,' ');
     task<void> t([p] () {
         p->first = 10;
         p->second = 'a';
     });
     t.then([p] () {
         printf("%d, %c\n", p->first, p->second);
     });
 }

To package together more values, you can use the std::tuple, or if you can’t stomach its member-access syntax, bite the bullet and create your own struct with meaningful member names.

I hope this helps!

Artur Laksberg
PPL Team