在.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异常,要特别注意。
参考:
- dotnet/runtime: .NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps. (github.com)
- Reference Source (microsoft.com)
- HashSet.cs (microsoft.com)
- HashSet.Add generates null pointer violation · Issue #36852 · dotnet/runtime (github.com)