CS 321 Spring 2013 > Lecture Notes for Monday, February 4, 2013 |
Threads are like processes, but share a common memory space. For efficiency reasons, each thread is typically not managed by the kernel as a separate process.
Threads allow us to do multiple, concurrent operations involving a common dataset in a way that is efficient and convenient. Doing the operations sequentially could result in some operations being excessively delayed. Switching between operations in our application is likely to be painful to write.
For example, in a web server, we could have one thread that takes requests for web pages. For each request, we could have a separate, temporary thread that manages the resulting connection and serves the responses.
Threads can be implemented in a “lightweight” manner, with less overhead than kernel processes. This allows threads to be created, terminated, and switched between very efficiently.
On a machine with multiple processors/cores, threads allow a single process to make use of more computing resources than single-thread code would.
Threads could be implemented using the standard kernel process mechanism. It is only necessary to give processes access to the same memory. However, this makes creating, terminating, and switching threads as expensive as the same operations for processes. This defeats an important reason for using threads.
Therefore, threads are often implemented using a lighter-weight mechanism. We allow a kernel process to “own” multiple threads. Each thread needs to keep track of the following.
All other process-related information: address space, open files, accounting information, running userid, parent/child information, etc., is the same for all threads owned by a process, and so can be tracked by the process.
Since threads need to keep track of so little information—essentially a few registers and a stack pointer—it is possible to switch between threads quickly.
Making a new thread is called spawning. One thread spawns another with access to the same address space.
Typically, there is one main thread (the master thread) that manages all the others (slaves).
Recall that, when a parent process forks a child, the parent often waits for the child to terminate. The corresponding operation for threads—usually the master waiting for a slave to terminate—is called joining. Explicitly letting a created thread continue without ever being joined is called detaching the thread.
A race condition is when scheduling decisions can affect the correctness of a program; these are common in poorly written multi-threaded code.
A data race occurs when two threads concurrently access the same data, one of them writes, and the other reads or writes. Despite its name, a data race may not be a race condition.
An operation is atomic if it is guaranteed not to be interspersed with other operations (for example, those performed by other threads), and it can never be half-completed.
The 2011 ANSI/ISO C++ standard includes a comprehensive threads package (the previous standard, published in 2003, had no thread support).
Most of the threads functionality is contained in the standard
header <thread>
.
Your compiler may need configuration before it handles C++11 threads
correctly.
For example, I am using g++ 4.7,
and I had to add the following options
to the command line: “-std=c++11 -pthread
”.
Support for a thread is provided by an object of type
std::thread
; each object encapsulates a different thread.
#include <thread> using std::thread;
When we create a thread, we give it a function call to make.
The thread makes the function call;
then it terminates when the function returns.
To create a thread, pass the constructor of std::thread
the name of a function (or function object) to call,
followed by the parameters to pass.
void foo(int k, double x);Then later in the code:
thread t1(foo, 5, 3.6);
Note: Only by-value parameters work this way. By-reference parameters can be handled, but it is more complicated.
The above creates a new thread, encapsulated by object t1
.
This thread makes the function call foo(5, 3.6)
and then terminates.
It is possible that the requested thread cannot be spawned
(for example, the system may have a limit on the number of threads).
In this case, the constructor of std::thread
will throw an exception.
A default-constructed std::thread
object
does not encapsulated a thread.
Use it later to create a thread by setting it equal
to a thread contructor call.
thread t2; t2 = thread(foo, 1, 2.2);
So, for example, we can create a vector
of
default-constructed threads and then use each to manage a thread
as above.
The above code uses the move assignment of std::thread
.
But std::thread
has no copy assignment,
since threads cannot be copied.
Thus, the following is illegal.
// t1, t2 are objects of type std::thread t2 = t1; // DOES NOT COMPILE!
If a thread
object encapsulates a thread,
then destroying the object terminates the thread.
Typically, when the process exits
(system call sys_exit
in *ix,
or coming to the end of main
),
every thread terminates.
So do not call exit
in a slave thread.
We see that C++11 threads do not allow for starting up some task and then exiting, leaving the new task running; to do that, fork a child process.
Every thread encapsulated by a thread
object
must be either joined or detached
before its thread
object is destroyed.
To join a slave thread,
some other thread (usually the master)
calls the thread object’s
join
member function.
This takes no parameters.
// NOT in the thread encapsulated by t1 t1.join();
Similarly,
to detach a slave thread,
call the thread object’s
detach
member function,
which also takes no parameters.
// NOT in the thread encapsulated by t2 t2.detach();
See
thread1.cpp
(NetRun link)
for a simple program using C++11 thread objects.
See
thread2.cpp
(NetRun link)
for an example of thread objects stored in a vector
and of handling thread-related exceptions.
ggchappell@alaska.edu