《白话C++》第12章并发,Page568 12.5.2 互斥 修建代码“独木桥”

2.修建代码“独木桥”

我们接下来的任务,就是要让“++_id;”这行代码变成一座“独木桥”。

首先,我们将要变成“独木桥”的代码,用一对花括号包围起来,明确划出边界。

...
int GetNewID()
{
    {//独木桥工地开始段,闲人免进
        return ++ _id;
    }//独木桥工地结束段
}
...

“花括号”可以让几行代码明确成一个代码段,更精确地设定段内各类符号的作用范围和栈变量的生命周期。

另外,我们在段内设立了一块“牌”,使用宏定义。我们暂时在当前源文件中IDCreator结构定义之前,定义了一个宏:

#define __concurrency_mutex_block_begin__

这个宏就只是一个符号,没有任何实质内容,因此也不影响代码的任何逻辑,不过宏的名字有清楚的含义,这正是我们所需的。

将它放到代码“独木桥”段的开始位置

......
int GetNewID()
{
    __concurrency_mutex_block_begin__   
    { 
        return ++ _id;
    } 
}
......

当我们使用类似doxygen的工具自动为代码生成文档时,工具会为用到该宏的代码生成索引,方便我们从几千几万行代码中检查几十甚至只有几处、需要关心是否存在并发冲突的地方

至此,关于“独木桥”代码段的表面工作就完成了。

接下来,是真正让独木桥成为同时只能过一个线程的实质技术了。最主要的方法,就是用互斥量进行加解锁操作,加锁后的代码只能有一个线程经过,直到解锁:

......
#include <mutex> ///互斥量
......

struct IDCreator
{
......

    int GetNewID()
    {
        int new_id;
        __concurrency_mutex_block_begin__
        { 
            _m.lock(); ///加锁
            new_id = ++ _id;
            _m.unlock(); ///解锁
        } 

        return new_id;
    }

private:
    ......
    int _id;
    std::mutex _m; //互斥量
};

请注意GetNewID函数引入一个临时变量,略显复杂,那是因为我们不能这么写:

int GetNewID()
{
    __concurrency_mutex_block_begin__   
    { 
        _m.lock(); //加锁
        return ++ _id;
        //解锁,不行!函数在前面已经返回,此行没机会执行
        _m.unlock(); 
    } 
}

我们已经在《语言篇》学过“守护锁”。利用析构函数调用的确定性,守护锁不仅可以帮我们解决忘记解锁的问题,还可以让代码回归简洁。

最新版的GetNewID()代码是:

int GetNewID()  ///加锁版
{
    __concurrency_mutex_block_begin__
    {
        lock_guard <mutex> lock(_m);
        return ++_id;
    }
}

同一个“互斥量”作用到多段代码“独木桥”,那么同一时刻只能有一个线程在其中的某一段代码上运行。

IDCreator类的GetNewID()每次递增ID的值,如果我们只是想知道ID是多大,可以再为它增加一个方法:

......
//读取最后一次的ID
int GetID() const
{
    __concurrency_mutex_block_begin__
    {
        std::lock_guard <std::mutex> lock(_m);
        return this->_id;
    }
}
......

这段代码又引出两个问题:

先说第一个问题:

GetID()函数只是在读取“_id”的值,需要互斥吗?要回答这个问题,得先清楚:谁和谁互斥?首先是抢着要通过GetID()的多个线程之间在互斥,其次是抢着要通过GetID()或者GetNewID()的多线程之间在互斥,因为两处用的是同一个互斥量“_m”,如图12-8所示。

如图12-8所示,多个线程读取同一个值,这之间不需要互斥,但如果一个线程正好在通过GetNewID()修改“_id”的值,而另一个线程正在读取“_id”,那么并发冲突仍然会发生。

来一个更直接粗暴的例子:有一个全局变量G,线程A正在将它修改为2014,线程B正在将它修改为981234,线程C正在将它修改为1655353?这都正常,不正常的是线程D读到的值可能根本就不是这仨。

【危险】:写一半的数据/Half-written data

某个线程正在读数据,另一个线程却正在改动数据,就有可能造成前者读到一个“写了一半”的数据。这类并发冲突发生的概率极低,一旦发生,则极其难以排查。

为了避免读和写同时发生,却造成同时读也不再可行,这可能会令不少C++程序员浑身不自在,开始担心会不会因此影响程序性能?后面的章节将讲解“读写锁”的方案,它可以让C++程序员自在一点。

3.忽略互斥量成员的变动

第二个问题:

第二个问题很直接,函数GetID() const根本无法通过编译!原因在于用到了成员数据“_m”,并且通过守护锁调用了“_m.lock()”和“_m.unlock()”,这二者都不是常量成员

调用GetID()表面上只是读取“_id”的值,但实际操作过程中发生了成员数据“_m”先上锁再解锁的动作,如果强行认定GetID()是常量成员操作,无疑会遮盖了“某个互斥体锁住了一段时间”的这一事实,因此编译器不认为GetID()是常量成员,是很严谨的做法

解决方法一:

接受GetID()不是常量操作这一事实,去除const修饰。

解决方法二:

忽略过程中对互斥量成员的变动性操作,为互斥量“_m”加上mutable上一次这个关键字用在语言篇的“Lambda”章节中),以明确表明这是一个易变的成员,并且对其修改的成果可以忽略,不实质影响整个对象的状态:

struct IDCreator
{
    ///读取最后一次的ID
    int GetID() const//保持常量成员函数修饰
    {
        __concurrency_mutex_block_begin__
        {
            std::lock_guard <std::mutex> lock(_m);
            return this->_id;
        }
    }

private:
    ......

    int _id;
    mutable std::mutex _m; 
};

【重要】:不要轻易使用“mutable data members”特性

“mutable data members”是一种补丁特性,它具有欺骗性。建议用C++写程序的前五年,请将“可变的数据成员/mutable data members”这一技术特性仅仅用到互斥量身上。

4.剥离互斥逻辑

人生需要思考的问题:

一,我是谁,我要做什么样的人?

二,在别人眼里我是谁?在别人眼里我是怎样的一个人?

设计一个类需要考虑的问题:

一,这个类是什么

二,外部环境将会如何使用这个类?

对于IDCreator而言,提供自增ID的功能,属于思索“我是谁”这样的哲学范畴里的设计;而,“_id++”在并发时有问题,得解决它,属于应对外部环境这样的现实范畴里设计。

如果讲究面向对象设计“抓侧重、寻共性、究本质、理关系”四要素,那么在终极设计里,必须要应对外部环境的那一部分设计都可以被剥离成另外一个类(或函数),而这个类(或函数)的自我职责就是:“咦,我要怎么使用刚才那个类?”如此这般最大化地将A类的外部问题转化为B类的内部问题,正是在《线程》脏结提出thread类不因该被派生的思想支撑。想想,如果跑A任务的线程和跑B任务的线程居然是两个不同的类。

IDCreator类如果奔着丰满的理想目标而实际,就应当将它的互斥量成员数据“_m”直接剥离掉,回到本节最初提供的那个单纯的版本。然后提供区分是否并发使用的辅助函数或辅助类,以对GetNewID()操作的封装为例:

......
int get_new_user_id()
{
    return IDCreator::Instance().GetNewID();
}

mutex mutex_for_new_user_id;

int get_new_user_id_with_multithreading
{
    __concurrency_mutex_block_begin__
    {
        lock_guard <mutex> lock(mutex_for_new_user_id);
        return IDCreator::Instance().GetNewID();
    }
}
......

假设我们写某个简单的程序正好不需要多线程,但却需要用户有各自唯一的ID,上面的设计多完美。

多线程的情况就使用“_with_multithreading”的版本,单线程环境下就用“_without_multithreadng”的版本,无需承担加锁的成本。

理想很丰满,现实的项目进度很赶,IDCreator现有设计已经非常适当,如果再要改进,那就不是为当下的需求考虑,而是为了未来的变化再预留可能。

不过未来会真么变化很难估计,这时候我们可以用上一些“惯用法”,所谓的“惯用法”就是大家都在用,并且大家都说好的方法。

和“互斥逻辑”相关的,刚讲过的“使用宏明确标识出并发冲突地段”和“使用mutable修饰互斥量以忽略它的变动性”,二者都是惯用法,

第三个惯用法是在类的内部刻意保留“非加锁”的版本,以GetNewID()为例:

......
    int GetNewID()  ///加锁版
    {
        __concurrency_mutex_block_begin__
        {
            lock_guard <mutex> lock(_m);
            return GetNewIDWithoutLock();
        }
    }
......

protected:
    int GetNewIDWithoutLock() ///不加锁版
    {
        return ++ _id;
    }

......

不少优秀的代码库,对不带锁的版本取名Inner_XXX,其含义显然没有“_WithoutLock”明确。

相关功能不考虑外部并发需求在内部实现(通常是protected权限),对外的加锁版本改为转发调用未加锁版本,只是在外层加上锁。

这种做法让当前类及派生类在同一功能上拥有考虑并发和无需考虑并发的两个实现,通常可以更好地应对将来的发展,同时保持优秀程序员固有的“懒惰”特性。

【重要】:通过调用转发,实现不同版本

先提供一个实现基础功能的版本(称为基础版本),再提供增加扩展功能版本,后者通过转发调用前者实现。除“不加锁”和“加锁”版本区分之外,“抛出异常”和“不抛出异常”之间的区分,也可以采用类似办法实现。

  • 27
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值