18.4 并行迭代
如果一个对CPU资源占用较大的计算可以很容易被分割为多个彼此完全独立的部分以任意顺序执行,则要使用并行循环。示例如下:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApp1;
internal static class Program
{
private static void Main()
{
var list = new List<int>();
for (var i = 0; i < 30; i++)
{
list.Add(i);
}
EncryptFiles(list);
}
// 模拟对多个文件的加密操作
private static void EncryptFiles(IEnumerable<int> files)
{
var stopWatch = new Stopwatch();
stopWatch.Start();
// 并行for循环
Parallel.ForEach(files, file =>
{
Console.WriteLine($"{stopWatch.ElapsedMilliseconds}: No.{file} Encrypting...");
// 模拟耗时操作[每个文件加密需要3s才能完成]
Thread.Sleep(3000);
Console.WriteLine($"{stopWatch.ElapsedMilliseconds}: No.{file} Encryption Completed.");
});
stopWatch.Stop();
}
}
18.4.1 在并行循环中处理异常
对某些文件的加密会失败,也就是说当前任务或者子任务都有可能引发这个异常。这些异常需要被收集到一个聚合异常中集中处理。
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApp1;
internal static class Program
{
private static void Main()
{
var list = new List<int>();
for (var i = 0; i < 30; i++)
{
list.Add(i);
}
EncryptFiles(list);
}
// 模拟对多个文件的加密操作
private static void EncryptFiles(IEnumerable<int> files)
{
var stopWatch = new Stopwatch();
var randomGenerator = new Random();
stopWatch.Start();
// 注册未处理异常事件
AppDomain.CurrentDomain.UnhandledException += (sender, eventArgs) =>
{
Console.WriteLine("Has unhandled exception.");
};
try
{
// 并行for循环
Parallel.ForEach(files, file =>
{
Console.WriteLine($"{stopWatch.ElapsedMilliseconds}: No.{file} Encrypting...");
// 模拟耗时操作[每个文件加密需要3s才能完成]
Thread.Sleep(3000);
// 有0.1的概率抛出异常
if (randomGenerator.Next(0, 10) == 0)
throw new NotSupportedException($"[This is a Test] No.{file}: Specified method is not supported");
Console.WriteLine($"{stopWatch.ElapsedMilliseconds}: No.{file} Encryption Completed.");
});
}
catch (AggregateException aggregateException)
{
aggregateException = aggregateException.Flatten();
foreach (var innerException in aggregateException.InnerExceptions)
{
switch (innerException)
{
case NotSupportedException notSupportedException:
Console.WriteLine($"{stopWatch.ElapsedMilliseconds}: {notSupportedException.Message}");
break;
default:
throw;
}
}
}
stopWatch.Stop();
}
}
18.4.2 取消并行迭代
与异步任务不同,并行迭代在所有迭代完成之前不会返回。因此,当需要取消并行循环时,通常需要从执行并行循环的线程之外的线程发起取消请求。比如,使用Task.Run()
调用Parallel.ForEach<T>()
。通过这种方式,可以异步的检查并行循环的执行状态,并且还可以允许用户手动退出并行循环。示例如下:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApp1;
internal static class Program
{
private static void Main()
{
var list = new List<int>();
for (var i = 0; i < 30; i++)
{
list.Add(i);
}
EncryptFiles(list);
}
// 模拟对多个文件的加密操作
private static void EncryptFiles(IEnumerable<int> files)
{
var stopWatch = new Stopwatch();
stopWatch.Start();
var randomGenerator = new Random();
using var cancellationTokenSource = new CancellationTokenSource();
// 注册未处理异常事件
AppDomain.CurrentDomain.UnhandledException += (sender, eventArgs) =>
{
Console.WriteLine("Has unhandled exception.");
};
// 注册任务取消事件
cancellationTokenSource.Token.Register(() => Console.WriteLine("Canceling..."));
// 用Task.Run包装并行迭代,使得并行迭代能够被取消
var task = Task.Run(() =>
{
// 设置并行迭代参数
var parallelOptions = new ParallelOptions { CancellationToken = cancellationTokenSource.Token };
try
{
// 并行for循环[加入parallelOptions,让并行循环中未开始迭代执行的任务也能及时取消]
Parallel.ForEach(files, parallelOptions, file =>
{
Console.WriteLine($"{stopWatch.ElapsedMilliseconds}: No.{file} Encrypting...");
// 模拟耗时操作[每个文件加密需要10s才能完成]
Thread.Sleep(TimeSpan.FromSeconds(10));
// 有0.1的概率抛出异常
if (randomGenerator.Next(0, 10) == 0)
throw new NotSupportedException(
$"[This is a Test] No.{file}: Specified method is not supported");
Console.WriteLine($"{stopWatch.ElapsedMilliseconds}: No.{file} Encryption Completed.");
});
}
catch (AggregateException aggregateException)
{
aggregateException = aggregateException.Flatten();
foreach (var innerException in aggregateException.InnerExceptions)
{
switch (innerException)
{
case NotSupportedException notSupportedException:
Console.WriteLine($"{stopWatch.ElapsedMilliseconds}: {notSupportedException.Message}");
break;
case OperationCanceledException operationCanceledException:
Console.WriteLine($"{stopWatch.ElapsedMilliseconds}: {operationCanceledException.Message}");
break;
default:
throw;
}
}
}
});
Console.WriteLine("*".PadRight(Console.WindowWidth - 1), '*');
Console.WriteLine("按任意键取消任务");
Console.ReadLine();
cancellationTokenSource.Cancel();
task.Wait();
stopWatch.Stop();
}
}
上面这段代码可以手动取消并行迭代任务,但问题在于:必须等待并行foreach中正在运行的任务运行完成后才能真正停止。如果想要快速停止所有任务,还需要在Task的Run方法及Wait方法中引入取消令牌,这样一旦任务被取消,即使还需要一段事件才能真正取消,也不会再等待任务结束,而是直接执行wait后的代码。这种方式显著提升了代码执行速度,示例如下:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApp1;
internal static class Program
{
private static void Main()
{
var list = new List<int>();
for (var i = 0; i < 30; i++)
{
list.Add(i);
}
EncryptFiles(list);
}
// 模拟对多个文件的加密操作
private static void EncryptFiles(IEnumerable<int> files)
{
var stopWatch = new Stopwatch();
stopWatch.Start();
var randomGenerator = new Random();
using var cancellationTokenSource = new CancellationTokenSource();
// 注册未处理异常事件
AppDomain.CurrentDomain.UnhandledException += (sender, eventArgs) =>
{
Console.WriteLine("Has unhandled exception.");
};
// 注册任务取消事件
cancellationTokenSource.Token.Register(() => Console.WriteLine("Canceling..."));
// 处理operationCanceledException和并行for循环可能抛出的NotSupportedException异常
try
{
// 用Task.Run包装并行迭代,使得并行迭代能够被取消
var task = Task.Run(() =>
{
// 设置并行迭代参数
var parallelOptions = new ParallelOptions { CancellationToken = cancellationTokenSource.Token };
// 并行for循环[加入parallelOptions,让并行循环中未开始迭代执行的任务也能及时取消]
Parallel.ForEach(files, parallelOptions, file =>
{
Console.WriteLine($"{stopWatch.ElapsedMilliseconds}: No.{file} Encrypting...");
// 模拟耗时操作[每个文件加密需要10s才能完成]
Thread.Sleep(TimeSpan.FromSeconds(10));
// 有0.1的概率抛出异常
if (randomGenerator.Next(0, 10) == 0)
throw new NotSupportedException(
$"[This is a Test] No.{file}: Specified method is not supported");
Console.WriteLine($"{stopWatch.ElapsedMilliseconds}: No.{file} Encryption Completed.");
});
// 在包裹并行迭代的Task.Run方法中使用取消令牌,让任务更快取消
}, cancellationTokenSource.Token);
Console.WriteLine("*".PadRight(Console.WindowWidth - 1), '*');
Console.WriteLine("按任意键取消任务");
Console.ReadLine();
cancellationTokenSource.Cancel();
// 在Wait方法中使用取消令牌,如果任务被取消(即使还需要一段事件才能真正取消)就不会再等待
task.Wait(cancellationTokenSource.Token);
stopWatch.Stop();
}
catch (AggregateException aggregateException)
{
// Task的内部包裹有并行迭代,可能会出现AggregateException又包裹AggregateException的情况,所以需要展开AggregateException
aggregateException = aggregateException.Flatten();
foreach (var innerException in aggregateException.InnerExceptions)
{
switch (innerException)
{
case NotSupportedException notSupportedException:
Console.WriteLine($"{stopWatch.ElapsedMilliseconds}: {notSupportedException.Message}");
break;
case OperationCanceledException operationCanceledException:
Console.WriteLine(
$"{stopWatch.ElapsedMilliseconds}: {operationCanceledException.Message}");
break;
default:
throw;
}
}
}
// 捕捉task.Wait(cancellationTokenSource.Token)抛出的任务被取消异常,因为任务按照预期取消所以该异常被捕获并被忽视
catch (OperationCanceledException)
{
Console.WriteLine($"{stopWatch.ElapsedMilliseconds}: task.Wait接收到取消令牌,不会等待task任务完全结束");
}
}
}
18.4.3 并行迭代选项——ParallelOption
18.4.3a CancellationToken 取消令牌
在18.4.2中使用了ParallelOptions中的CancellationToken选项,在并行迭代中设置了取消令牌。
18.4.3b MaxDegreeOfParallelism 最大并行度
ParallelOptions不仅可以传递取消令牌告诉循环不再进一步迭代,还能手动设置同时运行的线程数量(最大并行度),虽然在大多数情况下没有必要,但在下列情况下更改最大并行度还是很有意义的:
- 当需要分析或调试程序时,可以将最大并行度设置为1,此时并行迭代是同步进行的。
- 影响并行度的外部因素明确已知,如:对于处理USB端口数据的程序,但硬件上只有3个USB端口,那么创建多于可用端口数量的线程很可能没有意义。
- 应用场景中有长时间运行的循环迭代,线程池无法区分长时间运行的迭代和阻塞,因此会引入许多新线程,这对程序性能反而不利。
18.4.3c TaskScheduler
与异步任务相同,ParallelOptions也有TaskScheduler选项,用来指定自定义任务调度程序。例如,用户频繁点击下一步按钮,你可能希望使用自定义任务调度程序来优先调度最新创建的任务,而不是优先调度等待时间最长的任务。任务调度程序可以决定任务调度的顺序。
18.4.4 中断并行迭代
如18.4.2所述,要中断并行循环,可以使用取消令牌并在另一个线程中调用它。除此之外还可以使用Parallel.For方法的另一个重载版本,这个版本接收两个参数,它们的类型分别为Index和ParallelLoopState对象。当希望在循环体内部中断循环时可以调用ParallelLoopState对象的Break方法或Stop方法。其中,Break方法指示不再执行索引值高于当前值的迭代;Stop方法表明根本不需要运行更多的迭代(与取消令牌作用相同)。
现在假设有一个Parallel.For循环将执行20次,其中一些迭代可能比其它迭代运行的更快,并且任务调度程序不保证它们会以任何特定应用程序运行。假设迭代1已经完成,3、5、7、9正在运行中,它们被安排到4个不同的线程;迭代5和迭代7已经调用了Break方法。在这种情况下,迭代6、8、10永远不会开始,但迭代2和4仍然会被调度执行,迭代3和9仍然继续执行,因为它们在中断发生前就已经开始了。示例如下:
using System;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApp1;
internal static class Program
{
private static void Main()
{
EncryptFiles();
}
private static void EncryptFiles()
{
// 设置CancellationTokenSource
using var cancellationTokenSource = new CancellationTokenSource();
// 注册任务取消事件
cancellationTokenSource.Token.Register(() => Console.WriteLine("Canceling..."));
// 注册未处理的异常事件
AppDomain.CurrentDomain.UnhandledException += (sender, exceptionArgs) =>
{
Console.WriteLine("Has Unhandled Exception.");
Console.WriteLine("事件发送者:{0}", sender);
Console.WriteLine("事件参数:{0}", exceptionArgs);
};
// 设置ParallelOptions
var parallelOptions = new ParallelOptions
{
// 使用默认的任务调度策略
TaskScheduler = TaskScheduler.Default,
// 一次只能同时运行4个线程
MaxDegreeOfParallelism = 4,
// 设置取消令牌
CancellationToken = cancellationTokenSource.Token
};
try
{
var task1 = Task.Run(() =>
{
// 参数解释:开始索引(包含该索引),结束索引(不含该索引),并行循环选项, 循环主体(Action<Int32,ParallelLoopState>)
var loopResult = Parallel.For(0, 20, parallelOptions,
(i, loopState) =>
{
Console.WriteLine("Start Thread={0}, i={1}", Environment.CurrentManagedThreadId, i);
// 使用模式匹配代替if-else表达式
switch (i)
{
// 如果是迭代5就不再执行索引值高于5的迭代,已开始执行的迭代继续执行
case 5:
Console.WriteLine($"Break in iteration {i}");
loopState.Break();
break;
// 如果是迭代9就不再执行任何迭代,并且尽快返回结果到主线程 [与cancelToken作用相同]
case 9:
Console.WriteLine($"Stop in iteration {i}");
loopState.Stop();
break;
}
// 模拟耗时操作
for (var j = 0; j < 10; j++)
{
Thread.Sleep(500);
// 如果接收到退出信号就直接返回[加快退出速度]
if (loopState.ShouldExitCurrentIteration) return;
}
Console.WriteLine("Finish Thread={0}, i={1}", Environment.CurrentManagedThreadId, i);
});
if (loopResult.IsCompleted) Console.WriteLine("All iterations completed successfully.");
}, cancellationTokenSource.Token);
task1.Wait(cancellationTokenSource.Token);
}
catch (AggregateException aggregateException)
{
aggregateException = aggregateException.Flatten();
// 另外一种处理异常的方式
// 使用AggregateException中的 "Handle方法和try/catch" 代替了 "foreach和模式匹配" 的异常处理方式
try
{
aggregateException.Handle(innerException =>
{
ExceptionDispatchInfo.Capture(innerException).Throw();
return true;
});
}
catch (OperationCanceledException e)
{
Console.WriteLine(e.Message);
}
}
finally
{
cancellationTokenSource.Dispose();
}
}
}
运行结果如下:
// 第一次运行
Start Thread=6, i=0
Start Thread=7, i=5
Start Thread=8, i=15
Start Thread=10, i=10
Break in iteration 5
Finish Thread=6, i=0
Start Thread=6, i=1
Finish Thread=7, i=5
Finish Thread=6, i=1
Start Thread=6, i=2
Finish Thread=6, i=2
Start Thread=6, i=3
Finish Thread=6, i=3
Start Thread=6, i=4
Finish Thread=6, i=4
Process finished with exit code 0.
// 第二次运行
Start Thread=6, i=10
Start Thread=7, i=15
Start Thread=8, i=2
Start Thread=10, i=9
Stop in iteration 9
Process finished with exit code 0.
从运行结果可以看到,虽然在迭代0、5、15、10运行的时候已经触发Break方法,低于迭代5的迭代1、2、3、4也会在0、5、15、10之后开始运行,但不再执行索引值高于5的迭代。但如果在迭代10、15、2、9开始运行时触发了Stop方法,就会中断正在运行的所有迭代,且不再进行任何迭代,尽快返回到主线程,这一过程需要loopState.ShouldExitCurrentIteration配合。
18.5 线程同步
18.5.1 用Monitor实现线程同步
为同步多个线程,防止它们同时执行特定的代码段,需要用到监视器(monitor)来阻止第二个线程进入受保护的代码段,直到第一个线程退出那个代码段。监视器功能由System.Thread.Monitor提供,为标识受保护代码段的开始和结束位置,要分别调用静态方法Monitor.Enter()和Monitor.Exit()。需要注意的是,在Monitor.Enter()和Monitor.Exit()这两个之间的代码要用try-finally语句块包裹,否则代码段内发生的异常可能让Monitor.Exit()永远无法调用,无限期阻塞线程。
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApp1;
internal static class Program
{
private static readonly object Sync = new();
private static readonly double Total = Math.Pow(2, 3);
private static long Count { get; set; }
private static void Main()
{
var task = Task.Run(Decrement);
for (var i = 0; i < Total; i++)
{
var lockTaken = false;
try
{
Monitor.Enter(Sync, ref lockTaken);
Count++;
Console.Write($"({Environment.CurrentManagedThreadId}){Count}, ");
}
finally
{
if (lockTaken) Monitor.Exit(Sync);
}
}
task.Wait();
}
private static void Decrement()
{
for (var i = 0; i < Total; i++)
{
// 锁定标志,如果处于锁定状态则为true,否则为false
var lockTaken = false;
// 要用try-finally语句块包裹monitor,防止永久阻塞当前线程
try
{
Monitor.Enter(Sync, ref lockTaken);
Count--;
Console.Write($"({Environment.CurrentManagedThreadId}){Count}, ");
}
finally
{
if (lockTaken) Monitor.Exit(Sync);
}
}
}
}
结果如下:
(1)1, (1)2, (1)3, (1)4, (1)5, (1)6, (1)7, (1)8, (3)7, (3)6, (3)5, (3)4, (3)3, (3)2, (3)1, (3)0,
18.5.2 使用lock关键字
由于多线程代码要频繁使用Monitor来同步。同时try-finally语句块很容易被人遗忘,所以C#提供了特殊关键字lock来处理这种同步锁定。下面的示例从18.5.1的示例修改而来,演示了如何使用lock关键字:
using System;
using System.Threading.Tasks;
namespace ConsoleApp1;
internal static class Program
{
private static readonly object Sync = new();
private static readonly double Total = Math.Pow(2, 3);
private static long Count { get; set; }
private static void Main()
{
var task = Task.Run(Decrement);
for (var i = 0; i < Total; i++)
{
lock (Sync)
{
Count++;
Console.Write($"({Environment.CurrentManagedThreadId}){Count}, ");
}
}
task.Wait();
}
private static void Decrement()
{
for (var i = 0; i < Total; i++)
{
lock (Sync)
{
Count--;
Console.Write($"({Environment.CurrentManagedThreadId}){Count}, ");
}
}
}
}
结果如下:
(1)1, (1)2, (1)3, (1)4, (1)5, (1)6, (1)7, (1)8, (3)7, (3)6, (3)5, (3)4, (3)3, (3)2, (3)1, (3)0,
需要注意,同步是以牺牲性能为代价的,即使为了同步的需要可以忍受lock的速度,也不要不假思索地添加同步。对象设计的最佳实践是对可变的静态状态进行同步(永远不变的东西不必同步),不同步任何实例数据。如果允许多个线程访问特定的对象,那么必须为对象提供同步。任何要显式地和线程打交道的类通常应保证实例在某种程度上的线程安全。
18.5.3 lock对象的选择
在前面的例子中,同步变量Sync被声明为私有和只读,声明为只读是为了确保在lock的过程中其值不会发生改变。这就在同步块的进入和退出之间建立了关联。类似的,将Sync声明为私有,是为了确保类外的同步块不能同步同一个对象实例,这会造成代码阻塞。
注意,同步对象不能是值类型,原因在于:
- Monitor.Enter()接收到一个值类型时会先装箱,然后将装箱后的值传递给Monitor.Enter()
- Monitor.Exit()接收到同样的值类型时也会先装箱,然后将装箱后的值传递给Monitor.Exit()
- 结果是,Monitor.Enter()和Monitor.Exit()接收到了不同的同步对象实例,同步块的进入和退出失去了关联性。
18.5.4 为什么要避免锁定this、typeof(type)和string
一个貌似合理的模式是锁定代表类中实例数据的this关键字,以及为静态数据锁定从typeof(type)获取的类型实例。在这种情况下,使用this可为与特定对象实例关联的所有状态提供同步目标;使用typeof(type)则为一个类型的所有静态数据提供同步目标。
但这样做的问题在于,在另一个完全不相干的代码块中,可能创建一个完全不同的同步块B,而这个同步块B的同步目标可能就是同步块A中this或typeof(type)所指向的同步目标。换言之,虽然只有实例B自身内部的代码能用this关键字来阻塞,但创建实例B的实例A仍可将实例B传给实例A中的一个同步锁(即在实例A中把实例B当作了lock对象),结果就是对两套不同的数据进行同步的同步块可能相互阻塞。虽然看起来不可能,也没人会故意这么写,但谁也不能保证在复杂代码中一定不会出现这种情况。
不锁定string的原因也在于此,一个字符串常量S可能在多个位置出现,比如在实例A和实例B中出现,这些字符串共享同一个存储空间(这种情况叫做字符串留用),如果在实例A和实例B中均lock了字符串常量S,实例B中的同步块先于实例A执行,即使实例B与实例A没有任何关系,实例A也要等到实例B解除锁定后才能执行同步块中的内容,这样会使锁定范围大于预期,极端情况下也会死锁。
更好的做法是创建一个私有的object类型的只读字段,除了能访问它的那个类外,没有其它类能阻塞它。
18.5.5 将字段声明为volatile
编译器和CPU有时会对代码进行优化,使指令不按照它们的编码顺序执行,或干脆拿到一些无用的指令。若代码只在一个线程上执行,那么这种做法没有任何问题,但对于多线程程序,这种优化就可能造成预料之外的问题,因为优化可能造成两个线程对同一字段的读写顺序发生错乱。
解决该问题的一个方案是用volatile关键字声明字段,该关键字强迫对volatile字段的所有读写操作都是在代码指示的位置发生,而不是在通过优化而生成其它某个位置发生。volatile修饰符指出字段容易被硬件、操作系统或另一个线程修改。所以这种数据是易变的(volatile),编译器和“运行时”要更严谨地处理它。
值得注意的是,一般情况下很少使用volatile关键字,尽量使用lock替代volatile,除非对volatile的用法有绝对把握,否则不要使用volatile关键字。
18.5.6 同步设计最佳实践
title: 设计规范
1. 不要以不同顺序请求相同的两个或更多同步目标的排他权
2. 要确保同时持有多个锁的代码总是以相同顺序获得这些锁
3. 要将可变的静态数据封装到具有同步逻辑的公共API中
4. 避免同步对不大于本机指针大小(通常是64位)的值的简单读写操作,这种操作本来就是原子性的。换句话说,尽量用原子操作替代同步锁。
18.5.6a 避免死锁
死锁发生必须满足下列4个基本条件,缺一不可:
- 排他或互斥: 一个线程(Thread A)独占一个资源,没有其它线程(Thread B)能获取相同资源。
- 占用并等待: 一个排他的线程(Thread A)请求获取另一个线程(Thread B)独占的资源。
- 不可抢先: 一个线程(Thread A)占有的资源不能被强制拿走,只能等待Thread A主动释放。
- 循环等待: 两个或多个线程构成一个循环等待链,它们锁定两个或多个相同的资源,每个线程都在等待链中下一个线程占有的资源。
例如:线程A独占资源a(满足条件1),线程B独占资源b(满足条件1);线程A在执行过程中需要获取资源b(满足条件2),线程B在执行过程中需要获取资源a(满足条件2);这两个线程占用的资源不能被强行拿走(满足条件3);此时,线程A在等待获取资源b,线程B在等待获取资源a,它们在获取到想要的资源之前都不会释放自己占用的资源(满足条件4)。四个条件均满足,线程A和线程B死锁。
18.5.6b 何时提供同步
- 前面提到过,所有的静态数据都应该是线程安全的。所以,同步围绕可变的静态数据进行。这通常意味着程序员应声明私有静态变量,并提供公共方法来修改数据。在需要多线程访问的情况下,这些方法要在内部处理好同步问题。
- 实例数据不需要包含同步机制,同步会限制降低性能,并增大争夺锁或死锁的概率。除了显式设计成由多个线程访问的类之外,开发者在多个线程共享对象时,应针对要共享的数据解决好它们自己的同步问题(参考在16.2.4中提及的“事件的线程安全性”)。
18.5.6c 避免不必要的锁定
在不破坏数据完整性的前提下,要尽量避免不必要的同步。例如,在线程之间使用不可变类型,避免对同步的需要。类似地,避免锁定本来就是线程安全的操作,例如对原子操作进行锁定。
18.5.7 其他同步技术
18.5.7a 互斥锁Mutex
Mutex与Monitor在概念上几乎完全一致,只是lock关键字用了Monitor而不是Mutex。由于Mutex是跨进程资源,所以可以通过System.Security.AccessControl.MutexSecurity对象来设置访问控制。Mutex的一个用处是限制应用程序不能同时运行多个实例。示例如下:
using System;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApp1;
internal static class Program
{
private static void Main()
{
var mutexName = Assembly.GetEntryAssembly()?.FullName;
using var mutex = new Mutex(false, mutexName, out var firstApplicationInstance);
if (!firstApplicationInstance)
{
Console.WriteLine("This application is already running");
}
else
{
Console.WriteLine("Enter to shut down");
Console.ReadLine();
}
}
}
运行结果如下:
// powershell 1
PS E:\OneDrive\000.SharedFolder_SourceFile\Workspace\VisualStudio\Manuscript\ConsoleApp1> dotnet.exe run
Enter to shut down
// powershell 2
PS E:\OneDrive\000.SharedFolder_SourceFile\Workspace\VisualStudio\Manuscript\ConsoleApp1> dotnet.exe run
This application is already running
在上述示例中,应用程序在整个计算机上只能运行一次,即使它由不同的用户启动。要限制每个用户最多只能运行一个实例,需要在mutexName赋值时添加System.Enviroment.UserName作为后缀。
18.5.7b WaitHandle
System.Threading.WaitHandle是Mutex、EventWaitHandle和Semaphore等同步类使用的一个基础同步类,这个基础同步类包含WaitOne、WaitAll、WaitAny、SignalAndWait等方法,其中最关键的方法是WaitOne,详见微软文档。
关于WaitHandle最后需要注意的一点是,它包含一个SafeWaitHandle类型的句柄(handle),这个类型实现了IDisposable接口,当不再需要WaitHandle时要对其进行资源清理,对WaitHandle的子类也是如此。
18.5.7c 重置事件类
在18.3.5(返回void的异步方法)的示例(使用自定义同步上下文处理异步事件中未处理的异常)中使用了重置事件类(ManualResetEventSlim),在那个示例中强迫主线程等待从同步上下文抛出的异常,然后在主线程中处理异常。
重置事件类包括ManualResetEvent、ManualResetEventSlim和AutoResetEvent,它们提供的核心方法是Set方法和Wait方法(ManualResetEvent中提供的等价方法是WaitOne方法)。调用Wait方法会阻塞一个线程的执行,直到一个不同的线程调用Set方法或者超过设置的等待时间。
ManualResetEvent和ManualResetEventSlim区别在于:
- ManualResetEvent能够等待多个事件并且能够跨越多个线程
- ManualResetEventSlim的性能更好,如果不需要ManualResetEvent提供的功能则应该选用ResetEventSlim
AutoResetEvent和ManualResetEvent相似,它允许线程A使用Set方法通知其他线程(线程B和线程C)线程A已经运行到指定位置。区别在于,AutoResetEvent一次只解除一个线程(线程B)的Wait方法调用所造成的阻塞,然后AutoResetEvent自动重置,线程C的阻塞只能等到下一次解除。如果使用ManualResetEvent方法则没有这个问题,线程A通知线程B和线程C后不会自动重置,这样线程B和线程C均能在一个周期内解除阻塞。
18.5.7d 信号量和CountDownEvent
在18.3.4e中已经介绍过信号量,在18.3.4d1的第二个示例(利用同步上下文控制线程的并发度)中使用了SemaphoreSlim类来控制并发度。信号量限制了在一个关键执行区域内同时执行调用的线程数量,其本质是对资源池的一个计数,如果没有可用资源(计数为0)就阻塞对资源池的更多访问,直到其中的一个资源返回(计数+1)。有可用资源后,就把这个资源分配给以阻塞的请求(通常用先进先出的队列管理这些被阻塞的请求)。
信号量是计数为0时阻止更多访问,CountdownEvent是计数为0时才允许访问。例如,假定一个并行操作是下载多只股票的价格,只有在所以价格都下载完毕后,才能执行一个特定的搜索算法。这种情况下可用CountdownEvent对搜索算法进行同步,每下载一只股票就使计数递减1。计数为0才开始搜索。
值得一提的是,SemaphoreSlim实现了WaitAsync方法,即:异步的等待。如果当前线程得不到锁,那么可直接返回并执行其他工作,而不会阻塞当前线程。以后当锁可用时,代码可恢复执行并访问锁保护的资源。
18.5.7e 并发集合类
Microsoft.NET Framework 4 预定义了一组并发集合类,这些类专门用来包含内建的同步代码,时它们能支持多个线程同时访问而不必关心竞态条件。这些类分别是:
BlockingCollection<T>
:提供一个阻塞集合,允许在“生产者-消费者”模式中,生产者向集合写入数据,同时消费者从集合读取数据。该类提供了一个泛型集合类型,支持对添加和删除操作进行同步,而不必关心后端存储,这个后端存储可以是队列、栈、列表等集合。ConcurrentBag<T>
:线程安全的无序集合,由T类型的对象构成ConcurrentDictionary<TKey, TValue>
:线程安全的字典,由键值对构成的集合ConcurrentQueue<T>
:线程安全的队列,先进先出ConcurrentStack<T>
:线程安全的栈,先进后出
.Net Core 还以NuGet包的形式提供了一个额外的不可变集合库,称为System.Collections.Immutable.不可变集合的好处在于能够在线程之间自由传递而不用担心同步问题。
18.5.8 线程本地存储
同步的一个替代方案是隔离,线程本地存储是实现隔离的一个办法,利用线程本地存储,线程就有了专属的变量实例。这样就没有同步的必要了。线程的本地存储有的实现有两个例子,一个是ThreadLocal<T>
和ThreadStaticAttribute
,在“使用自定义任务调度程序限制同时运行的线程数”这一例子(详见18.2.7b)中就已经使用了ThreadStaticAttribute
。
【示例1】用ThreadLocal<T>
实现线程本地存储
using System;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApp1;
internal static class Program
{
private static ThreadLocal<int> _count = new(() => 0);
private static int Count
{
get => _count.Value;
set => _count.Value = value;
}
private static void Main()
{
var thread = new Thread(Decrement);
thread.Start();
for (var i = 0; i < 100; i++)
{
Count++;
}
thread.Join();
Console.WriteLine($"Main Count = {Count}");
}
private static void Decrement()
{
for (var i = 0; i < 50; i++)
{
Count--;
}
Console.WriteLine($"Decrement Count = {Count}");
}
}
输出结果如下:
Decrement Count = -50
Main Count = 100
在执行Main方法的线程中,Count的值永远不会被执行Decrement方法的线程更改。
【示例2】用ThreadStaticAttribute<T>
实现线程本地存储
using System;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApp1;
internal static class Program
{
[field: ThreadStatic] private static int Count { get; set; }
private static void Main()
{
var thread = new Thread(Decrement);
thread.Start();
for (var i = 0; i < 100; i++)
{
Count++;
}
thread.Join();
Console.WriteLine($"Main Count = {Count}");
}
private static void Decrement()
{
for (var i = 0; i < 50; i++)
{
Count--;
}
Console.WriteLine($"Decrement Count = {Count}");
}
}
输出结果如下:
Decrement Count = -50
Main Count = 100
结果与示例1相同。
除了替代线程同步以外,使用线程本地存储的另一个原因是要将经常需要的上下文信息提供给其他方法使用,同时不显式地通过参数来传递数据。例如,假如调用栈中的多个方法都需要用户安全信息,就可使用线程本地存储字段而不是参数来传递数据。这样使API更加简洁,同时仍能以线程安全的方式将信息传给方法。但这要求开发者保证总是设置线程本地数据,这一点在Task或其他线程上尤为重要,因为基础线程是重用的。
最后,决定是否使用线程本地存储时需要进行一番性价比分析,例如可考虑为一个数据库连接使用线程本地存储。取决于数据库管理系统,数据库连接可能相当昂贵,为每个线程都创建连接可能不太现实。令一方面,如果锁定一个连接来同步所有数据库调用会造成可伸缩性的急剧下降,每个模式都有利与弊,具体实现应具体分析。
18.6 关于多线程的一些注意事项(大坑)
18.6.1 第一坑 Task.Run、Task.Factory.StartNew、new Task()
考虑如下代码:
internal class Program
{
private static void Main(string[] args)
{
var stopWatch = new Stopwatch();
stopWatch.Start();
var taskList = new List<Task>();
for (int i = 0; i < 5; i++)
{
// new Task()仅仅是创建任务而不会自动执行任务
taskList.Add(new Task(() =>
{
Console.WriteLine($"Task {Task.CurrentId} start {stopWatch.Elapsed}");
Thread.Sleep(TimeSpan.FromSeconds(1));
Console.WriteLine($"Task {Task.CurrentId} is finished {stopWatch.Elapsed}");
}));
}
// 任务不会执行,直到等待超时
Task.WaitAll(taskList.ToArray(), TimeSpan.FromSeconds(10));
Console.WriteLine($"exit {stopWatch.Elapsed}");
stopWatch.Stop();
}
}
这段代码Task中的任务不会执行直到等待超时,原因在于new Task()仅仅是创建任务而不会自动执行任务,等待超时后放弃等待直接返回。如果想让上面的代码正常工作,要手动Task.Star,将上述代码改为:
internal static class Program
{
private static void Main(string[] args)
{
var stopWatch = new Stopwatch();
stopWatch.Start();
var taskList = new List<Task>();
for (int i = 0; i < 5; i++)
{
// new Task仅仅是创建任务而不自动执行任务
taskList.Add(new Task(() =>
{
Console.WriteLine($"Task {Task.CurrentId} start {stopWatch.Elapsed}");
Thread.Sleep(TimeSpan.FromSeconds(1));
Console.WriteLine($"Task {Task.CurrentId} is finished {stopWatch.Elapsed}");
}));
}
// 手动开始任务
taskList.ForEach(task => task.Start(TaskScheduler.Default));
// 这里注意下:Task.WaitAll可以接收一个一维数组表示的Task表,不能是其他类型的集合
Task.WaitAll(taskList.ToArray(), TimeSpan.FromSeconds(10));
Console.WriteLine($"exit {stopWatch.Elapsed}");
stopWatch.Stop();
}
}
或者更省事点,用Task.Run()创建任务,将上述代码改为:
using System.Diagnostics;
namespace LearnDotNetCore;
internal static class Program
{
private static void Main(string[] args)
{
var stopWatch = new Stopwatch();
stopWatch.Start();
var taskList = new List<Task>();
for (int i = 0; i < 5; i++)
{
// new Task仅仅是创建任务而不自动执行任务
taskList.Add(Task.Run(() =>
{
Console.WriteLine($"Task {Task.CurrentId} start {stopWatch.Elapsed}");
Thread.Sleep(TimeSpan.FromSeconds(1));
Console.WriteLine($"Task {Task.CurrentId} is finished {stopWatch.Elapsed}");
}));
}
// Task.Run创建的任务会自动开始,不要手动开始了
//taskList.ForEach(task => task.Start(TaskScheduler.Default));
Task.WaitAll(taskList.ToArray(), TimeSpan.FromSeconds(10));
Console.WriteLine($"exit {stopWatch.Elapsed}");
stopWatch.Stop();
}
}
使用Task.Run()创建的任务会自动执行。相当于在一个Task(用task命名)创建后自动执行了task.Start();
这时又有了一个问题,当Task里的委托为异步委托时又会怎样。考虑如下代码:
internal static class Program
{
private static void Main(string[] args)
{
var stopWatch = new Stopwatch();
stopWatch.Start();
var taskList = new List<Task>();
for (int i = 0; i < 5; i++)
{
// new Task仅仅是创建任务而不自动执行任务
taskList.Add(Task.Run(async () =>
{
Console.WriteLine($"Task {Task.CurrentId} start {stopWatch.Elapsed}");
await Task.Delay(1000);
// 这里的Task.CurrentId=null,从Task.Delay线程返回后没有回到最初的线程
Console.WriteLine($"Task {Task.CurrentId} is finished {stopWatch.Elapsed}");
}));
}
// Task.Run创建的任务会自动开始,不要手动开始了
//taskList.ForEach(task => task.Start(TaskScheduler.Default));
Task.WaitAll(taskList.ToArray(), TimeSpan.FromSeconds(10));
Console.WriteLine($"exit {stopWatch.Elapsed}");
stopWatch.Stop();
}
}
输出结果如下:
Task 2 start 00:00:00.0372326
Task 1 start 00:00:00.0372323
Task 3 start 00:00:00.0372326
Task 5 start 00:00:00.0372323
Task 4 start 00:00:00.0372326
Task is finished 00:00:01.0562771
Task is finished 00:00:01.0562771
Task is finished 00:00:01.0562773
Task is finished 00:00:01.0562789
Task is finished 00:00:01.0562823
exit 00:00:01.0566986
好像除了从Task.Delay线程返回后丢失了线程号(没有返回到原来的线程继续执行)以外没有其他问题。
我们知道,Task.Run方法是对Task.Factory.StartNew方法的简化,现在我想指定线程的创建和执行策略,这时就要用到Task.Factory.StartNew方法了,代码如下:
internal static class Program
{
private static void Main(string[] args)
{
var stopWatch = new Stopwatch();
stopWatch.Start();
var taskList = new List<Task>();
for (int i = 0; i < 5; i++)
{
// new Task仅仅是创建任务而不自动执行任务
taskList.Add(Task.Factory.StartNew(async () =>
{
Console.WriteLine($"Task {Thread.GetCurrentProcessorId()} start {stopWatch.Elapsed}");
await Task.Delay(1000);
// 这里的Task.CurrentId=null,从Task.Delay线程返回后没有回到最初的线程
Console.WriteLine($"Task {Thread.GetCurrentProcessorId()} is finished {stopWatch.Elapsed}");
}, TaskCreationOptions.PreferFairness));
}
// 将Task.Run换成Task.Factory.StartNew后Task.WaitAll失效了
Task.WaitAll(taskList.ToArray(), TimeSpan.FromSeconds(10));
Console.WriteLine($"exit {stopWatch.Elapsed}");
stopWatch.Stop();
}
}
结果如下:
Task 11 start 00:00:00.0668269
Task 3 start 00:00:00.0668264
Task 5 start 00:00:00.0668284
Task 7 start 00:00:00.0668261
Task 8 start 00:00:00.0668260
exit 00:00:00.0813407
似乎是await Task.Delay(1000)
及以后代码没有执行,但其实所有代码都执行了,只是没有等待而已。在代码最后加个延时等待3秒看看:
internal static class Program
{
private static void Main(string[] args)
{
var stopWatch = new Stopwatch();
stopWatch.Start();
var taskList = new List<Task>();
for (int i = 0; i < 5; i++)
{
// new Task仅仅是创建任务而不自动执行任务
taskList.Add(Task.Factory.StartNew(async () =>
{
Console.WriteLine($"Task {Thread.GetCurrentProcessorId()} start {stopWatch.Elapsed}");
await Task.Delay(1000);
// 这里的Task.CurrentId=null,从Task.Delay线程返回后没有回到最初的线程
Console.WriteLine($"Task {Thread.GetCurrentProcessorId()} is finished {stopWatch.Elapsed}");
}, TaskCreationOptions.PreferFairness));
}
// 将Task.Run换成Task.Factory.StartNew后Task.WaitAll失效了
Task.WaitAll(taskList.ToArray(), TimeSpan.FromSeconds(10));
// 暂停当前线程,等待其他线程的结果
Thread.Sleep(TimeSpan.FromSeconds(3));
Console.WriteLine($"exit {stopWatch.Elapsed}");
stopWatch.Stop();
}
}
结果如下:
Task 6 start 00:00:00.0377081
Task 1 start 00:00:00.0377078
Task 10 start 00:00:00.0377081
Task 2 start 00:00:00.0377075
Task 5 start 00:00:00.0377070
Task 7 is finished 00:00:01.0686752
Task 8 is finished 00:00:01.0686751
Task 5 is finished 00:00:01.0686751
Task 6 is finished 00:00:01.0686778
Task 10 is finished 00:00:01.0686799
exit 00:00:03.0665701
现在,所有语句的执行结果都出来了。那么,Task.WaitAll为什么会失效?这取决于Task.WaitAll等待的是什么,先看下面这个例子:
using System.Diagnostics;
namespace LearnDotNetCore;
internal static class Program
{
private static void Main(string[] args)
{
var stopWatch = new Stopwatch();
stopWatch.Start();
// Task.Run
Task taskRun = Task.Run(async () =>
{
Console.WriteLine("Task.Run开始运行, 线程号: " + Thread.GetCurrentProcessorId() + " " + stopWatch.Elapsed);
await Task.Delay(1000);
Console.WriteLine("Task.Run运行结束, 线程号: " + Thread.GetCurrentProcessorId() + " " + stopWatch.Elapsed);
});
// Task.Factory.StartNew
Task<Task> taskFactoryStartNew = Task.Factory.StartNew(async () =>
{
Console.WriteLine("Task.Factory.StartNew开始运行, 线程号: " + Thread.GetCurrentProcessorId() + " " +
stopWatch.Elapsed);
await Task.Delay(1000);
Console.WriteLine("Task.Factory.StartNew运行结束, 线程号: " + Thread.GetCurrentProcessorId() + " " +
stopWatch.Elapsed);
});
// 等待一次即可
taskRun.GetAwaiter().GetResult();
// 等待两次:如果等待一次得到的还是Task,如果不等待Task,主线程不会阻塞,获取不到后台线程返回的结果
taskFactoryStartNew.GetAwaiter().GetResult().GetAwaiter().GetResult();
Console.WriteLine($"exit {stopWatch.Elapsed}");
stopWatch.Stop();
}
}
在上述代码中,Task.Run的返回值为Task
,Task.Factory.StartNew的返回值为Task<Task>
,为什么相同的委托得到了不同的结果?其实,Task.Factory.StartNew的返回值才是合乎逻辑的。我们知道,Task.Factory.StartNew对委托类型的返回值进行了一次封装,表示为: Task<Object>,那么这个Object是什么呢?先看这段代码:
// Task.Factory.StartNew
// Task.Factory.StartNew
Task<Task> taskFactoryStartNew = Task.Factory.StartNew(async () =>
{
Console.WriteLine("Task.Factory.StartNew开始运行, 线程号: " + Thread.GetCurrentProcessorId() + " " +
stopWatch.Elapsed);
await Task.Delay(1000);
Console.WriteLine("Task.Factory.StartNew运行结束, 线程号: " + Thread.GetCurrentProcessorId() + " " +
stopWatch.Elapsed);
});
这个Object用了async/await语法糖,这表示,在Object中使用了异步方法(用A表示),方法A执行时创建了新线程,方法A执行完成并返回后,后续代码会不会回到主线程取决于上下文。但可以肯定,代码被拆分成了几个部分。伪代码如下:
var action2 = new Action(() =>
{
Console.WriteLine("Task.Factory.StartNew运行结束, 线程号: " + Thread.GetCurrentProcessorId() + " " +
stopWatch.Elapsed);
});
var action1 = new Action(() =>
{
Thread.Sleep(1000);
});
// Task.Factory.StartNew
Task<Task> taskFactoryStartNewEqual = Task.Factory.StartNew(() =>
{
Console.WriteLine("Task.Factory.StartNew开始运行, 线程号: " + Thread.GetCurrentProcessorId() + " " +
stopWatch.Elapsed);
return 包裹了action1和action2的一个Task(至于具体是什么,参考状态机);
});
对Task执行Wait操作后可以得到Task包裹的内容(Task<void>被省略为Task) ,如果没有得到想要的内容就阻塞当前线程并等待。对Task<Task>
执行Wait操作后得到内部的Task对象后即认为得到了想要的内容,至于内部Task又包裹了什么,Wait操作并不关心。
现在可以回答“Task.WaitAll为什么会失效”这一问题了。Task.WaitAll等待的是一组Task对象,只要拿到Task对象Task.WaitAll就完成了任务,完成任务后主线程不再阻塞(即使此时内部Task中的任务并没有完成),当主线程执行完毕,后台线程被强制终结,所以看不到后台线程向控制台的输出。
那么同样的操作为什么Task.Run()能成功呢?原因在于:Task.Run()自动提取了Task<Task>
或Task<Task<T>>
,让嵌套消失了。我们在Task.Factory.StartNew方法返回之前手动调用Unwrap方法也能达到同样的目的,示例如下:
using System.Diagnostics;
namespace LearnDotNetCore;
internal static class Program
{
private static void Main(string[] args)
{
var stopWatch = new Stopwatch();
stopWatch.Start();
// Task.Run
Task taskRun = Task.Run(async () =>
{
Console.WriteLine("Task.Run开始运行, 线程号: " + Thread.GetCurrentProcessorId() + " " + stopWatch.Elapsed);
await Task.Delay(1000);
Console.WriteLine("Task.Run运行结束, 线程号: " + Thread.GetCurrentProcessorId() + " " + stopWatch.Elapsed);
});
// Task.Factory.StartNew
Task taskFactoryStartNew = Task.Factory.StartNew(async () =>
{
Console.WriteLine("Task.Factory.StartNew开始运行, 线程号: " + Thread.GetCurrentProcessorId() + " " +
stopWatch.Elapsed);
await Task.Delay(1000);
Console.WriteLine("Task.Factory.StartNew运行结束, 线程号: " + Thread.GetCurrentProcessorId() + " " +
stopWatch.Elapsed);
}).Unwrap();
// 等待一次即可
taskRun.GetAwaiter().GetResult();
// taskFactoryStartNew用了UnWarp解包裹,现在taskFactoryStartNew和taskRun的行为一致了
taskFactoryStartNew.GetAwaiter().GetResult();
Console.WriteLine($"exit {stopWatch.Elapsed}");
stopWatch.Stop();
}
}
18.6.2 第二坑 Wait与WaitAsync
WaitAsync是dotNET 6 引入的一个特性,用来异步等待一个任务的完成。异步等待的时候可以指定一个 Timeout 时间或者一个取消令牌 CancellationToken ,waitAsync 不会阻塞当前线程,而是在等待的过程中可以执行其他任务。
Wait是一个同步方法,它会阻塞当前线程,直到一个任务完成或超时。Wait 不能指定取消令牌,也不能捕获超时异常,只能通过返回值判断是否超时。
在WaitAsync被引入之前,开发者必须手动实现异步等待,示例如下:
public static async Task<T> TimeOutAfter<T>(this Task<T> task, TimeSpan timeout)
{
// 如果task已经完成了,直接返回。此时的task是同步完成的异步任务
if (task.IsCompleted) return await task;
// 构建一个超时定时器任务
var cts = new CancellationTokenSource();
var timeoutChecker = Task.Delay(timeout, cts.Token);
// 首先完成的是哪个Task?
var completedTask = await Task.WhenAny(task, timeoutChecker);
// 如果首先完成的Task是timeoutChecker,则抛出异常
if (task != completedTask) throw new TimeoutException();
// 如果首先完成的Task是task,则取消超时定时器任务(timeoutChecker)
cts.Cancel();
// 返回完成后的任务
return await task;
}
有了WaitAsync后,可以直接使用WaitAsync代替扩展方法TimeOutAfter<T>了。WaitAsync的使用示例如下:
public static async Task TimeoutSampleAsync()
{
var tasks = new List<Task>();
tasks.AddRange(new[]
{
Task.Delay(TimeSpan.FromSeconds(5)),
Task.Delay(TimeSpan.FromSeconds(10)),
Task.Delay(TimeSpan.FromSeconds(7)),
});
var task = Task.WhenAll(tasks);
try
{
// 等待6秒后就放弃等待
await task.WaitAsync(TimeSpan.FromSeconds(6));
}
catch (TimeoutException e)
{
Console.WriteLine($"{nameof(TimeoutException)}: {e.Message}");
}
finally
{
Console.WriteLine(string.Join(",", tasks.Select(t => t.Status.ToString())));
Console.WriteLine(task.Status);
}
}
18.6.3 第三坑 ConfigureAwait与同步上下文
ConfigureAwait(false)方法包含bool类型的形参continueOnCapturedContext,返回类型为ConfiguredTaskAwaitable,如果continueOnCapturedContext被设置为false,即使有当前上下文或调度程序用于回调,它也会假装没有。通常用于避免强制在原始上下文或调度程序中进行回调。
永远不要使用task.ConfigureAwait(true),这没有任何意义,如果当前的异步程序具有同步上下文,那么在await后会自动返回到同步上下文中去,ConfigureAwait(true)存粹是多此一举。
永远不要使用task.ConfigureAwait(false).GetAwaiter().GetResult(),这同样没任何意义,无论是在TaskAwaiter上(task.GetAwaiter()的返回值)还是在ConfiguredTaskAwaitable.ConfiguredTaskAwaiter上(task.ConfigureAwait(false).GetAwaiter())进行操作,都是没有任何区别的。
在dotnetCore时代,只要不是开发桌面应用(需要同步上下文将异步计算的结果返回到UI线程),就不需要关心ConfigureAwait和同步上下文的问题,同步上下文基本上不存在。
18.6.4 第四坑 Task与ValueTask
ValueTask<TResult>是在dotNET Core 2.0时引入的,其目的是在异步方法同步完成时直接返回结果而不增加额外的内存分配。后来,“即使异步完成也要避免额外内存分配”这一需求也出现了。因此,在dotNET Core 2.1中,引入了非泛型的ValueTask,非泛型的ValueTask返回的结果为void,如果有结果返回,还是异步完成,根本无法避免额外的内存分配(不分配内存,异步完成的结果存到哪里去?)
也就是说,ValueTask的作用是尽量避免对异步方法返回结果的封装。如果异步方法同步完成,那么可以直接获得异步方法的TResult,不需要再分配资源去保存异步方法的执行状态等信息(任务都完成了,有必要保存"任务完成"这一状态吗?);如果异步方法没有返回值,那么直接保存这一异步方法的完成状态就行(有必要为void返回值预留额外的内存空间吗?)。另外ValueTask是值类型,如果ValueTask对象没有被引用类型引用过,那么ValueTask对象的释放过程不需要GC参与,这也显著提升了性能。
注意事项:
- ValueTask对象只能等待一次,等待一次后ValueTask对象就会被回收,如果等待多次要先将ValueTask对象转换为Task对象
- ValueTask对象不是强制阻塞的,使用GetAwaiter().GetResult()不一定能获取到结果,要手动追踪ValueTask对象是否完成。
- 在使用ValueTask替代Task之前,要考虑下是否值得用ValueTask替代Task,毕竟ValueTask不能多次等待且不是强制阻塞的(你真的在乎这点性能提升吗?)