双重检查锁(double-checked locking)

在软件工程中,双重检查锁(double-checked locking[1])是一种设计模式,通过在加锁前检查锁标志(criterion),可以减少加锁的开销。只有当标志显示需要的时候才会加锁。

这个模式在某些软硬件混合实现的场景下可能是不安全的,因此有时也被认为是一种“反模式”[2]。

该模式通常被用于多线程下延迟初始化的场景,尤其是单例模式。延迟初始化是指在某个资源首次被访问时才进行初始化(例如建立网络链接、加载某些数据等场景)。

以下是几种常见语言使用双重检查来单例模式的例子。Java的实现会着重展开讲。

== C++11 ==

在C++中不需要双重检查来实现单例模式。

If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization. 

如果控制流并发执行到声明语句,且该变量正在初始化,并发的执行流应当等初始化完成才能继续。

— § 6.7 [stmt.dcl] p4 (C++11标准6.7节第四段)

Singleton& GetInstance() {
  static Singleton s; //静态初始化
  return s;
}

如果希望用双重检查惯用法(idiom)来实现以上逻辑(比如Visual Studio低于2015的版本没有实现前面引用的C++11标准对并发初始化的要求[3]),需要使用acquire fence和release fence[4](注:内存屏障,防止编译器和CPU的指令重排):

#include 
#include 


class Singleton {
 public:
  Singleton* GetInstance();


 private:
  Singleton() = default;


  static std::atomic s_instance;
  static std::mutex s_mutex;
};


Singleton* Singleton::GetInstance() {
  Singleton* p = s_instance.load(std::memory_order_acquire);
  if (p == nullptr) {
    std::lock_guard lock(s_mutex);
    p = s_instance.load(std::memory_order_relaxed);
    if (p == nullptr) {
      p = new Singleton();
      s_instance.store(p, std::memory_order_release);
    }
  }
  return p;
}

== Golang ==

package main
import "sync"


var arrMu sync.Mutex
var arr *[]int


// getArr 获取 arr, 在需要时延迟初始化,双重检查
// 避免锁住整个函数,并且保证arr只会被初始化一次
func getArr() *[]int {
    up := (*unsafe.Pointer)(unsafe.Pointer(&arr))
    if atomic.LoadPointer(up) != nil { // 1st check
        return arr
    }


    arrMu.Lock()
    defer arrMu.Unlock()


    if arr != nil { // 2nd check
        return arr
    }
    a := &[]int{0, 1, 2}
    atomic.StorePointer(up, unsafe.Pointer(a))
    return arr
}


func main() {
    // 双重可以保证两个goroutine同时调用getArr()
    // 不会导致重复初始化
    go getArr()
    go getArr()
}

== Java ==

针对单例的实现,很多地方[2]都会给出这样一个例子:

// 单线程版
class Foo {
    private Helper helper;
    public Helper getHelper() {
        if (helper == null) {
            helper = new Helper();
        }
        return helper;
    }


    // 其他方法和成员...
}

这个实现的问题是无法满足多线程的场景。当两个线程同时调用 getHelper() 时必须加锁,否则他们可能会各创建一个对象,也可能有一个会拿到没有完全初始化完成的对象。

加锁需要的同步开销很大,如下例所示:

// 正确但开销很大的多线程版本
class Foo {
    private Helper helper;
    public synchronized Helper getHelper() {
        if (helper == null) {
            helper = new Helper();
        }
        return helper;
    }


    // 其他方法和成员...
}

然而,首次调用 getHelper() 就会创建单例对象,并且只有少数几个在当时尝试去调用的线程之间需要同步,之后所有的调用只需要返回该成员变量的引用即可。给一个方法加上 synchronized 关键字有时可能会导致100倍甚至更高的性能损耗[5],每次调用该方法加锁、解锁的开销似乎不太有必要:一旦初始化完成,加锁、解锁就显得毫无必要了。许多程序员尝试用如下方法优化这个场景:

  1. 检查变量是否被初始化(不加锁)。如果已初始化,立即返回。

  2. 加锁。

  3. 再次检查该变量是否初始化:如果另一个线程之前已经加过锁,它可能已经完成了初始化,在这种情况下直接返回初始化的对象引用即可。

  4. 否则需要初始化并返回

