C++多线程——互斥量

并发的优点

  • 分离关注点
    通过将相关的代码与无关的代码分离,可以使程序更容易理解和测试,从而减少出错的可能性

  • 更好的性能
    将一个单个任务分成几部分,且各自并行运行,从而降低总运行时间。这就是任务并行(task parallelism)。

为性能而使用并发就像所有其他优化策略一样:它拥有大幅度提高应用性能的潜力,但它也可能使代码复杂化,使其更难理解,并更容易出错。

并发的缺点

  • 线程是有限的资源
    如果让太多的线程同时运行,则会消耗很多操作系统资源,从而使得操作系统整体上运行得更加缓慢。不仅如此,因为每个线程都需要一个独立的堆栈空间,所以运行太多的线程也会耗尽进程的可用内存或地址空间。对于一个可用地址空间为4GB(32bit)的平坦架构的进程来说,这的确是个问题:如果每个线程都有一个1MB的堆栈(很多系统都会这样分配),那么4096个线程将会用尽所有地址空间,不会给代码、静态数据或者堆数据留有任何空间。
  • 上下文切换
    运行越多的线程,操作系统就需要做越多的上下文切换,每一次切换都需要耗费本可以花在有价值工作上的时间。

互斥量

当程序中有共享数据,肯定不想让其陷入条件竞争,或是不变量被破坏。那么,将所有访问共享数据结构的代码都标记为互斥岂不是更好?这样任何一个线程在执行这些代码时,其他任何线程试图访问共享数据结构,就必须等到那一段代码执行结束。于是,一个线程就不可能会看到被破坏的不变量,除非它本身就是修改共享数据的线程。
当访问共享数据前,使用互斥量将相关数据锁住,再当访问结束后,再将数据解锁。线程库需要保证,当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,都必须等到之前那个线程对数据进行解锁后,才能进行访问。这就保证了所有线程能看到共享数据,而不破坏不变量。

不使用互斥量

#include 
#include 
#include 
#include 

using namespace std;

void print(const char* s)
{
    while (*s != '\0')
    {
        cout << *s;
        s++;
        this_thread::sleep_for(chrono::seconds(1));
    }
}

void fun1()
{
    const char* s1 = "hello";
    print(s1);
}

void fun2()
{
    const char* s2 = "world";
    print(s2);
}

int main()
{
    thread t1(fun1);
    thread t2(fun2);

    t1.join();
    t2.join();
    return 0;
}

输出结果:
在这里插入图片描述

使用互斥量

C++中通过实例化std::mutex创建互斥量,通过调用成员函数lock()进行上锁,unlock()进行解锁。不过,不推荐实践中直接去调用成员函数,因为调用成员函数就意味着,必须记住在每个函数出口都要去调用unlock(),也包括异常的情况。

C++标准库为互斥量提供了一个RAII语法的模板类std::lock_guard,其会在构造的时候提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁的互斥量总是会被正确的解锁。

#include 
#include 
#include 
#include 
using namespace std;

mutex mlock;//互斥量

void print(const char* s)
{
    mlock.lock();
    while (*s != '\0')
    {
        cout << *s;
        s++;
        this_thread::sleep_for(chrono::seconds(1));
    }
    mlock.unlock();
}

void fun1()
{
    const char* s1 = "hello";
    print(s1);
}

void fun2()
{
    const char* s2 = "world";
    print(s2);
}

int main()
{
    thread t1(fun1);
    thread t2(fun2);

    t1.join();
    t2.join();
    return 0;
}

输出结果:
在这里插入图片描述

注意:对一个unlock的mutex进行unlock的行为导致的结果是未定义的:

If the mutex is not currently locked by the calling thread, it causes undefined behavior.

在同一个线程中lock两次会造成死锁:

If the mutex is currently locked by the same thread calling this function, it produces a deadlock (with undefined behavior)

每次使用lock都要记得unlock,太麻烦,直接使用lock_guard
C++多线程——互斥量_第1张图片
构造时,mutex objectlock,析构时,mutex objectunlock
尽管lock_guard不管理mutex object的生存周期,但是mutex object的生存期应该至少到lock_guard析构,因为lock_guard在析构时需要进行unlock,如果mutex object已经不存在了,那怎么unlock呢?

mutex mlock;//互斥量

void print(const char* s)
{
    lock_guard<mutex> lg(mlock);
    while (*s != '\0')
    {
        cout << *s;
        s++;
        this_thread::sleep_for(chrono::seconds(1));
    }
}

C中的互斥量

