聚合国内IT技术精华文章,分享IT技术精华,帮助IT从业人士成长

pthread_once实现简析

2014-03-05 20:30 浏览: 2263838 次 我要评论(0 条) 字号:

想到这个主题,也是由于最近在写自己的开源代码时,发现使用的singleton类是线程不安全的,虽然在应用主线程都已确保了初始化的正确性,但从一个通用库的角度来讲,这么做就无法保证使用者不出问题,也限制了自由度,pthread_once就是解决这个问题的一个良方,不过首先我们还是先从一些基本的singleton实现说起。

网上关于singleton类的实现真是一搜一箩筐,从最简单的说起(注:为了代码简洁,以下均不使用模板)

一)直接返回 local static 对象

class foo_t
{
public:                                                                                                                        
    static foo_t& instance()
    {   
        static foo_t foo;
        return foo;
    }   
};

这就是最简单的singleton使用,这里有一个小问题就是:foo对象必须以 local static 的形式存在,关于这一点在《Effective C++》一书中Scott Meyers大神已经说明原因,non-local static 变量的初始化顺序存在不确定性,而 local static 变量的初始化时机是可知的。

不过这种写法也有问题,就是多线程环境下无法保证其正确性,因为当 static foo_t foo; 在运行时第一次达到时,相对应着的是多条指令,就像是类似下面的情况:

if (!foo_init_flag)
{
    foo = foo_t();
    foo_init_flag = true;
}

当同时多个线程调用instance()时,对于foo对象的正确初始化,就无法得到保证了。

一个解决方法是将所有单例类在主线程启动时进行统一的初始化,这个方法也是最有效和最简单的。

当然如果你使用的是c++ 11,就不必为此烦恼了,c++ 11 的 local static 变量初始化已经是多线程安全的了,也即是说,如果你希望你的应用现在,将来只会在 c++ 11上运行的话,就目前看来这种直接返回 local static 的方法或许会是你最简单有效的方案,如果你希望你的应用更具移植性和跨平台性,那么请继续往下看。

 

二)每次 get_instance() 时都进行lock

为了解决上面提到的多线程安全的问题,可以在 get_instance() 方法中进行锁保护,

                                                       
    static foo_t& instance()
    {   
        mutex_t lock;
        // do init...
        ...
        return foo;
    }

这样做当然可以解决问题,但效率的损失显而易见,所以这个方案除了做demo,肯定不能应用到真实环境,pass之~

三)使用 double checked locking pattern(DCLP)机制

最早人们都使用这种方案来保证正确性而且能不失效率,我们先来看一下实现代码:

class foo_t
{
public:
    static foo_t& instance()
    {   
        if (NULL == foo)    // 1
        {   
            mutex_t scope_lock(mutex);

            {   
                if (NULL == foo)    // 2
                {   
                    foo = new foo_t();
                }   
            }   
        }   

        return *foo;
    }   

private:
    static foo_t    *foo;
    mutex_t          mutex;
};

foo_t* foo_t::foo = NULL;

当多个线程同时首次进入 instance(),都会发现 1处 为真,之后则开始竞争互斥锁,竞争得到锁的线程会进行初始化,然后释放锁,之后其他线程在拿到锁之后,会在 2处 再进行一次check(这里已是串性化地进行check),这时发现实例已被初始化,则直接返回实例,整个过程看似毫无纰漏,但meyers大神又在这时给了众人当头一棒:http://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf

在此文中meyers清楚地阐述了在c++中这种做法的危险性,在第3页第4页中,meyers指出 new Singleton; 这一个步骤在真正运行时会分解成三个行为:

Step 1: Allocate memory to hold a Singleton object.
Step 2: Construct a Singleton object in the allocated memory.
Step 3: Make pInstance point to the allocated memory.

也即是:

1. 分配内存

2. 构造foo对象

3. 将指针指向分配的内存

但就是这三个行为,可能会被cpu指令重排,然后执行的顺序发生了变化,例如:3 => 1 => 2

这在通常情况下不会有什么危险,因为乱序执行就是cpu的一种优化手段,而且在外层有互斥锁的保护,但问题就在于,我们的互斥锁的保护是有条件的,也就是 foo 对象必须为空,而这个条件却被步骤3所影响,倘若cpu先执行了步骤3,这时另一个cpu同时进行 1处的判断,发现指针已不为空,直接返回对象供上层使用,而这时你返回的却是一个根本还没构造完毕的对象!其后果就不得而知了,但一定不会是你希望的那样。。。

那解决方法呢?memory barrier登场~,也即是我们俗称的内存屏障,它作为一个“界线”,来告诉cpu和编译器不要对这条界线前后的指令做调换,由于cpu和编译器都可能进行重排,所以我们需要保证这两者都不会对我们不允许进行重排的代码进行重排,这就需要内存屏障来完成。

