从C语言到C++_40(多线程相关)C++线程接口+线程安全问题加锁(shared_ptr+STL+单例)(上)

简介: 从C语言到C++_40(多线程相关)C++线程接口+线程安全问题加锁(shared_ptr+STL+单例)

此篇建议学了Linux系统多线程部分再来看。

1. C++多线程

       在C++11之前,涉及到多线程问题,都是和平台相关的,比如windows和linux下各有自己的接口,这使得代码的可移植性比较差。

       C++11中最重要的特性就是支持了多线程编程,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。


1.1 thread库

查下文档:


如图所示,C++11提供了thread库,thread是一个类,在使用的时候需要包含头文件pthread。

构造函数:

  • 默认构造函数thread()使用该构造函数创建的线程对象仅是创建对象,线程并没有被创建,也没有允许。
  • thread(Fn&& fn, Args&&... args),这是一个万能引用模板。使用该构造函数时,第一个参数是可调用对象,可以是左值也可以是右值,比如函数指针,仿函数对象,lambda表达式等等。后面的可变参数就是传给线程函数的实参,是一个参数包,也就是可变参数。
  • thread(const thread&) = delete,线程之间是禁止拷贝的。
  • thread(thread&& x),移动构造函数。

成员函数:

  • get_id,用来获取当前线程的tid值。调用该函数通常都是当前线程,但是当前的从线程从并没有自己的thread对象

       所以线程库由提供了一个命名空间,该空间中有上图所示的几个函数,可以通过命名空间来直接调用,如:

this_thread::get_id(); // 获取当前线程tid值

哪个线程执行这条语句就返回哪个线程的tid值,命名空间中的其他几个函数的用法也是这样。

  • yield调用该接口的线程会让其CPU,让CPU调度其他线程。
  • sleep_until调用该接口的线程会延时至一个确定的时间点。
  • sleep_for调用该接口的线程会延时一个时间段,如1s。
  • operator=(thread&& t),移动赋值。

将一个线程对象赋值给另一个线程对象,通常这么用:

  thread t1; // 仅创建对象,不创建线程
  t1 = thread(func); // t1线程函数并且执行

此时原本只创建的线程对象就有一个线程在跑了。

注意:只能赋右值,不能赋左值,因为赋值运算符重载被禁掉了,只有移动赋值


  • join,线程等待,用来回收线程资源。一般主线程会调用该函数,以t.join()的形式,t就是需要被等待的线程对象,此时主线程会阻塞在这里,直到从线程运行结束。

     如上面的多线程一样,必须使用join,否则线程资源不会回收,而且如果从线程运行的时间比主线程长的话,主线程会直接运行完并且回收所有资源,导致从线程被强制结束。


  • joinable,用来判断线程是否有效。

如果是以下任意情况则线程无效:

  1. 采用无参构造函数构造的线程对象
  2. 线程对象的状态已经转移给其他线程对象
  3. 线程已经调用 join 或者 detach 结束
  • detach,线程分离,从线程结束后自动回收资源。

其他的就不介绍了,用到的时候自行查文档即可。

要谨记:thread是禁止拷贝的,不允许拷贝构造以及赋值,但是可以移动构造和移动赋值


使用一下:

#include <iostream>
#include <thread>
using namespace std;
 
void Print(int n, int& x)
{
  for (int i = 0; i < n; ++i)
  {
    cout << this_thread::get_id() << ":" << i << endl;
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    ++x;
  }
}
int main()
{
  int count = 0;
  thread t1(Print, 10, ref(count)); // 可调用对象和其实参
  thread t2(Print, 10, ref(count));
 
  t1.join();
  t2.join();
 
  cout << count << endl;
 
  return 0;
}

       多次运行的结果不一样,可能会出现像第一行一样的抢着打印的问题(学了Linux多线程应该比较清楚),下面就应该想到加锁了。


1.2 mutex库

如上图所示,C++11提供了mutex库,mutex同样是一个类,在使用的时候要包含头文件mutex。

构造函数:

  • 只有默认构造函数mutex(),在创建互斥锁的时候不需要传任何参数。
  • mutex(const mutex&)=delete,禁止拷贝。

其他成员函数:

  • lock(),给临界区加锁,加锁成功继续向下执行,失败则阻塞等待。
  • unlock(),给临界区解锁。
  • try_lock(),给临界区尝试加锁,加锁成功返回true,加锁失败返回false。使用try_lock时,如果申请失败则不阻塞,跳过申请锁的部分,执行非临界区代码。来看伪代码:
mutex mtx;
 
if(mtx.try_lock())
{
  // 临界区代码
  // ......
}
else
{
  // 非临界区代码
  // ......
}

mutex不能递归使用,如下面伪代码所示:

    void Func(int n)
  {
    lock(); // 加锁
    // 临界区代码
    // ......
    Func(n - 1); // 递归调用
    unlock(); // 解锁
  }


在递归中不能使用这样的锁,会造成死锁。

最上面例子的正确使用如下:

#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
 
void Print(int n, int& x, mutex& mtx)
{
  for (int i = 0; i < n; ++i)
  {
    mtx.lock();
    cout << this_thread::get_id() << ":" << i << endl;
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    ++x;
    mtx.unlock();
  }
}
 
int main()
{
  mutex m;
  int count = 0;
  thread t1(Print, 10, ref(count), ref(m));
  thread t2(Print, 10, ref(count), ref(m));
 
  t1.join();
  t2.join();
  cout << count << endl;
  return 0;
}

后面再来看看怎么实现交错打印的效果,再看看另一种用法:(lambda)

