目录
一、thread类
二、互斥锁
1.四种锁
(1)mutex
(2)recursive_mutex
(3)time_mutex
(4)recurive_timed_mutex
2.lock_guard
3.unique_lock
4.锁的原理
三、原子操作
四、简单的线程池
五、条件变量
一、thread类
在C++11之前,涉及多线程问题,都是和平台相关的,比如windows系统下和linux系统下都有各自的接口,这使得代码的可移植性比较差。C++11最重要的特性就是支持了多线程。使得C++在并行编程时不需要依赖第三方库(可以跨平台使用)。并且在原子操作中引入了原子类的概念,要使用标准库中的线程,必须包含头文件thread。
函数名 | 功能 |
thread() | 构造一个线程对象,没有任何关联的线程函数,即没有启动任何线程 |
thread(fn,args,args2,...) | 构造一个线程对象,并关联线程函数fn,args1,args2...为线程函数的参数 |
get_id() | 获取线程id |
joinable() | 线程是否正在执行,joinable代表一个正在执行的线程 |
join() | 该函数调用后会阻塞住线程,当该线程结束后,主线程继续执行 |
detach() | 在创建线程对象之后会马上调用,用于把创建线程与线程对象分开,分离的线程变为后台线程,创建的线程的死活与主线程无关 |
1.线程对象可以关联一个线程,用来控制线程和获取线程的状态。
2.当创建一个线程之后,没有提供任何线程函数,该对象没有对应的任何线程。
下面来写一个简单的创建线程的小程序:
void Func(int n)
{
cout << n << endl;
}
int main()
{
thread();//创建一个空线程,什么都不做
thread t1(Func, 10);
thread t2([](int n) { cout << n << endl; }, 20);
t1.join();
t2.join();
}
有一个细节需要注意,那就是thread在向函数传参的时候,不能使用左值引用进行传参:
void Func(int& n)//用引用接收会发生错误
{
cout << n << endl;
}
int a=10;
thread t1(Func,a);
这里引入了一个ref函数来满足这一操作。即:
thread t1(Func,ref(a));
二、互斥锁
在C++11中,Mutex包含了四种互斥量的种类:
1.四种锁
(1)mutex
函数名 | 函数功能 |
lock() | 上锁 |
unlock() | 解锁 |
try_lock() | 非阻塞获取锁 |
当线程函数调用lock()的时候,有以下三种情况:
(1)锁没有被取走,直接获取锁。
(2)当前线程已经有该锁了,形成死锁。
(3)锁被其他线程取走,进行阻塞等待。
当函数调用try_lock的时候,有以下三种情况:
(1)锁没有被取走,直接获取锁。
(2)该线程已经有该锁了,产生死锁。
(3)锁被其他线程取走,会返回false,而不是被阻塞。
(2)recursive_mutex
允许递归上锁,来过得互斥对象的多层所有权,在释放互斥量的时候也需要采用等量的unlock来进行解锁。
(3)time_mutex
比mutex多了两个成员函数.
函数名 | 函数功能 |
try_lock_for() | 接受一个时间范围,表示在一段时间之内,线程如果没有获得锁则被阻塞住,如果此期间其他线程释放了锁,则该线程可以获得锁,如果超时,返回false |
try_lock_util() | 接受一个时间点作为参数,在指定时间未到来之前,线程如果没有获得锁则被阻塞住,如果此期间其他线程释放了锁,则该线程可以获得锁,如果超时返回false |
(4)recurive_timed_mutex
使用的比较少,这里不多介绍。
2.lock_guard
lock_guard是C++11中定义的模板类。主要通过RAII的方式,对其管理的互斥量进行了封装,在需要加锁的地方,只需要用上述介绍的任意互斥体实例化一个lock_guard,调用构造函数成功上锁,出作用域前,lock_guard对象要被销毁,调用析构函数自动解锁,可以有效避免死锁的问题。
缺陷:太单一,用户没有办法对锁进行控制,因此C++11又提供了unique_lock。
3.unique_lock
与lock_gard类似 ,unique_lock类模板也是采用RAII的方式进行封装,并且也是独占所有权的方式管理mutex对象的上锁和解锁操作,即其对象之间不能发生拷贝。
在构造时,unique_lock对象需要传入一个Mutex对象作为它的参数,新创建的unique_lock对象负责传入的Mutex对象的上锁和解锁的操作。使用以上类型互斥量实例化unique_lock对象的时候,自动调用构造函数上锁,unique_lock更加的灵活,提供了很多成员函数。
上锁、解锁:lock,try_lock,try_lock_for,try_lock_util和unlock
修改操作:移动赋值,交换(swap:与另一个unique_lock对象互换所管理的互斥量所有权),释放(release:返回当前unique_lock所管理的互斥量的指针)
获取属性:owns_lock(返回当前对象是否上来锁),operator>bool(与owns_lock()的功能向同),mutex(返回当前unique_lock所管理的互斥量的指针)
4.锁的原理
我们都知道,锁是来控制信号量的,防止两个线程对信号量进行混乱的修改。
static int x = 0;
void Func1(int n)
{
for (int i = 0; i < n; i++)
{
cout << this_thread::get_id() << "->" << x << endl;
++x;
}
}
void Func2(int n)
{
for (int i = 0; i < n; i++)
{
cout << this_thread::get_id() << "->" << x << endl;
++x;
}
}
int main()
{
thread t1(Func1, 10);
thread t2(Func2, 10);
t1.join();
t2.join();
return 0;
}
在这段代码中,t1和t2两个线程对同一个信号量进行++操作,由于底层的++操作不是原子的,可能会导致x的数值混乱,因为对于一个++操作来说,它底层的汇编大概会分为三步,分别是ld,++,sd
假设当一个线程该执行完读入和x++后,被切走了,第二个线程读入并执行++多次,然后写回。第一个线程时间片再到来的时候,会带着它的上下文数据,发现改进行++了,就进行++操作,然后写回。这就导致线程2做的工作全白做了。因此会造成混乱。
因此需要引入锁这一现象:
当线程1到来时,抢到锁之后会将a1寄存器的值(初值为0)与内存中锁的值进行交换,当时间片结束之后,带着它的上下文数据离开。当线程2到来的时候也会和内存中的mutex的值进行交换,只不过此时mutex的值是0,最终线程2的a1寄存器也为0,因此就可以通过a1寄存器的值来判断谁拿到了锁,从而让它对临界资源进行修改。
那么问题来了,我们应该在循环里面进行枷锁操作还是在循环外面进行加锁操作呢?
如果在循环外枷锁,就相当于两个线程串行运行了,降低了效率。但如果加在里面虽然是并行运行,这样频繁的加锁解锁是需要消耗资源的。
这里我们选择加在循环的外面,因为++执行的太快了,不适合频繁的加锁解锁。
void Func1(int n)
{
mtx.lock();
for (int i = 0; i < n; i++)
{
cout << this_thread::get_id() << "->" << x << endl;
++x;
}
mtx.unlock();
}
三、原子操作
C++将原子操作也封装成了一个对象:
原子类型支持多个原子操作的函数:
可以将上文中的x定义为原子类型,表示的是一条汇编语句就执行了他的++操作。
atomic<int> x = 0;
void Func1(int n)
{
//mtx.lock();
for (int i = 0; i < n; i++)
{
cout << this_thread::get_id() << "->" << x << endl;
++x;
}
//mtx.unlock();
}
这样书写和加锁的操作是一样的
四、简单的线程池
int main()
{
atomic<int>x = 0;
int N, M;
cin >> N, M;
vector<thread> vthds;
vthds.resize(N);
for (int i = 0; i < N; i++)
{
vthds[i] = thread([M,&x]
{
for (int i = 0; i < M; i++)
{
++x;
}
}
);
}
for (auto& e : vthds)
{
cout << x << endl;
e.join();
}
return 0;
}
我们可以将vector的每一个元素类型都设为thread类型,在循环调用,循环等待,就可以完成线程池的工作了。
五、条件变量
假如我们设计一个程序,让线程1和线程2交替进行打印,线程1打印奇数,线程2打印偶数。很容易想到使用加锁操作进行解决:
int main()
{
int n = 100;
int i = 0;
mutex mtx;
thread t1([n, &i, &mtx]
{
while (i < n)
{
mtx.lock();
cout << this_thread::get_id() << "->" << i << endl;
++i;
mtx.unlock();
}
});
thread t2([n, &i, &mtx]
{
while (i < n)
{
mtx.lock();
cout << this_thread::get_id() << "->" << i << endl;
++i;
mtx.unlock();
}
});
t1.join();
t2.join();
return 0;
}
当运行这段代码的时候很快就能发现问题:我们无法控制两个线程交替枪锁,在大部分的时候都是第一个线程抢到了锁。
这就无法满足我们交替进行打印的条件。这是因为当线程1抢到锁的时候线程2被阻塞住了,那么如何控制两个线程进行交替执行呢?
此时就需要引入条件变量:condition_variable,它提供了几个函数供我们选择:
函数 | 作用 |
wait | 阻塞,直到被notify |
wait_for | 最多等待多长时间 |
notify_one | 唤醒一个线程 |
notify_all | 唤醒所有线程 |
void wait(unique_lock<mutex>& lck);
void wait(unique_lock<mutex>& lck, Predicate pred);
注意当某个线程调用了wait函数的时候,会调用unlock()释放锁,一旦被notify了,会立刻调用lock()获取锁。因此调用wait的时候需要传入锁。对于第二方式来说第二个参数表示的是一个标记,只有当prep返回值为false的时候才会发生阻塞,相当于
while(!prep())
wait(lock);
对于notify_one函数来说,当有线程在该条件变量上阻塞的时候,会通知其开始抢锁,当没有线程在条件变量上阻塞的时候,什么都不会做。
我们可以使用条件变量的等待-通知机制来完成两个线程的交替执行:
int n = 100;
int i = 0;
mutex mtx;
condition_variable cv;
bool flag = false;
thread t1([&n, &i, &mtx, &cv, &flag]
{
while (i < n)
{
unique_lock<mutex> lock(mtx);
cv.wait(lock, [&flag]() {return flag; });
cout << this_thread::get_id() << "->" << i << endl;
++i;
flag = false;
cv.notify_one();
}
}
);
thread t2([&n, &i, &mtx,&cv,&flag]
{
while (i < n)
{
unique_lock<mutex> lock(mtx);
cv.wait(lock, [&flag]() {return !flag; });
cout << this_thread::get_id() << "->" << i << endl;
++i;
flag = true;
cv.notify_one();
}
});
t1.join();
t2.join();
注意,添加条件变量调用wait的时候,需要将锁进行封装成unique_lock类型,该类型会在创建时调用构造函数自动上锁,在销毁的时候会调用析构函数自动解锁。
分析这段代码,线程1首先获取flag,上锁,wait的第二个变量是false,此时线程1在条件变量下发生阻塞等待;执行到线程2,上锁后,线程2第二个变量是true,继续执行++i,flag设为true,notify_one()唤醒一个线程。
唤醒线程2,阻塞等待,唤醒线程1,继续执行++i,flag设为false,接着唤醒,以此类推。
此时的运行结果是: