之前从来没有写过多线程的代码,想要学习一下多线程,上网找了很久,相关的书都很少,后来看到有很多人推荐c++并发编程实战这本书,就买了一本。刚读了几章,看了一下目录,发现知识点还是挺多的,对于初学者的我来说暂时应该是够了。唯二让我觉得很是不满的地方就是这个翻译和排版,感觉出版社不是很用心,有的时候翻译的就跟不是中文一样,绕来绕去的没看明白他想表达的意思,简单一句话可以说到重点或者举几个例子就可以说明白,它非要绕一下。有很多地方没弄明白它的重点,有些通过查资料自己弄明白了。这里根据自己的理解做个总结。
现在的c++基本都是C11往上,这本书也是基于C11的书。
第一章各种历史轨迹前世今生,略过它。第二章,线程的管理,也就是线程的一些最基本的使用。这里先总结第二章相关的内容。
每个程序至少有一个线程,也就是我们的入口函数main所在的线程,不管什么程序肯定得有main,main所在的线程也就是我们的主线程。
线程启动
在c++中想要使用线程要用到一个头文件thread这个头文件,然后利用thread对象进行多线程的处理。
void fun();
std::thread t(fun);//生成一个线程
在这里生成线程的同时线程被启动。这里书中写到“std::thread可以使用可调用类型构造,将带有函数调用符类型的实例传入std::thread类中,替换默认的构造函数。”。这里指的可调用类型应该就是以下的几种:
- 普通函数:最为常见,我们一开始学习c和c++的时候在main函数之前声明的那些函数就是普通函数。
- 类成员函数:包括成员函数和静态函数,区别在于成员方法参数列表中隐藏者this指针。
- 仿函数:用类模拟函数行为,通过重载操作符(),使用的时候生成的类对象可以像调用函数一样进行调用。
- 函数指针:指向函数的指针,回调中经常使用。
- lambda函数:C11开始新添加的特性,与Java中的匿名函数类似。
- std::function函数包装:C11开始加入的标准,可以用来包装所有可调用的实体。
书中举了一个仿函数的例子,之前没看明白他表达的意思,后来查找关于这方面的内容后,发现他想表达的应该是thread的参数类型是所有的可调用对象类型。也就是上面提到这些。另外thread的参数默认是以复制的形式被传入的,应该注意副本与原始对象的一致,如需要改变原始对象数据的时候。
注意,如果传递了一个临时变量,而不是以个命名的变量,c++会将他解析为函数声明而不是类型对象的定义。
std::thread my_thread(background_task());
这个相当于声明了一个my_thread的函数,参数是一个没有参数并且返回值是background_task对象的函数指针,返回值是一个std::thread对象。
为了避免这个问题,有3种方法:
std::thread my_thread((background_task())); //
std::thread my_thread{background_task()}; //
第一种是使用两个括号,避免被c++解析成函数。第二种是是新的初始化语法。最后一种是使用lambda函数。
std::thread my_thread([]{
do_something();
do_something_else();
});
启动线程后需要决定是使用join还是detach,即是让创建这个线程的线程等待新线程的结束,还是直接将新线程进行分离当前线程不再理会新线程。必须在线程销毁前做出决定,否则线程析构的时候会调用terminate函数终止程序,之后在决定会触发异常。
如果需要是用引用或者指针就要注意数据的有效性,线程结束前被释放就会出错,可以利用join来确保线程完成后才可以结束。
在特殊情况下的等待
在使用join进行等待的时候需要提前将异常抛出,防止因为异常而终止,因为在join之前出现异常容易跳过join。书中提到一般会在异常处理中也进行join的调用,这样可以避免生命周期问题。异常处理采用的是try-catch。
struct func; // 定义在清单2.1中
void f()
{
int some_local_state=0;
func my_func(some_local_state);
std::thread t(my_func);
try
{
do_something_in_current_thread();
}
catch(...)
{
t.join(); //
throw;
}
t.join(); //
}
这样就不管正常还是异常都可以被处理,但是书中并不是很推荐这种方法,因为try-catch会把作用域打乱。
还有一种在书中经常会看到的RAAII(资源获取就是初始化(Resource Acquisition Is Initialization)),简单的说就是在构造对象的时候分配资源,在生命周期内始终有效,在析构的时候进行释放,保证一定会调用析构函数。
class thread_guard
{
std::thread& t;
public:
explicit thread_guard(std::thread& t_):
t(t_)
{}
~thread_guard()
{
if(t.joinable()) //
{
t.join(); //
}
}
thread_guard(thread_guard const&)=delete; //
thread_guard& operator=(thread_guard const&)=delete;
};
struct func; // 定义在清单2.1中
void f()
{
int some_local_state=0;
func my_func(some_local_state);
std::thread t(my_func);
thread_guard g(t);
do_something_in_current_thread();
} //
这个例子中在最后结束的时候会对局部对象进行逆序销毁,第一个被销毁的是g,这个时候会调用类的析构函数,通过析构函数将线程join,不管 do_something_in_current_thread是否发生异常都不影响。
但是因为每个线程只能被join或detach一次,之后相当于已经不能再使用,所以要joinable进行判断,如果对已经调用过join的线程重复操作会导致错误。
同时对拷贝构造器和拷贝赋值操作设置为=delete,可以防止编译器自动生成这些函数,因为编译器对某些操作会进行默认的生成,同时这些生成会带有一些特性(可以在书的附录中找到),因为在线程中利用赋值或者复制是危险的,所以这里去掉了它。
delete
有时候拷贝没有意义,所以为了避免拷贝操作,以前采用的是将这些函数声明为私有成员,并且不实现它们。如果对实例进行拷贝则会引起编译错误,如果其他成员函数和友元函数想要拷贝,会引起连接错误(因为没有实现)。
class no_copies
{
public:
no_copies(){}
private:
no_copies(no_copies const&); // 无实现
no_copies& operator=(no_copies const&); // 无实现
};
no_copies a;
no_copies b(a); // 编译错误
在C11中,刻意提供了一个通用的机制,利用=delete删掉这个函数。
no_copies(no_copies const&) = delete;
no_copies& operator=(no_copies const&) = delete;
这样就省事了很多,同时错误会在编译的时候被发现,链接时候的错误就可以在编译的时候被发现。这样编译器也不会在你不知道的时候为你生成这两个函数,因为它们被删掉了。删掉的函数可以正常的参与重载解析,并且被调用只会亲戚编译错误,可以用来删除特定的重载。
去掉拷贝和拷贝赋值后,需要显式写一个移动构造函数和移动赋值操作,这样类只负责移动。(thread就是这样的类)
如果是右值对象(c11新加入,常量和临时变量出现在=右面的)可以直接进行使用,如果想要移动左值(=左面的,在栈或者堆中的命名对象或成员,有明确的内存地址)对象必须要显式进行std::move或static_cast<T&&>将左值转化为右值。
右值引用
右值引用只能绑定右值不能绑定左值,通过&&表示。左值引用也只能绑定左值(const引用除外,const引用比较特殊,它左值、右值、常量、非常量都可以)。
右值引用比较常见的是用在移动上,因为右值一般都是临时的,所以可以直接将他的值取走而不是复制,这样并不影响程序的正确性。这样可以省去不少的空间,书中举了这样的一个例子。
一个函数以vector作为参数,需要将其拷贝进函数而不对原始数据做出操作。以前的方法是用一个左值的const引用传入,然后内部拷贝。
void process_copy(std::vector<int> const& vec_)
{
std::vector<int> vec(vec_);
vec.push_back(42);
}
上面的函数不管左值还是右值都没有问题,但都是以复制的形式,这对于右值而言有点多余,因为右值直接对原值操作就可以。所以可以做一个右值版本的重载。又许多类只支持移动构造函数,不支持复制构造函数。
void process_copy(std::vector<int> && vec)
{
vec.push_back(42);
}
移动构造函数允许指针的所有权进行转移,可以在实例之间进行传递,这样也就节省了许多空间和时间成本,并且像是std::unique_ptr<>这类只允许有一个对象的情况,可以保证没有被复制只存在一个对象。c++允许std::unique_ptr<>作为函数的返回值,这个时候指针是被移动了而不是被复制了。
如果想把左值变为右值可以使用static_cast<X&&>或std::move()来进行处理。 如果函数的参数是右值引用,那么在这个函数中这个参数是被当做左值进行处理的。
书中有这样一句话,我看的不是很明白“C++标准库不会将一个对象显式的转移到另一个对象中,除非将其销毁的时候或对其赋值的时候(拷贝和移动的操作很相似)”,个人理解是除了要销毁这个对象的时候或者为对象进行赋值以外,不会显式的将一个对象转移到另一个对象上(除了赋值之外,我们一般也不会用)。
实践中移动可以保证类中的状态不变,但是不保证是否正确。
书中还介绍了右值引用和引用模板,如果是以右值引用作为一个模板参数,当提供的实参是左值的时候,模板会自动将类型认定为左值引用,当提供右值的时候,会当作普通的数据进行处理。
模板:
template<typename T>
void foo(T&& t)
{}
推导:
foo(42); // foo<int>(42)
foo(3.14159); // foo<double><3.14159>
foo(std::string()); // foo<std::string>(std::string())
int i = 42;
foo(i); // foo<int&>(i)
将T&&作为原始引用,也就是引用的引用,所以解释为
foo<int&>(); // void foo<int&>(int& t);
这样就适应了左值和右值,同时右值也可以不用被复制。
后台运行线程
detach之后的线程将被从程序中分离,就有后台的c++运行时库进行管理,我们的程序也就没法跟它进行交互。分离后的线程被称为守护线程,没有显式用户接口,可以长时间在后台存在,用不对还是挺危险的,另外分离后的线程只能确定什么时候结束。
本文介绍C++多线程编程的基础知识,包括线程的启动、管理、等待和分离,以及如何使用std::thread类。探讨了线程安全、资源管理(RAII)和异常处理策略,同时讲解了右值引用、移动语义和模板推导在多线程环境中的应用。

1914

被折叠的 条评论
为什么被折叠?



