C++11多线程

644 阅读10分钟

C++11多线程库

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

线程库简介

Linux下使用std::thread

C++11以来,C++引入了标准线程库std::thread。标准线程库的实现由各平台自行决定。在C++有标准线程库之前,Linux下已经存在了一个广受好评(或者说,不得不用)的一个线程库,pthread。所以Linux上的std::thread其实就是对之前存在的pthread的一层包装。 Linux下一般使用的C++实现是libstdc++,由于设计原因,线程库以单独的libpthread提供,并且libstdc++使用弱链接的方式引用libpthread

因此在Linux下要么使用thread.h(pthread),要么添加额外的编译选项,将生成目标链接到pthread库

CMake中,可以使用

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

的方式,强制为编译和链接增加选项-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)

这里我们将目标test对线程库Threads::Threads的链接属性设置为PUBLIC,这样随后如果有目标静态链接 test或者动态链接test,都可以隐式地加入对Threads::Threads的依赖。那些不直接依赖动态链接库,而是使用dlopen()的方式打开动态链接库的目标,则必须手动添加对Threads::Threads的依赖。

-lpthread和-pthread的区别

编译程序包括 预编译, 编译,汇编,链接,包含头文件了,仅能说明有了线程函数的声明, 但是还没有实现, 加上-lpthread是在链接阶段,链接这个库。

<stdio.h>等都是静态库,不需要做额外的表示,连接时会直接链接进代码里。pthread是动态库,需要用-lpthread,所有的动态库都需要用-lxxx来引用

用gcc编译使用了POSIX thread的程序时通常需要加额外的选项,以便使用thread-safe的库及头文件,一些老的书里说直接增加链接选项 -lpthread 就可以了

而gcc手册里则指出应该在编译和链接时都增加 -pthread 选项

编译选项中指定 -pthread 会附加一个宏定义 -D_REENTRANT,该宏会导致 libc 头文件选择那些thread-safe的实现;链接选项中指定 -pthread 则同 -lpthread 一样,只表示链接 POSIX thread 库。由于 libc 用于适应 thread-safe 的宏定义可能变化,因此在编译和链接时都使用 -pthread 选项而不是传统的 -lpthread 能够保持向后兼容,并提高命令行的一致性。

目前gcc 4.5.2中已经没有了关于 -lpthread的介绍了。所以以后的多线程编译应该用-pthread,而不是-lpthread。

创建线程

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

一个简单的串行程序如下:

//c++11多线程 std::thread
#include <thread>
#include <iostream>

void f1()
{
    std::cout << "f1" << std::endl;
}

int main(int argc, char *argv[])
{
    f1();
    return 0;
}

这是一个典型的单线程的单进程程序,任何程序都是一个进程,main()函数就是其中的主线程,单个线程都是顺序执行。

将上面的程序改造成多线程程序其实很简单,让f1()函数在另外的线程中执行:

void f1()
{
    std::cout << "f1" << std::endl;
}

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

线程对象和对象内部管理的线程的生命周期并不一样,如果线程执行的快,可能内部的线程已经结束了,但是线程对象还活着,也有可能线程对象已经被析构了,内部的线程还在运行。

假设t1线程是一个执行的很慢的线程,主线程并不想等待子线程结束就想结束整个任务,直接删掉t1.join()是不行的,程序会被终止(析构t1的时候会调用std::terminate,程序会打印terminate called without an active exception)。

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

//c++11多线程 std::thread
#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();
    //t1.join();
    std::cout << "test finished" << std::endl;
}
/**************
 * join与detach的区别
 * join阻塞等待线程执行完毕
 * detach守护进程放到后台运行
 * join与detach只能二选一,后面也不能互相调用
 * 可以使用joinable()函数判断一个线程对象能否调用join()。
 * *****************/
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);
                              ^
*/

仿函数

// 仿函数
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

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

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

但是,如果重载的operator()运算符有参数,就不会发生上面的错误。

匿名函数

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);

传值还是引用

如果线程入口函数的的参数是引用类型,在线程内部修改该变量,主线程的变量会改变吗?

#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类,内部也有若干个变量,当使用构造函数创建对象的时候,是将参数先赋值给这些变量,所以这些变量只是个副本,然后在线程启动并调用线程入口函数时,传递的参数只是这些副本,所以内部怎么操作都是改变副本,而不影响外面的变量。

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

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

当然这样并不好,多个线程同时修改同一个变量,会发生数据竞争。

同理,构造函数的第一个参数是可调用对象,默认情况下其实传递的还是一个副本。

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

class A {
public:
    void f(int x, char c) {}
    int g(double x) {return 0;}
    int operator()(int N) {return 0;}
};

void foo(int x) {}

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.join();
    t2.join();
    t3.join();
    t4.join();
    t5.join();
    t6.join();
    return 0;
}

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

只能移动不可复制

线程对象之间是不能复制的,只能移动,移动的意思是,将线程的所有权在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),从整体上来看,所有线程之间共享数据的问题,都是修改数据导致的,如果所有的共享数据都是只读的,就不会发生问题。但是这是不可能的,大部分共享数据都是要被修改的。

众所周知,cout是线程不安全的(缓冲区),尽量使用printf

mutex

使用std::mutex进行资源保护,头文件是#include <mutex>,共有两种操作:锁定(lock)解锁(unlock)

解决办法就是要对cout这个共享资源进行保护。在c++中,可以使用互斥锁std::mutex进行资源保护,头文件是#include <mutex>,共有两种操作:锁定(lock)解锁(unlock)。将cout重新封装成一个线程安全的函数:

#include <mutex>

//互斥锁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();
}

