C#基础教程(九)容器foreach和多线程安全问题

一 foreach深扒语法糖

//使用foreach遍历list1,并删除偶数
foreach(int n in list1)
{
    if(n%2==0)
    {
        list1.Remove(n);
    }
}
//在编译成IL代码时,foreach会变成基于迭代器IEnumerator调用方式
//var替换成Dictionary<TKey, TValue>.Enumerator
var enumerator = list1.GetEnumerator();
while (enumerator.MoveNext())
{
   int n= enumerator.Current;
   if(n%2==0)
   {
       list1.Remove(n);
   }
}

二 Collection和Concurrent的foreach区别

collection程序集和Concurrent程序集的容器在实现方面有所区别的。collection有list,dictionary等等,拿list为例,看下面一段代码,经常会报:ExceptionResource.InvalidOperation_EnumFailedVersion(集合已修改可能无法执行枚举操作)

static void Main(string[] args)
{
    //这种写法vs2013是会报错
    var dict = new Dictionary<int, int>()
    {
        [1001] = 1,
        [1002] = 10,
        [1003] = 20
    };
    foreach (var userid in dict.Keys)
    {
         dict[userid] = dict[userid] + 1;
    }
}

所以用ILSpy去扒源码作分析。

    IL_000d: callvirt instance void class 
    [System.Collections]System.Collections.Generic.Dictionary`2<int32, int32>::set_Item(!0, !1)
	IL_001b: callvirt instance void class 
    [System.Collections]System.Collections.Generic.Dictionary`2<int32, int32>::set_Item(!0, !1)
	IL_0029: callvirt instance void class 
    [System.Collections]System.Collections.Generic.Dictionary`2<int32, int32>::set_Item(!0, !1)
	IL_0037: callvirt instance valuetype
    [System.Collections]System.Collections.Generic.Dictionary
`2/KeyCollection/Enumerator<!0, !1> class 
    [System.Collections]System.Collections.Generic.Dictionary`2/KeyCollection<int32, int32>::GetEnumerator()
	.try
	{
		IL_003d: br.s IL_005a
		// loop start (head: IL_005a)
			IL_003f: ldloca.s 1
			IL_0041: call instance !0 valuetype [System.Collections]System.Collections.Generic.Dictionary`2/KeyCollection/Enumerator<int32, int32>::get_Current()
			IL_004c: callvirt instance !1 class [System.Collections]System.Collections.Generic.Dictionary`2<int32, int32>::get_Item(!0)
			IL_0053: callvirt instance void class [System.Collections]System.Collections.Generic.Dictionary`2<int32, int32>::set_Item(!0, !1)
			IL_005a: ldloca.s 1
			IL_005c: call instance bool valuetype [System.Collections]System.Collections.Generic.Dictionary`2/KeyCollection/Enumerator<int32, int32>::MoveNext()
			IL_0061: brtrue.s IL_003f
		// end loop

		IL_0063: leave.s IL_0074
	} // end .try
	finally
	{

	} // end handler    

从IL代码中可以看到,先执行了三次字典的索引器操作,然后调了 Dictionary.GetEnumerator 来生成字典的迭代类,这思路就非常清晰了,然后我们看一下类索引器都做了些什么。

从图中可以看到,每一次的索引器操作,这里都执行了version++,所以字典初始化完成之后,这里的 version=3,没有问题吧,然后继续看代码,寻找 Dictionary.GetEnumerator 方法启动迭代类。

上面代码的 _version = dictionary._version; 一定要看仔细了,在启动迭代类的时候记录了当时字典的版本号,也就是_version=3,然后继续探索moveNext方法干了什么,如下图:

从图中可以看到,当每次执行moveNext的过程中,都会判断一下字典的 version 和 当初初始化迭代类中的version 版本号是否一致,如果不一致就抛出异常,所以这行代码就是点睛之笔了,当在foreach体中执行了 dict[userid] = dict[userid] + 1; 语句,相当于又执行了一次类索引器操作,这时候字典的version就变成 4 了,而当初初始化迭代类的时候还是3,自然下一次执行 moveNext 就是 3 != 4 抛出异常了。

如果你非要让我证明给你看,这里可以使用dnspy直接调试源码,在异常那里下一个断点再查看两个version版本号。

回到最初的源代码, 居然在 .Net 5 中可以的,接下来用ILSpy去查查底层源码, net5 中分别对 类索引器 都做了啥修改。

上面是List引发集合修改报错原因,Clear、Insert、Remove、Add操作时,就会引发上面的异常。Dictionary同样如此

所以解决这种问题用for()循环去解决。但Concurrent,如Dictionary并不存在这种问题,可以去扒源码。

三 Collection和Concurrent多线程安全问题

在单线程操作List,Dictionary等集合的时候一点问题都没有,但是在多线程情况下,问题就出来了,经常会报错。我们可以看看源码,List的Add方法是线程不安全的,List的源码中的Add方法,使用了每次当当前的元素达到上限,通过创建一个新的数组实例,并给长度翻倍的操作。如果单线程操作不会有问题,直接扩容,然后继续往里面加值。下面是List的Add方法和核心逻辑:

也就是说,当多个线程同时添加元素,且刚好它们都执行到了扩容这个阶段,当一个线程扩大了这个数组的长度,且进行了+1操作后,另外一个线程刚好也在执行扩容的操作,这个时候它给Capacity的值设为2048,但是另外一个线程已经将this_size设为2049了,所以这个时候就报异常了。当然不止这一个问题,还有Copy的时候也会出问题,如果里面的元素过多,另外一个线程拿到空值的几率很大。总之,不同线程导致了扩容不一致长度会出错。我们下面来讲讲如何解决这个问题,方法是多样的。

用锁来解决线程集合安全并不是理想选择,耗性能(2-3倍时间),产生死锁也很麻烦,闲话少说。从.Net 4.0框架开始,在System.Collections.Concurrent命名空间下,增加了用于多线程协同的并发集合类(线程安全集合),这种线程安全是相对的,并不是绝对的

仔细阅读官方文档,我们会发现在文档的底部线程安全性小节里这样描述:

ConcurrentDictionary<TKey,TValue> 的所有公共和受保护的成员都是线程安全的,可从多个线程并发使用。但是,通过一个由 ConcurrentDictionary<TKey,TValue> 实现的接口的成员(包括扩展方法)访问时,不保证其线程安全性,并且可能需要由调用方进行同步。

也就是说,调用 ConcurrentDictionary 本身的方法和属性可以保证都是线程安全的。但是由于 ConcurrentDictionary 实现了一些接口(例如 ICollection、IEnumerable 和 IDictionary 等),使用这些接口的成员(或者这些接口的扩展方法)不能保证其线程安全性。System.Linq.Enumerable.ToList 方法就是其中的一个例子,该方法是 IEnumerable 的一个扩展方法,在 ConcurrentDictionary 实例上使用该方法,当它被其它线程改变时可能抛出 System.ArgumentException 异常。下面是一个简单的示例:

static void Main(string[] args)
{
    var cd = new ConcurrentDictionary<int, int>();
    Task.Run(() =>
    {
        var random = new Random();
        while (true)
        {
            var value = random.Next(10000);
            cd.AddOrUpdate(value, value, (key, oldValue) => value);
        }
    });

    while (true)
    {
        cd.ToList(); //调用 System.Linq.Enumerable.ToList,抛出 System.ArgumentException 异常
    }
}

System.Linq.Enumerable.ToList 扩展方法:

System.Linq.Enumerable.ToList

发生异常是因为扩展方法 ToList 中调用了 List 的构造函数,该构造函数接收一个 IEnumerable<T> 类型的参数,且该构造函数中有一个对 ICollection<T> 的优化(由 ConcurrentDictionary 实现的)。

System.Collections.Generic.List<T> 构造函数:
System.Collections.Generic.List

在 List 的构造函数中,首先通过调用 Count 获取字典的大小,然后以该大小初始化数组,最后调用 CopyTo 将所有 KeyValuePair 项从字典复制到该数组。因为字典是可以由多个线程改变的,在调用 Count 后且调用 CopyTo 前,字典的大小可以增加或者减少。当 ConcurrentDictionary 试图访问数组超出其边界时,将引发 ArgumentException 异常。

ConcurrentDictionary<TKey,TValue> 中实现的 ICollection.CopyTo 方法:

ConcurrentDictionary-CopyTo-ArgumentException

如果您只需要一个包含字典所有项的单独集合,可以通过调用 ConcurrentDictionary.ToArray 方法来避免此异常。它完成类似的操作,但是操作之前先获取了字典的所有内部锁,保证了线程安全性。

ConcurrentDictionary-ToArray

注意,不要将此方法与 System.Linq.Enumerable.ToArray 扩展方法混淆,调用 Enumerable.ToArray 像 Enumerable.ToList 一样,可能引发 System.ArgumentException 异常。看下面例子:

static void Main(string[] args)
{
    System.Collections.Generic.IDictionary<int, int> cd = new ConcurrentDictionary<int, int>();
    Task.Run(() =>
    {
        var random = new Random();
        while (true)
        {
            var value = random.Next(10000);
            cd[value] = value;
        }
    });

    while (true)
    {
        cd.ToArray(); //调用 System.Linq.Enumerable.ToArray,抛出 System.ArgumentException 异常
    }
}

此时调用 Enumerable.ToArray,就像调用 Enumerable.ToList 时一样,引发了 System.ArgumentException 异常。

1.线程安全集合:
就是当多线程访问时,采用了加锁的机制;即当一个线程访问集合时,会对这个集合进行保护,其他线程不能对其访问,直到该线程操作完集合之后,其他线程才可以使用。防止出现数据不一致或者数据被污染的情况。

只能保证集合的原子性、可见性,但是无法保证对集合操作的有序性,例如:多个线程同时将元素加入到集合,无法保证元素加入到集合的顺序,多次运行情况结果会不同

BlockingCollection<T>
提供具有阻塞和限制功能的线程安全集合,实现IProducerConsumerCollection<T>接口。支持容量大小的限制和完成添加限制,当标记为完成添加后只允许移除(Take)操作,无法进行添加(Add)操作。

BoundedCapacity:获取集合限定容量,在通过构造函数时可以指定集合的容量大小。

IsAddingCompleted:判断是否已经标记完成添加,

IsCompleted:判断是否标记完成添加并且为空。

Add:将项添加到集合中。

CompleteAdding:将集合标记为完成添加。调用该函数后IsAddingCompleted为true,如果集合中包含的项数为0,那么IsCompleted也为true。

Take:从集合中移除一项。

TryAdd:尝试将项加入集合

TryTake:尝试从集合中移除项。

ConcurrentBag<T>
提供可供多个线程同时安全访问的无序包。对应非泛型列表List<T>。

包(Bag)和数据上的集(Set)的区别是包可包含重复元素,而集中不能包含重复元素。

Count:获取无序包中的元素数量

IsEmpty:判断无序包是否为空。

TryPeek:从无序包中获取一个元素,但不进行移除。

TryTask:从无序包获取一个元素并移除。

ConcurrentDictionary<TKey,TValue>
提供可供多线程同时访问的键值对的线程安全集合,对应Dictionary<TKey, TValue>。

IsEmpty:判断字典是否为空。

AddOrUpdate:将键值对添加到字典中,如果Key值已经存在,则更新Value值。

Clear:将所有键值对从字典中移除。

GetOrAdd:如果键不存在,则将键值对添加到字典中并返回Value值,如果键已经存在,则返回现有值,不会更新现有值。

TryUpdate:尝试更新键对应的Value值。

ConcurrentQueue<T>
队列,提供线程安全的先进先出(FIFO)集合,对应Queue<T>。

Enqueue:将对象添加到队列末尾处。

TryDequeue:尝试移除并返回位于队列开头处的对象。

TryPeek:尝试返回队列开头处的对象但不将其移除。

ConcurrentStack<T>
栈,提供线程安全的后进先出(LIFO)集合,对应Stack<T>

Push:将对象插入栈顶部。

PushRange:将多个对象插入栈的顶部。

TryPeek:尝试返回栈顶部的对象但不将其移除。

TryPop:尝试弹出并返回栈顶部的对象。

TryPopRange:尝试弹出并返回栈顶部的多个对象。

2.自身同步化

System.Collections集下的集合,提供一个同步化Synchronized()静态方法,可以解决异步造成的报错问题。

ArrayList list = ArrayList.Synchronized(new ArrayList(1000000));

3.自身加控制锁

static ArrayList list = new ArrayList(1000000);
for (int i = 0; i < 10; i++)
{
    new Task(() =>
    {
        lock (list.SyncRoot)
        for (int j = 0; j < 10000000; j++)
        {
            list.Add(j.ToString());
        }
     }).Start();
}
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值