注:文中meyers还提到了volatile关键字也无法解决问题,详细原因大家可仔细研究其论文,这里不再展开,值得注意的是,在vs2005上,volatile关键字可以解决 DCLP 问题,因为微软对于volatile的实现让其隐含了memory barrier的语义。

四)终于到猪脚 pthread_once 了,它可以在任何平台上保证你的 多线程安全的singleton类的正确性,函数的本意就是只运行某个函数一次,使用代码就不贴了,网上随处可寻,这里直接进入源码一窥究竟,看看 pthread_once 到底是如何解决 DCLP 所没有解决的问题的:

int
__pthread_once (once_control, init_routine)
     pthread_once_t *once_control;
     void (*init_routine) (void);
{
  /* XXX Depending on whether the LOCK_IN_ONCE_T is defined use a
     global lock variable or one which is part of the pthread_once_t
     object.  */
  if (*once_control == PTHREAD_ONCE_INIT)
    {   
      lll_lock (once_lock, LLL_PRIVATE);

      /* XXX This implementation is not complete.  It doesn't take
     cancelation and fork into account.  */
      if (*once_control == PTHREAD_ONCE_INIT)
    {   
      init_routine (); 

      *once_control = !PTHREAD_ONCE_INIT;
    }   

      lll_unlock (once_lock, LLL_PRIVATE);
    }   

  return 0;
}

P.S. 这段代码是glibc-2.9版本中x86 平台上pthread_once的实现,由于cpu指令重排(cpu order)的问题牵涉到硬件架构,所以这里我们只看x86平台。

在你看过了这段代码之后,你可能会心里嘀咕一番,“这不就是 DCLP 机制吗?和我们自己实现的有什么两样??”,没错,我在刚找到这份代码时,也百思不得其解,经过痛苦地google查阅资料之后,发现秘密就在于 lll_lock 之中,其实现是一个基于 gcc内嵌指令的宏,所以也依赖于不同的硬件架构,由于过长,我这里只贴出x86平台上的关键代码:

#define lll_lock(futex, private) 
__asm __volatile (__lll_lock_asm_start                   
               ...  // 省略了其他指令
               ...  // 省略了其他指令
               : "memory");

memory是一个内嵌指令,作用是告诉gcc编译器:

1)不要将该段内嵌汇编指令与前面的指令重新排序;也就是在执行内嵌汇编代码之前,它前面的指令都执行完毕
2)不要将变量缓存到寄存器,因为这段代码可能会用到内存变量,而这些内存变量会以不可预知的方式发生改变

说白了,它就是一个针对编译器的内存屏障,这就保证了编译器不会对我们的代码进行重排,那么CPU重排呢?这memory指令就不会关心了,但这也无需我们去操心,因为在x86/x64平台上硬件体系已对此做了保证,有兴趣的朋友可能看看这篇文章:http://bartoszmilewski.com/2008/11/05/who-ordered-memory-fences-on-an-x86/

所以,我们上面的 DCLP 事例代码,即使没有使用的lock并不具备barrier/fence,只要在x86/x64平台上,依然可以保证正确性(当然,其他不保证这一点的硬件体系下,需要加入内存屏障来保护,貌似powerPC就需要如此)。

之后我又去查看了早期版本(glibc-2.1)的pthread_once实现,发现当时没有加入lll_lock族,而是直接使用的pthread_mutex_xxx,经过查阅之后,了解到大多具有同步语义的 pthread操作已经带有屏障功能,具体参见:

http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap04.html#tag_04_11

http://www.hpl.hp.com/personal/Hans_Boehm/misc_slides/reordering.pdf

通过上面两篇文章,可以得出一个结论就是:假设我们的 DCLP 代码使用的是pthread_mutex_lock/unlock,那么就不会发生问题,但这样做不太符合应用开发者“面向接口编程”的理念,毕竟从理念上来讲 lock 和 barrier/fence虽然关系微妙,但却并不一定存在包含关系(从meyers的论述中即可知),某些时候,我们应该抱着“无知”的态度去使用它们。

综上所述,要写一个线程安全的singleton,pthread_once是值得我们信赖的帮手,即使你不了解底层硬件体系,编译器,它也能完成你希望的,这里附上一个我的chaos库中的实现:https://github.com/lyjdamzwf/chaos/blob/dev/chaos/utility/singleton.h

最后做一下总结:

*) local static变量不是多线程安全的,而c++ 11中的local static是多线程安全的

*) double checked locking pattern(DCLP)存在out-of-order问题,需要提供内存屏障来保证 编译器/CPU 不会“打乱”你的代码

*) x86/x64平台上DCLP不会出现问题,但这只是解决了cpu重排问题,对于编译器重排我们还要进行保证

*) pthread中的同步操作都具备 屏障(cpu和编译器) 功能

新年愉快 :)

-EOF

 

 

 

 

 



网友评论已有0条评论, 我也要评论

发表评论

*

* (保密)

Ctrl+Enter 快捷回复