聊聊C++线程同步机制

  线程同步是一个经常出现的场景,考虑一个生产者消费者模式,一个线程作为生产,一个线程作为消费。生产者往一个队列中加入元素,消费者往一个队列中取元素。实现对一个公共区域的同时访问操作,是C++多线程经常会遇到的问题,所以C++提供了线程同步的机制。

  1.消费者轮询。

参考下面代码:一个线程运行Producer,一个线程运行Comsumer,共同操作一个队列,这会导致严重问题:CPU会被跑满、一个线程加任务,一个线程取任务,导致共享资源被同时访问。当任务较少时,消费者循环访问队列,CPU被无效使用。这种方式好比你无时无刻都要去检查你钱包里的钱有没有多出来,有的话就用了它,这样你估计会累死。

std::queue<Element> que;

void Producer()
{
    while (true)                 //循环放数据
    {
        que.push(Element());
    }
}
void Comsumer()
{
    while (true)         //有数据,取数据
    {
        if (!que.empty())
            que.pop();
    }
}
int main()
{
    std::thread thProducer(Producer);
    std::thread thComsumer(Comsumer);
    //......省略以下代码
    return 0;
}

解决CPU被无效使用的情况,我们可以用线程短暂休眠。则就是第二种方式。 

2.消费者轮询加延迟。

将消费者和生产者都加上延迟,这样当没有任务时,消费者休眠,生产者投放,节约了CPU。这种情况就是,你每隔2小时检查你的钱包里的钱,有就花了,没有就再休息。在任务量小时,出现问题的概率少,在任务量大时,会严重出现共享资源同时访问的问题----生产者消费者同时操作公共队列。

不恰当的例子就是:两个人分别往钱包里放钱和取钱,OK,结果把钱包撤破了,扯破了之后谁都用不了了。所有我们希望消费者和生成者任一时刻,只有一个人在访问公共的队列。

