C++并发编程基础

281 阅读13分钟

C++11中提供了多线程的标准库,提供了管理线程、保护共享数据、线程间同步操作、原子操作等类。

线程库thread

C++11引入了标准线程库std::thread。标准线程库的实现由各平台自行决定。在C++有标准线程库之前,Linux下已经存在了一个广受好评(或者说,不得不用)的一个线程库,pthread。所以Linux上的std::thread其实就是对之前存在的pthread的一层包装。

Linux下一般使用的C++实现是libstdc++,由于设计原因,线程库以单独的libpthread提供,并且libstdc++使用弱链接的方式引用libpthread ,因此在Linux下要么使用thread.h(pthread),要么添加额外的编译选项,将生成目标链接到pthread库,单独编译文件时使用g++ -o main main.cpp -pthread-lpthread是老式的解决办法,不再使用),如果编译报错,可以使用以下解决办法:

CMake中,可以强制为编译和链接增加选项-pthread

set_target_properties(${TARGET} PROPERTIES
COMPILE_FLAGS "-pthread"
LINK_FLAGS "-pthread")

注意不能用target_link_libraries(${TARGET} pthread)代替,因为后者会扩展为-lpthread,并且仅对链接阶段生效。

上述方法不适合跨平台的使用,并且在目标比较多的时候,添加起来比较麻烦。CMAKE中提供了单独的Threads库来解决这个问题

add_library(test ${src})  
set(THREADS_PREFER_PTHREAD_FLAG ON)
find_package(Threads REQUIRED)
target_link_libraries(test PUBLIC Threads::Threads)

创建线程

多线程库对应的头文件是#include <thread>,类名为std::thread

任何程序都是一个进程,main()函数就是其中的主线程,单个线程都是顺序执行,改造成多线程程序其实很简单,让f1()函数在另外的线程中执行:

  1. 首先,构建一个std::thread对象t1,构造的时候传递了一个参数,这个参数是一个函数,这个函数就是这个线程的入口函数,函数执行完了,整个线程也就执行完了。
  2. 线程创建成功后,就会立即启动,并没有一个类似start的函数来显式的启动线程。
  3. 一旦线程开始运行, 就需要显式的决定是要等待它完成(join),或者分离它让它自行运行(detach)。注意:只需要在std::thread对象被销毁之前做出这个决定。这个例子中,对象t1是栈上变量,在main函数执行结束后就会被销毁,所以需要在main函数结束之前做决定。
  4. 这个例子中选择了使用t1.join(),主线程会一直阻塞着,直到子线程完成,join()函数的另一个任务是回收该线程中使用的资源。
void f1()
{
    std::cout << "f1" << std::endl;
}

int main(int argc, char *argv[])
{
    std::thread t1(f1);
    t1.join();
    return 0;
}

线程对象和对象内部管理的线程的生命周期并不一样,如果线程执行的快,可能内部的线程已经结束了,但是线程对象还活着,也有可能线程对象已经被析构了,内部的线程还在运行。假设t1线程是一个执行的很慢的线程,主线程并不想等待子线程结束就想结束整个任务,直接删掉t1.join()是不行的,程序会被终止(析构t1的时候会调用std::terminate,程序会打印terminate called without an active exception)。

与之对应,我们可以调用t1.detach(),从而将t1线程放在后台运行,所有权和控制权被转交给C++运行时库,以确保与线程相关联的资源在线程退出后能被正确的回收。参考UNIX守护进程(daemon process)的概念,这种被分离的线程被称为守护线程(daemon threads)。线程被分离之后,即使该线程对象被析构了,线程还是能够在后台运行,只是由于对象被析构了,主线程不能够通过对象名与这个线程进行通信

joindetach的区别:

  • join阻塞等待线程执行完毕,detach守护进程放到后台运行
  • joindetach只能二选一,后面也不能互相调用
  • 可以使用joinable()函数判断一个线程对象能否调用join()
#include <thread>
#include <iostream>
void f1()
{
    std::this_thread::sleep_for(std::chrono::milliseconds(500));
    std::cout << "f1" << std::endl;
}
void test()
{
    std::thread t1(f1);
    t1.detach();
    std::cout << "test finished" << std::endl;
}
int main(int argc, char *argv[])
{
    test();
    std::this_thread::sleep_for(std::chrono::milliseconds(1000));
    return 0;
}

// 使用 t1.detach()时
// test() finished
// f1

// 使用 t1.join()时
// f1
// test() finished

  1. 由于线程入口函数内部有个500ms的延时,所以在还没有打印的时候,test()已经执行完成了,t1已经被析构了,但是它负责的那个线程还是能够运行,这就是detach()的作用。
  2. 如果去掉main函数中的1s延时,会发现什么都没有打印,因为主线程执行的太快,整个程序已经结束了,那个后台线程被C++运行时库回收了。
  3. 如果将t1.detach()换成t1.join()test函数会在t1线程执行结束之后,才会执行结束。一旦一个线程被分离了,就不能够再被join了。如果非要调用,程序就会崩溃,可以使用joinable()函数判断一个线程对象能否调用join()
void test() {
    std::thread t1(function_1);
    t1.detach();

    if(t1.joinable())
        t1.join();

    assert(!t1.joinable());
}

thread构造函数

std::thread类的构造函数是使用可变参数模板实现的,也就是说,可以传递任意个参数,第一个参数是线程的入口函数,而后面的若干个参数是该函数的参数。第一参数的类型并不是c语言中的函数指针(c语言传递函数都是使用函数指针),在c++11中,增加了**可调用对象(Callable Objects)**的概念,总的来说,可调用对象可以是以下几种情况:

  • 函数指针
  • 重载了operator()运算符的类对象,即仿函数
  • lambda表达式(匿名函数)
  • std::function

函数指针

// 普通函数 无参
void function_1() {
}
// 普通函数 1个参数
void function_2(int i) {
}
// 普通函数 2个参数
void function_3(int i, std::string m) {
}
std::thread t1(function_1);
std::thread t2(function_2, 1);
std::thread t3(function_3, 1, "hello");

t1.join();
t2.join();
t3.join();

线程函数不能重载,如果将重载的函数作为线程的入口函数,会发生编译错误,编译器搞不清楚是哪个函数

// 普通函数 无参
void function_1() {
}

// 普通函数 1个参数
void function_1(int i) {
}
std::thread t1(function_1);
t1.join();
// 编译错误
/*
C:\Users\Administrator\Documents\untitled\main.cpp:39: 
error: no matching function for call to 'std::thread::thread(<unresolved overloaded function type>)'
     std::thread t1(function_1);                          ^
*/

仿函数

一个仿函数类生成的对象,使用起来就像一个函数一样,比如对象f,当使用f()时就调用operator()运算符。所以也可以让它成为线程类的第一个参数,如果这个仿函数有参数,同样的可以写在线程类的后几个参数上。

t2之所以编译错误,是因为编译器并没有将Fctor()解释为一个临时对象,而是将其解释为一个函数声明,编译器认为你声明了一个函数,这个函数不接受参数,同时返回一个Factor对象。解决办法就是在Factor()外包一层小括号(),或者在调用std::thread的构造函数时使用{},这是c++11中的新的同意初始化语法。

但是,如果重载的operator()运算符有参数,就不会发生上面的错误。仿函数经常用于配合模板类简化复用代码

// 仿函数
class Fctor {
public:
    // 具有一个参数
    void operator() () {

    }
};
Fctor f;
std::thread t1(f);  
// std::thread t2(Fctor()); // 编译错误 
std::thread t3((Fctor())); // ok
std::thread t4{Fctor()}; // ok

匿名函数

std::thread t1([](){
    std::cout << "hello" << std::endl;
});

std::thread t2([](std::string m){
    std::cout << "hello " << m << std::endl;
}, "world");

std::function

class A{
public:
    void func1(){
    }
    void func2(int i){
    }
    void func3(int i, int j){
    }
};

A a;
std::function<void(void)> f1 = std::bind(&A::func1, &a);
std::function<void(void)> f2 = std::bind(&A::func2, &a, 1);
std::function<void(int)> f3 = std::bind(&A::func2, &a, std::placeholders::_1);
std::function<void(int)> f4 = std::bind(&A::func3, &a, 1, std::placeholders::_1);
std::function<void(int, int)> f5 = std::bind(&A::func3, &a, std::placeholders::_1, std::placeholders::_2);

std::thread t1(f1);
std::thread t2(f2);
std::thread t3(f3, 1);
std::thread t4(f4, 1);
std::thread t5(f5, 1, 2);

