第二章 线程管理
第二章 线程管理
线程管理的基础
每个程序至少有一个线程:执行main()函数的线程是程序的入口点。
其他线程有其各自的入口函数。这些线程与主线程 main() 同时运行。
当线程执行完其入口函数后,线程就会退出。这与main()函数执行完毕后程序结束的情形类似。
为了启动一个线程,你需要创建一个std::thread对象,然后等待这个线程结束。
启动线程
线程在std::thread对象构造时就开始启动了,无返回值时,启动后自动结束,存在返回值时,参数传递完成后结束。
std::thread允许使用带有函数调用符类型的实例传入std::thread类中,来替换默认的构造函数。
class background_task
{
public:
void operator()() const
{
do_something();
do_something_else();
}
};
background_task f;
std::thread my_thread(f);
Warning
- C++的最短模糊规则: 在某些情况下,C++语法无法区分对象参数的创建和函数类型的指定. 在C++11引入统一初始化之前,这种现象在C++中相当常见.
sample 2.0
std::thread my_thread(background_task());
// 这行代码会被编译器解析为一个名为my_thread0的函数声明,该函数返回一个std::thread对象,并接受一个没有参数且返回background_task类型的函数作为参数.
std::thread my_thread((background_task())); //使用额外的括号解决
std::thread my_thread{background_task()}; //使用花括号进行列表初始化
//使用lambda表达式也能避免这个问题
std::thread my_thread([]{
do_something();
do_something_else();
});
- 在多线程编程中,需要特别注意的是,正在运行的线程可能会尝试访问已经被销毁或生命周期已经结束的变量。
- 为了避免这种情况,一种常见的解决方案是让线程函数自足,即将所需的数据复制到线程中,而不是共享数据。这样,每个线程都有自己的数据副本,不会影响到其他线程。
- 如果你选择使用可调用对象作为线程函数,虽然这个对象本身会被复制到新的线程中,但是你需要注意:如果这个对象包含了指向其他对象的指针或引用,那么这些对象并不会被复制。
因此,你需要确保这些被引用的对象在整个线程执行期间都是有效的。
sample 2.1
// A function that returns while a thread still has access to local variables
struct func
{
int& i;
func(int& i_)
: i(i_) {}
void operator()()
{
for (unsigned j = 0; j < 1000000; ++j)
{
do_something(i); // 1. 潜在访问隐患:悬空引用
}
}
};
void oops()
{
int some_local_state = 0;
func my_func(some_local_state);
std::thread my_thread(my_func);
my_thread.detach(); // 2. 不等待线程结束
} // 3. 新线程可能还在运行
Info
Operator()
在C++中,operator()被称为函数调用运算符。它是一个可以被类重载的特殊运算符,允许类的对象像函数一样被调用。
当operator()被重载后,你可以直接使用类的对象名后跟一对括号来调用它,括号内可以包含参数。
sample
#include <cassert> // for assert()
class Matrix
{
private:
double m_data[4][4]{};
public:
double& operator()(int row, int col);
double operator()(int row, int col) const; // for const objects
};
double& Matrix::operator()(int row, int col)
{
assert(row >= 0 && row < 4);
assert(col >= 0 && col < 4);
return m_data[row][col];
}
double Matrix::operator()(int row, int col) const
{
assert(row >= 0 && row < 4);
assert(col >= 0 && col < 4);
return m_data[row][col];
}
/////////////////////////
#include <iostream>
int main()
{
Matrix matrix;
matrix(1, 2) = 4.5;
std::cout << matrix(1, 2) << '\n';
return 0;
}
21.10 — Overloading the parenthesis operator – Learn C++ (learncpp.com)
等待线程完成
在多线程编程中,你可以通过join()方法来等待一个线程完成。这个方法会阻塞当前线程,直到目标线程执行完毕。
但是,join()方法只能被调用一次,调用后,std::thread对象就不再与已完成的线程关联,joinable()将返回false。
- "阻塞当前线程"并不特指主线程。任何线程,无论是主线程还是子线程,都可以调用
join()方法来等待另一个线程完成。 - 当一个线程调用了其他线程的
join()方法后,这个线程会被阻塞,直到被join()的线程执行完毕。
后台运行线程
在多线程编程中,使用detach()可以让线程在后台运行,这意味着主线程不能与之直接交互 — 主线程不会等待这个线程结束。
C++运行库会保证,当线程退出时,相关的资源能够被正确回收。
- 分离的线程通常被称为守护线程(daemon threads),它们没有任何显式的用户接口,并在后台运行。
这种线程的特点是长时间运行,可能会在后台监视文件系统,清理缓存,或者优化数据结构。
另一方面,分离线程的生命周期只能确定线程什么时候结束,这种**"发后即忘"(fire and forget)**的任务就使用到线程的这种方式。 - 当线程的joinable()返回true,才可以使用detach()。
一旦线程被分离,就没有任何std::thread对象能引用它,因此分离的线程不能被加入。
sample 2.2
// Detaching a thread to handle other documents
void edit_document(std::string const& filename)
{
open_document_and_display_gui(filename);
while (!done_editing())
{
user_command cmd = get_user_input();
if (cmd.type == open_new_document)
{
std::string const new_name = get_filename_from_user();
std::thread t(edit_document, new_name); // 启动一个新线程开始显示和处理文档
t.detach(); // 分离这个新线程
}
else
{
process_user_input(cmd);
}
}
}
向线程函数传递参数
在多线程编程中,当你在创建线程时将参数传递给线程函数,这些参数默认是被复制的,而不是被引用。
- 这就是为什么即使在函数中期望一个引用,参数也会被复制。
- 这种复制是线程安全的 — 原始线程和新线程都有自己的一份参数副本,它们之间不会互相干扰。
当指向动态变量的指针作为参数传递给线程时,可能在传递到新线程过程中产生崩溃,导致一些未定义的行为。
sample 2.3
void f(int i,std::string const& s);
void not_oops(int some_param)
{
char buffer[1024];
sprintf(buffer,"%i",some_param);
/*
* 在从char*到std::string类型转换的过程中,函数很有可能在转化成功之前崩溃;
* 但是std::thread的构造函数会复制提供的变量,就只复制了没有转换成期望类型的字符串字面值,最终造成程序崩溃。
*/
// std::thread t(f,3,buffer);
std::thread t(f,3,std::string(buffer)); // 使用std::string,避免悬垂指针
t.detach();
}
当然,如果你希望新线程能够修改原始数据,你可以通过std::ref将参数转换成为引用的形式,避免其在构造的过程中使用默认拷贝。
sample 2.4
void update_data_for_widget(widget_id w,widget_data& data);
std::thread t(update_data_for_widget,w,std::ref(data));
可以使用std::move将一个参数,移动到线程中去。
sample 2.5
// 同一时间内,只允许一个 std::unique_ptr 实现指向一个给定对象。
void process_big_object(std::unique_ptr<big_object>);
std::unique_ptr<big_object> p(new big_object);
p->prepare_data(42);
// 使用move函数,进行移动语义将指针的所有权先转移到新创建线程的内部存储中, 再传递给process_big_object函数。
std::thread t(process_big_object,std::move(p));
转移线程所有权
在C++中,std::thread对象不能被复制,支持使用std:move()函数std::thread的所有权。
- 当一个线程对象被移动后,原来的线程对象就不再拥有任何线程的所有权。
- 在给一个
std::thread对象赋值之前,你需要确保它已经不再拥有任何线程的所有权。这通常通过调用join()或detach()方法来实现。 - 如果一个
std::thread对象在其关联的线程还没有结束时就被赋予了新的所有权,那么程序会调用std::terminate()来终止程序的运行。这通常会导致程序崩溃。
所以不能通过赋一个新值给std::thread对象的方式来"丢弃"一个线程。
sample 2.6
void some_function();
void some_other_function();
std::thread t1(some_function);
std::thread t2=std::move(t1);
t1=std::thread(some_other_function);
std::thread t3;
t3=std::move(t2);
t1=std::move(t3); // t1已持有线程, 赋值操作将使程序崩溃
例子:转移线程所有权时加入检查
sample 2.7.0
// scoped_thread and example usage
class scoped_thread
{
std::thread t;
public:
explicit scoped_thread(std::thread t_)
: t(std::move(t_))
{
if (!t.joinable()) // 检查线程是否可加入(可访问)
throw std::logic_error(“No thread”);
}
~scoped_thread()
{
t.join();
}
scoped_thread(scoped_thread const&) = delete;
scoped_thread& operator=(scoped_thread const&) = delete;
};
struct func; // define in sample 2.1
void f()
{
int some_local_state;
scoped_thread t(std::thread(func(some_local_state))); // 传入一个匿名新线程
do_something_in_current_thread();
}
sample 2.7.1
// Spawn some threads and wait for them to finish
void do_work(unsigned id);
void f()
{
std::vector<std::thread> threads;
for (unsigned i = 0; i < 20; ++i)
{
threads.push_back(std::thread(do_work, i));
}
// 对每个线程调用join();注意这里的mem_fn直接获取对象的函数指针
std::for_each(threads.begin(), threads.end(),
std::mem_fn(&std::thread::join));
}
决定运行时的线程数量
stdhardware_concurrency() 返回可用的硬件并发线程数量。
这个数值可以作为创建线程数量的一个参考,但并不能保证与CPU核心数完全相等,特别是在支持超线程的系统中。
因此,在确定程序中的线程数量时,建议仅将它作为一个估计值来使用。
sample 2.8
// A naïve parallel version of std::accumulate
// 结构体模板, 定义了一个函数调用运算符用于在给定的迭代器范围内累加元素。
template<typename Iterator, typename T>
struct accumulate_block
{
void operator()(Iterator first, Iterator last, T& result)
{
result = std::accumulate(first, last, result);
}
};
// 把输入范围分割成多个块,并为每个块创建一个线程来执行累加操作。
template<typename Iterator, typename T>
T parallel_accumulate(Iterator first, Iterator last, T init)
{
unsigned long const length = std::distance(first, last);
if (!length) // 长度为0直接返回初始值
return init;
unsigned long const min_per_thread = 25; // 每个线程建议处理的块长度
unsigned long const max_threads = (length + min_per_thread - 1) / min_per_thread; // 期待的最大线程数量,最小为1
unsigned long const hardware_threads = std::thread::hardware_concurrency();
unsigned long const num_threads = // 当参考硬件线程为 0 时选择 2 避免单核机器上启动太多的线程
std::min(hardware_threads != 0 ? hardware_threads : 2,
max_threads);
unsigned long const block_size = length / num_threads; // 每个线程实际处理块长度
std::vector<T> results(num_threads);
std::vector<std::thread> threads(num_threads - 1); // 留一个线程给主线程
Iterator block_start = first;
for (unsigned long i = 0; i < (num_threads - 1); ++i)
{
Iterator block_end = block_start;
std::advance(block_end, block_size); // block_end迭代器指向当前块的末尾
threads[i] = std::thread( // 为每个线程设置独立的结果存储区
accumulate_block<Iterator, T>(),
block_start, block_end, std::ref(results[i]));
block_start = block_end;
}
accumulate_block<Iterator, T>()(
block_start, last, results[num_threads - 1]); // 主线程处理最后一个数据块
std::for_each(threads.begin(), threads.end(),
std::mem_fn(&std::thread::join)); // 等待所有线程完成
return std::accumulate(results.begin(), results.end(), init); // 将所有线程的结果累加起来
}
- 没有处理线程间的同步问题 – 它确保了数据块之间没有依赖关系。
识别线程
std::thread::id 是线程标识类型,可以通过两种方式进行检索:
调用
std::thread对象的成员函数get_id()来直接获取。
返回std::thread::type表示“没有相关联的线程”。在当前线程中调用
std::this_thread::get_id()也可以获得线程标识。
stdid 对象可以自由拷贝和对比,可以作为容器的键值进行排序或哈希等操作。
若两个对象的 std::thread::id 相等,那它们就是同一个线程,或者都“没有线程”。
在多线程编程中,可以使用线程标识符来判断线程是否需要进行某些操作,例如在启动其他线程之前存储初始线程的ID,并在每个线程中检查其拥有的线程ID是否与初始线程的ID相同。