void Comsumer()
{
    while (true)         //有数据,取数据
    {
        if (!que.empty())
            que.pop();
        if (que.empty())
            std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

引出我们的第三种方式,互斥量 。

3.使用std::mutex。锁的特点是:当获取锁操作失败时,线程会进入睡眠,等待锁释放时被唤醒

保证每次取公共数据的时候,先获取mutex,使得每次只能有一个线程去访问公共资源。这样,往钱包里放钱和取钱的人,同一时刻只有一个,不至于把钱包弄破。代码如下:生产者一秒取锁生成一个数据,然后释放锁(作用域退出释放),休眠。消费者还是轮序检查是否有数据,有数据就取,没数据就轮询。这还是存在浪费CPU的问题:当生成者一直没有数据,消费者就一个轮训,但是保证了每次只要一个人去访问队列,个人认为:当持锁时间相对长时,则需要其他的线程等待相对长的时间时,这种情况比较适用,不浪费CPU,而且协调效率高。(意思就是:没活干,先睡觉,睡醒了再干)。

优点是:实现了共享数据的有序访问。

缺点:

1.抢锁的顺序不可控。因为只有强锁和解锁的过程,并没有规定谁先抢。(如果需要顺序,可使用信号量,可参考同步和互斥的概念区别)

2.在生产者没有生产数据的时候,消费者会被唤醒检查。

适用场景是:线程占用临界资源相对较长(线程休眠的作用显现出来),一个线程在使用时,另一个线程先休眠。

std::queue<Element> que;
std::mutex mu;
void Producer()
{
    while (true)                 //循环放数据
    {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::lock_guard<std::mutex> lock(mu);
        que.push(Element());
        std::cout << "thread = " << std::this_thread::get_id() << " push" << std::endl;
    }
}
void Comsumer()
{
    while (true)         //有数据,取数据
    {
        std::lock_guard<std::mutex> lock(mu);
        if (!que.empty())
        {
            que.pop();
            std::cout << "thread = " << std::this_thread::get_id() << " pop" << std::endl;
        }
    }
}

如果场景是:相对长的时间间隔才会发生一个消息,然后我们去主动叫线程唤醒。最好的情况是,生产一个数据,就发一个消息告诉消费者,我产生数据了,然后消费者消费。避免了浪费锁,也避免浪费CPU资源。引出下面一个适用场景。

4.使用std::condition_variable。

std::condition_variable是又一种线程同步机制,在每次有元素加进队列时,发送消息唤醒线程,当没有数据时,消费者等待被唤醒。这样就保证了有事件到达时,才唤醒消费者去处理。这就好比,钱包里有钱了,然后告诉你,你再去拿来消费。注意这里使用的是unique_lock,它保证了当没有消息时,能把锁解开,让其他线程使用,不至于造成死锁。但是有一个问题,消费者必须比生成者先生成,不然消费者的发送的唤醒消息,没办法被接收到,导致明明有数据,确不能被使用。所以代码如下:

优点:不必浪费多余的CPU,有一个消息处理一个数据

缺点:当生产者生成数据很快,消费数据跟不上,导致没有那么多能力去处理已经在que里的任务,则唤醒消息cv.nodify()就没有发挥作用,导致虽然有任务在队列里,却无人处理。

适用场景:任务量不多,处理任务的速度相对较快。(保证每个任务,都有人处理)当然也可以增开多个线程作为消费者线程。

#include <iostream>
#include <thread>
#include <queue>
#include <mutex>

typedef struct Element
{
}Element;

std::condition_variable cv;
std::queue<Element> que;
std::mutex mu;

void Producer()
{
    while (true)                 //循环放数据
    {
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        std::lock_guard<std::mutex> lock(mu);
        que.push(Element());
        std::cout << "thread = " << std::this_thread::get_id() << " push" << std::endl;
        cv.notify_one();
    }
}
void Comsumer()
{
    while (true)         //有数据,取数据
    {
        std::unique_lock<std::mutex> lock(mu);
        cv.wait(lock);
        que.pop();
        std::cout << "thread = " << std::this_thread::get_id() << " pop" << std::endl;
    }
}

int main()
{
    std::thread thComsumer(Comsumer);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::thread thProducer(Producer);

    std::this_thread::sleep_for(std::chrono::hours(1));
}

引出下面一个场景:生成者速度快,消费者占用周期小,处理时间相对长(休眠几乎用不上)。boost中spinlock自旋锁给我们提供了这样一个机制

5.使用boost::spinlock 

每次投放的任务比较多,消费者占用的共享资源时间短,spinlock是一个好的选择,因为任务大部分时间都是在被处理当中,几乎没有休眠的过程。则我们使用一个加锁量级轻的加锁方式,避免线程休眠,spinlock在等待锁的时候,不休眠,进行轮询检查,如果任务多的时候,充分利用了CPU。

优点:充分利用CPU。

缺点:任务持锁长时,空等时间过长,浪费CPU。

适用场景:持锁时间短,任务量大。

boost::detail::spinlock splock;

void Producer()
{
    while (true)
    {
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        std::lock_guard<boost::detail::spinlock> lock(splock);
        for (int i = 0; i < 10; i++)
        {
            que.push(Element());
            std::cout << "thread = " << std::this_thread::get_id() << " push" << std::endl;
        }
    }
}
void Comsumer()
{
    while (true)
    {
        std::lock_guard<boost::detail::spinlock> lock(splock);
        if (!que.empty())
        {
            que.pop();
            std::cout << "thread = " << std::this_thread::get_id() << " pop" << std::endl;
        }
    }
}

6.使用boost的shared_mutex

读写锁:boost提供的是shared_mutex,OK名不符意。区分读和写,处于读操作时,可以允许多个线程同时获得读操作。但是同一时刻只能有一个线程可以获得写锁。其它获取写锁失败的线程都会进入睡眠状态,直到写锁释放时被唤醒。 写锁会阻塞其它读写锁。当有一个线程获得写锁在写时,读锁也不能被其它线程获取;写优先于读,当有线程因为等待写锁而进入睡眠时,则后续读者也必须等待 

优点:区分了读写,在读操作多的时候,只需要轻量级加锁,即可访问到正确值。

缺点:在写操作频繁时,作用不显著,还新增了写锁的资源。
适用场景:读取数据的频率远远大于写数据的频率的场合。

std::queue<Element> que;
boost::shared_mutex shmu;

void Producer()
{
    while (true)
    {
        std::this_thread::sleep_for(std::chrono::milliseconds(200));
        std::unique_lock<boost::shared_mutex> lock(shmu);   //加写锁

        que.push(Element());
        std::cout << "thread = " << std::this_thread::get_id() << " push" << std::endl;
    }
}
void Comsumer1()
{
    while (true)
    {
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        boost::shared_lock<boost::shared_mutex> lock(shmu);
        std::cout << "thread = " << std::this_thread::get_id() << " read" << std::endl;
    }
}
void Comsumer2()
{
    while (true)
    {
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        boost::unique_lock<boost::shared_mutex> lock(shmu);
        if (!que.empty())
        {
            que.pop();
            std::cout << "thread = " << std::this_thread::get_id() << " pop" << std::endl;
        }
    }
}

int main()
{
    std::thread thProducer(Producer);
    std::thread thComsumer1(Comsumer1);
    std::thread thComsumer2(Comsumer2);

    std::this_thread::sleep_for(std::chrono::hours(1));
}

个人理解是没一种方式都会有自己使用的场景,不能说哪种一定好,哪种一定不好,即使第一种轮询的方法,都会在某些场合适用。俗话说什么场合干什么样的事情。

猜你喜欢

转载自blog.csdn.net/JamesDa/article/details/83866296
今日推荐