在C++11之前,涉及到多线程问题,都是和平台相关的,比如Windows和Linux下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行支持了,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含相关的头文件
头文件一定记得如下几个:
#include//线程库
#include//条件变量
#include//互斥锁
thread提供了无参的构造函数,调用无参的构造函数创建出来的线程对象没有关联任何线程函数,即没有启动任何线程。
由于thread提供了移动赋值函数,因此当后续需要让该线程对象与线程函数关联时,可以以带参的方式创建一个匿名对象,然后调用移动赋值将该匿名对象关联线程的状态转移给该线程对象。
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t1;
//...
t1 = thread(func, 10);
t1.join();
return 0;
}
场景:实现线程池的时候就是需要先创建一批线程,但一开始这些线程什么也不做,当有任务到来时再让这些线程来处理这些任务。
template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
参数说明:
调用带参的构造函数创建线程对象,能够将线程对象与线程函数fn进行关联。
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t2(func, 10);
t2.join();
return 0;
}
thread提供了移动构造函数,能够用一个右值线程对象来构造一个线程对象。
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t3 = thread(func, 10);
t3.join();
return 0;
}
成员函数 | 功能 |
---|---|
join | 对该线程进行等待,在等待的线程返回之前,调用join函数的线程将会被阻塞 |
joinable | 判断该线程是否已经执行完毕,如果是则返回true,否则返回false |
detach | 将该线程与创建线程进行分离,被分离后的线程不再需要创建线程调用join函数对其进行等待 |
get_id | 获取该线程的id |
swap | 将两个线程对象关联线程的状态进行交换 |
说明:
此外,joinable函数还可以用于判定线程是否是有效的,如果是以下任意情况,则线程无效:
调用thread的成员函数get_id可以获取线程的id,但该方法必须通过线程对象来调用get_id函数,如果要在线程对象关联的线程函数中获取线程id,可以调用this_thread命名空间下的get_id函数。
void func()
{
cout << this_thread::get_id() << endl; //获取线程id
}
int main()
{
thread t(func);
t.join();
return 0;
}
this_thread命名空间中还提供了以下三个函数:
函数名 | 功能 |
---|---|
yield | 当前线程“放弃”执行,让操作系统调度另一线程继续执行 |
sleep_until | 让当前线程休眠到一个具体时间点 |
sleep_for | 让当前线程休眠一个时间段 |
线程函数的参数是以值拷贝方式拷贝到线程空间中的,就算线程函数的参数为引用类型,在线程函数中修改后也不会影响到外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。
#include
void ThreadFunc1(int& x)
{
x += 10;
}
int main()
{
int a = 10;
// 在线程函数中对a修改,不会影响外部实参,因为:线程函数参数虽然是引用方式,但其实际引用的是线程栈中的拷贝
thread t1(ThreadFunc1, a);
t1.join();
cout << a << endl;
}
如果要通过线程函数的形参改变外部的实参,可以通过以下三种方式:
方式一:借助std::ref函数
当线程函数的参数类型为引用类型时,如果要想线程函数形参引用的是外部传入的实参,而不是线程栈空间中的拷贝,那么在传入实参时需要借助ref函数保持对实参的引用。
#include
void ThreadFunc1(int& x)
{
x += 10;
}
int main()
{
int a = 10;
// 如果想要通过形参改变外部实参时,必须借助std::ref()函数
thread t2(ThreadFunc1, std::ref(a));
t2.join();
cout << a << endl;
}
方式二:地址的拷贝
将线程函数的参数类型改为指针类型,将实参的地址传入线程函数,此时在线程函数中可以通过修改该地址处的变量,进而影响到外部实参。
#include
void ThreadFunc2(int* x)
{
*x += 10;
}
int main()
{
int a = 10;
// 地址的拷贝
thread t3(ThreadFunc2, &a);
t3.join();
cout << a << endl;
return 0;
}
方式三:借助lambda表达式
将lambda表达式作为线程函数,利用lambda函数的捕捉列表,以引用的方式对外部实参进行捕捉,此时在lambda表达式中对形参的修改也能影响到外部实参。
#include
int main()
{
int a = 10;
// 借助lambda表达式
thread t3([&a]{a+=10;});
t3.join();
cout << a << endl;
return 0;
}
启动一个线程后,当这个线程退出时,需要对该线程所使用的资源进行回收,否则可能会导致内存泄露等问题。thread库给我们提供了两种回收线程资源的方式。
主线程创建新线程后,可以调用join函数等待新线程终止,当新线程终止时join函数就会自动清理线程相关的资源。
join函数清理线程的相关资源后,thread对象与已销毁的线程就没有关系了,因此一个线程对象一般只会用一次join,否则程序就会崩溃。
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t(func, 20);
t.join();
t.join(); //程序崩溃
return 0;
}
但如果一个线程对象join后,又调用移动赋值函数,将一个右值线程对象的关联线程的状态转移过来了,那么这个线程对象又可以调用一次join。
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t(func, 20);
t.join();
t = thread(func, 30);
t.join();
return 0;
}
但采用join的方式结束线程,在某些场景下也可能出现问题。比如该线程被join之前,如果中途因为某些原因导致程序不再执行后续代码,这时这个线程将不会被join。
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
bool DoSomething()
{
return false;
}
int main()
{
thread t(func, 20);
//...
if (!DoSomething())
return -1;
//...
t.join(); //不会被执行
return 0;
}
因此采用join方式结束线程时,join的调用位置非常关键,为了避免上述问题,可以采用RAII(资源获取即初始化)的方式对线程对象进行封装,也就是利用对象的声明周期来控制线程资源的释放。
class myThread
{
public:
myThread(thread& t)
:_t(t)
{}
~myThread()
{
if (_t.joinable())
_t.join();
}
//防拷贝
myThread(myThread const&) = delete;
myThread& operator=(const myThread&) = delete;
private:
thread& _t;
};
使用方式:
使用myThread类对线程对象进行封装后,就能保证线程一定会被join。
int main()
{
thread t(func, 20);
myThread mt(t); //使用myThread对线程对象进行封装
//...
if (!DoSomething())
return -1;
//...
t.join();
return 0;
}
主线程创建新线程后,也可以调用detach函数将新线程与主线程进行分离,分离后新线程会在后台运行,其所有权和控制权将会交给C++运行库,此时C++运行库会保证当线程退出时,其相关资源能够被正确回收。
在C++11中,mutex中总共包含了四种互斥量
mutex锁是C++11提供的最基本的互斥量,mutex对象之间不能进行拷贝,也不能进行移动。
mutex中常用的成员函数:
成员函数 | 功能 |
---|---|
lock | 对互斥量进行加锁 |
try_lock | 尝试对互斥量进行加锁 |
unlock | 对互斥量进行解锁,释放互斥量的所有权 |
线程函数调用lock时,可能会发生以下三种情况: |
线程调用try_lock时,类似也可能会发生以下三种情况:
recursive_mutex叫做递归互斥锁,该锁专门用于递归函数中的加锁操作。
timed_mutex中提供了以下两个成员函数:
recursive_timed_mutex就是recursive_mutex和timed_mutex的结合,recursive_timed_mutex既支持在递归函数中进行加锁操作,也支持定时尝试申请锁。
使用互斥锁时,如果加锁的范围太大,那么极有可能在中途返回时忘记了解锁,此后申请这个互斥锁的线程就会被阻塞住,也就是造成了死锁问题。因此使用互斥锁时如果控制不好就会造成死锁,最常见的就是此处在锁中间代码返回,此外还有一个比较常见的情况就是在锁的范围内抛异常,也很容易导致死锁问题。
因此C++11采用RAII(资源获取即初始化)的方式对锁进行了封装,于是就出现了lock_guard和unique_lock。
lock_guard是C++11中的一个模板类
template <class Mutex>
class lock_guard;
lock_guard类模板主要是通过RAII的方式,对其管理的互斥锁进行了封装。
模拟实现lock_guard类的代码
template<class _Mutex>
class lock_guard
{
public:
// 在构造lock_gard时,_Mtx还没有被上锁
explicit lock_guard(_Mutex& _Mtx)
: _MyMutex(_Mtx)
{
_MyMutex.lock();
}
// 在构造lock_gard时,_Mtx已经被上锁,此处不需要再上锁
lock_guard(_Mutex& _Mtx, adopt_lock_t)
: _MyMutex(_Mtx)
{}
~lock_guard() _NOEXCEPT
{
_MyMutex.unlock();
}
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
private:
_Mutex& _MyMutex;
};
但由于lock_guard太单一,用户没有办法对锁进行控制,因此C++11又提供了unique_lock。
unique_lock与lock_guard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装。在创建unique_lock对象调用构造函数时也会调用lock进行加锁,在unique_lock对象销毁调用析构函数时也会调用unlock进行解锁。
但lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:
多线程最主要的问题是共享数据带来的问题(即线程安全)。如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦。
#include
using namespace std;
#include
unsigned long sum = 0L;
void fun(size_t num)
{
for (size_t i = 0; i < num; ++i)
sum++;
}
int main()
{
cout << "Before joining,sum = " << sum << std::endl;
thread t1(fun, 10000000);
thread t2(fun, 10000000);
t1.join();
t2.join();
cout << "After joining,sum = " << sum << std::endl;
return 0;
}
C++98中传统的解决方式:可以对共享修改的数据可以加锁保护。
#include
using namespace std;
#include
#include
std::mutex m;
unsigned long sum = 0L;
void fun(size_t num)
{
for (size_t i = 0; i < num; ++i)
{
m.lock();
sum++;
m.unlock();
}
}
int main()
{
cout << "Before joining,sum = " << sum << std::endl;
thread t1(fun, 10000000);
thread t2(fun, 10000000);
t1.join();
t2.join();
cout << "After joining,sum = " << sum << std::endl;
return 0;
}
虽然加锁可以解决,但是加锁有一个缺陷就是:只要一个线程在对sum++时,其他线程就会被阻塞,会影响程序运行的效率,而且锁如果控制不好,还容易造成死锁。
因此C++11中引入了原子操作。所谓原子操作:即不可被中断的一个或一系列操作,C++11引入的原子操作类型,使得线程间数据的同步变得非常高效。
原子类型名称 | 对应的内置类型名称 |
---|---|
atomic_bool | bool |
atomic_char | char |
atomic_schar | signed char |
atomic_uchar | unsigned char |
atomic_int | int |
atomic_uint | unsigned int |
atomic_short | short |
atomic_ushort | unsigned short |
atomic_long | long |
atomic_ulong | unsigned long |
atomic_llong | long long |
atomic_ullong | unsigned long long |
atomic_char16_t | char16_t |
atomic_char32_t | char32_t |
atomic_wchar_t | wchar_t |
注意:需要使用以上原子操作变量时,必须添加头文件
#include
using namespace std;
#include
#include
atomic_long sum{ 0 };
void fun(size_t num)
{
for (size_t i = 0; i < num; ++i)
sum ++; // 原子操作
}
int main()
{
cout << "Before joining, sum = " << sum << std::endl;
thread t1(fun, 1000000);
thread t2(fun, 1000000);
t1.join();
t2.join();
cout << "After joining, sum = " << sum << std::endl;
return 0;
}
在C++11中,程序员不需要对原子类型变量进行加锁解锁操作,线程能够对原子类型变量互斥的访问。
更为普遍的,程序员可以使用atomic类模板,定义出需要的任意原子类型。
atmoic<T> t; // 声明一个类型为T的原子类型变量t
注意:原子类型通常属于"资源型"数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等,为了防止意外,标准库已经将atmoic模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了。
#include
int main()
{
atomic<int> a1(0);
//atomic a2(a1); // 编译失败
atomic<int> a2(0);
//a2 = a1; // 编译失败
return 0;
}
condition_variable中提供的成员函数,可分为wait系列和notify系列两类。
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_for和wait_until函数的使用方式与wait函数类似
注意
调用wait系列函数时,传入互斥锁的类型必须是unique_lock。
notify系列成员函数的作用就是唤醒等待的线程,包括notify_one和notify_all
注意:条件变量下可能会有多个线程在进行阻塞等待,这些线程会被放到一个等待队列中进行排队。
两个线程交替打印1-100,t1打印奇数,t2打印偶数
同步和互斥
但如果只有同步和互斥是无法满足题目要求的。
#include
int main()
{
mutex mtx;
condition_variable cv;
int n = 100;
int x = 1;
//问题1:如何保证t1先运行,t2阻塞
//问题2:如何防止一个线程不断运行?
thread t1([&, n]() {
while (1)
{
unique_lock<mutex> lock(mtx);
if (x >= 100)
break;
//cv.wait(lock);
//if (x % 2 == 0) //偶数就阻塞
//{
// cv.wait(lock);
//}
cv.wait(lock, [&x]() {return x % 2 != 0; });
cout << this_thread::get_id() << ":" << x << endl;
++x;
cv.notify_one();
}
});
thread t2([&, n]() {
while(1)
{
unique_lock<mutex> lock(mtx);
if (x > 100)
break;
//if (x % 2 != 0) //奇数就阻塞
//cv.wait(lock);
cv.wait(lock, [&x]() {return x % 2 == 0; });
cout << this_thread::get_id() << ":" << x << endl;
++x;
cv.notify_one();
}
});
t1.join();
t2.join();
return 0;
}