13

According to the C++17 standard, what is the output of this program?

#include <iostream>
#include <string>
#include <future>

int main() {
  std::string x = "x";

  std::async(std::launch::async, [&x]() {
    x = "y";
  });
  std::async(std::launch::async, [&x]() {
    x = "z";
  });

  std::cout << x;
}

The program is guaranteed to output: z?

Solomon Ucko
  • 5,724
  • 3
  • 24
  • 45
Yasuo
  • 133
  • 7
  • 1
    Looks like undefined behavior to me. Is there anything that guarantees the synchronization of those tasks? – Nathan Pierson Jun 05 '23 at 02:04
  • 2
    The behavior of program is undefined and can be "x", "y", "z" depending on the timing and scheduling of the threads. There is a potential for data race and multiple threads may access and modify the same variable concurrently without proper synchronization. – Byte Ninja Jun 05 '23 at 02:09
  • 8
    A future returned by `std::async` is special - it always blocks in a destructor, so the output is guaranteed to be `z` - there are no races here. – Evg Jun 05 '23 at 02:14
  • @HosseinTaghizad — as you said, the behavior of the program is **undefined**. It **might** display “x”, “y”, or “z”, or it might do something completely different. – Pete Becker Jun 05 '23 at 02:15
  • @Evg — the data race is in the unsequenced modifications of `x`. – Pete Becker Jun 05 '23 at 02:16
  • 2
    @PeteBecker If the retuned future blocks on destruction the second call to `async` can't happen until after `x` has been modified by the first call. – NathanOliver Jun 05 '23 at 02:17
  • 2
    @PeteBecker There are no unsequenced modifications of `x` in this code. – Evg Jun 05 '23 at 02:18
  • @PeteBecker Then give a reason for why it is undefined. `std::async(std::launch::async, [&x]() { x = "y"; });` blocks, so `std::async(std::launch::async, [&x]() { x = "z"; });` is guaranteed to be sequenced after. – NathanOliver Jun 05 '23 at 02:20
  • 1
    @Evg — yup. Got it. Of course, spinning up one thread and blocking until it completes then spinning up another thread and blocking until it completes is utterly pointless. – Pete Becker Jun 05 '23 at 02:24
  • @Evg: Checking the standard, I believe cppreference got this somewhat wrong and the code actually does have undefined behavior. See my answer for more details. – Jerry Coffin Jun 05 '23 at 03:28
  • @SamVarshavchik: I'd suggest undeleting your answer. After checking the standard, I believe it's actually correct. – Jerry Coffin Jun 05 '23 at 03:34
  • @qingleizhai Your original title was entirely undescriptive, so I changed it. Titles should be specific to your question. – Passer By Jun 05 '23 at 07:29

2 Answers2

17

C++ reference explicitly mentions the behavior of this code:

If the std::future obtained from std::async is not moved from or bound to a reference, the destructor of the std::future will block at the end of the full expression until the asynchronous operation completes, essentially making code such as the following synchronous:

std::async(std::launch::async, []{ f(); }); // temporary's dtor waits for f()
std::async(std::launch::async, []{ g(); }); // does not start until f() completes

So your code is guaranteed to print z - there are no data races.

Jerry Coffin
  • 476,176
  • 80
  • 629
  • 1,111
Evg
  • 25,259
  • 5
  • 41
  • 83
6

I don't believe that cppreference is entirely accurate in this case.

The standard says that the dtor for std::future releases any shared state (§[futures.unique_future]/9):

~future();
Effects:

  • Releases any shared state (31.6.5);
  • destroys *this.

The description of releasing the shared state says (§[futures.state]/5):

When an asynchronous return object or an asynchronous provider is said to release its shared state, it means:

  • if the return object or provider holds the last reference to its shared state, the shared state is destroyed; and
  • the return object or provider gives up its reference to its shared state; and
  • these actions will not block for the shared state to become ready, except that it may block if all of the following are true: the shared state was created by a call to std::async, the shared state is not yet ready, and this was the last reference to the shared state.

[emphasis added]

Summary

In essence, the code has undefined behavior. While an implementation is allowed generate code to block for the shared state to become ready, it is not required to do so, and is not even required to document whether it will do so or not. As such, what you have is pretty much the typical situation for undefined behavior: you may get what you expect, but it isn't required.

Reference

I quoted from N4713, which (if memory serves) is pretty much the C++17 standard. It looks like the wording remains the same up through at least N4950 (which is pretty much C++23.).

Jerry Coffin
  • 476,176
  • 80
  • 629
  • 1,111
  • Mmmmhhh, so even adding the [[nosdiscard]] to std::async is a guarantee for "correct" behavior then. From practical experience: returning from std::async doesn't mean Function has been even called yet, and this CAN be a noticable race condition. And yes there are places in my code where I actually do have to synchronize (check Function has started) on the line after calling std::async. – Pepijn Kramer Jun 05 '23 at 05:21
  • 4
    [\[futures.async\]/5.4](https://timsong-cpp.github.io/cppwp/std17/futures.async#5.4) is the normative wording that requires blocking. – T.C. Jun 05 '23 at 05:49
  • @T.C.: I may have to reread that when I'm not tired. Right now I'm not seeing how it requires the synchronization that's necessary for this case. – Jerry Coffin Jun 05 '23 at 06:31
  • 2
    @JerryCoffin In English: _"synchronizes with"_ meaning, in practice, blocking. `std::async` is special because `~future()` (releasing the shared state) blocks until thread completion. – Passer By Jun 05 '23 at 07:25
  • @PasserBy: The problem in this case is with trying to boil "synchronizes with" down to "in practice blocking." Reading through [data.races], I'm not at all sure that's true in this case. If we were writing to a scalar (e.g., an int), it clearly would be. Likewise if we were writing to something atomic. But we're not--we're writing to an `std::string`. [intro.races]/12-17 tell us about visible side effects when writing to scalars and atomics, but don't even seem to define the term "visible side effect" with relation to something like `std::string` that's neither scalar nor atomic. – Jerry Coffin Jun 05 '23 at 15:25
  • @PasserBy: That leaves me believing that yes, the *intent* was *probably* that these shouldn't overlap--but I'm not entirely sure that's really the case. – Jerry Coffin Jun 05 '23 at 15:33
  • Going to have to be a bit of a pedant... N4659 was the final working draft of C++17 (see https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4661.html). Its successor, N4700, was considered the first working draft of C++20 (source: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4701.html) and N4713 was the next C++20 draft after it (source: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4714.html) – AJM Jun 05 '23 at 16:44
  • But you're right about N4950. It *is* the final working draft of C++23 (source: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/n4951.html) – AJM Jun 05 '23 at 16:52
  • @AJM: Okay--but doing a quick check, N4659 seems the same in all the relevant areas. – Jerry Coffin Jun 05 '23 at 17:47
  • 1
    @JerryCoffin We're not writing to a `std::string`. "Writing" and "reading" are not defined for objects of class type. You can only read or write objects of scalar type. The assignments in the lambdas are not "writes"; they are calls to `std::string::operator=`, which does a whole lot of reads and writes to the scalar subobjects of its receiver (and maybe others on the heap). These operations are all observable side effects, they can be synchronized, and the OP's code has the expected behavior. (The standard isn't going to make synchronization with objects *impossible*!) – HTNW Jun 05 '23 at 18:40
  • @HTNW: Well, it wouldn't *intentionally* make things impossible( and I've already said I'm pretty sure the *intent* is for this to be defined). I'm a lot less certain about all the pieces fitting together so that it really is defined though. – Jerry Coffin Jun 05 '23 at 18:54