// 有问题的多线程版本
// "双重检查" 惯用法
class Foo {
    private Helper helper;
    public Helper getHelper() {
        if (helper == null) {
            synchronized (this) {
                if (helper == null) {
                    helper = new Helper();
                }
            }
        }
        return helper;
    }


    // 其他方法和成员...
}

乍一看,这好像是个高效的算法。然而,这种技巧有很多小问题,通常需要避免。例如,考虑这种场景,按以下顺序发生了一系列事件:

  1. 线程A发现该变量未初始化,因此加锁并尝试初始化该变量。

  2. 由于某些语言的语义,编译器生成的代码允许将共享变量指向一个部分初始化的对象,此时A尚未完成对该对象的初始化。例如,在Java中,如果构造函数被内联(inline),共享变量可能会立即被更新,指向该对象新分配的地址,然后才执行被内联的构造函数[6]。

  3. 线程B发现该变量已经“被初始化了”(至少看起来是),返回该对象。由于线程B认为变量已经初始化,它不会加锁。在A对该对象的初始化完成、并对B可见之前(可能是A还没完成初始化,或者因为缓存一致性的问题涉及到的内存变动尚未同步到B),如果B使用该对象,程序可能就会崩溃。

在J2SE 1.4(或更早版本)使用多重检查锁的危险是,它往往执行正常,而想要区分正确的实现和有一点小问题的实现往往很困难。由于编译器的实现、调度器对线程的交错调度策略和并发系统的其他特性,如上不正确实现双重检查锁导致的异常可能是间歇出现的,而且很难复现。

在J2SE 5.0里这个问题被修复了,volatile关键字可以保证在多线程环境下正确处理单例对象。新的惯用法如下:

// 在 Java 1.5及之后版本有效,基于volatile的acquire/release语义
// 在 Java 1.4及更早版本volatile的语义存在问题
class Foo {
    private volatile Helper helper;
    public Helper getHelper() {
        Helper localRef = helper;
        if (localRef == null) {
            synchronized (this) {
                localRef = helper;
                if (localRef == null) {
                    helper = localRef = new Helper();
                }
            }
        }
        return localRef;
    }


    // 其他方法和成员...
}

注意这个局部变量 "localRef",似乎看起来没必要,实际作用是,当 helper 被初始化以后(大多数情况下),这个 volatile 字段只需要被访问一次(最后return的是 localRef 的值,而不是 helper),这最高可以使该方法的整体性能提高25%[7](注:volatile的内存屏障语义,会导致每次读、写的时候对应缓存失效)。

Java 9 引入了 VarHandle 类,可以使用 "relaxed" 级别的原子操作来访问变量,在使用弱内存模型(weak memory model)的机器上(注:指CPU)读操作会更快,但代价是更复杂的机制和不保证顺序一致性(sequencial consistency,访问该变量不再是synchronization order)[8]。

// 在Java 9有效,基于VarHandle的acquire/release语义
class Foo {
    private volatile Helper helper;


    public Helper getHelper() {
        Helper localRef = getHelperAcquire();
        if (localRef == null) {
            synchronized (this) {
                localRef = getHelperAcquire();
                if (localRef == null) {
                    localRef = new Helper();
                    setHelperRelease(localRef);
                }
            }
        }
        return localRef;
    }


    private static final VarHandle HELPER;
    private Helper getHelperAcquire() {
        return (Helper) HELPER.getAcquire(this);
    }
    private void setHelperRelease(Helper value) {
        HELPER.setRelease(this, value);
    }


