Task多线程使用的一个小坑

Task多线程使用的一个小坑

Task多线程代码中慎用List

很多年以前, 微软 .NetFramework 4 中引入了任务并行库([TPL][tpl])使得多线程编程变得简单无比, 而后在 C#5.0 中引入了异步编程模型和, 通过加入 async/await 两个关键字的配合, 让异步编程降到了语言级别, 也因此, 此后异步编程在 .Net 程序中应用越来越广泛 在 ASP.Net 中也推荐使用异步API, 可以说 async/await 配合 Task 的这套编程模型在 .NetCore 几乎成了标准的编程方法. 尤其在大凡涉及到阻塞调用, 重复任务时, 更是逢之必Task.

然而, Task 虽好, 可也得小心. 随着这个模型的广泛应用, 可能很多人不管什么方法都先来一个 Task 取代原来的方法返回值. 甚至渐渐忘记了这个东西本来的面目: 多线程. 如果只是无脑的修改成异步方法, 而调用这些方法时, 却仍旧如以前同步方法一样什么都往里面仍. 运气不好的话, 就会得到 Task 从天空深处传来一句得意的回音: 大人, 时代变了.

举个栗子

在多线程环境中, 如果使用了非线程安全的数据结构. 比如 List, Array, Dictionary 等等, 很容易一不小心就让程序走向不可预知的错误, 要么无端报错, 要么结果错乱. 比如下面这个例子: 多调用几次, 就会抱一个超标错误, 并且在查看错误信息时发现一切正常:

示例伪代码

// some id
var ids = new int[]{1,2,3,4,5,6,7,8,};

var list = new List<object>();
var tasks = ids.Select(id => GetData(list, id));
await Task.WhenAll(tasks);

public async Task GetData(List<object> list, int id){
	var data = await GetDataById();
	// 这里可能会报错: Source array was not long enough. Check the source index, length, and the array's lower bounds.
    // 而且报错时机不固定, 调试会发现: 数组长度, 数组容量, 数组元素看起来都是正确的
	list.Add(data);
}

报错原因

原因是, List 不是线程安全的, 而 List 的容量(Capacity)会随着数据的增加而动态申请, 但是因为程序是多线程执行, 所以可能在同一时间会有多条数据同时写入 List, 此时 List 还未完成新的容量的申请, 导致 List 溢出.

解决办法:

  1. (推荐) 不要再多线程环境使用非线程安全数据结构. 等线程全部执行完毕后再组装结果:
// some id
var ids = new int[]{1,2,3,4,5,6,7,8,};

var tasks = ids.Select(async id => GetData(id));
var list = await Task.WhenAll(tasks);

public async Task<object> GetData(int id){
	var data = await GetDataById();
	return data
}
  1. 使用线程安全的数据结构代替

    C# System.Collections.Concurrent 命名空间下的数据结构都是线程安全的, 可以使用对应的数据结构来代替 List

// some id
var ids = new int[]{1,2,3,4,5,6,7,8,};

var list = new ConcurrentBag<object>();
var tasks = ids.Select(id => GetData(list, id));
await Task.WhenAll(tasks);

public async Task GetData(ConcurrentBag<object> list, int id){
	var data = await GetDataById();
	list.Add(data);
}