线程传值&引用

如果线程入口函数的的参数是引用类型,在线程内部修改该变量,主线程的变量会改变吗,std::thread类内部也有若干个变量,当使用构造函数创建对象的时候,是将参数先赋值给这些变量,所以这些变量只是个副本,然后在线程启动并调用线程入口函数时,传递的参数只是这些副本,所以内部怎么操作都是改变副本,而不影响外面的变量

如果想真正传引用,可以在调用线程类构造函数的时候用std::ref()包装

std::thread t1(f, std::ref(m));

需要注意,多个线程同时修改同一个变量,会发生数据竞争。同理,构造函数的第一个参数是可调用对象,默认情况下其实传递的还是一个副本,除非显式传递引用对象

class A {
public:
    void f(int x, char c) {}
    int g(double x) {return 0;}
    int operator()(int N) {return 0;}
};
int main() {
    A a;
    std::thread t1(a, 6); // 1. 调用的是 copy_of_a()
    std::thread t2(std::ref(a), 6); // 2. a()
    std::thread t3(A(), 6); // 3. 调用的是 临时对象 temp_a()
    std::thread t4(&A::f, a, 8, 'w'); // 4. 调用的是 copy_of_a.f()
    std::thread t5(&A::f, &a, 8, 'w'); //5.  调用的是 a.f()
    std::thread t6(std::move(a), 6); // 6. 调用的是 a.f(), a不能够再被使用了
}

对于线程t1来说,内部调用的线程函数其实是一个副本,所以如果在函数内部修改了类成员,并不会影响到外面的对象。只有传递引用的时候才会修改

#include <iostream>
#include <thread>
#include <string>

// 仿函数
class Fctor {
public:
    // 具有一个参数 是引用
    void operator() (std::string& msg) {
        msg = "wolrd";
    }
};

int main() {
    Fctor f;
    std::string m = "hello";
    std::thread t1(f, m);

    t1.join();
    std::cout << m << std::endl;
    return 0;
}
// g++编译器: 编译报错

只能移动不可复制

线程对象之间是不能复制的,只能移动,移动的意思是,将线程的所有权在std::thread实例间进行转移。

void some_function();
void some_other_function();
std::thread t1(some_function);
// std::thread t2 = t1; // 编译错误
std::thread t2 = std::move(t1); //只能移动 t1内部已经没有线程了
t1 = std::thread(some_other_function); // 临时对象赋值 默认就是移动操作
std::thread t3;
t3 = std::move(t2); // t2内部已经没有线程了
t1 = std::move(t3); // 程序将会终止,因为t1内部已经有一个线程在管理了

互斥锁mutex

并发代码中最常见的错误之一就是竞争条件(race condition)。而其中最常见的就是数据竞争(data race),从整体上来看,所有线程之间共享数据的问题,都是修改数据导致的,如果所有的共享数据都是只读的,就不会发生问题。但是这是不可能的,大部分共享数据都是要被修改的。

mutex

c++中,可以使用互斥锁std::mutex进行资源保护,头文件是#include <mutex>,共有两种操作:锁定(lock)与解锁(unlock),使用std::mutex声明锁并加锁解锁即可

需要注意锁的作用域,在线程内部声明并创建的锁只作用于线程内部,失去了并发编程中锁的作用,因此使用mutex保护共享资源时,mutex应声明为全局或者静态变量。或者至少要保证两个线程使用的是同一个mutex

同时需要注意,如果mutex.lock()mutex.unlock()之间的语句发生了异常,unlock()语句没有机会执行,会导致mutex一直处于锁着的状态,其他使用shared_print()函数的线程就会阻塞,解决这个问题也很简单,使用c++中常见的RAII技术,即获取资源即初始化(Resource Acquisition Is Initialization)技术,这是c++中管理资源的常用方式。简单的说就是在类的构造函数中创建资源,在析构函数中释放资源,因为就算发生了异常,c++也能保证类的析构函数能够执行

不需要自己写个类包装mutexc++库已经提供了std::lock_guard类模板

#include <mutex>
#include <thread>
volatile long shared = 0;
std::mutex lock;
void sharead_add()
{
    lock.lock();
    for (int i = 0; i < 100000; i++)
    {
        shared++;
    }
    lock.unlock();
}

