实际上,这两个方法最终都会返回所有任务的自身,可以像任何其他任务一样再次操作。为了获取对应任务的结果,你可以检查该任务的 Result 属性。 处理多任务的异常有点棘手。方法 WaitAll 和 WhenAll 不管哪个任务被收集到异常时都会抛出异常。不过,对于 WaitAll ,将会收集所有的异常到对应的 InnerExceptions 属性;对于 WhenAll ,只会抛出第一个异常。为了确认哪个任务抛出了哪个异常,您需要单独检查每个任务的 Status 和 Exception 属性。 在使用 WaitAny 和 WhenAny 时必须足够小心。他们会等到第一个任务完成 (成功或失败),即使某个任务出现异常时也不会抛出任何异常。他们只会返回已完成任务的索引或者分别返回已完成的任务。你必须等到任务完成或访问其 result 属性时捕获异常,例如:
var completedTask = await Task.WhenAny(backgroundTasks); try { var result = await completedTask; } catch (Exception e) { // handle exception }
如果你想连续运行多个任务,代替并发任务,可以使用延续 (continuations)的方式:
var compositeTask = Task.Run(() => DoComplexCalculation(42)) .ContinueWith(previous => DoAnotherComplexCalculation(previous.Result), TaskContinuationOptions.OnlyOnRanToCompletion)
ContinueWith() 方法允许你把多个任务一个接着一个执行。这个延续的任务将获取到前面任务的结果或状态的引用。 你仍然可以增加条件判断是否执行延续任务,例如只有在前面任务成功执行或者抛出异常时。对比连续等待多个任务,提高了灵活性。 当然,您可以将延续任务与之前讨论的所有功能相结合:异常处理、取消和并行运行任务。这就有了很大的表演空间,以不同的方式进行组合:
var multipleTasks = new[] { Task.Run(() => DoComplexCalculation(1)), Task.Run(() => DoComplexCalculation(2)), Task.Run(() => DoComplexCalculation(3)) }; var combinedTask = Task.WhenAll(multipleTasks); var successfulContinuation = combinedTask.ContinueWith(task => CombineResults(task.Result), TaskContinuationOptions.OnlyOnRanToCompletion); var failedContinuation = combinedTask.ContinueWith(task => HandleError(task.Exception), TaskContinuationOptions.NotOnRanToCompletion); await Task.WhenAny(successfulContinuation, failedContinuation);
任务同步
如果任务是完全独立的,那么我们刚才看到的协调方法就已足够。然而,一旦需要同时共享数据,为了防止数据损坏,就必须要有额外的同步。 两个以及更多的线程同时更新一个数据结构时,数据很快就会变得不一致。就好像下面这个示例代码一样:
var counters = new Dictionary< int, int >(); if (counters.ContainsKey(key)) { counters[key] ++; } else { counters[key] = 1; }
当多个线程同时执行上述代码时,不同线程中的特定顺序执行指令可能导致数据不正确,例如:
所有线程将会检查集合中是否存在同一个 key
结果,他们都会进入 else 分支,并将这个 key 的值设为1
最后结果将会是1,而不是2。如果是接连着执行代码的话,将会是预期的结果。
上述代码中,临界区 (critical section) 一次只允许一个线程可以进入。在C# 中,可以使用 lock 语句来实现:
var counters = new Dictionary< int, int >(); lock (syncObject) { if (counters.ContainsKey(key)) { counters[key]++; } else { counters[key] = 1; } }
在这个方法中,所有线程都必须共享相同的的 syncObject 。作为最佳做法,syncObject 应该是一个专用的 Object 实例,专门用于保护对一个独立的临界区的访问,避免从外部访问。 在 lock 语句中,只允许一个线程访问里面的代码块。它将阻止下一个尝试访问它的线程,直到前一个线程退出。这将确保线程完整执行临界区代码,而不会被另一个线程中断。当然,这将减少并行性并减慢代码的整体执行速度,因此您最好最小化临界区的数量并使其尽可能的短。
使用 Monitor 类来简化 lock 声明:
var lockWasTaken = false; var temp = syncObject; try { Monitor.Enter(temp, ref lockWasTaken); // lock statement body } finally { if (lockWasTaken) { Monitor.Exit(temp); } }
尽管大部分时间您都希望使用 lock 语句,但 Monitor 类可以在需要时给予额外的控制。例如,您可以使用 TryEnter() 而不是 Enter(),并指定一个限定时间,避免无止境地等待锁释放。
其他同步基元
Monitor 只是 .NET Core 中众多同步基元的一员。根据实际情况,其他基元可能更适合。
Mutex 是 Monitor 更重量级的版本,依赖于底层的操作系统,提供跨多个进程同步访问资源[1], 是针对 Mutex 进行同步的推荐替代方案。