> 文章列表 > C++多线程

C++多线程

C++多线程

目录

一、线程库thread

1.1 线程对象的构造

1.2 thread类的成员函数

1.3 this_thread类

1.4 线程函数的参数问题

二、互斥量库mutex

2.1 mutex的种类

2.2 lock_guard与unique_lock

三、原子性操作库atomic

四、条件变量库condition_variable

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


在C++11之前,涉及到多线程问题,都是和平台相关的,比如Windows和Linux下有各自的接口,这使得代码的可移植性较差。C++11中最重要的特性就是对线程进行了支持,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念 

一、线程库thread

1.1 线程对象的构造

调用无参构造函数

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

thread t;

由于thread提供了移动赋值函数,因此当后续需要让该线程对象与线程函数关联时,可以以带参的方式创建一个匿名对象,然后调用移动赋值将该匿名对象关联线程的状态转移给该线程对象

#include <iostream>
#include <thread>
using namespace std;
void func(int num)
{for (int i = 0; i < num; ++i) {cout << i << endl;}
}
int main()
{thread t;//...t = thread(func, 100);t.join();return 0;
}

调用带参构造函数

template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
  • fn:可调用对象,比如函数指针、仿函数、lambda表达式、被包装器包装后的可调用对象等
  • args...:调用可调用对象fn时所需要的若干参数。
#include <iostream>
#include <thread>
using namespace std;
void func(int num)
{for (int i = 0; i < num; i++) {cout << i << endl;}
}
int main()
{thread t(func, 10);t.join();return 0;
}

调用移动构造函数

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

#include <iostream>
#include <thread>
using namespace std;
void func(int num)
{for (int i = 0; i < num; ++i) {cout << i << endl;}
}
int main()
{thread t = thread(func, 10);t.join();return 0;
}

注意: 

  • 线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态
  • 若创建线程对象时没有提供线程函数,那么该线程对象实际没有对应任何线程。
  • 若创建线程对象时提供了线程函数,那么就会启动一个线程来执行这个线程函数,该线程与主线程一起运行
  • thread类是防拷贝的,不允许拷贝构造和拷贝赋值,但是可以移动构造和移动赋值,可以将一个线程对象关联线程的状态转移给其他线程对象,并且转移期间不影响线程的执行

1.2 thread类的成员函数

thread类中常用的成员函数如下:

joinable()函数还可以用于判定线程是否是有效的,若是以下任意情况,则线程无效:

  • 采用无参构造函数构造的线程对象(该线程对象没有关联任何线程)
  • 线程对象的状态已经转移给其他线程对象(已经将线程交给其他线程对象管理)
  • 线程已经调用join或detach结束(线程已经结束)

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

join方式

主线程创建新线程后,可以调用join()函数等待新线程终止,当新线程终止时join()函数就会自动清理线程相关的资源。join()函数清理线程的相关资源后,thread对象与已销毁的线程就没有关系了,因此一个线程对象一般只会使用一次join(),否则程序会崩溃

#include <iostream>
#include <thread>
using namespace std;
void func(int n)
{for (int i = 0; i < n; i++) {cout << i << endl;}
}
int main()
{thread t(func, 10);t.join();t.join(); //程序崩溃return 0;
}

但如果一个线程对象join后,又调用移动赋值函数,将一个右值线程对象的关联线程的状态转移过来了,那么这个线程对象又可调用一次join

#include <iostream>
#include <thread>
using namespace std;
void func(int n)
{for (int i = 0; i < n; i++) {cout << i << endl;}
}
int main()
{thread t(func, 10);t.join();t = thread(func, 10);t.join();return 0;
}

但采用join的方式结束线程,在某些场景下也可能会出现问题。如在该线程被join前,若中途因为某些原因导致程序不再执行后续代码,这时这个线程将不会被join

#include <iostream>
#include <thread>
#include <windows.h>
using namespace std;
void func(int num)
{for (int i = 0; i < num; i++) {cout << i << endl;}
}
bool DoSomething(){ return false; }
int main()
{thread t(func, 10);Sleep(3);if (!DoSomething()) return -1;t.join(); //不会被执行return 0;
}

因此采用join方式结束线程时,join()函数的调用位置非常关键,为了避免上述问题,可以采用RAII的方式对线程对象进行封装,即利用对象的生命周期来控制线程资源的释放

#include <iostream>
#include <thread>
#include <windows.h>
using namespace std;
class Thread
{
public:Thread(thread& t):_thread(t) {}~Thread() {if (_thread.joinable()) _thread.join();}
private://防拷贝Thread(const Thread&) = delete;Thread& operator=(const Thread&) = delete;
private:thread& _thread;
};void func(int num)
{for (int i = 0; i < num; i++) {cout << i << endl;}
}
bool DoSomething(){ return false; }
int main()
{thread t(func, 10);Thread T(t);Sleep(3);if (!DoSomething()) return 1;return 0;
}
  • 每当创建一个线程对象后,就用Thread类对其进行封装产生一个Thread对象。
  • 当Thread对象生命周期结束时就会调用析构函数,在析构中会通过joinable()判断这个线程是否需要被join,若需要那么就会调用join对其该线程进行等待

detach方式

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

  • 使用detach的方式回收线程的资源,一般在线程对象创建好之后就立即调用detach函数。
  • 否则线程对象可能会因为某些原因,在后续调用detach函数分离线程之前被销毁掉,这时就会导致程序崩溃。
  • 因为当线程对象被销毁时会调用thread的析构函数,而在thread的析构函数中会通过joinable判断这个线程是否需要被join,如果需要那么就会调用terminate终止当前程序(程序崩溃)

1.3 this_thread类

函数名 功能
get_id 获取当前线程ID
yeild 当前线程出让时间片,CPU调度其他时间片
sleep_until 使调用线程休眠到一个固定时间(绝对时间)
sleep_for 使调用线程休眠一个时间段(相对时间)

当想获取线程ID时,可以通过线程对象的get_id()接口,但想在与线程关联的线程函数中获取线程ID,这个办法就行不通了,可以调用this_thread类中的接口get_id()

#include <iostream>
#include <thread>
using namespace std;
void func()
{cout << this_thread::get_id() << endl;
}
int main()
{thread t(func);t.join();return 0;
}

1.4 线程函数的参数问题

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

若要通过线程函数的形参改变外部的实参,可以参考以下三种方法:

方法一:借助std::ref()

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

#include <iostream>
#include <thread>
using namespace std;
void add(int& num) {num++;
}
int main()
{int num = 0;thread t(add, ref(num));t.join();cout << num << endl;return 0;
}

方法二:指针地址

将线程函数的参数类型改为指针类型,将实参的地址传入线程函数,此时在线程函数中可以通过修改该地址处的变量,进而影响到外部实参

#include <iostream>
#include <thread>
using namespace std;
void add(int* num) {++(*num);
}
int main()
{int num = 0;thread t(add, &num);t.join();cout << num << endl;return 0;
}

方法三:lambda表达式

将lambda表达式作为线程函数,利用lambda函数的捕捉列表,以引用的方式对外部实参进行捕捉,此时在lambda表达式中对形参的修改也能影响到外部实参

#include <iostream>
#include <thread>
using namespace std;
int main()
{int num = 0;thread t([&num](){ ++num; });t.join();cout << num << endl;return 0;
}

二、互斥量库mutex

2.1 mutex的种类

在C++11中,mutex类中总共包了四种互斥量

std::muetx

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

常用成员函数如下:

线程函数调用lock时:

  • 若该互斥量当前没有被其他线程获取,则调用线程获取互斥量,直到调用unlock()之前,该线程一直拥有该锁
  • 若该互斥量已经被其他线程获取,则当前的调用线程会被阻塞,直至其他线程将互斥量释放
  • 若当前线程已获取该互斥量,却又调用lock(),则会产生死锁(deadlock)

线程调用try_lock时:

  • 若该互斥量当前没有被其他线程获取,则调用线程获取该互斥量,直到调用unlock()之前,该线程一直拥有该锁
  • 若该互斥量已经被其他线程获取,则try_lock()调用返回false,调用线程不会被阻塞
  • 若当前线程已获取该互斥量,却又调用try_lock(),则会产生死锁(deadlock)