int main()
{
    std::thread t1(sharead_add);
    std::thread t2(sharead_add);
    t1.join();
    t2.join();
    std::cout << shared << std::endl;
    return 0;
}

std::lock_guard

锁管理遵循RAII习语来处理资源。锁管理器在构造函数中自动绑定它的互斥体,并在析构函数中释放它。这大大减少了死锁的风险,因为运行时会处理互斥体

锁管理器在C++ 11中有两种:简单的std::lock_guard,以及高级用例的std::unique_lock

{
  std::mutex m,
  std::lock_guard<std::mutex> lockGuard(m);
  sharedVariable= getVar();
}

注意开括号 { 和闭括号 },保证std::lock_guard生命周期只在{}里有效。当生命周期离开临界区时,它的生命周期就结束了。函数体范围或循环范围也限制了对象的生命周期。

    {
        std::lock_guard<std::mutex> guard(lock); // 自动上锁解锁,避免死锁
        for (int i = 0; i < 100000; i++)
        {
            shared++;
        }
    }

可以实现自己的std::lock_guard

class MutexLockGuard
{
 public:
  explicit MutexLockGuard(std::mutex& mutex)
    : mutex_(mutex)
  {
    mutex_.lock();
  }

  ~MutexLockGuard()
  {
    mutex_.unlock();
  }

 private:
  std::mutex& mutex_;
};

std::unique_lock

lock_guard只能保证在析构的时候执行解锁操作,lock_guard本身并没有提供加锁和解锁的接口,但是有些时候会有这种需求。看下面的例子。

class LogFile {
    std::mutex _mu;
    ofstream f;
public:
    LogFile() {
        f.open("log.txt");
    }
    ~LogFile() {
        f.close();
    }
    void shared_print(string msg, int id) {
        {
            std::lock_guard<std::mutex> guard(_mu);
            //do something 1
        }
        //do something 2
        {
            std::lock_guard<std::mutex> guard(_mu);
            // do something 3
            f << msg << id << endl;
            cout << msg << id << endl;
        }
    }
};

上面的代码中,一个函数内部有两段代码需要进行保护,这个时候使用lock_guard就需要创建两个局部对象来管理同一个互斥锁(其实也可以只创建一个,但是锁的力度太大,效率不行),修改方法是使用unique_lock。它提供了lock()unlock()接口,能记录现在处于上锁还是没上锁状态,在析构的时候,会根据当前状态来决定是否要进行解锁(lock_guard就一定会解锁)。上面的代码修改如下:

class LogFile {
    std::mutex _mu;
    ofstream f;
public:
    LogFile() {
        f.open("log.txt");
    }
    ~LogFile() {
        f.close();
    }
    void shared_print(string msg, int id) {
        std::unique_lock<std::mutex> guard(_mu);
        //do something 1
        guard.unlock(); //临时解锁

        //do something 2

        guard.lock(); //继续上锁
        // do something 3
        f << msg << id << endl;
        cout << msg << id << endl;
        // 结束时析构guard会临时解锁
        // guard.ulock(); // 析构的时候也会自动执行
    }
};

在无需加锁的操作时,可以先临时释放锁,然后需要继续保护的时候,可以继续上锁,这样就无需重复的实例化lock_guard对象,还能减少锁的区域。同样,可以使用std::defer_lock设置初始化的时候不进行默认的上锁操作:

void shared_print(string msg, int id) {
    std::unique_lock<std::mutex> guard(_mu, std::defer_lock);
    //do something 1

    guard.lock();
    // do something protected
    guard.unlock(); //临时解锁

    //do something 2

    guard.lock(); //继续上锁
    // do something 3
    f << msg << id << endl;
    cout << msg << id << endl;
    // 结束时析构guard会临时解锁
}

这样使用起来就比lock_guard更加灵活,然后这也是有代价的,因为它内部需要维护锁的状态,所以效率要比lock_guard低一点,在lock_guard能解决问题的时候,就是用lock_guard,反之,使用unique_lock条件变量需要避免死锁,只能使用unique_lock

另外,请注意,unique_locklock_guard都不能复制,lock_guard不能移动,但是unique_lock可以

// unique_lock 可以移动,不能复制
std::unique_lock<std::mutex> guard1(_mu);
std::unique_lock<std::mutex> guard2 = guard1;  // error
std::unique_lock<std::mutex> guard2 = std::move(guard1); // ok

