C++并发编程实战总结1

本文介绍C++多线程编程的基础知识,包括线程的启动、管理、等待和分离,以及如何使用std::thread类。探讨了线程安全、资源管理(RAII)和异常处理策略,同时讲解了右值引用、移动语义和模板推导在多线程环境中的应用。

之前从来没有写过多线程的代码,想要学习一下多线程,上网找了很久,相关的书都很少,后来看到有很多人推荐c++并发编程实战这本书,就买了一本。刚读了几章,看了一下目录,发现知识点还是挺多的,对于初学者的我来说暂时应该是够了。唯二让我觉得很是不满的地方就是这个翻译和排版,感觉出版社不是很用心,有的时候翻译的就跟不是中文一样,绕来绕去的没看明白他想表达的意思,简单一句话可以说到重点或者举几个例子就可以说明白,它非要绕一下。有很多地方没弄明白它的重点,有些通过查资料自己弄明白了。这里根据自己的理解做个总结。

现在的c++基本都是C11往上,这本书也是基于C11的书。

第一章各种历史轨迹前世今生,略过它。第二章,线程的管理,也就是线程的一些最基本的使用。这里先总结第二章相关的内容。

每个程序至少有一个线程,也就是我们的入口函数main所在的线程,不管什么程序肯定得有main,main所在的线程也就是我们的主线程。

线程启动

在c++中想要使用线程要用到一个头文件thread这个头文件,然后利用thread对象进行多线程的处理。

void fun();
std::thread t(fun);//生成一个线程

在这里生成线程的同时线程被启动。这里书中写到“std::thread可以使用可调用类型构造,将带有函数调用符类型的实例传入std::thread类中,替换默认的构造函数。”。这里指的可调用类型应该就是以下的几种:

  1. 普通函数:最为常见,我们一开始学习c和c++的时候在main函数之前声明的那些函数就是普通函数。
  2. 类成员函数:包括成员函数和静态函数,区别在于成员方法参数列表中隐藏者this指针。
  3. 仿函数:用类模拟函数行为,通过重载操作符(),使用的时候生成的类对象可以像调用函数一样进行调用。
  4. 函数指针:指向函数的指针,回调中经常使用。
  5. lambda函数:C11开始新添加的特性,与Java中的匿名函数类似。
  6. 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++运行时库进行管理,我们的程序也就没法跟它进行交互。分离后的线程被称为守护线程,没有显式用户接口,可以长时间在后台存在,用不对还是挺危险的,另外分离后的线程只能确定什么时候结束。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值