在这里插入图片描述
互斥变量使用pthread_mutex_t数据类型来表示,在使用互斥变量之前应该对其初始化,可以把它设置为常量PTHREAD_MUTEX_INITALIZER(只对静态分配的互斥量),也可以调用函数pthread_mutex_init(),如果动态地分配互斥量(调用malloc),那么释放内存前需要调用pthread_mutex_destory()

#include 
int pthread_mutex_init(pthread_mutex_t* restrict mutex, const pthread_mutexattr_t* restrict attr);
int pthread_mutex_destroy(pthread_mutex_t* mutex);
//成功返回0,失败返回错误代码

调用pthread_mutex_lock对互斥量进行加锁,如果互斥量已经上锁,那么线程会阻塞。

#include 
int pthread_mutex_lock(pthread_mutex_t* mutex);
int pthread_mutex_unlock(pthread_mutex_t* mutex);
int pthread_mutex_trylock(pthread_mutex_t* mutex);
//成功返回0,失败返回错误代码

由于调用pthread_mutex_lock可能会阻塞,所以如果不希望线程阻塞的话,可以调用pthread_mutex_trylock,如果互斥量被锁住,该函数会返回EBUSY,然后继续执行下面的语句,如果互斥量没锁住,那么该函数会给互斥量加锁。

避免死锁

  • 同一线程中对一个互斥量加锁两次
    如果在同一个线程中对一个互斥量加锁两次,那么自身会陷入死锁状态,因为第一次加锁的是这个线程,当它第二次进行加锁时,需要等待锁释放,但是互斥量已经被它自己加锁了,别的线程不可能释放锁,只能这个线程自身释放锁,于是乎系铃人不知道自己是系铃人,等着别人去解铃,进而陷入了死锁。
  • 多个互斥量的加锁顺序
    如果程序中使用了多个互斥量,如A、B,当线程1占有互斥量A,线程2占用互斥量B,并且线程1尝试对B加锁,线程2尝试对A加锁时,就会产生死锁,两个线程都在等待对方占有的锁被释放,但是又都不释放自己占有的锁。
    可以通过控制加锁的顺序来避免这种现象的发生,如两个线程中都是先对A加锁再对B加锁,或者都是先对B加锁再对A加锁,这样就不会产生死锁。
    但是在大型程序中去控制锁的顺序是比较困难的,这种情况下,可以使用pthread_mutex_trylock尝试加锁,如果失败,就先释放已经占有的锁。

互斥量与自旋锁

参考博客:c++线程中的几种锁

假设我们有一个两个处理器core1和core2计算机,现在在这台计算机上运行的程序中有两个线程:T1和T2分别在处理器core1和core2上运行,两个线程之间共享着一个资源。

首先我们说明互斥锁的工作原理,互斥锁是是一种sleep-waiting的锁。假设线程T1获取互斥锁并且正在core1上运行时,此时线程T2也想要获取互斥锁(pthread_mutex_lock),但是由于T1正在使用互斥锁使得T2被阻塞。当T2处于阻塞状态时,T2被放入到等待队列中去,处理器core2会去处理其他任务而不必一直等待(忙等)。也就是说处理器不会因为线程阻塞而空闲着,它去处理其他事务去了。

而自旋锁就不同了,自旋锁是一种busy-waiting的锁。也就是说,如果T1正在使用自旋锁,而T2也去申请这个自旋锁,此时T2肯定得不到这个自旋锁。与互斥锁相反的是,此时运行T2的处理器core2会一直不断地循环检查锁是否可用(自旋锁请求),直到获取到这个自旋锁为止。

从“自旋锁”的名字也可以看出来,如果一个线程想要获取一个被使用的自旋锁,那么它会一致占用CPU请求这个自旋锁使得CPU不能去做其他的事情,直到获取这个锁为止,这就是“自旋”的含义。

当发生阻塞时,互斥锁可以让CPU去处理其他的任务;而自旋锁让CPU一直不断循环请求获取这个锁。通过两个含义的对比可以我们知道“自旋锁”是比较耗费CPU的。

自旋锁的类型:spinlock_t
相关函数:

#include 
spin_lock_init(spinlock_t *x);//初始化
spin_lock(x);//只有在获得锁的情况下才返回,否则一直“自旋”
spin_trylock(x);//如立即获得锁则返回真,否则立即返回假
spin_unlock(x);释放锁
spin_is_locked(x)//该宏用于判断自旋锁x是否已经被某执行单元保持(即被锁),如果是,   返回真,否则返回假。

注意:自旋锁适合于短时间的的轻量级的加锁机制。

其他

  • 原子操作
  • 读写锁
  • unique_lock
  • 条件变量

你可能感兴趣的:(操作系统)