// lock_guard 不能移动,不能复制
std::lock_guard<std::mutex> guard1(_mu);
std::lock_guard<std::mutex> guard2 = guard1;  // error
std::lock_guard<std::mutex> guard2 = std::move(guard1); // error
// unique_lock 可以移动,不能复制
std::unique_lock<std::mutex> guard1(_mu);
std::unique_lock<std::mutex> guard2 = guard1;  // error
std::unique_lock<std::mutex> guard2 = std::move(guard1); // ok

// lock_guard 不能移动,不能复制
std::lock_guard<std::mutex> guard1(_mu);
std::lock_guard<std::mutex> guard2 = guard1;  // error
std::lock_guard<std::mutex> guard2 = std::move(guard1); // error

死锁

死锁通常是代码块里用到了多个互斥锁,如fun1里运行了fun2,fun1与fun2都用到了全局锁,多线程执行时导致死锁

#include <iostream>
#include <thread>
#include <string>
#include <mutex>
#include <fstream>
using namespace std;

class LogFile {
    std::mutex _mu;
    std::mutex _mu2;
    ofstream f;
public:
    LogFile() {
        f.open("log.txt");
    }
    ~LogFile() {
        f.close();
    }
    void shared_print(string msg, int id) {
        std::lock_guard<std::mutex> guard(_mu);
        std::lock_guard<std::mutex> guard2(_mu2);
        f << msg << id << endl;
        cout << msg << id << endl;
    }
    void shared_print2(string msg, int id) {
        std::lock_guard<std::mutex> guard(_mu2);
        std::lock_guard<std::mutex> guard2(_mu);
        f << msg << id << endl;
        cout << msg << id << endl;
    }
};

void function_1(LogFile& log) {
    for(int i=0; i>-100; i--)
        log.shared_print2(string("From t1: "), i);
}

int main()
{
    LogFile log;
    std::thread t1(function_1, std::ref(log));

    for(int i=0; i<100; i++)
        log.shared_print(string("From main: "), i);

    t1.join();
    return 0;
}

std::lock

c++标准库中提供了std::lock()函数,能够保证将多个互斥锁同时上锁

std::lock(_mu, _mu2);

同时,lock_guard也需要做修改,因为互斥锁已经被上锁了,那么lock_guard构造的时候不应该上锁,只是需要在析构的时候释放锁就行了,使用std::adopt_lock表示无需上锁:

std::lock_guard<std::mutex> guard(_mu2, std::adopt_lock);
std::lock_guard<std::mutex> guard2(_mu, std::adopt_lock);

也就是要么同时拿到两把锁,要么都拿不到

条件变量(Condition)

条件变量用于线程间的互相协作,典型场景为简单的消费者生产者模型,一个线程往队列中放入数据,一个线程从队列中取数据,取数据前需要判断队列中是否有数据,由于队列是线程间共享的,所以需要使用互斥锁进行保护,一个线程在往队列添加数据的时候,另一个线程不能取,反之亦然。

