在.NET中有普通集合类型比如Queue,Dictionary等,也有对应的并发集合比如ConcurrentQueue,ConcurrentDictionary。顾名思义,前者是非线安全的,如果要在多线程环境下使用,需要自己加锁,后者则是线程安全的。

但线程安全是有代价的,那就是会影响效率。在有些应用场景下,写入很少但读很频繁,在允许“脏读”的情况下,是不是直接使用普通的集合类型就可以呢,答案是可以的,但是需要非常慎重,否则可能会抛出异常。今天我就遇到了这个问题。

场景


为了简化问题,假设有个类SymbolManager,其内部有一个名为symbols的HashSet<String>类型,这个HashSet里保存了一系列代码,程序会在每天早上的9点重新加载本地文件里的代码到这个HashSet集合里,同时这个类对外提供了一个IsSymbol 方法,以判断某个代码是否在此HashSet中。

class SymbolManager
{
	private static readonly SymbolManager instance = new SymbolManager();
	public static SymbolManager Instance
	{
		get
		{
			return instance;
		}
	}
	private HashSet<string> symbols = new HashSet<string>();
	private readonly string SymbolFile = "SymbolLists.txt";
	public static readonly TimeSpan reloadSymbolTime = new TimeSpan(9, 0, 0);
	private SymbolManager()
	{
		LoadSymbolList();
		ThreadPool.QueueUserWorkItem(TimeLoadWorker);
	}

	void LoadSymbolList()
	{
		try
		{
			symbols.Clear();
			symbols.TrimExcess();
			if (File.Exists(SymbolFile))
			{
				using (StreamReader sr = new StreamReader(File.OpenRead(SymbolFile)))
				{
					while (!sr.EndOfStream)
					{
						string s = sr.ReadLine().Trim();
						if (!string.IsNullOrEmpty(s))
						{
							symbols.Add(s);
						}
					}
				}
			}
		}
		catch (Exception e)
		{
			//Logger.LogError(e);
		}
	}

	void TimeLoadWorker(object state)
	{
		while (true)
		{
			if (DateTime.Now.TimeOfDay < reloadSymbolTime)
			{
				Thread.Sleep(reloadSymbolTime - DateTime.Now.TimeOfDay);
				LoadSymbolList();
			}
			//等待到第二天
			Thread.Sleep(new TimeSpan(24, 1, 0) - DateTime.Now.TimeOfDay);
		}
	}

	public bool IsSymbol(string code)
	{
		return symbols.Contains(code);
	}
}

这个类就是典型的“写”少“读”多的场景,只有在类初始化以及每天的9点这两个时间点会重新加载代码到集合。除此之外,其余时间都是在“读”操作,而且在这个场景中“读”操作非常频繁。如果要完全保证正确,那么就要在对symbols进行“写”和“读”的时候进行加锁,但为了这个极不频繁的“写”,而对非常频繁的“读”进行加锁会影响程序效率。如果我们允许“脏读”,即允许在某次调用IsSymbol的时候,没有读到最新的文件里加载的代码,这关系不大,在这种宽松的情况下,上面的代码是不是就没有问题呢?其实不然,在某种竟态条件下,上面的代码会抛出异常。

问题出现


上面代码在生产环境中抛了一个异常,在一个线程中会频繁调用SymbolManager的IsSymbol方法以判断某个代码是否在预设的HashSet列表中。同时在SymbolManager方法的内部,每天9点定时会重新清空并加载HashSet列表,于是当两个线程同时读写HashSet的时候,抛出了异常:

ERROR - 未将对象引用设置到对象的实例。System.NullReferenceException   在 System.Collections.Generic.HashSet`1.Contains(T item)

为什么在symbols.Contains里会抛出一个NullReferenceException异常呢?Contains方法的参数code可以确定一定不为null,symbols也可以确定不为null,那这个异常到底来自哪里?

原因


“源码面前,了无秘密”,微软已经将.NET开源,不管是.NET Framework,还是.NET Core,源码都可以直接查看,而不用像之前那样费劲的通过反编译查看代码。.NET Framework的代码可以直接在微软网站上查看,.NET Core的源码在Github上可以查看。

竟然是HashSet的Contains方法抛出了异常,那就直接看HashSet的源代码吧,Contains的源代码如下:

/// <summary>
/// Checks if this hashset contains the item
/// </summary>
/// <param name="item">item to check for containment</param>
/// <returns>true if item contained; false if not</returns>
public bool Contains(T item)
{
	if (m_buckets != null)
	{
		int hashCode = InternalGetHashCode(item);
		// see note at "HashSet" level describing why "- 1" appears in for loop
		for (int i = m_buckets[hashCode % m_buckets.Length] - 1; i >= 0; i = m_slots[i].next)
		{
			if (m_slots[i].hashCode == hashCode && m_comparer.Equals(m_slots[i].value, item))
			{
				return true;
			}
		}
	}
	// either m_buckets is null or wasn't found
	return false;
}

乍一看,好像也没地方抛出异常吧。于是以HashSet的Contains方法会抛出NullReferenceException在网上找,没有找到答案,但是却找到了另外一个case,就是HashSet的Add方法会抛出NullReferenceException 为什么呢?有人给了解答,HashSet的Add方法会调用 AddIfNotPresent方法:

/// <summary>
/// Adds value to HashSet if not contained already
/// Returns true if added and false if already present
/// </summary>
/// <param name="value">value to find</param>
/// <returns></returns>
private bool AddIfNotPresent(T value)
{
	if (m_buckets == null)
	{
		Initialize(0);
	}

	int hashCode = InternalGetHashCode(value);
	int bucket = hashCode % m_buckets.Length;
#if FEATURE_RANDOMIZED_STRING_HASHING && !FEATURE_NETCORE
            int collisionCount = 0;
#endif
	for (int i = m_buckets[hashCode % m_buckets.Length] - 1; i >= 0; i = m_slots[i].next)
	{
		if (m_slots[i].hashCode == hashCode && m_comparer.Equals(m_slots[i].value, value))
		{
			return false;
		}
#if FEATURE_RANDOMIZED_STRING_HASHING && !FEATURE_NETCORE
                collisionCount++;
#endif
	}

	....
}

这个方法,第一步判断内部的m_buckets数组是否为空,如果为空,则需要初始化,调用Initialize(0)方法。Initialize方法如下:

/// <summary>
/// Initializes buckets and slots arrays. Uses suggested capacity by finding next prime
/// greater than or equal to capacity.
/// </summary>
/// <param name="capacity"></param>
private void Initialize(int capacity)
{
	Debug.Assert(m_buckets == null, "Initialize was called but m_buckets was non-null");

	int size = HashHelpers.GetPrime(capacity);

	m_buckets = new int[size];
	m_slots = new Slot[size];
}

这个方法非常简单,就是先获取一个大于capacity的最小质数,然后初始化m_buckets和m_slots数组。

我们再次回到上面的AddIfNotPresent方法中,方法首先判断m_buckets是否为null,如果为bull,就调用Initialize初始化,否则后续就直接使用m_slots。

问题就出在这里,假设有两个线程T1和T2,同时往一个空的HashSet中插入记录。

  • 线程T1首先调用Add,发现m_buckets为null,就调用Initialize初始化,当执行完成m_buckets的初始化,但m_slots还没有开始执行的这一刻,此时m_slots为null,线程T2开始执行。
  • 线程T2调用Add,发现m_buckets不为null,于是直接在后面的for循环里使用m_slots,但m_slots还没有开始初始化,m_slots整个为null,这下就抛出NullReferenceException异常了。

受到这个问题的启发,再次回到上面Contains的源代码里,可以看到它首先判断m_buckets不为null,再执行后续的操作,在后续的for循环操作中直接使用了m_slots变量,与上面这个Add方法类似,在多线程条件下m_buckets不为null并不能保证m_slots一定不为null,因为这俩的初始化不是在一个事务里完成的,或者说不是原子性操作。

那么在SymbolManager的代码里,哪些地方可能会初始化HashSet的这两个变量呢?名为symbols的HashSet变量在类一开始就初始化并加载了数据,这就说明m_buckets和m_slots一定不为空,在哪里有可能让m_buckets和m_slots为null,进而直接访问导致抛出NullReferenceException异常呢?

目光自然落到了在重新加载的方法前面的这两行代码:

symbols.Clear();
symbols.TrimExcess();

Clear代码就是把HashSet里面的元素都清空,TrimExcess方法就是对集合进行缩容。好像也没什么问题,继续看源代码,Clear的源代码如下:

/// <summary>
/// Remove all items from this set. This clears the elements but not the underlying 
/// buckets and slots array. Follow this call by TrimExcess to release these.
/// </summary>
public void Clear()
{
	if (m_lastIndex > 0)
	{
		Debug.Assert(m_buckets != null, "m_buckets was null but m_lastIndex > 0");

		// clear the elements so that the gc can reclaim the references.
		// clear only up to m_lastIndex for m_slots 
		Array.Clear(m_slots, 0, m_lastIndex);
		Array.Clear(m_buckets, 0, m_buckets.Length);
		m_lastIndex = 0;
		m_count = 0;
		m_freeList = -1;
	}
	m_version++;
}

注意注释里说的话,调用Clear方法,只是清空元素,而不是清除m_buckets和m_slots,如果要清除这两者可以在调用Clear后,调用TrimExcess。真相接近大白。

接着看TrimExcess源代码:

/// <summary>
/// Sets the capacity of this list to the size of the list (rounded up to nearest prime),
/// unless count is 0, in which case we release references.
/// 
/// This method can be used to minimize a list's memory overhead once it is known that no
/// new elements will be added to the list. To completely clear a list and release all 
/// memory referenced by the list, execute the following statements:
/// 
/// list.Clear();
/// list.TrimExcess(); 
/// </summary>
public void TrimExcess()
{
	Debug.Assert(m_count >= 0, "m_count is negative");

	if (m_count == 0)
	{
		// if count is zero, clear references
		m_buckets = null;
		m_slots = null;
		m_version++;
	}
	else
	{
		Debug.Assert(m_buckets != null, "m_buckets was null but m_count > 0");

		// similar to IncreaseCapacity but moves down elements in case add/remove/etc
		// caused fragmentation
		int newSize = HashHelpers.GetPrime(m_count);
		Slot[] newSlots = new Slot[newSize];
		int[] newBuckets = new int[newSize];

		// move down slots and rehash at the same time. newIndex keeps track of current 
		// position in newSlots array
		int newIndex = 0;
		for (int i = 0; i < m_lastIndex; i++)
		{
			if (m_slots[i].hashCode >= 0)
			{
				newSlots[newIndex] = m_slots[i];

				// rehash
				int bucket = newSlots[newIndex].hashCode % newSize;
				newSlots[newIndex].next = newBuckets[bucket] - 1;
				newBuckets[bucket] = newIndex + 1;

				newIndex++;
			}
		}

		Debug.Assert(newSlots.Length <= m_slots.Length, "capacity increased after TrimExcess");

		m_lastIndex = newIndex;
		m_slots = newSlots;
		m_buckets = newBuckets;
		m_freeList = -1;
	}
}

可以看到,当元素个数为0时,会直接将m_buckets和m_slots设置为null,至此真相水落石出。

  • 当SymbolManager内部的定时任务线程A 在9:00自行重新加载任务时,先调用了Clear方法,继而调用了TrimExcess方法,后面这个方法会将名为symbols的HashSet变量内部的m_buckets和m_slots两个变量设置为null。紧接着方法继续执行,读取文件里面的代码,然后调用HashSet的Add方法尝试添加到集合中,在Add方法中,首先判断m_buckets是否为空,如果为空,会调用Initialize方法进行初始化,首先初始化m_buckets,然后,然后奇迹出现了,由于某种原因,时光暂停,然后执行线程B去了,此时m_slots还未来得及初始化,其值为null。
  • 外部线程B调用SymbolManager的IsSymbol方法时,会调用名为symbols的HashSet变量的Contains方法,在Contains方法中首先检查m_buckets是否不为null,因为在线程A中初始化过,所以m_buckets不为null,Contains方法想当然的认为如果m_buckets不为null,那么m_slots也一定不为null,因为在单线程条件下确实是这样,但因为是多线程,在线程A中还没来得及初始化m_slots,此时在线程B中直接使用为null的m_slots,当然会抛出经典的NullReferenceException异常。

解决方法


针对上述情况,最简单的做法就是调用Clear之后,不要调用TrimExcess方法,这样m_buckets和m_slots对象就不会为null,这样在Contains方法里就不会抛出NullReferenceException异常了。另外,重新加载的方法里,可以先创建一个新的临时HashSet变量,然后待将文件里的内容全部读取到临时的HashSet之后,再将该临时变量一次性赋值给symbols,这样能减少并发发生概率,在Singleton实现中也采用了此策略。

总之,在多线程环境下使用非并发集合需要特别慎重,除了常见的容易出现“脏读”之外,在某些极端情况下,还会抛出NullReferenceException异常,要特别注意。

 

 

参考: