Chapter 2 - Part 1. Managing Threads

Chapter 2 - Part 1. Managing Threads

Ayano Kagurazaka Lv3

If not specified, all the code is written in c++23 standard, compiled with g++ 13.2
command: g\++ -std=c\++2b -O0 ch2.cpp -o out && ./out

Chapter 2. Managing Threads - Part 1

Basic thread management

A thread is wrapped in a std::thread object and in the constructor we can specify the task that the thread will run. After the function returns, the thread ends.

Here’s how to construct and start a thread:

1
2
3
4
5
6
7
8
9
10
11
12
#include <thread>
#include <iostream>

void hello(){
std::cout << "Hello Concurrent World\n";
}

int main(){
std::thread t(hello);
t.join();
}

std::thread t(hello) created a std::thread object that will execute the function hello and start it. And the t.join() will make the code wait for the thread to finish.

Output:

1
Hello Concurrent World

We can of course also overload the () operator to make an object callable. Here’s an example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

#include <thread>
#include <iostream>

class task{
public:
void operator()() const{
std::cout << "Hello Concurrent World from object!\n";
}
};

int main(){
auto new_task = task();
std::thread t(new_task);
t.join();
}

The code above will output:

1
Hello Concurrent World from object!

Similar to the syntax of std::bind, std::thraed can also be constructed with a member function address and the corresponding object:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

class task{
public:
void operator()() const{
std::cout << "Hello Concurrent World from object!\n";
}

void work() const{
std::cout << "Hello Concurrent World from member function!\n";
}
};

int main(){
task new_task;
std::thread t(&task::work, &new_task);
t.join();
}

The output is:

1
Hello Concurrent World from member function!

The first parameter is the member function address, and the second parameter is the object address that the member function will be called on, similar to the syntax of std::bind.

icon-padding

All cpp program has the “main” thread started by cpp runtime. We can also define our own thread, and it will start when it’s defined.

Wait for it… or not?

After we start a thread, if we don’t explicitly state whether we will wait for it to finish or not, the thread will

We can wait the thread to finish by using thread.join() method. This will block the current thread when the code reaches thread.join() until the thread we are waiting for finishes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

#include <thread>
#include <iostream>
#include <chrono>

void hello(){
std::cout << "Thread start\n";
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
std::cout << "Thread end\n";
}

int main(){
std::thread t(hello);

t.join();
std::cout << "finished\n";
}

Output:

1
2
3
Thread start
Thread end
finished

At line 12, we started the thread and Thread start is printed. Then we wait for 1000 milliseconds and print Thread end. After that, we print finished and the program ends. From this we can see the t.join() actually blocks the main thread until the thread t finishes.

We can also forget about the thread object, don’t wait for it, and let it run.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

#include <thread>
#include <iostream>
#include <chrono>

void hello(){
std::cout << "Thread start\n";
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
std::cout << "Thread end\n";
}

int main(){
std::thread t(hello);

t.detach();
std::cout << "finished\n";
}

Output(machine dependent):

1
2
finished
Thread start

Which is expected. After the thread t was created, the main thread keeps running, printing finished. Since creating a thread takes time, the Thread start is printed after finished and before program finishes.

Potential problem about detached thread

Well, the problem is intuitive to think about. If we are using an object that is passed to the thread. And the object has a reference variable that is referencing something on the stack. When the variable the member variable is referencing goes out of scope, a dangling reference occurs and leads to unexpected and undefined behavior.

Here’s an example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

class problem{
public:
problem(int& a): data(a){

}
void operator()(){
for(int i = 0; i < 11451419; i++){
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << data + i << std::endl;
}
}
private:
int& data;
};

void wtf(){
int a = 42;
auto p = problem(a);
std::thread t(p);
t.detach();
std::this_thread::sleep_for(std::chrono::seconds(3));

}

int main(){
wtf();
std::cout << "wtfok\n";
std::this_thread::sleep_for(std::chrono::seconds(5));

}

Output:

1
2
3
4
5
6
7
8
42
43
wtfok
2
3
4
5
6

Which is clearly not what we expected. Since the thread is detached at line 24, the function won’t wait the thread to finish. Thus, when a in wtf goes out of scope, the thread is still running. Therefore, at line 13, data is the reference to a destroyed object.

Thus, when a thread only need to read but not modify the data, it’s the best to pass in a copy of the data not the reference to a variable in another scope. Thus, if the original data is destroyed, the object is still there and there will be no dangling references.

Exceptional situation

In some cases, the thread.join() won’t be called, like when there’s an exception and the scope terminated early:

exception in thread mark:8
1
2
3
4
5
6
7
8
9
10
11

void f(){
// operations
}

void ex(){
std::thread t(f);
// something that will throw an exception
t.join();
}

In this case t.join() at line 8 won’t be executed because this function terminated before the position we want the thread to join. Therefore, this thread won’t end after this program finish and will result in the same problem as detached thread.

One way to resolve this is to use try-catch statement:

exception in thread mark:8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

void f(){
// operations
}

void ex(){
std::thread t(f);
try{
// something that will throw an exception
}
catch(...){
t.join();
throw;
}
t.join();
}

In this case, no matter what happens, the thread will be joined, and the thread will end normally whether there is an exception or not.

Another way is to use the concept of RAII(Resource Acquisition Is Initialization). To do it, we define a ThreadWrapper class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ThreadWrapper{
public:
explicit ThreadWrapper(std::thread& t): t(t){

}
~ThreadWrapper(){
if(t.joinable()){
t.join();
}
}
ThreadWrapper(ThreadWrapper const&) = delete;
ThreadWrapper& operator=(ThreadWrapper const&) = delete;
private:
std::thread& t;
};

icon-padding

We wish to operate the thread directly, so we are getting the reference of the thread object. And in the destructor, we check if the thread is joinable, if it is, we join it. And we also delete the copy constructor and copy assignment operator because one thread should not be duplicated by default.

Then we can use it like this:

1
2
3
4
5
6
7
8
9
10
11

void f(){
// operations
}

void ex(){
std::thread t(f);
ThreadWrapper wrapper(t);
// something that will throw an exception
}

In this case, even there is an exception and code terminates early, the destructor of ThreadWrapper will be called, and the thread will be joined.

Detached threads

A detached thread always run in the background with no direct means of communication, and is often called a daemon thread. Most of the time, a detached thread will run till the program ends.

If a thread is detached, its ownership is transferred to the cpp runtime and no longer have association with current thread (since it’s ‘detached’). Therefore, we can’t join it or detach it again.

In the class ThreadWrapper, before we try to join the thread, we check the if the thread is joinable by calling t.joinable() and if it’s true, we join it.

A thread that is waiting to be joined can also be detached by calling detach() method (but remember to use joinable() to make sure when the thread is joinable at join() position). Since both join() and detach() requires the object to have a thread associated with it, and we can communicate with it. Therefore, if joinable() of a thread object returns true, we can call join() or detach() on it.

Function parameters when calling thread

For functions with parameters, we can pass the parameters in the constructor of std::thread object:

pass parameters to thread
1
2
3
4
5
6
7
8
9
10

void printRange(int start, int end){
for (int i = start; i < end; i++) std::cout << i << std::endl;
}

int main(){
auto t = std::thread(printRange, 0, 10);
t.join();
}

The output is:

1
2
3
4
5
6
7
8
9
10
0
1
2
3
4
5
6
7
8
9

Potential problems about passing parameters

The problem could occur might not be so obvious. Suppose we are passing a value that will be implicitly cast to another type (for example a char* to a string):

potential hazard
1
2
3
4
5
6
7
8
9
10
11
12

void work(const std::string& s){
// do sth with the string
}

void problem2(){
char s[1024];
// getting the string from somewhere
std::thread t(work, s);
t.detach();
}

The problem is not obvious in the first place, but when the thread starts and the parameter is passed to the thread, the thread will have to copy the char* and implicitly cast the char* to std::string. Since the thread is detached, because the constructor is only copying s, which takes time, the implicit cast might occur after s goes out of scope because the constructor doesn’t do any implicit casting, leading to undefined behavior (dereferencing pointer pointing to freed memory).

One way we can fix this problem is to cast the parameter to the type we want before passing it to the thread:

fix
1
2
3
4
5
6
7
8
9
10
void work(const std::string& s){
// do sth with the string
}

void problem2_fixed(){
char s[1024];
// getting the string from somewhere
std::thread t(work, std::string(s));
t.detach();
}

In this case we are constructing a std::string object while s is still in the scope, thus we don’t have to worry about scope of variables.


Another problem is related to the default behavior of std::thread constructor. By default, the constructor copies the parameter into the thread, which means even though we define the parameter as a reference, it will still produce a copy and the reference will be referencing the copy rather than the original object.

Whether it’s a copy or not can be checked by comparing the address of them:

copying
1
2
3
4
5
6
7
8
9
10
11
12

void dontCopy(const int& i){
std::cout << "in thread:" << &i << std::endl;
}

int main(){
int a = 5;
std::cout << "original:" << &a << std::endl;
std::thread t(dontCopy, a);
t.join();
}

The output is:

1
2
original:0x16ef16ccc
in thread:0x11fe05cd8

Even more weird happen when we remove the const modifier:

copying
1
2
3
4
5
6
7
8
9
10
11
12

void whyNoCopy(int& i){
std::cout << "in thread:" << &i << std::endl;
}

int main(){
int a = 5;
std::cout << "original:" << &a << std::endl;
std::thread t(whyNoCopy, a);
t.join();
}

this code actually won’t compile, part of the error message is:

1
2
3
4
5
/opt/homebrew/Cellar/gcc/13.2.0/include/c++/13/bits/std_thread.h: In instantiation of 'std::thread::thread(_Callable&&, _Args&& ...) [with _Callable = void (&)(int&); _Args = {int&}; <template-parameter-1-3> = void]':
ch2.cpp:72:31: required from here
/opt/homebrew/Cellar/gcc/13.2.0/include/c++/13/bits/std_thread.h:157:72: error: static assertion failed: std::thread arguments must be invocable after conversion to rvalues
157 | typename decay<_Args>::type...>::value,
|

Take a look at the error message and the header file std_thread.h:

std_thread.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
   template<typename _Callable, typename... _Args,
typename = _Require<__not_same<_Callable>>>
explicit
thread(_Callable&& __f, _Args&&... __args)
{
static_assert( __is_invocable<typename decay<_Callable>::type,
typename decay<_Args>::type...>::value,
"std::thread arguments must be invocable after conversion to rvalues"
);

using _Wrapper = _Call_wrapper<_Callable, _Args...>;
// Create a call wrapper with DECAY_COPY(__f) as its target object
// and DECAY_COPY(__args)... as its bound argument entities.
_M_start_thread(_State_ptr(new _State_impl<_Wrapper>(
std::forward<_Callable>(__f), std::forward<_Args>(__args)...)),
_M_thread_deps_never_run);
}

In the first case, we have a const lvalue reference to the copied and moved rvalue reference, so the code compiles, but we don’t get the desired result.

In the second case, the template__is_invocable<typename decay<_Callable>::type, typename decay<_Args>::type...>::value is used to check if the parameter is invocable after conversion to rvalues. Because we cannot pass a rvalue reference to a non-const lvalue reference, this will be evaluated to false, failing static_assertion and fail to compile.

The solution will be wrapping the parameter in std::ref:

std::ref
1
2
3
4
5
6
7
8
9
10
11
12

void whyNoCopy(int& i){
std::cout << "in thread:" << &i << std::endl;
}

int main(){
int a = 5;
std::cout << "original:" << &a << std::endl;
std::thread t(whyNoCopy, std::ref(a));
t.join();
}

By doing so, the parameter will be passed correctly as a reference rather than a copied a rvalue.

The output is:

1
2
original:0x16afdacc4
in thread:0x16afdacc4

Meaning we are passing the original reference to the thread.

See more in next part~

Comments