std:recursive_mutex

recursive_mutex被称为递归互斥锁,该锁专门用于递归函数中的加锁操作

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

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

std::timed_mutex

timed_mutex中提供了以下两个成员函数:

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

timed_mutex也提供了lock、try_lock和unlock成员函数,其的特性与mutex相同

std::recursive_timed_mutex

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

2.2 lock_guard与unique_lock

使用互斥锁时可能出现的问题

使用互斥锁时,若在加锁区域抛出异常,则后续的解锁代码则不会执行,导致此后所有申请该锁的线程都被阻塞。或者加锁的范围太大,那么极有可能在中途返回时忘记了解锁

因此C++11采用RAII的方式对锁进行了封装,于是就有了lock_guard和unique_lock

lock_guard

lock_guard是C++11中的一个模板类

template <class Mutex>
class lock_guard;

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

  • 在需要加锁的地方,用互斥锁实例化一个lock_guard对象,在lock_guard的构造函数中会调用lock()进行加锁
  • 当lock_guard对象出作用域前会自动调用析构函数,而在lock_guard的析构函数中调用了unlock()自动解锁

从lock_guard对象定义到该对象析构,这段区域的代码都属于互斥锁的保护范围。若只想保护某一段代码,可以通过定义匿名的局部域来控制lock_guard对象的生命周期

#include <iostream>
#include <mutex>
using namespace std;
mutex mtx;
void func()
{//匿名局部域{lock_guard<mutex> lg(mtx); //调用构造函数加锁if (true) {return; //调用析构函数解锁}} //调用析构函数解锁
}
int main()
{func();return 0;
}

模拟实现lock_guard

  • lock_guard类中包含一个锁成员变量(引用类型),这个锁就是lock_guard对象管理的互斥锁
  • 调用lock_guard的构造函数时需要传入一个被管理互斥锁,用该互斥锁来初始化锁成员变量后,调用互斥锁的lock函数进行加锁
  • lock_guard的析构函数中调用互斥锁的unlock进行解锁
  • 需要删除lock_guard类的拷贝构造和拷贝赋值,lock_guard类本身也是不支持拷贝的
template<class Mutex>
class lock_guard
{
public:lock_guard(Mutex& mtx) :_mtx(mtx) {mtx.lock(); //加锁}~lock_guard() {mtx.unlock(); //解锁}	
private:lock_guard(const lock_guard&) = delete;lock_guard& operator=(const lock_guard&) = delete;
private:Mutex& _mtx;
};

unique_lock

由于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所管理的互斥量的指针)

如下场景就适合使用unique_lock:

  • 要用互斥锁保护func1()的大部分代码,但是中间有一小块代码调用了func2(),而func2()不需要用func1()中的互斥锁进行保护,func2()内部的代码由其他互斥锁进行保护
  • 因此在调用func2()之前需要对当前互斥锁进行解锁,当func()调用返回后再进行加锁,这样当调用func2()时其他线程调用func1()就能够获取到这个锁

三、原子性操作库atomic

线程安全问题

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

上述代码中分别让两个线程对同一个变量num进行了100000次++操作,理论上最终num的值应该是200000,但最终打印出n的值却是小于200000的

根本原因就是++操作并不是一个原子操作,该操作分为三步:

  • load:将共享变量n从内存加载到寄存器中
  • update:更新寄存器里面的值,执行+1操作
  • store:将新值从寄存器写回共享变量num的内存地址

++操作对应的汇编代码如下:

因此可能当线程1刚将num的值加载到寄存器中就被切走了,也就是只完成了++操作的第一步,而线程2可能顺利完成了一次完整的++操作才被切走,而这时线程1继续用之前加载到寄存器中的值完成剩余的两步操作,最终就会导致两个线程分别对共享变量num进行了一次++操作,但最终num的值却只被++了一次

加锁解决线程安全问题