int main()
{
    std::thread t3(shared_mutex);
    for (int i = 0; i < 10; i++)
    {
        std::cout << "main" << i << std::endl;
    }
    t3.join();
}

使用std::mutex声明锁并加锁解锁即可。如果mu.lock()mu.unlock()之间的语句发生了异常,会发生什么?unlock()语句没有机会执行!导致导致mu一直处于锁着的状态,其他使用shared_print()函数的线程就会阻塞。

解决这个问题也很简单,使用c++中常见的RAII技术,即**获取资源即初始化(Resource Acquisition Is Initialization)**技术,**这是c++中管理资源的常用方式。简单的说就是在类的构造函数中创建资源,在析构函数中释放资源,因为就算发生了异常,c++也能保证类的析构函数能够执行。**我们不需要自己写个类包装mutexc++库已经提供了std::lock_guard类模板

std::lock_guard

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

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

使用std::lock_guard:

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

注意开括号 { 和闭括号 },保证std::lock_guard生命周期只在这{}里面有效。当生命周期离开临界区时,它的生命周期就结束了。

函数体范围或循环范围也限制了对象的生命周期。

void shared_print(string msg, int id) {
    //构造的时候帮忙上锁,析构的时候释放锁
    std::lock_guard<std::mutex> guard(mu);
    //mu.lock(); // 上锁
    cout << msg << id << endl;
    //mu.unlock(); // 解锁
}

可以实现自己的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

使用锁封装资源

使用锁将数据封装成一个类。由于cout是个全局共享的变量,没法完全封装,就算你封装了,外面还是能够使用cout,并且不用通过锁。下面使用文件流举例:

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

std::mutex mu;
class LogFile {
    std::mutex m_mutex;
    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,主线程中直接使用,子线程中通过引用传递过去(也可以使用单例来实现),这样就能保证资源被互斥锁保护着,外面没办法同时使用资源。

但是这个时候还是得小心了!用互斥元保护数据并不只是像上面那样保护每个函数,就能够完全的保证线程安全,如果将资源的指针或者引用不小心传递出来了,所有的保护都白费了!要记住一下两点:

  1. 不要提供函数让用户获取资源。
 std::mutex mu;
 class LogFile {
     std::mutex m_mutex;
     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;
     }
     // Never return f to the outside world
     ofstream& getStream() {
         return f;  //never do this !!!
     }
 };
  1. 不要资源传递给用户的函数。
 class LogFile {
     std::mutex m_mutex;
     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;
     }
     // Never return f to the outside world
     ofstream& getStream() {
         return f;  //never do this !!!
     }
     // Never pass f as an argument to user provided function
     void process(void fun(ostream&)) {
         fun(f);
     }
 };

以上两种做法都会将资源暴露给用户,造成不必要的安全隐患。

设计安全的数据处理过程

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

//互斥锁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;
    }

死锁

这里的死锁通常是代码块里用到了多个互斥锁,例如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);

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

互斥锁std::mutex是一种最常见的线程间同步的手段,但是在有些情况下不太高效。

条件变量(Condition Variable)

实现一个简单的消费者生产者模型,一个线程往队列中放入数据,一个线程往队列中取数据,取数据前需要判断一下队列中确实有数据,由于这个队列是线程间共享的,所以,需要使用互斥锁进行保护,一个线程在往队列添加数据的时候,另一个线程不能取,反之亦然。

c++11中提供了#include <condition_variable>头文件,其中的std::condition_variable可以和std::mutex结合一起使用,其中有两个重要的接口,notify_one()wait()wait()可以让线程陷入休眠状态,在消费者生产者模型中,如果生产者发现队列中没有东西,就可以让自己休眠,但是不能一直不干活啊,notify_one()就是唤醒处于wait中的其中一个条件变量(可能当时有很多条件变量都处于wait状态)。那什么时刻使用notify_one()比较好呢,当然是在生产者往队列中放数据的时候了,队列中有数据,就可以赶紧叫醒等待中的线程起来干活了。

/**
 * 
 * 生产者消费者模型
 * 
 **/
#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;
}
  1. consumer中,在判断队列是否为空的时候,使用的是while(q.empty()),而不是if(q.empty()),这是因为wait()从阻塞到返回,不一定就是由于notify_one()函数造成的,还有可能由于系统的不确定原因唤醒(可能和条件变量的实现机制有关),这个的时机和频率都是不确定的,被称作伪唤醒,如果在错误的时候被唤醒了,执行后面的语句就会错误,所以需要再次判断队列是否为空,如果还是为空,就继续wait()阻塞。
  2. 在管理互斥锁的时候,使用的是std::unique_lock而不是std::lock_guard,而且事实上也不能使用std::lock_guard,这需要先解释下wait()函数所做的事情。可以看到,在wait()函数之前,使用互斥锁保护了,如果wait的时候什么都没做,岂不是一直持有互斥锁?那生产者也会一直卡住,不能够将数据放入队列中了。所以,**wait()函数会先调用互斥锁的unlock()函数,然后再将自己睡眠,在被唤醒后,又会继续持有锁,保护后面的队列操作。**而lock_guard没有lockunlock接口,而unique_lock提供了。这就是必须使用unique_lock的原因。
  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

除了notify_one()函数,c++还提供了notify_all()函数,可以同时唤醒所有处于wait状态的条件变量。

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 );

内容来源

  1. blog.csdn.net/skylinethj/…

  2. www.jianshu.com/p/109df8a7e…

  3. zhuanlan.zhihu.com/p/128519905

  4. www.jianshu.com/p/4a2578dd9…

  5. www.jianshu.com/p/c01e992a3…

  6. www.jianshu.com/p/34d219380…

  7. www.jianshu.com/p/c1dfa1d40…

  8. www.runoob.com/w3cnote/cpp…