编译器在编译代码或CPU在执行代码的时候,为了提高编译器和处理器的执行效率,有时会对指令重排序(reorder)。编译器优化重排序是在编译时期完成的,指令重排序和内存重排序是在CPU运行时进行的。
  • 编译器优化的重排序,是指在不改变单线程语义的情况下重新安排语句的执行顺序
  • 指指令级并行重排序,是指处理器的指令级并行技术将多条指令重叠执行,如果不存在数据的依赖性将会改变语句对应机器指令的执行顺序
  • 内存系统的重排序,因为使用了读写缓存区,使得看起来并不是顺序执行的
通常情况下,重排序不会产生什么问题,但在涉及到多线程时,可能会遇到问题。
  • 重排序可能会导致多线程程序出现内存可见性问题。
  • 重排序会导致有序性问题,程序的读写顺序于内存的读写顺序不一样。

这里分别以C#和C++中的两个的例子来说明编译器的重排序对程序的影响,以及如何避免出错。

C#中的双检锁


双检锁(double-check locking)是一项比较有名的技术,可以用它来将单例(singleton)的构造延迟到应用程序首次请求该对象时进行,也叫延迟初始化(lazy initialization):如果程序永远不请求对象,则对象永远不会被构造,从而可以节省时间和内存。

单线程下的单例模式实现很简单,但当多个线程同时请求单例实例对象时就可能出问题,这时就需要一些线程同步机制确保单实例对象只被构造一次。

双检锁这个技术曾在Java中大量使用,但后来有一天,人们发现Java不能保证该技术在任何情况下都能正确工作。但CLR能很好的支持双检锁技术,这应该归功于CLR的内存模型以及volatile字段访问。

C#中多线程下的单例模式实现如下:

public sealed class Singleton
{
	private static object s_lock = new object();
	private static Singleton s_value = null;//引用单实例对象
	private Singleton()//私有构造函数阻止外部直接new实例化
	{

	}
	public static Singleton GetInstance()
	{
		if (s_value == null)//第一次检查,如果不为空,则直接返回
		{
			Monitor.Enter(s_lock);//还没有创建,确保只有一个线程创建
			if (s_value == null)//如果还没有创建,创建它
			{
				Singleton s = new Singleton();//创建一个临时对象
				Volatile.Write(ref s_value, s);//将引用保存到s_value中
			}
			Monitor.Exit(s_lock);
		}
		return s_value;
	}
}

双检锁技术的思路是,对GetInstance方法的一个调用,可以快速的检查s_value字段,判断该对象是否已经创建。如果是,则直接返回对它的引用。这里的妙处在于,如果对象已经构造好,就不需要线程同步,程序会运行的非常快。另外,如果调用GetInstance方法的第一个线程发现对象还没有创建,就会获取一个线程同步锁来确保只有一个线程构造单实例对象。这意味着只有线程第一次查询单实例对象时,才会出现性能上的损失。

这种double check的双检锁模式非常常见,比如我们有一个普通的Dictionary对象,应用场景允许脏读,但有可能多个线程会往里面写,所以要加锁,当然可以直接使用ConcurrentDictionary,代码如下:

Dictionary<int, string> dic = new Dictionary<int, string>();
private static object writeLock = new Object();