条件变量与一个锁对象绑定,condition.wait(lock)等待`condition.notify_one()线程唤醒

c++11中提供了#include <condition_variable>头文件,其中的std::condition_variable可以和std::mutex结合一起使用,其中有两个重要的接口,notify_one()wait()wait()可以让线程陷入休眠状态

在消费者生产者模型中,如果生产者发现队列中没有东西,就可以让自己休眠,notify_one()唤醒处于wait中的其中一个条件变量(可能当时有很多条件变量都处于wait状态)。

需要注意:

  1. consumer中,在判断队列是否为空的时候,使用的是while(q.empty()),而不是if(q.empty()),这是因为wait()从阻塞到返回,不一定就是由于notify_one()函数造成的,还有可能由于系统的不确定原因唤醒(可能和条件变量的实现机制有关),这个的时机和频率都是不确定的,被称作伪唤醒,如果在错误的时候被唤醒了,执行后面的语句就会错误,所以需要再次判断队列是否为空,如果还是为空,就继续wait()阻塞。
  2. 在管理互斥锁的时候,使用的是std::unique_lock而不是std::lock_guard,事实上也不能使用std::lock_guard。为了避免死锁,wait()函数会先调用互斥锁的unlock()函数,然后再将自己睡眠,在被唤醒后,又会继续持有锁,保护后面的队列操作。
  3. 使用细粒度锁,尽量减小锁的范围,在notify_one()的时候,不需要处于互斥锁的保护范围内,所以在唤醒条件变量之前可以将锁unlock()

还可以将cond.wait(locker);换一种写法,wait()的第二个参数可以传入一个函数表示检查条件,这里使用lambda函数最为简单,如果这个函数返回的是truewait()函数不会阻塞会直接返回,如果这个函数返回的是falsewait()函数就会阻塞着等待唤醒,如果被伪唤醒,会继续判断函数返回值。

std::unique_lock<std::mutex> locker(mu);
cond.wait(locker, [](){ return !q.empty();} );  // Unlock mu and wait to be notified
#include <iostream>
#include <thread>
#include <deque>
#include <mutex>
#include <condition_variable>

std::deque<int> q;
std::mutex mu;
std::condition_variable condition;// 条件变量
const static int MAX_SIZE = 10; 
/**
 * @brief 生产者
 */
void product()
{
    int size = 0;
    while (size <= MAX_SIZE)
    {
        //生产者
        std::unique_lock<std::mutex> lock(mu);
        q.push_back(size);
        lock.unlock();
        condition.notify_one(); //条件变量,通知线程
        std::this_thread::sleep_for(std::chrono::seconds(1));
        size++;
    }
}
/**
 * @brief 消费者
 */
void consumer()
{
    int data = 0;
    while (data < MAX_SIZE)
    {
        std::unique_lock<std::mutex> lock(mu);
        while (q.empty())
        {
            condition.wait(lock); //lock-mu 等待被唤醒
        }
        data = q.front();
        q.pop_front();
        lock.unlock();
        std::cout << data << std::endl;
    }
}
int main(int argc, char **argv)
{
    std::thread t1(product);
    std::thread t2(consumer);
    t1.join();
    t2.join();
    return 0;
}

std::this_thread

std::this_thread是c++11提供的辅助函数类,封装了一些有用的函数

get_id: 获取线程 ID。

std::thread::id this_id = std::this_thread::get_id();

yield: 当前线程放弃执行,操作系统调度另一线程继续执行。

sleep_for: 线程休眠某个指定的时间片(time span),该线程才被重新唤醒,不过由于线程调度等原因,实际休眠时间可能比 sleep_duration 所表示的时间片更长。

std::chrono::milliseconds dura( 2000 );
std::this_thread::sleep_for( dura );

sleep_until: 线程休眠至某个指定的时刻(time point),该线程才被重新唤醒。

template< class Clock, class Duration >
void sleep_until( const std::chrono::time_point<Clock,Duration>& sleep_time );

future

使用future执行异步任务future 通常由某个 Provider创建,可以把 Provider 想象成一个异步任务的提供者,Provider在某个线程中设置共享状态的值,与该共享状态相关联的 future 对象调用 get(通常在另外一个线程中) 获取该值,如果共享状态的标志不为 ready,则调用 get 会阻塞当前的调用者,直到 Provider 设置了共享状态的值(此时共享状态的标志变为 ready),<future>头文件中包含了以下几个类和函数:

  • Providers类:std::promise, std::package_task
  • Futures类:std::future, shared_future
  • Providers 函数:std::async()
  • 其他类型:std::future_error, std::future_errc, std::future_status, std::launch

Providers

std::promise

promise 对象可以保存某一类型 T 的值,该值可被 future 对象读取(可能在另外一个线程中),因此 promise 也提供了一种线程同步的手段。在 promise 对象构造时可以和一个共享状态(通常是std::future)相关联,并可以在相关联的共享状态(std::future)上保存一个类型为 T 的值。

可以通过 get_future 来获取与该 promise 对象相关联的 future 对象,调用该函数之后,两个对象共享相同的共享状态(shared state)

  • promise 对象是异步 Provider,它可以在某一时刻设置共享状态的值。
  • future 对象可以异步返回共享状态的值,或者在必要的情况下阻塞调用者并等待共享状态标志变为 ready,然后才能获取共享状态的值。
#include <future>
#include <thread>
#include <iostream>
void future1(std::future<int> &f)
{
    //获取外部共享状态的值
    int x = f.get();
    std::cout << "获取共享状态: " << x << std::endl;
}
int main(int argc, char *argv[])
{
    //promise可以保存共享状态
    std::promise<int> promise;
    //与future绑定
    std::future<int> future = promise.get_future();
    //thread 执行 future
    std::thread t1(future1, std::ref(future));
    //promise设置值
    promise.set_value(10);
    t1.join();
    return 0;
}

std::packaged_task

表示包装任务std::packaged_task 包装一个可调用的对象,并且允许异步获取该可调用对象产生的结果,从包装可调用对象意义上来讲,std::packaged_taskstd::function类似,只不过 std::packaged_task 将其包装的可调用对象的执行结果传递给一个 std::future 对象(该对象通常在另外一个线程中获取std::packaged_task任务的执行结果)。

std::packaged_task 对象内部包含了两个最基本元素,一、被包装的任务(stored task),任务(task)是一个可调用的对象,如函数指针、成员函数指针或者函数对象,二、共享状态(shared state),用于保存任务的返回值,可以通过 std::future 对象来达到异步访问共享状态的效果。

可以通过 std::packged_task::get_future 来获取与共享状态相关联的 std::future 对象。在调用该函数之后,两个对象共享相同的共享状态,具体解释如下:

  • std::packaged_task 对象是异步 Provider,它在某一时刻通过调用被包装的任务来设置共享状态的值。
  • std::future 对象是一个异步返回对象,通过它可以获得共享状态的值,当然在必要的时候需要等待共享状态标志变为 ready.

std::packaged_task 的共享状态的生命周期一直持续到最后一个与之相关联的对象被释放或者销毁为止。

int task_f(int a, int b)
{
    std::cout << "packaged_task包装异步任务" << std::endl;
    return a + b;
}
int main(){
     //packaged_task包装异步任务
    std::packaged_task<int(int, int)> packaged_task(task_f);
    //绑定future,可以返回结果
    std::future<int> future1 = packaged_task.get_future();
    //thread执行异步包装任务,返回值在future上,共享状态
    std::thread t2(std::move(packaged_task), 1, 1);
    std::cout << future1.get() << std::endl;
    t2.join();

    return 0;
}

实例

使用锁封装对象

使用锁将数据封装成一个类。由于cout是个全局共享的变量,无法完全封装,下面使用文件流举例:

#include <iostream>
#include <thread>
#include <string>
#include <mutex>
#include <fstream>
using namespace std;

class LogFile {
    std::mutex mu;
    ofstream f;
public:
    LogFile() {
        f.open("log.txt");
    }
    ~LogFile() {
        f.close();
    }
    void shared_print(string msg, int id) {
        std::lock_guard<std::mutex> guard(mu);
        f << msg << id << endl;
    }
};

void function_1(LogFile& log) {
    for(int i=0; i>-100; i--)
        log.shared_print(string("From t1: "), i);
}

int main()
{
    LogFile log;
    std::thread t1(function_1, std::ref(log));

    for(int i=0; i<100; i++)
        log.shared_print(string("From main: "), i);

    t1.join();
    return 0;
}

上面的LogFile类封装了一个mutex和一个ofstream对象,然后shared_print函数在mutex的保护下,是线程安全的。使用的时候,先定义一个LogFile的实例log,主线程中直接使用,子线程中通过引用传递过去(也可以使用单例来实现),这样就能保证资源被互斥锁保护着,外面没办法同时使用资源。

设计安全的数据处理过程

需要注意的是,即使每个函数都是线程安全的,但是组合起来不一定是线程安全的,因为每个函数内部的锁只保护函数内部的数据处理过程,对于全局变量/数据来说,其仍然被多线程操作,没有被同步(使用全局锁进行保护)

//互斥锁mutex
void shared_mutex()
{
    std::mutex lock;
    lock.lock();
    for (int i = 0; i < 10; i++)
    {
        std::cout << "shared_mutex " << i << std::endl;
    }
    lock.unlock();
}
//使用模板std::lock_guard<std::mutex> 自动管理和释放锁
void mutex_lock_guard()
{
    std::mutex lock;
    {
        std::lock_guard<std::mutex> lockGuard(lock);
        for (int i = 0; i < 10; i++)
        {
            std::cout << "mutex_lock_guard " << i << std::endl;
        }
    }
}
    //竞态条件,t3 t4是线程安全的,但组合起来却不是
    std::thread t3(shared_mutex);
    std::thread t4(mutex_lock_guard);

    for (int i = 0; i < 10; i++)
    {
        std::cout << "main " << i << std::endl;
    }