本篇博客主要介绍C++11中有关线程的知识,主要包括线程库thread,锁mutex,条件变量condition_variable,以及源子库atomic。

线程库thread

thread库的引入

在C++11之前,只要是涉及到多线程的问题,都是和平台相关的,由于windows和Linux下各自有自己的接口,这使得代码的可移植性比较差,为了解决这个问题C++11中对线程进行了支持,使得C++在并行编程时不需要依赖第三方库,可以通过直接包含对应的库就可以实现线程的相关操作,并且在库中引入了,原子操作中以及原子类的概念。

使用时必须包含<thread>头文件

线程对象的构造方法

1.调用无参的构造函数

thread提供了无参构造函数,调用无参的构造函数创建出来的线程对象没有关联任何线程函数即没有启动任何线程,比如:

//无参构造,创建线程但不启动
thread t1;

使用无参构造出来的线程对象没有任何线程函数,即使没有启动线程。但是并不意味着无参构造函数就没有丝毫的作用,由于thread提供了移动赋值函数,因此当后续需要让该线程对象与线程函数关联时,可以以带参的方式创建一个匿名对象,然后调用移动赋值将该匿名对象关联线程的状态转移给该线程对象。比如下面这样:

void func(int n)
{
    for(int i = 0;i < n;i++)
    {
        std::cout<<i<<" ";
    }
}
int main()
{
    thread t1;
    t1 = thread(func, 10);
    t1.join();
    return 0;
}
2.调用带参的构造函数

thread带参构造函数定义如下:

template<class Fn,class... Args>
explicit thread(Fn&& fn,Args&&... args);

参数讲解:

  • fn:可调用对象,比如函数指针、仿函数、lambda表达式、被包装器包装后的可调用对象等。
  • args...:调用可调用对象fn时所需要的若干参数。

调用带参的构造函数创建线程对象,能够将线程对象与线程函数fn进行关联。比如:

void func(int n)
{
    for(int i = 0;i < n;i++)
    {
        std::cout<<i<<" ";
    }
}
int main()
{
    thread t2(func,10);
    t2.join();
    return 0;
}
3.调用移动构造

thread提供了移动构造函数,能够用一个右值线程对象来构造一个线程对象。比如:

void func(int n)
{
    for(int i = 0;i < n;i++)
    {
        std::cout<<i<<" ";
    }
}
int main()
{
    thread t3(func,10);
    t3.join();
    return 0;
}
注意:

thread线程是防拷贝的,不允许拷贝构造和拷贝赋值,但是可以移动构造和移动赋值,可以将一个线程对象关联线程的状态转移给其它线程对象,并且在这期间不影响线程的执行。

thread提供的成员函数

补充

joinable函数还可以判断当前线程是否有效,如果是以下情况贤臣是无效的。

  • 采用无参构造函数,因该线程没有关联任何线程。
  • 线程对象的状态已经转移给其它线程对象。
  • 当前线程已经join/detach了
获取线程id的方法
使用方法:

可以被线程对象调用,如果在线程内查询的话也可以使用this_thtread::get_id();

举个例子:
void func()
{
    std::cout<<this_thread::get_id()<<std::endl;
}
int main()
{
    thread t(func);
    std::cout<<t.get_id()<<std::endl;
    t.join();
    return 0;
}
线程参数

线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,就算线程函数的参数为引用类型,在线程函数中修改后也不会影响到外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。比如:

void add(int& num)
{
    num++;
}
int main()
{
    int num = 0;
    thread t(add,num);
    t.join();
    std::cout<<num<<" ";
    return 0;
}

输出结果为:0

为什么会这样?

  • 因为add函数的参数int&收到的根本不是你传上去的num,而是thread类创建出来的对象,包含的对象,所以引用改变不了传上去的num。
我们可以借助以下的方法实现改变我们传上去的参数。

1.std::ref函数

当线程函数的参数类型为引用类型时,如果要想线程函数形参引用的是外部传入的实参,而不是线程栈空间中的拷贝,那么在传入实参时需要借助ref函数保持对实参的引用。比如:

void add(int& num)
{
    num++;
}
int main()
{
    int num = 0;
    thread t2(add,std::ref(num));
    t2.join();
    return 0;
}

thread对象包含的对象是模板类型,当你将ref传进去,那么该模板类型是引用,也就是对num的引用,所以把模板类的num引用在传给add函数,也就是把对num引用在进行引用,所以就实现了对外部传入实参的更改。

2.传入指针类型

除了使用ref,我们还可以通过地址的拷贝将线程函数的参数类型改为指针类型,将实参的地址传入线程函数,此时在线程函数中可以通过修改该地址处的变量,进而影响到外部实参。比如:

void add(int* num)
{
    (*num)++;
}
int main()
{
    int num = 0;
    thread t(add,&num);
    t.join();
    return 0;
}

与ref差不多只是这个更好理解了,将num的地址传给thread构造,再由thread将该参数传给add,也就是thread对象存储外部实参的对象充当了中转的一个角色。

3.使用lambda表达式

也可以通过借助lambda表达式,因为lambda表达式可以不传参,而是通过捕获的方式获得参数,将lambda表达式作为线程函数,利用lambda函数的捕捉列表,以引用的方式对外部实参进行捕捉,此时在lambda表达式中对形参的修改也能影响到外部实参。比如:

int main()
{
    int num = 0;
    thread t([&num]{num++;});
    t.join();
    return 0;
}
join和detach函数

我们启动一个线程后,当这个线程退出时,需要对该线程所使用的资源进行回收,否则可能会导致内存泄露等问题。thread库给我们提供了如下两种回收线程资源的方式:

join函数

当主线程创建新线程后,可以调用join函数等待线程,当线程执行完毕后,调用join函数就会清理线程的相关资源。但是一个线程只能join一次,执行多次join会报错。但是可以join后重新移动赋值得到新的线程比如:

void func(int n)
{
    for(int i = 0;i < n;i++)
    {
        std::cout<<i<<" ";
    }
}
int main()
{
    thread t(func,10);
    t.join();
    t = thread(func,10);
    t.join();
    return 0;
}

因为有时可能会出现调用链太长,导致使用者可能出现忘记join的情况,所以我推荐这里使用RAII思想管理线程,这样出了作用域自行会join,比如:

class myThread
{
private:
    thread t_;
    mythread(const thread&) = delete;
    mythread& operator=(mythread mt) = delete;
public:
    myThread(thread&& t)
        :t_(t)
    {}
    ~mythread()
    {
        t_.join();
    }
}
detach函数

主线程创建新线程后,也可以调用detach函数将新线程与主线程进行分离,分离后新线程会在后台运行,其所有权和控制权将会交给C++运行库,此时C++运行库会保证当线程退出时,其相关资源能够被正确回收,以下是使用的注意事项:

  • 在使用detach后也要保证主线程是最后退出的,如果让主线程先退出,会出现问题。
  • 使用detach的方式回收线程的资源,一般在线程对象创建好之后就立即调用detach函数。否则线程对象可能会因为某些原因,在后续调用detach函数分离线程之前被销毁掉,这时就会导致程序崩溃。
void printMessage
{
    std::cout<<执行线程<<std::endl;
}
int main()
{
    std::thread t(printMessage);
    //分离线程
    t.detach();
    //结合项目需求保证主线程最后退出
    ...
    return 0;
}

互斥量库mutex

线程安全问题

多个线程对同一个资源进行修改会涉及到线程安全问题,该资源叫做临界资源。

举个例子

void func(int n,int& num)
{
    for(int i = 0;i < n;i++)
    {
        num++;
    }
}
int main()
{
    //num为临界资源,两条线程均可以更改num
    int num = 0;
    thread t1(func,10000,std::ref(num));
    thread t2(func,10000,std::ref(num));
    return 0;;
}

四种mutex互斥量

1.std::mutex

mutex锁是C++11提供的最基本的互斥量,mutex对象之间不能进行拷贝,也不能进行移动。

mutex中常用的成员函数如下:

线程函数调用lock会发生以下三种情况:

  • 如果该互斥量当前没有被其他线程锁住,则调用线程将该互斥量锁住,直到调用unlock之前,该线程一致拥有该锁。
  • 如果该互斥量已经被其他线程锁住,则当前的调用线程会被阻塞。
  • 如果该互斥量被当前调用线程锁住,则会产生死锁(deadlock)。

线程调用try_lock时,给我的感觉就是非阻塞申请锁资源,申请到锁资源就会往下执行直到unlock,申请不到直接返回false。

  • 如果该互斥量当前没有被其他线程锁住,则调用线程将该互斥量锁住,直到调用unlock之前,该线程一致拥有该锁。
  • 如果该互斥量已经被其他线程锁住,则try_lock调用返回false,当前的调用线程不会被阻塞。
  • 如果该互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
2.std::recursive_mutex

recursive_mutex叫做递归互斥锁,该锁专门用于递归函数中的加锁操作。

  • 如果在递归函数中使用mutex互斥锁进行加锁,那么在线程进行递归调用时,可能会重复申请已经申请到但自己还未释放的锁,进而导致死锁问题。而recursive_mutex允许同一个线程对互斥量多次上锁(即递归上锁),来获得互斥量对象的多层所有权,但是释放互斥量时需要调用与该锁层次深度相同次数的unlock。

同样recursive_mutex也提供了lock,try_lock,unlock成员函数,其特性与mutex大致相同。

3.std::timed_mutex

timed_mutex比前面多提供了两个成员函数。

  • try_lock_for:接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间之内还是没有获得锁),则返回false。
  • try_lock_untill:接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间点到来时还是没有获得锁),则返回false。

同样timed_mutex也提供了lock,try_lock,unlock成员函数,其特性依然与mutex大致相同。

4.std::recursive_timed_mutex

recursive_timed_mutex就是recursive_mutex和timed_mutex的结合,recursive_timed_mutex既支持在递归函数中进行加锁操作,也支持定时尝试申请锁。

锁的使用

1.解决线程安全问题

举个例子

#include<mutex>
int sum = 0;
void func(size_t n,std::mutex mtx)
{
    for(int i = 0;i<n;i++)
    {
        mtx.lock();
        sum += i;
        mtx.unlock();
    }
}
int main()
{
    std::mutex mtx;
    thread t1(func,10000.std::ref(mtx));
    thread t2(func,10000,std::ref(mtx));
    t1.join();
    t2.join();
    return 0;
}
  • for外加锁比for内加锁效率更加高效一些,因为这样避免了线程不断的切换也就避免了频繁的上下文切换,减少了上下文切换所需的时间,所以效率更高。
  • 使用锁的方式除了上述的将锁传递给不同线程使用,传参时除了可以使用ref还可以使用指针,不可以直接传递锁对象过去,原因前面已经介绍过了,还可以定义全局变量。
2.lock_gurd/unique_lock的使用

C++11使用了RAII思想对锁进行了封装,于是出现了lock_gurd/unique_lock。

lock_guard类模板主要是通过RAII的方式,对其管理的互斥锁进行了封装。

  • 在需要加锁的地方,用互斥锁实例化一个lock_guard对象,在lock_guard的构造函数中会调用lock进行加锁。
  • 当lock_guard对象出作用域前会调用析构函数,在lock_guard的析构函数中会调用unlock自动解锁。
模拟实现lock_guard
tempalte<class myMtx>
class MyMutex
{
private:
    myMtx& mtx_;
    lock_guard(const MyMutex& mymtx) = delete;
    MyMutex& operator=(MyMutex mymtx) = delete;
public:
    lock_guard(myMtx& mtx)
        mtx_(mtx)
    {
        mtx_.lock();
    }
    ~lock()
    {
        mtx_.unlock();
    }
}

由于lock_guard太单一,用户没有办法直接对锁进行控制,因此C++11又提供了unique_lock。

unique_lock与lock_guard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装。在创建unique_lock对象调用构造函数时也会调用lock进行加锁,在unique_lock对象销毁调用析构函数时也会调用unlock进行解锁。

但与lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:

  • 加锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock。
  • 修改操作:移动赋值、swap、release(返回它所管理的互斥量对象的指针,并释放所有权)。
  • 获取属性:owns_lock(返回当前对象是否上了锁)、operator bool(与owns_lock的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)。

原子性操作库atomic

atomic的引入

我们知道,多线程最主要的问题是共享数据带来的问题(即线程安全)。如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦。就比如下面的例子:

void func(int n,int& sum)
{
    for(int i = 0;i < n;i++)
    {
        sum++;
    }
}
int main()
{
    int sum = 0;
    thread t1(func,10000,std::ref(sum));
    thread t2(func,10000,std::ref(sum));
    t1.join();
    t2.join();
    return 0;
}

上述代码中sum就是临界资源,本应该结果是20000,但是结果大概率是小于20000的,因为sum++看起来是一步,实际汇编语言是三步。

  1. 先将sum从内存中拿到寄存器当中
  2. 在寄存器中将sum值进行更改
  3. 将sum值放回内存当中

试想如果一个线程执行完第二步被切走了,下一个线程在执行第一步,也就是从内存中拿取sum值,这时sum值还没有更新还是0,则这个线程也是将sum从0加到1,这时切回刚才那个线程将加好的值放回来也就是把1放进内存,明明加了两次却只按加了一次算,所以结果可能小于20000。这种情况C++98选择加锁保护,但是涉及到频繁切换上下文,导致效率减缓,所以C++11推出了原子操作atomic。

atomic详解

C++11中引入了原子操作类型,使得线程间数据的同步变得非常高效。如下:

注意:

  • 需要用大括号对原子类型的变量进行初始化。
  • 程序员不需要对原子类型进行加锁解锁操作,线程能够对原子类型变量进行互斥访问。

举个例子:

void func(int n,atomic_int sum)
{
    for(int i = 0;i < n;i++)
    {
        sum++;
    }
}
int main()
{
    atomic_int sum = {0};
    thread t1(func,10000,std::ref(sum));
    thread t2(func,10000,std::ref(sum));
    t1.join();
    t2.join();
    return 0;
}

亦或者,也可以使用atomic类模板定义出任意原子类型,比如上述代码还可以改为:

void func(int n,atomic_int sum)
{
    for(int i = 0;i < n;i++)
    {
        sum++;
    }
}
int main()
{
    atomic<int> sum = 0;
    thread t1(func,10000,std::ref(sum));
    thread t2(func,10000,std::ref(sum));
    t1.join();
    t2.join();
    return 0;
}
  • 原子类型通常属于“资源类型”数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等。
  • 为了防止意外,标准库已经将atomic模板类中的拷贝构造、移动构造、operator=默认删除掉了。
  • 原子类型不仅仅支持原子的++操作,还支持原子的--、加一个值、减一个值、与、或、异或操作。

条件变量库condition_variable

condition_variable中提供的成员函数,可分为wait系列和notify系列

wait系列成员

wait系列成员函数的作用就是让调用线程进行阻塞等待,包括wait、wait_for和wait_until。

下面先以wait为例进行介绍,wait函数提供了两个不同版本的接口:

//版本1
void wait(unique_lock<mutex>& lock);
//版本2
template<class Predicate>
void wait(unique_lock<mutex>& lock,Predcate pred);
函数讲解
  • 调用第一个版本的wait函数时只需要传入一个互斥锁,线程调用wait后会立即被阻塞,直到被唤醒。
  • 调用第二个版本的wait函数时除了需要传入一个互斥锁,还需要传入一个返回值类型为bool的可调用对象,与第一个版本的wait不同的是,当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么该线程还需要继续被阻塞。

为什么要在调用wait的时候传入一个互斥锁?

因为当你调用wait时可能会陷入阻塞,如果带锁进入阻塞就会陷入死锁,谁也拿不到这个锁,而传入所的话,这样陷入阻塞就会自动解锁,然后当着线程在被唤醒时,重新陷入锁的竞争,所以wait不光可以让线程陷入阻塞,还会释放该线程的锁资源。

wait_for和wait_until的函数使用方法与wait类似
  • wait_for函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个时间段,表示让线程在该时间段内进行阻塞等待,如果超过这个时间段则线程被自动唤醒。
  • wait_until函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个具体的时间点,表示让线程在该时间点之前进行阻塞等待,如果超过这个时间点则线程被自动唤醒。
  • 线程调用wait_for或wait_until函数在阻塞等待期间,其他线程调用notify系列函数也可以将其唤醒。此外,如果调用的是wait_for或wait_until函数的第二个版本的接口,那么当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么当前线程还需要继续被阻塞。
注意:
  • 调用wait系列函数时,传入互斥锁的类型必须时unique_lock。

notify系列成员函数

  • notify系列成员函数的作用就是唤醒等待的线程,包括notify_one和notify_all。
  • notify_one:唤醒等待队列中的首个线程,如果等待队列为空则什么也不做。\nnotify_all:唤醒等待队列中的所有线程,如果等待队列为空则什么也不做。

实现两个线程交替打印1-100

int main()
{
    int i = 1;
    mutex mtx;
    condition_variable cv;
    thread t1([&i](){
    for(;i <= 100;i++)
    {
        unique_lock<mutex> lock(mtx);
        if(i == 100)
           break;
        //如果为奇数不进入阻塞
        cv.wait(lock,[&i](){return i % 2 != 0}
        std::cout<< i <<std::endl;
        cv.notify_all();
    }
    )});
    thread t2([&i](){
    for(i <= 100;i++)
    {
        unique_lock<mutex> lock(mtx);
        //如果为偶数不进入阻塞
        cv.wait(lock,[&i](){return i % 2 == 0});
        std::cout<< i << std::endl;
        cv.notify_all();
    }});
    t1.join();
    t2.join();
    return 0;
}

个人经验

当我们创建了一批线程,这批线程都执行同一件事,我们可以观察这批线程会出会临界资源的问题,如果会那么要及时改进,如果不会我们可以给它当作一条线程来看,不要当成一批线程来看,要不然太乱了。

向上面实现的交替书写1-100的数字,我们使用的是两个线程,是不是也可以使用两批线程,且执行同一功能的线程不会相互影响,那么我们就可以当作两条线程来看,一定是第一条线程先走,因为1是奇数,当书写完1的时候,一定执行第二条线程,因为2是偶数,这样看就不会很乱。

核心思想就是把相同功能的线程当成一条线程,前提是这些相同功能的函数不会出现临界区资源泄露的问题。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