int main()
{
  mutex mtx;
  int x = 0;
  int n = 10;
  thread t1([&](){
    for (int i = 0; i < n; ++i)
    {
      mtx.lock();
      cout << this_thread::get_id() << ":" << i << endl;
      std::this_thread::sleep_for(std::chrono::milliseconds(100));
      ++x;
      mtx.unlock();
    }
  });
 
  thread t2([&](){
    for (int i = 0; i < n; ++i)
    {
      mtx.lock();
      cout << this_thread::get_id() << ":" << i << endl;
      std::this_thread::sleep_for(std::chrono::milliseconds(100));
      ++x;
      mtx.unlock();
    }
  });
 
  t1.join();
  t2.join();
  cout << x << endl;
  return 0;
}

上面代码的问题:如果加锁解锁之间存在抛异常就死锁了,这时就要用到RAII锁。


1.3 RAII锁

lock_guard是一个类,采用了RAII方式来加锁解锁——将锁的生命周期和对象的生命周期绑定在一起。看下在Linux篇章写的代码:(把锁封装了)

#pragma once
#include <iostream>
#include <pthread.h>
 
class Mutex
{
public:
    Mutex(pthread_mutex_t* mtx) 
        :_pmtx(mtx)
    {}
    void lock()
    {
        pthread_mutex_lock(_pmtx);
        std::cout << "进行加锁成功" << std::endl;
    }
    void unlock()
    {
        pthread_mutex_unlock(_pmtx);
        std::cout << "进行解锁成功" << std::endl;
    }
    ~Mutex()
    {}
protected:
    pthread_mutex_t* _pmtx;
};
 
class lockGuard // RAII风格的加锁方式
{
public:
    lockGuard(pthread_mutex_t* mtx) // 因为不是全局的锁,所以传进来,初始化
        :_mtx(mtx)
    {
        _mtx.lock();
    }
    ~lockGuard()
    {
        _mtx.unlock();
    }
protected:
    Mutex _mtx;
};

看库里的构造函数:

  • lock_guard(mutex_type& m),在创建这个对象的时候需要传入一把锁,在构造函数中,进行了加锁操作。
  • lcok_guard(const lock_guard&)=delete,该对象禁止拷贝,因为互斥锁就不可以拷贝。

析构函数的作用就是将lock_guard对象的资源释放,也就是进行解锁操作。

lock_guard只有构造函数和析构函数,使用该类对象加锁时不需要我们去关心锁的释放,但是它不能在对象生命周期结束之前主动解锁。

看一下unique_lock:

unique_lock也是一种RAII的加锁对象,它和lock_guard的功能一样,将锁的生命周期和对象的生命周期绑定在一起,但是又有区别。

  • unique_lock(mutex_type& m),这个和lock_guard的用法一样,在构造函数中加锁。
  • unique_lock(const unique_lock&)=delete,同样禁止拷贝。

析构函数中和lock_guard一样,也是进行解锁操作。

  • lock,加锁。
  • unlock,解锁。
  • try_lock,尝试加锁。

lock_guard中就没有这几个接口,所以unique_lock可以在析构之前主动解锁,主动解锁后仍然可以再主动加锁,这一点lock_guard是不可以的。

  • try_lock_for,尝试加锁一段时间,时间到后自动解锁。
  • try_lock_until,尝试加锁到指定时间,时间到来后自动解锁。

用法很多,需要使用的时候可以结合库文档来使用。用一下lock_guard和lambda的另一种用法:

int main()
{
  mutex mtx;
  int n = 10;
  int m;
  cin >> m;
 
  vector<thread> v(m);
  for (int i = 0; i < m; ++i)
  {
    // 移动赋值给vector中线程对象
    v[i] = thread([&](){
      for (int i = 0; i < n; ++i)
      {
        {
          lock_guard<mutex> lk(mtx);
          cout << this_thread::get_id() << ":" << i << endl;
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
      }
    });
  }
  for (auto& t : v)
  {
    t.join();
  }
  return 0;
}

从C语言到C++_40(多线程相关)C++线程接口+线程安全问题加锁(shared_ptr+STL+单例)(中):https://developer.aliyun.com/article/1522534

目录
相关文章
|
21天前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
15 3
|
21天前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
14 2
|
21天前
|
Java
Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口
【10月更文挑战第20天】《JAVA多线程深度解析:线程的创建之路》介绍了Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口。文章详细讲解了每种方式的实现方法、优缺点及适用场景,帮助读者更好地理解和掌握多线程编程技术,为复杂任务的高效处理奠定基础。
27 2
|
6月前
|
存储 算法 C语言
从C语言到C++_39(C++笔试面试题)next_permutation刷力扣
从C语言到C++_39(C++笔试面试题)next_permutation刷力扣
59 5
|
6月前
|
存储 编译器 C语言
从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题(下)
从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题
59 1
|
6月前
|
存储 编译器 Linux
从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题(中)
从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题
66 1
|
6月前
|
编译器 C语言 C++
从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题(上)
从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题
48 0
|
6月前
|
编译器 程序员 C语言
从C语言到C++⑨(第三章_C&C++内存管理)详解new和delete+面试题笔试题(下)
从C语言到C++⑨(第三章_C&C++内存管理)详解new和delete+面试题笔试题
44 0
|
6月前
|
编译器 C语言 C++
从C语言到C++⑨(第三章_C&C++内存管理)详解new和delete+面试题笔试题(中)
从C语言到C++⑨(第三章_C&C++内存管理)详解new和delete+面试题笔试题
51 0
|
6月前
|
存储 程序员 编译器
从C语言到C++⑨(第三章_C&C++内存管理)详解new和delete+面试题笔试题(上)
从C语言到C++⑨(第三章_C&C++内存管理)详解new和delete+面试题笔试题
46 0