    static {
        try {
            MethodHandles.Lookup lookup = MethodHandles.lookup();
            HELPER = lookup.findVarHandle(Foo.class, "helper", Helper.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }


    // 其他方法和成员...
}

如果 helper 对象是静态(static,每个class加载器一个),另一种实现方案是 "Initialization-on-demand holder" 惯用法[9]:

// Java正确的延迟初始化
class Foo {
    private static class HelperHolder {
       public static final Helper helper = new Helper();
    }


    public static Helper getHelper() {
        return HelperHolder.helper;
    }
}

内嵌类在被引用的时候才会被加载,这保证了以上实现的正确性。

Java 5 中 final 的语义可以在不使用 volatile 的情况下安全发布 helper 对象[11]:

public class FinalWrapper {
    public final T value;
    public FinalWrapper(T value) {
        this.value = value;
    }
}


public class Foo {
   private FinalWrapper helperWrapper;


   public Helper getHelper() {
      FinalWrapper tempWrapper = helperWrapper;


      if (tempWrapper == null) {
          synchronized (this) {
              if (helperWrapper == null) {
                  helperWrapper = new FinalWrapper(new Helper());
              }
              tempWrapper = helperWrapper;
          }
      }
      return tempWrapper.value;
   }
}

局部变量 tempWrapper 是必须的:如果在两个 null 检查中都使用 helperWrapper ,return语句可能失败,因为Java内存模型允许读乱序[12]。这个实现的性能不一定比 volatile 版本更好。

== C# ==

在 .NET 中实现双重检查很容易。常用的模式是在单例实现中加入双重检查:

public class MySingleton {
  private static object myLock = new object();
  private static volatile MySingleton mySingleton = null;
  // 在.NET 2.0 及以上版本'volatile'不是必须的


  private MySingleton() {
  }


  public static MySingleton GetInstance() {
    if (mySingleton == null) { // 1st check
      lock (myLock) {
        if (mySingleton == null) { // 2nd check
          mySingleton = new MySingleton();
          // .NET 1.1中,volatile隐含了write-release 语义
          // 会在构造函数调用和复制之间加上必要的内存屏障
          // 加锁的屏障不够,因为在释放锁之前对象就可见了
          // .NET 2.0及以后的版本锁就够了,不需要volatile
        }
      }
    }
    // 在.NET 1.1中,加锁的屏障不够,因为不是所有线程都会加锁
    // 在校验和读取mySingleton之间需要read-acquire语义的内存
    // 因为mySingleton是volatile的,所以被自动加上了这个屏障
    // 在.NET 2.0 及后续版本, 'volatile' 就不是必要的了
    return mySingleton;
  }
}

在这个例子中,"lock hint" 是 mySingleton 对象,当它被初始化完成以后就不在是null了。

在 .NET 4.0中引入的 Lazy 类内部默认使用双重检查锁(ExecutionAndPublication 模式)来保存初始化时抛出的异常,或传给它的函数执行的结果[13]:

public class MySingleton
{
    private static readonly Lazy _mySingleton = new Lazy(() => new MySingleton());


    private MySingleton() { }


    public static MySingleton Instance
    {
        get
        {
            return _mySingleton.Value;
        }
    }
}

== 其他参考 ==

  • Test and Test-and-set 惯用法,用于一种low-level锁机制

    • https://en.wikipedia.org/wiki/Test_and_Test-and-set

  • Initialization-on-demand holder惯用法,在java中用于线程安全的替代做法

    • https://en.wikipedia.org/wiki/Initialization-on-demand_holder_idiom

引用

  1. Schmidt, D et al. Pattern-Oriented Software Architecture Vol 2, 2000 pp353-363

  2. David Bacon et al. The "Double-Checked Locking is Broken" Declaration.

  3. "Support for C++11-14-17 Features (Modern C++)".

  4. Double-Checked Locking is Fixed In C++11

  5. Boehm, Hans-J (Jun 2005). "Threads cannot be implemented as a library" (PDF). ACM SIGPLAN Notices. 40 (6): 261–268. doi:10.1145/1064978.1065042.

  6. Haggar, Peter (1 May 2002). "Double-checked locking and the Singleton pattern". IBM.

  7. Joshua Bloch "Effective Java, Second Edition", p. 283-284

  8. "Chapter 17. Threads and Locks". docs.oracle.com. Retrieved 2018-07-28.

  9. Brian Goetz et al. Java Concurrency in Practice, 2006 pp348

  10. Goetz, Brian; et al. "Java Concurrency in Practice – listings on website". Retrieved 21 October 2014.

  11. [1] Javamemorymodel-discussion mailing list

  12. [2] Manson, Jeremy (2008-12-14). "Date-Race-Ful Lazy Initialization for Performance – Java Concurrency (&c)". Retrieved 3 December 2016.

  13. Albahari, Joseph (2010). "Threading in C#: Using Threads". C# 4.0 in a Nutshell. O'Reilly Media. ISBN 978-0-596-80095-6. Lazy actually implements […] double-checked locking. Double-checked locking performs an additional volatile read to avoid the cost of obtaining a lock if the object is already initialized.

你可能感兴趣的:(双重检查锁(double-checked locking))