对C++锁的一些思考

C++中的多线程编程是一个相对复杂,坑比较多,并且出现问题较难排查的一个编程领域,也是c++编写大型项目中避不开的部分。

加锁的方式有很多,应用场景也各都稍有不同,根据不同维度(或者侧重点,比如是原子化操作还是通知,技术属于乐观锁还是悲观锁,内核级别还是用户级别,linux平台还是win平台)可以分成很多类别,但其作用都是服务于多线程程序的稳定运行,所以一般都统称为“锁”。

比如原子类型(使用场景如在线程并发环境下的任务ID生成器class idGenerator,内部的计数器应该使用std::atomic_int类似的类型才能保证线程安全),在使用中基本看不到锁的操作,也一样纳入到锁的范畴,而且,它还是多种值得研究的锁分类的一个典型代表,比如乐观锁,自旋锁,用户态锁等。

最近有同事讨论std::shared_ptr是不是线程安全的。我之前一直笃信它是线程安全的,所以在代码中用得很放心。但为了一探究竟,从代码层面理清这个问题,还是有待好好钻研一下。还好能找到stl的代码,在大致看过之后,我还是笃信它是线程安全的。当然,我的“笃信”在于它互相之间拷贝和移动中引用计数维护的方面。而至于其中存放的实际数据,则没有任何的安全策略。那么是不是说它就不线程安全了呢?

这个问题我觉得应该从std::shared_ptr数据结构本质上来说,C++一系列的智能指针都旨在代替各种使用场景下的原始指针的使用,那么如果要问std::shared_ptr存放的数据是否线程安全,可以先想想int*类型的临界资源是否线程安全。答案显而易见,并不是。它的线程安全需要你自己加锁维护。

不知道我有没有表述清楚,本人一向口拙笔拙,其实意思就是关于std::shared_ptr的线程安全问题,需要从两方面考虑,第一个是stl提供的这个工具类,对它的线程安全考虑主要就是并发下的引用计数是否准确问题,当然,它是线程安全的;而第二个是它作为一个指针指向的内容是否线程安全,而这一方面,工具类是没有为你做任何事情的。

对于之前一篇博文,其中实现的ttl cache代码再研究了一下,果然还是发现了多线程下的漏洞,在于并发下调用_CheckInConstructor()和_CheckInDestructor()时,其中use_count()并不一定是出现满足条件的值。而这个问题的修改又并不是一个简单的加解锁就解决的,因为对其中智能指针构造(拷贝)导致引用计数变化的代码在构造函数的初始化列表里,而获取引用计数的代码在函数体内部。问题变得有点棘手。

为了看起来简单,我写了个简单的抽象的问题描述demo:

atomic_int g_count = 0;

template<int n>
class N { public: N(int p = 0) { (n % 2) ? (++g_count) : (--g_count); } };

class T
{
public:
	T() :n1(0), n2(0), n3(0), n4(0), n5(0), n6(0), n7(0), n8(0) { assert(g_count == 0); }
private:
	N<1> n1; N<2> n2; N<3> n3; N<4> n4; N<5> n5; N<6> n6; N<7> n7; N<8> n8;
};
在单线程中,T类型构造函数中的assert永远不会触发,但是在多线程的并发环境下,如果某一个线程在初始化列表n1~n8构造过程中有另一个线程抢到了cpu时间片,恰好不对称的改变了g_count,则assert便会触发。实验结果是极其容易引发断言,测试代码如下:
void test()
{
	int count = 10;
	std::vector<std::thread*> tv;
	tv.resize(count);
	for (int i = 0; i < count; ++i)
	{
		tv[i] = new std::thread([]() 
		{
			for (int j = 0; j < 1000; ++j)
			{
				T t;
				std::this_thread::sleep_for(std::chrono::milliseconds(10));
			}
		});
	}
	for (int k = 0; k < count; ++k)
	{
		if (tv[k])
		{
			tv[k]->join();
			delete tv[k];
		}
	}
}

对于这个问题,在T构造函数的assert前或者后加锁都是没有任何作用的,并不能达到对初始化列表和函数体内代码同时原子化的目的(其实连单独对初始化列表的原子化都达不到)。

不过可能我运气一向比较好,在思忖好一阵,也就在快要放弃,打算推倒重来的时候,灵光乍现,有了!代码改成下面这样:

std::atomic_int g_count = 0;
std::mutex g_mutex;


template<int n>
class N { public: N(int p = 0) { (n % 2) ? (++g_count) : (--g_count); } };

template<typename LOCK> class OnlyLock { public: OnlyLock(LOCK& l) { l.lock(); } };
template<typename LOCK> class OnlyUnLock { public: OnlyUnLock(LOCK& l) { l.unlock(); } };

class T
{
public:
	T() :l(g_mutex), n1(0), n2(0), n3(0), n4(0), n5(0), n6(0), n7(0), n8(0) { assert(g_count == 0); OnlyUnLock<std::mutex> l(g_mutex); }
private:
	OnlyLock<std::mutex> l;
	N<1> n1; N<2> n2; N<3> n3; N<4> n4; N<5> n5; N<6> n6; N<7> n7; N<8> n8;
};

因为c++的类成员初始化顺序是和声明顺序一致的,所以OnlyLock变量放在其他所有成员变量之前就好了,最后在构造函数体最末尾OnlyUnLock。

但是还是有个问题,如果T类有基类,而且在基类构造函数中对临界资源值有修改,同时要在T类构造函数中取值并且保证整个对象构造过程的原子性,上面的技巧则是不能达到要求的,除非去修改基类,把OnlyLock挪到基类第一个成员位置。

再研究了下ttl cache,上述改法应该可以奏效,但是考虑到是否会代来性能问题或者是否有更好的解决问题方案,还需要再仔细琢磨一下,所以暂时也没有代码修改。

对于上述加锁处理,还应该说明的一点是,这种非RAII的加锁方式一般应该避免使用,RAII能为程序的健壮性从代码层次提供有更有效的提升,比如不会因为忘记unlock而造成死锁,同时也保证了异常出现并抛到外层时加过的锁会自动解除。这种RAII的机制在很多同类型的场景中都应用到,比如scope_ptr。






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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值