欢迎来到博主的专栏:c++杂谈
博主ID:代码小豪
更多关于操作系统的原理就不赘述了,我们来看看常见的由于线程并发导致的问题。
#include
#include
#include
int num = 0;
std::mutex mtx;
void Add_num_Ntime(int n) {
//mtx.lock();
for (int i = 0; i < n; i++) {
num++;
}
//mtx.unlock();
}
int main() {
std::thread threads[2];
threads[0] = std::thread(Add_num_Ntime, 100000);
threads[1] = std::thread(Add_num_Ntime, 200000);
threads[0].join();
threads[1].join();
std::cout << num << std::endl;
return 0;
}
这是很经典的多线程并发场景下对临界资源进行修改的场景,该代码的目的是对num增加N次,比如线程0让num增加100000次,线程1让num增加200000次,那么当线程1和线程2计算结束后,num的值应为300000。但是在不加锁的情况下,num的值基本不可能会是300000。其原因就在于num++这个操作不是原子的,因此并发操作会导致寄存器中关于num的值被污染,最终导致num的计算结果错误。
那么加上互斥锁之后,这个问题就迎刃而解了,因为互斥锁可以让线程在锁的范围内,从并行运行变为串行运行,其原理在于,当一个线程获取到锁(lock)之后,其余的线程无法进入锁中的代码,直到线程将锁进行释放(unlock)。因此上述的代码中,假设线程0先获取到锁,那么线程0就会对num增加100000次,在此期间,由于锁一直在线程0身上,因此即使线程0没运行完,调度切换到线程1之后,线程1也无法进入锁当中的代码,也就无法对num进行修改操作了,直到线程0执行结束,此时num=100000,将锁释放,接着线程1就可以获取到锁,进入锁中的代码,执行对num增加200000次的操作,因此最终的结果可以保证是300000。
但是线程由并行变为串行,这意味着本来是多个线程可以一起运行的,但是现在只有一个可以运行,所以加了锁之后,程序的运行效率一定是降低的。但是可以保证运行的安全性(或者说正确性?)。可以想象,一个程序肯定是要优先保证安全性的情况下,才要去考虑运行速度,因为如果程序当中的数据,都不能保证正确,那么这个程序能完成任务吗?比如微信支付2.5,结果扣费了25,你还会使用用微信吗?
在c++11标准中正式引入
我们先来看看mutex的构造函数
default (1)
constexpr mutex() noexcept;
copy [deleted] (2)
mutex (const mutex&) = delete;
mutex只支持默认构造函数,拷贝构造函数被禁用。
mutex中具有以下四个成员函数
void lock();
bool try_lock();
void unlock();
native_handle_type native_handle();
其中,lock表示申请获取锁,如果锁是空闲的(没有其他锁使用),那么该线程就能获取到锁,如果锁是未归还的,那么该线程就会阻塞等待在lock函数中,直到持有锁的线程将其归还。
而unlock则是释放锁,一个线程持有锁了,那么当这个线程执行完任务后,需要需要unlock来释放锁,不然其他的线程就无法使用了。
如果申请不到锁,那么线程就会阻塞等待其他锁,如果不想让线程阻塞等待锁,可以是try_lock。如果此时锁处于空闲状态,就能成功获取锁,并且try_lock返回true。反之返回0,而try_lock无论是否申请成功,都不会阻塞等待。一般情况下是线程可能处理多个任务。如果一个任务需要锁,那么就可以使用try_lock尝试申请,申请失败后执行下一个任务。那么关于try_lock有一个误区,有些人觉得lock会导致阻塞等待,而try_lock不会,那么是不是代表try_lock具有更好的效率呢?这里我们需要辩证的看待,首先要确定一点,线程阻塞等待时,只会占用非常非常小的CPU的效率,如果线程真的没有其他特别重要任务,还是让其乖乖的等待其他线程释放锁吧。
由于锁需要多个线程去竞争,因此一个mutex需要被多个线程去使用,所以mutex一般有两种使用方法,最简单的方式就是将mutex声明为全局变量,这样所有的线程就能使用共同的锁,而还有一种方法则是传引用的方式。
int num = 0;
void Add_num_Ntime(int n,std::mutex& mtx) {
mtx.lock();
for (int i = 0; i < n; i++) {
num++;
}
mtx.unlock();
}
int main() {
std::mutex mtx;
std::thread threads[2];
threads[0] = std::thread(Add_num_Ntime, 100000,mtx);
threads[1] = std::thread(Add_num_Ntime, 200000,mtx);
threads[0].join();
threads[1].join();
std::cout << num << std::endl;
return 0;
}
但是这么做可行吗?理论上是正确的,在主线程中存在一个唯一的mutex,接着其余线程通过引用传参的方式,获取唯一的mutex,那么每个线程中对于锁的竞争是构成的,但是我们运行一下试试呢?
ok,可以看到报错了,而且报错原因也是怪怪的,实际上错误的原因和报错没什么关系。这是和
int pthread_create(pthread_t *tidp,const pthread_attr_t *attr, void *
(*start_rtn)(void*), void *arg);
我们可以看到,这个函数其实是固定的参数和返回值,但是c++的thread类却允许我们使用可变的参数列表,所以
而c++的编写者也是考虑到了这一点,所以在c++11中还推出了ref函数,这个ref函数就是为了解决类似的传引用的问题。比如
int num = 0;
void Add_num_Ntime(int n,std::mutex& mtx) {
mtx.lock();
for (int i = 0; i < n; i++) {
num++;
}
mtx.unlock();
}
int main() {
std::mutex mtx;
std::thread threads[2];
threads[0] = std::thread(Add_num_Ntime, 100000,ref(mtx));//传引用的参数之前加上ref
threads[1] = std::thread(Add_num_Ntime, 200000,ref(mtx));
threads[0].join();
threads[1].join();
std::cout << num << std::endl;
return 0;
}
如果觉得上述的原理不好理解的话,只需要记住结论,有关线程任务的参数如果出现传引用,那么一定要在参数前面加上ref。
除了mutex以外,
class recursive_mutex;
class timed_mutex;
class recursive_timed_mutex;
recursive_mutex
通常用于递归函数中使用,因为普通mutex无法运用在递归当中。我们简单的模拟一下递归函数。
std::mutex mtx;
void func(int x,int y){
mtx.lock();
//....
func();
//...
mtx.unlock();
}
假设现在有一个线程0在执行func的过程中获取到了mtx,那么此时mtx就不再是一个空闲的互斥锁了,那么当线程0进入下一层递归时,又需要再次申请一个mtx,由于此时mtx还没有被释放,所以依旧是属于未归还状态,因此线程0无法继续运行,这也是死锁的一个场景。
而解决方式就是将锁mtx的类型,改为recursive_mutex。recursive_mutex支持递归场景下的互斥。recursive_mutex的成员函数和使用方法与mutex没有太多区别,只是支持在递归的场景下使用。
timed_mutex支持计时的功能。相比较mutex多了以下的成员函数
template <class Rep, class Period>
bool try_lock_for (const chrono::duration<Rep,Period>& rel_time);
template <class Clock, class Duration>
bool try_lock_until (const chrono::time_point<Clock,Duration>& abs_time);
那么什么场景会使用timed_mutex呢?比如我们不想线程在申请锁失败后阻塞等待,又不想像try_lock那样,申请失败头也不回的跑了,希望是有一个时间作为缓冲,比如让线程等待1s,2s的时间内,如果锁好了就拿去用,锁没好就干其他的事情,此时使用timed_mutex就可以实现。
// timed_mutex::try_lock_for example
#include // std::cout
#include // std::chrono::milliseconds
#include // std::thread
#include // std::timed_mutex
std::timed_mutex mtx;
void fireworks() {
// 在等待锁的过程中,每隔1s就会打印一个'-',直到获取锁:
while (!mtx.try_lock_for(std::chrono::milliseconds(1000))) {
std::cout << "-";
}
// 获取锁后, 等待1s,该线程会打印一个'*'
std::this_thread::sleep_for(std::chrono::milliseconds(5000));
std::cout << "*\n";
mtx.unlock();
}
int main()
{
std::thread threads[2];
// spawn 2 threads:
for (int i = 0; i < 2; ++i)
threads[i] = std::thread(fireworks);
for (auto& th : threads) th.join();
return 0;
}
而recursive_timed_mutex则是recursive_mutex和timed_mutex的结合体,这里不多赘述。
我们来想象下面两种场景
在上面的场景中,都有一个共同点,就是由于一些原因,线程在未归还锁之前,退出了当前执行的任务,一般情况下,如果没有使用unlock,锁是无法被归还的。场景1的行为是未定义的,因此不要强制退出一个线程。而场景2我们可以用lock_guard或者unique_lock来解决。
RAII(Resource Acquisition Is Initialization)是C++的核心编程理念,指资源获取即初始化。其核心思想是将资源(内存、文件句柄、锁等)的生命周期与对象的生命周期绑定:对象构造时获取资源,析构时自动释放资源。这种方法利用栈对象确定性析构的特性,确保资源在任何执行路径(包括异常)下都能正确释放,避免资源泄漏。
那么lock_guard是怎么做到在生命周期结束时释放锁的呢?lock_guard是一个只有构造函数和析构函数的对象,首先lock_guard需要用户传一个锁的引用,当调用构造函数时,调用锁的lock函数,当调用析构函数时,调用锁的unlock函数,这样就能保证锁的生命周期,与lock_guard的生命周期是一致的。具体原理可以参考下面的代码。
class lock_guard{
public:
lock_guard(mutex&mtx) :_mtx(mtx){_mtx.lock();}
~lock_guard(){_mtx.unlock();}
private:
mutex& _mtx;
}
所以,如果我们使用lock_guard,就不用担心由于抛异常导致锁丢失的问题,因为lock_guard的生命周期一旦结束,锁也会随之释放。而lock_guard的构造函数如下:
locking (1)
explicit lock_guard (mutex_type& m);
adopting (2)
lock_guard (mutex_type& m, adopt_lock_t tag);
其中方法1类似于我们上面所写的示例代码,传入一个锁参数,构造lock_guard时,将锁给锁上,而方法2是只获取锁的使用权。这是什么意思呢?我们看看下面的实力代码。
// constructing lock_guard with adopt_lock
#include // std::cout
#include // std::thread
#include // std::mutex, std::lock_guard, std::adopt_lock
std::mutex mtx; // mutex for critical section
void print_thread_id (int id) {
mtx.lock();
std::lock_guard<std::mutex> lck (mtx, std::adopt_lock);
std::cout << "thread #" << id << '\n';
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
}
int main ()
{
std::thread threads[10];
// spawn 10 threads:
for (int i=0; i<10; ++i)
threads[i] = std::thread(print_thread_id,i+1);
for (auto& th : threads) th.join();
return 0;
}
改代码创建10个线程,每个线程会打印信息,接着休眠1s,在整个过程中,线程只调用lock,没有调用unlock,在平时,其他的线程将会进入死锁状态。而lock_ruard获取了mtx的使用权,当lock_guard的生命周期结束时,会将锁进行释放,所以如果添加了lock_guard,将不会有死锁的问题。
unique_lock的功能更加强大,不仅仅有RAII机制,还可以像一般的互斥锁一样,手动的调用成员函数来进行加锁,解锁等操作。
这些功能就不说,我们重点讲讲unique_lock的构造函数和析构函数。
当unique_lock调用析构函数时,如果之前没有使用unlock释放当前的锁,那么就会自动的释放锁。
unique_lock的构造函数很多,不支持赋值和拷贝,支持移动构造和移动复制。大多数是为了支持更多类型的锁,比如6和7的构造函数是为了适应timed_lock。而我们重点放在3、4、5中。
default (1)
unique_lock() noexcept;
locking (2)
explicit unique_lock (mutex_type& m);
try-locking (3)
unique_lock (mutex_type& m, try_to_lock_t tag);
deferred (4)
unique_lock (mutex_type& m, defer_lock_t tag) noexcept;
adopting (5)
unique_lock (mutex_type& m, adopt_lock_t tag);
locking for (6)
template <class Rep, class Period>
unique_lock (mutex_type& m, const chrono::duration<Rep,Period>& rel_time);
locking until (7)
template <class Clock, class Duration>
unique_lock (mutex_type& m, const chrono::time_point<Clock,Duration>& abs_time);
copy [deleted] (8)
unique_lock (const unique_lock&) = delete;
move (9)
unique_lock (unique_lock&& x);
unique_lock⾸先在构造的时候传不同的tag,⽤以⽀持在构造的时候不同的⽅式处理锁对象。
传入tag | 对应操作 |
---|---|
不传tag | 在构造时调用lock |
defer_lock | 在构造时不调用lock(用户手动的去加锁) |
try_to_lock | 在构造时调用try_lock(尝试去锁) |
adopt_lock | 不调用锁对象的lock,而是获取对象的权限 |
我们判断下面的代码能否成功运行?为什么?
std::mutex foo,bar;
void task_a () {
foo.lock();
std::this_thread::sleep_for(std::chrono::seconds(1));
bar.lock(); // replaced by:
std::cout << "task a\n";
foo.unlock();
bar.unlock();
}
void task_b () {
bar.lock();
std::this_thread::sleep_for(std::chrono::seconds(1));
foo.lock(); // replaced by:
std::cout << "task b\n";
bar.unlock();
foo.unlock();
}
int main ()
{
std::thread th1 (task_a);
std::thread th2 (task_b);
th1.join();
th2.join();
return 0;
}
答案是不能,为什么?因为th1和th2会陷入死锁状态,这是因为th1获取了锁foo,而th2获取了锁bar,但是th1想要继续运行,就要获取锁bar,而th2想要继续运行,就要获取foo。但是能成功获取吗?不能,因为锁都在别人那呢。有人也许看到这会感到不屑,觉得谁会写出这么奇怪的代码啊?但是实际上,如果在编写比较大型的项目时,一旦业务增多,程序员是不是要添加框架啊?如果此时项目需要加锁,在越复杂的逻辑中,就越容易出现死锁的问题。
那么如何应对这种需要申请多个锁的场景呢?lock是⼀个函数模板,可以⽀持对多个锁对象同时锁定,如果其中⼀个锁对象没有锁住,lock函数会把已经锁定的对象解锁⽽进⼊阻塞,直到锁定所有的所有的对象。
void task_a() {
// foo.lock(); bar.lock(); // replaced by:
std::lock(foo, bar);
std::cout << "task a\n";
foo.unlock();
bar.unlock();
}
void task_b() {
// bar.lock(); foo.lock(); // replaced by:
std::lock(bar, foo);
std::cout << "task b\n";
bar.unlock();
foo.unlock();
}
int main()
{
std::thread th1(task_a);
std::thread th2(task_b);
th1.join();
th2.join();
return 0;
}
还有一个成员函数叫做call once,意思是所有线程只能执行一次。多线程执⾏时,让第⼀个线程执⾏Fn⼀次,其他线程不再执⾏Fn。这个函数用的不多,留一个印象就好。
// call_once example
#include // std::cout
#include // std::thread, std::this_thread::sleep_for
#include // std::chrono::milliseconds
#include // std::call_once, std::once_flag
int winner;
void set_winner (int x) { winner = x; }
std::once_flag winner_flag;
void wait_1000ms (int id) {
// count to 1000, waiting 1ms between increments:
for (int i=0; i<1000; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(1));
// claim to be the winner (only the first such call is executed):
std::call_once (winner_flag,set_winner,id);
}
int main ()
{
std::thread threads[10];
// spawn 10 threads:
for (int i=0; i<10; ++i)
threads[i] = std::thread(wait_1000ms,i+1);
std::cout << "waiting for the first among 10 threads to count 1000 ms...\n";
for (auto& th : threads) th.join();
std::cout << "winner thread: " << winner << '\n';
return 0;
}