#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
void func(int& num, int count, mutex& mtx)
{mtx.lock();for (int i = 0; i < count; i++) {//mtx.lock();++num;//mtx.unlock();}mtx.unlock();
}
int main()
{int num = 0;int count = 100000; //每个线程对num++的次数mutex mtx;thread t1(func, ref(num), count, ref(mtx));thread t2(func, ref(num), count, ref(mtx));t1.join();t2.join();cout << num << endl;return 0;
}

可以选择在for循环体里面进行加锁解锁,也可以选择在for循环体外进行加锁解锁。但效果终究是不尽人意的,在for循环体里面进行加锁解锁会导致线程的频繁进行加锁解锁操作,在for循环体外面进行加锁解锁会导致两个线程的执行逻辑变为串行

原子类解决线程安全问题

注意: 需要用大括号对原子类型的变量进行初始化

#include <iostream>
#include <thread>
#include <atomic>
using namespace std;
void func(atomic_int& num, int count)
{for (int i = 0; i < count; i++) {++num;}
}
int main()
{atomic_int num = { 0 };int count = 100000; //每个线程对n++的次数thread t1(func, ref(num), count);thread t2(func, ref(num), count);t1.join();t2.join();cout << num << endl; //打印n的值return 0;
}
  • 原子类型通常属于"资源类型"数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等
  • 为了防止意外,标准库已经将atomic模板类中的拷贝构造、移动构造、operator=默认删除
  • 原子类型不仅仅支持原子的++操作,还支持原子的--、+=、-=、与、或、异或操作

原子操作原理CAS

  • 在对变量进行计算之前(如 ++ 操作),首先读取原变量值,称为 旧的预期值 A
  • 然后在更新之前再获取当前内存中的值,称为 当前内存值 V
  • 如果 A==V 则说明变量从未被其他线程修改过,此时将会写入新值 B
  • 如果 A!=V 则说明变量已经被其他线程修改过,当前线程应当重新++

四、条件变量库condition_variable

wait系列成员函数

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

下面以wait为例,wait()函数提供了两个不同的版本:

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

为什么调用wait系列函数时需要传入一个互斥锁?

  • 因为wait系列函数一般是在临界区中调用的,为了让当前线程调用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:唤醒等待队列中的首个线程,若等待队列为空则什么也不做
  • notify_all:唤醒等待队列中的所有线程,若等待队列为空则什么也不做

注意: 条件变量下可能会有多个线程在进行阻塞等待,其会被放到一个等待队列中进行排队

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

尝试用两个线程交替打印1-100的数字,要求一个线程打印奇数,另一个线程打印偶数,并且打印数字从小到大依次递增

该题目主要考察的就是线程的同步和互斥

  • 互斥:两个线程都在向控制台打印数据,为了保证两个线程的打印数据不会相互影响,因此需要对线程的打印过程进行加锁保护
  • 同步:两个线程必须交替进行打印,因此需要用到条件变量让两个线程进行同步,当一个线程打印完再唤醒另一个线程进行打印

但只有同步和互斥是无法满足题目要求的,无法保证哪一个线程会先进行打印,不能说先创建的线程就一定先打印,后创建的线程先打印也是有可能的

#include <iostream>
#include <thread>
#include <atomic>
#include <condition_variable>
using namespace std;
int main()
{int i = 1;int n = 100;mutex mtx;condition_variable cv;size_t flag = 1;//1 or 2 代表哪个线程可以打印//奇数thread t1([&]() {while (i < n)//最大99{unique_lock<mutex> lock(mtx);cv.wait(lock, [&flag]()->bool { return flag == 1; });cout << this_thread::get_id() << ":" << i << endl;i += 1;flag = 2;cv.notify_one();}});//偶数thread t2([&]() {while (i <= n)//最大100{unique_lock<mutex> lock(mtx);cv.wait(lock, [&flag]()->bool { return flag == 2; });cout << this_thread::get_id() << ":" << i << endl;i += 1;flag = 1;cv.notify_one();}});this_thread::sleep_for(chrono::seconds(1));cout << "t1:" << t1.get_id() << endl;cout << "t2:" << t2.get_id() << endl;t1.join();t2.join();return 0;
}

毕业设计网