void multiThreadAdd(int key, string value)
{
	if (!dic.TryGetValue(key, out string v)
	{
		lock (writeLock)
		{
			if (!dic.TryGetValue(key, out v)
			{
				dic.Add(key, value);
			}
		}
	}
}

但在Java中,这个模式出了问题。Java虚拟机(JVM)在GetInstance方法开始时将s_value的值读入CPU寄存器。然后,对第二个if语句求值时,它直接查询寄存器,造成第二个if语句的求值总是true,结果就是多个线程都会创建Singleton对象。当然,只有多个线程恰好同时调用GetInstance才会发生这种情况。在大多数应用程序中,发生这种情况的概率都是极低的。这正是该问题在Java中长时间都没有被发现的原因。

在CLR中,对任何锁方法的调用都构成了一个完整的内存栅栏,在栅栏之前写入的任何变量都必须在栅栏之前完成;在栅栏之后的任何变量读取都必须在栅栏之后开始,对于GetInstance方法,这意味着s_value字段的值,在调用了Monitor.Enter之后必须重新读取;调用前缓存到寄存器中的东西不算数了。

第二个if里面有一个Volatile.Write的调用,这个是重点。如果第二个if语句中的代码写成这样(而且很可能会写出这样):

s_value = new Singleton();

我们的想法是想让编译器按以下顺序生成代码:

  1. 为Singleton分配内存
  2. 调用构造函数来初始化字段
  3. 然后再将引用赋值给s_value字段

第3步,使一个值对其它线程可见成为发布(publishing)。

但编译器也可能这样做:

  1. 为Singleton分配内存
  2. 然后将引用发布到s_value
  3. 再调用构造函数。

如果是单线程,这样的顺序改变无关紧要。但如果在多线程场景下,编译器在将引用发布给s_value(第2步)之后,在调用构造函数(第3步)之前, 如果另外一个线程调用了GetInstance方法,那会发生什么?这个线程会发现s_value不为null,所以开始使用Singleton对象,但那个对象的构造器还没有开始执行或执行完成!这是一个很难追踪的bug。这种编译器的reorder,有时候会带来难以察觉的问题,在后面我会还会举一个C++里面的例子。

在第二个if里,对Volatile.Write的调用修正了这个问题,它保证temp中的引用,只有在构造器结束执行之后才发布到s_value中。解决这个问题的另外一个办法是使用volatile关键字来标记s_value字段,使得s_value的写入变得具有“易变性"。但这样也会使所有的读取操作也变得具有”易变性“,这完全没有必要,使用volatile关键字,会使性能受到一些损害。

除了上面这个volatile写入,对于复杂的对象初始化,也非常容易犯错误,比如在您能看出这个Double Check里的问题吗?这篇文章里说的就是这种情况,代码如下:

private object m_mutex = new object();
private Dictionary<int, Category> m_categories;

public Category GetCategory(int id)
{
    if (this.m_categories == null)
    {
        lock (this.m_mutex)
        {
            if (this.m_categories == null)
            {
                LoadCategories();
            }
        }
    }
    return this.m_categories[id];
}

private void LoadCategories()
{
    this.m_categories = new Dictionary<int,Category>();
    this.Fill(GetCategoryRoots());
}

private void Fill(IEnumerable<Category> categories)
{
    foreach (var cat in categories)
    {
        this.m_categories.Add(cat.CategoryID, cat);
        Fill(cat.Children);
    }
}

 这个问题在于第二个if里面的LoadCategories方法,在方法的第一行就为m_categories设置了一个空字典。如果现在立即有另一个线程访问了GetCategory方法就会发现m_categories字段不是null,并直接执行this.m_categories[id]这行代码——但此时,第一个线程还没有将这个字典填充完毕!,解决方法就是:

private void LoadCategories()
{
    var categories = new Dictionary<int,Category>();
    Fill(categories, GetCategoryRoots());
    Volatile.Write(ref this.m_categories,categories);
}

对于复杂的初始化,我们先定义一个临时变量,然后初始化赋值,最后一次性赋值给原始的对象。这里反映了Double Check在使用时的一个准则:在满足if条件的时候,一定要确保所有的初始化已经完成了。或者说,一定要将“满足if条件”的字段初始化操作放在临时变量初始化完毕之后,原子性的写入到要保护的单实例对象上来。

因为可能会用到锁,所以双检锁会损害效率,在C#中,可以利用静态字段初始化的特性来实现单例模式,简单得多,而且没有锁。

public sealed class Singleton
{
	private static Singleton s_value = new Singleton();
	private Singleton() { }
	public static Singleton GetInstance() => s_value;
}

CLR能保证对类的构造器调用时线程安全的。这种方式的缺点是,首次访问类的任何成员都会调用类的构造器。所以如果Singleton类型定义了其它的静态成员,就会在访问其它任何静态成员时创建Singleton对象。这个问题可以通过以下方式解决。

public sealed class Singleton
{
	private static Singleton s_value;
	private Singleton() { }
	public static Singleton GetInstance()
	{
		if (s_value == null)
		{
			Singleton temp = new Singleton();
			Interlocked.CompareExchange(ref s_value, temp, null);
		}
		return s_value;
	}
}

如果多个线程同时调用GetSingleton,这个版本可能创建两个(或更多)Singleton对象,然而,对Interlock.CompareExchange的调用确保只有一个引用才会发布到s_value字段中,没有通过这个字段固定下来的任何对象都会被垃圾回收。由于在大多数应用程序都很少发生多个线程同时调用GetSingleton的情况,所以不太可能同时创建多个Singleton对象。

虽然可能创建多个Singleton对象,但上述代码有很多优势。首先,它的速度非常快,其次,它永远不阻塞线程。相反在双锁检中,如果一个线程池线程在一个Monitor或者其它任何内核模式的线程同步构造上阻塞,线程池就会创建另外一个线程来保持CPU的饱和,因此会分配并初始化更多的内存,而且所有DLL都会收到一个线程连接通知。而使用CompareExchange则永远不会发生这种情况。当然,只有在构造器没有副作用的时候才能使用这个技术(每次new的对象都完全相同)。

FCL中Lazy类封装了上面的双检锁和Interlock.CompareExchange模式:

public class Lazy 
{
	public Lazy(Func<T> valueFactory, LazyThreadSafetyMode mode);
	public bool IsValueCreated {get;}
	public T Value {get;}
}

public enum LazyThreadSafetyMode
{
	None,
	PublicationOnly,      //CompareExchange模式
	ExecutionAndPublication //双检锁模式
}

上面的Singleton的实现可以改写为:

System.Lazy<Singleton> s = new System.Lazy<Singleton>(
	() =>
	{
		return new Singleton();
	},
	LazyThreadSafetyMode.ExecutionAndPublication
);

这里的LazyThreadSafeMode采用的ExecutionAndPublication,就是双检锁技术。如果采用PublishOnly,就是上面说所得Interlock.CompareExchange无锁技术。如果选择None,则不加锁,这一般在UI线程中使用,因为一个应用程序一般只有一个UI线程,所以不存在线程同步问题。

现在获取s.Value就能获取到Singleton单例实例。Lazy类简化了C#中Singleton的实现。

以独立语句将new对象置入智能指针


在C++中,如果new一个对象,那么在使用完成之后一定要delete释放这块空间,否则就会造成内存泄露。

class Investment
{
};
Investment *createInvestment();
void f()
{
    Investment *p = createInvestment();
    //...
    delete p;
}

在f函数中,新建了一个p对象,我们保存了该对象的指针,createInvestment方法可能是一个工厂方法用来创建Investment对象。

一切看起来很妥当,但实际上在若干情况下,f可能无法删除createInvestment创建的资源,比如:

  • 在创建Investment语句和最后删除资源delete语句中间的...区域,可能存在过早的return语句。如果return执行,控制流就返回了,delete不会被执行
  • 如果在...区域内产生了异常,f方法将不会执行delete p语句。

无论上述那种原因,都会导致delete语句不会被执行,从而泄露资源。当然我们可以非常谨慎的避免出现这种情况,但单纯的依赖“f总是会执行delete语句”行不通。

所以我们需要使用以对象来管理资源的策略,即用动态指针来管理创建的资源,当我们获得对象时,立即将其放入到管理对象中,比如上述代码可以修改为:

void f()
{
    std::shared_ptr<Investment> p(createInvestment());
}

share_ptr,是“引用计数型智慧指针(reference-counting smart  pointer; RCSP)” . createInvestment方法返回的Investment对象立刻放到share_ptr中,当方法执行完成,或者在方法中续抛出了异常,share_ptr都会帮助我们删除分配的资源。它类似垃圾回收,当无人指向该资源时,会将其删除。

接下来,假设我们有个函数用来获取优先级,有个函数用来在某动态分配的Widget上进行某些带有优先级的处理:

#include <memory>
class Widget
{
};

int priority();
void processWidget(std::shared_ptr<Widget> pw, int priority);

现在考虑调用processWidget方法。

processWidget(new Widget,priority());

以上编译不通过,因为无法将new Widget得到的原始指针转换为share_ptr<Widget>智能指针。如果把代码修改为如下,就能通过编译:

processWidget(std::shared_ptr<Widget>(new Widget),priority());

令人惊讶的是,虽然上述的方式符合前面说的“使用对象管理式资源”,但上述对这个方法的调用,可能会导致资源泄露。

编译器在生成processWidget方法的时候,必须首先核算即将被传递的各个实参,上述第二个实参只是一个单纯的对priority函数的调用。但第一个实参share_ptr<Widget>(new Widget),却由两部分构成。

  • 执行“new Widget"表达式
  • 调用share_ptr的构造函数

于是,在调用processWidget之前,编译器必须创建代码,做以下三件事:

  • 调用priority
  • 执行”new Widget“表达式
  • 调用share_ptr的构造函数

C++编译器以何种次序完成上述操作的弹性很大。可以确定的是,new Widget一定实在调用share_ptr的构造函数之前执行,因为前者是后者的实参。但对priority方法的调用可以放在第一或第二或第三步执行。如果编译器选择以第二步执行它(或许编译器认为这样能生成更高效的代码),最终获得这样的操作序列:

  1. 执行”new Widget“
  2. 调用priority
  3. 调用share_prt的构造函数

现在,万一对priority方法的调用产生了异常会发生什么情况?在这种情况下new Widget返回的指针将会丢失,因为它还没有来得及放到share_ptr内,这个share_ptr是我们用来防止资源泄露的武器。如果发生以上情况,则processWidget的调用过程中可能产生资源泄露,因为在”资源被创建“和”资源被转换为资源管理对象“的两个时间点之间,由于编译器重排序,插入了其它执行语句,从而可能抛出异常。

解决这个问题的方法也很简单,使用分离语句,人工保证上面的执行顺序。首先创建Widget,然后将它放入智能指针,最后把智能指针传递给方法:

std::shared_ptr<Widget> pw(new Widget);
processWidget(pw, priority());

这样,就没有编译器”重排序“的问题,从而不会有资源泄露。

总结


本文列举了两个例子来说明编译器重排序可能对程序造成的错误。第一个例子是经典的双检锁,编译器对代码的重排序可能会在多线程模式下对代码产生意想不到的错误,这个时候就需要同步锁来强制代码按照我们要求的方式来生成。第二个例子是在使用智能指针管理资源对象时可能产生的资源泄露。编译器的重排序,可能会使得原始对象创建之后和把对象放入智能指针之前,这中间会被插入其它的操作,如果这些操作抛出了异常,那么创建出来的原始对象的指针就会丢失,从而产生资源泄露。所以在编写多线程代码以及在对原始资源进行管理的时候就要注意以上问题,本文也针对这两个场景提供了相应的解决方法。

 

参考: