DEV Community

Milad Kahsari Alhadi
Milad Kahsari Alhadi

Posted on

Good, Bad, Ugly in Concurrent Programming with C++

As you know, We can write and develop programs with C++ by different variants of paradigms like procedural, object-oriented, functional and also concurrency.

Today, I wanted to discuss why we should care about using std::async when we want to develop software with the concurrency paradigm (especially multithreaded approach of concurrency, not multiprocessing).

Alt Text

When we want to develop a program which their threads are executing concurrently, we can use thread, async, packaged_task and … that all of them have cons and pros.

As I realized until now when concurrent programming, std::thread is a good worker, std::async is a bad worker and std::packaged_task is an ugly worker but why? Consider we want to develop a program that has to run two tasks (functions) concurrently.

If we wanted to use std::thread to develop such software, we should be aware of the fact std::thread does not provide an easy way to return a value from a thread.

We could get the return value of the tasks | functions via references, variable pointers or global variables but these approaches share data between multiple threads and look a bit cumbersome.

Another approach would be to use a condition variable. The condition variable is associated with a condition and synchronizes threads when the condition is fulfilled but using the condition variable for returning from a thread seems like a big overhead.

Fortunately, the STL provides a mechanism to return from a thread without using condition variables. The solution is to use std::future which provides a mechanism to access the result of asynchronous operations:

  1. 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.

  2. The creator of the asynchronous operation can then use a variety of methods to query, wait for, or extract a value from the std::future. These methods may block if the asynchronous operation has not yet provided a value.

  3. When the asynchronous operation is ready to send a result to the creator, it can do so by modifying shared state (e.g. std::promise::set_value) that is linked to the creator's std::future.

Note that std::future references shared state that is not shared with any other asynchronous return objects (as opposed to std::shared_future). 

However, in the first step, I wanted to use std::thread without considering return-value-get issue of concurrent functions and also the existence of std:future solution.

With std::thread we can run tasks concurrently but it has some limitations like we can't reach the return value of the functions, we may face data races | race condition and also other low-level multithreading issues.

In the following photo, you see the functions (tasks) which I wanted to execute them in different threads:

Alt Text

With std::thread and also using join mechanisms (not detach) we can run those functions concurrently. A C++ thread object but not always represents a thread of execution, which is an OS concept.

When thread::join() is called, the calling thread will block until the thread of execution has completed. This is one mechanism that can be used to know when a thread has finished. When thread::join() returns, the OS thread of execution has completed and the C++ thread object can be destroyed.

The thread::detach() is called, the thread of execution is detached from the thread object and is no longer represented by a thread object — they are two independent things.

The C++ thread object can be destroyed and the OS thread of execution can continue on. If the program needs to know when that thread of execution has completed, some other mechanism needs to be used. join() cannot be called on that thread object anymore, since it is no longer associated with a thread of execution.

It is considered an error to destroy a C++ thread object while it is still “joinable”. That is, in order to destroy a C++ thread object either join() needs to be called or detach() must be called.

If a C++ thread object is still joinable when it’s destroyed, an exception will be thrown. However, in the following photo, you see how can we use std::thread to run Task1 and Task2 functions:

Alt Text

When we execute the program, we will get the following output which shows our program works without any issues and problems. Also, you will notice the scheduling pattern of Windows in order to run scheduled threads.

Alt Text

We have no problem here with std::thread because std::threads always give us such concurrent execution pattern output but when we want to execute functions which will return a value, std::thread has not provided a well-known and easy approach to get the return value of the functions.

In that situation we should run tasks with std::async, which will return a std::future object that gives us the ability to get the return value of the functions via get member function of std::future object.

Alt Text

However, what will happen if we want to execute the functions with std::async? I rewrite the program with std::async like the following one:

Alt Text

It is interesting for us because when we used std::async to run tasks concurrently, it didn’t give us a real concurrent execution result and also functions didn’t execute by different and independent threads.

As you see, tasks run in the context of the same thread with ID 1268. It means one same thread executes Task1 and Task2. You can see the execution result of the program in the following photo:

Alt Text

See? Task1 and Task2 have been executed by one thread but if we use std::future with by std::async, we get a different output from the previous one. For example, if we rewrite the program as the following one we will get different outputs again.

Alt Text

When we execute the program, we will get the following output from the program execution which is interesting for us again because it has a concurrent execution pattern right now. Main, Task1 and also Task2 have been executed by different threads.

Alt Text

However, that’s weird. When we use std::future with by std::async, program acts concurrently, without std::future it acts asynchronously. This uncontrolled behaving can be a little dangerous in some situations. But wait, if we call std::async, with execution policy of std::launch::deferred, what we will get?

Alt Text

If we execute the program with std::launch::deferred (lazy evaluation), the program runs in a serialized flow (asynchronous) completely and gives us the following output:

Alt Text

You see the main, Task1, and also Task2 functions have been executed by one same thread. It means when we used deferred policy, the functions will be executed in the main context which is in some situations a preferred approach.

However, the goal of this article was the fact: YOU SHOULD BE AWARE THESE EXECUTION PATTERNS OF THE ASYNC.

And you will consider this execution policy in the coding and developing your concurrent software because of std::async act different when you used these launch policies especially when std::async runs by default execution policy of std::launch::async | std::launch::deferred. Also, I should mention here, when we use the default policy of the async, in the load time of the program, the system will specify the execution pattern of the program.

However, if you want, your program will be taking advantage of the independent threads to execute tasks concurrently without conflict and uncontrolled behaving, you should use package_task which gives you the power to run tasks by std::thread and also getting the return value of the tasks with by std::future.

In the following photo, you see the rewritten version of the program with a packaged_task. The std::packaged_task is one of the possible ways of associating a task with an std::future.

The benefit of the packaged task is to decouple the creation of the future with the execution of the task. However, if you wanted to run a task with threads, you can use this solution explicitly.

Alt Text

When we execute the program, we will get a full concurrent result because we used std::thread. Also, because packaged_task returns a future object, we can use it to retrieve the return value of tasks without any issues. The typical usage of a packaged task is:

  1. creating the packaged task with a function,
  2. retrieving the future from the packaged task,
  3. passing the packaged task elsewhere,
  4. invoking the packaged task.

Alt Text

This is the difference you should care about them completely. The std::packaged_task decouples the creation of the future with the execution of the task. We learned how to benefit from this decoupling. We created tasks in one thread and we executed them in the other thread.

Also, as I mentioned already, with packaged_task we can get the return value of a function easily besides running it with std::thread. In the following example, you can see how I could get the return value of a function and then print it out in the console.

Alt Text

Anyway, packaged_task, async, thread, future, promise and concurrent programming with C++ has a lot of stories that I mention a tiny part of it in this article.

Also, in a conversation with Felix Petriconi, he said to me: in general, there is a big issue with std::async and std::future. The complete design is broken in many ways and the C++ committee is currently trying to fix it. We hope to get it in C++23.

Nevertheless, for more information about multithreading, you should take a book and go through it although it has a lot of downs and ups. It is a wide and hot topic in software development and also software engineering.

Oldest comments (2)

Collapse
 
matheuspicioli profile image
Matheus Picioli

Uau. Very nice!
The lowest level programming is very interesting.
In my college i will starting with OS, and the previous explanation of my teatcher excited me, to start in this world!

Collapse
 
clightning profile image
Milad Kahsari Alhadi

Yeah, but however, in general, there is a big issue with std::async and std::future. The complete design is broken in many ways and the C++ committee is currently trying to fix it.