一個項目的一個功能點,需要從接口接受返回數據,並對返回的數據進行一些業務處理,處理完成之后,添加到一個List<T>中,然后在View中循環這個List<T>,展示所有的數據。每次從接口中取回的數據量不等,最多會有上百條。雖說上百條也不算多,但是每條數據都要經過一系列的業務處理,感覺這樣也挺耗時的,於是考慮使用Parallel.Foreach來進行並行處理。
項目完成之后,對比了一下並行和非並行的情況,發現並行之后並沒有提高多少效能,倒是遇到了一些比較怪異的問題。
Parallel.Foreach 中對List<T>執行Add操作之后,List<T>的Count有時候並不是執行並行的操作的執行次數,而且List<T>中會有Item為null的情況。其實這個問題的解決方案很簡單,就是因為List<T>不是線程安全的類,在多線程情況下就會導致一些不可預知的情況,加個鎖就可以解決問題了。但是,如果能更好的了解到底是什么原因導致的,豈不更好,於是在一個同事的幫助下,找到了List<T>的源碼(感謝微軟開放了源碼,地址:http://referencesource.microsoft.com/#mscorlib/system/collections/generic/list.cs,cf7f4095e4de7646),從List<T>的源碼中更進一步的了解了導致以上問題的原因。
在分析上面兩個問題之前,我們先了解一下List<T>的內部情況。從源碼中我們可以看到List<T>是通過一個Array來進行處理的,如果初始沒有對List<T>設置容量,List<T>容量將為0,如果此時使用Add添加新項的時候,就會給List<T>設置一個初始容量(初始值為4)。使用Add添加新項的時候,如果已經達到容量最大值,List<T>會自動擴充容量的值,擴充后的容量的值為原來既有項目數量的2倍(其實也就是原來容量的2倍)。
我們把Add方法和擴容方法摘抄如下:
public void Add(T item) { if (_size == _items.Length) EnsureCapacity(_size + 1); _items[_size++] = item; _version++; }
private void EnsureCapacity(int min) { if (_items.Length < min) { int newCapacity = _items.Length == 0? _defaultCapacity : _items.Length * 2; // Allow the list to grow to maximum possible capacity (~2G elements) before encountering overflow. // Note that this check works even when _items.Length overflowed thanks to the (uint) cast if ((uint)newCapacity > Array.MaxArrayLength) newCapacity = Array.MaxArrayLength; if (newCapacity < min) newCapacity = min; Capacity = newCapacity; } }
了解了List<T>內部內部擴容情況之后,下面就以上兩個問題進行分析。
1、 List<T>中的Item數量比預期的少。
導致這個問題的原因其實還是挺明顯的。當兩個線程(ThreadA和TreadB),同時調用Add方法添加不同的值的時候,如果此時ThreadA和ThreadB獲取到的size相同,就會出現下面這種情況:
ThreadA:List<T>[size] = A;
ThreadB:List<T>[size] = B;
這種情況下,在size這個位置只會有一個ThreadB設置的值,ThreadA設置的值將會被替換掉,這也就是造成Item數量比預期少的原因。
2、 List<T>中的Item有null。
其實和上面類似,看Add中的代碼:
_items[_size++] = item;
我們改變一下,變成:
(1)_size = _size+1;
(2)Items[_size] = item;
如果ThreadA執行完(1)之后ThreadB獲取到新的_size也執行了(1)那此時_size就相當於是加2了,所以_size+1索引位置的項就是T的默認值了(值類型會值類型的默認值,引用類型為null)。這樣就能解釋為什么會出現null的原因了。
其實這兩個問題完全就是同一個問題,只不過表象不同而已。最終解決方案很簡單,要么自己加鎖,要么使用線程安全的ConcurrentBag<T>。