C#使用异步操作时的注意要点(翻译)

异步操作时应注意的要点 使用异步方法返回值应避免使用void 对于预计算或者简单计算的函数建议使用Task.FromResult代替Task.Run 避免使用Task.Run()方法执行长时间堵塞线程的工作 避免使用Task.Result和Task.Wait()来堵塞线程 建议使用await来代替continueWith任务 创建TaskCompletionSource 建议使用CancellationTokenSource(s)进行超时管理时总是释放(dispose) 建议将协作式取消对象(CancellationToken)传递给所有使用到的API 建议取消那些不会自动取消的操作(CancellationTokenRegistry,timer) 使用StreamWriter(s)或Stream(s)时在Dispose之前建议先调用FlushAsync 建议使用 async/await而不是直接返回Task 使用场景 使用定时器回调函数 创建回调函数参数时注意避免 async void 使用ConcurrentDictionary.GetOrAdd注意场景 构造函数对于异步的问题 异步操作时需要注意的要点 1.使用异步方法返回值应当避免使用void 在使用异步方法中最好不要使用void当做返回值,无返回值也应使用Task作为返回值,因为使用void作为返回值具有以下缺点 无法得知异步函数的状态机在什么时候执行完毕 如果异步函数中出现异常,则会导致进程崩溃 ❌异步函数不应该返回void static void Main(string[] args) { try { // 如果Run方法无异常正常执行,那么程序无法得知其状态机什么时候执行完毕 Run(); } catch (Exception ex) { Console.WriteLine(ex.Message); } Console.Read(); } static async void Run() { // 由于方法返回的为void,所以在调用此方法时无法捕捉异常,使得进程崩溃 throw new Exception("异常了"); await Task.Run(() => { }); } ☑️应该将异步函数返回Task static async Task Main(string[] args) { try { // 因为在此进行await,所以主程序知道什么时候状态机执行完成 await RunAsync(); Console.Read(); } catch (Exception ex) { Console.WriteLine(ex.Message); } } static async Task RunAsync() { // 因为此异步方法返回的为Task,所以此异常可以被捕捉 throw new Exception("异常了"); await Task.Run(() => { }); } 注:事件是一个例外,异步事件也是返回void 2.对于预计算或者简单计算的函数建议使用Task.FromResult代替Task.Run 对于一些预先知道的结果或者只是一个简单的计算函数,使用Task,FromResult要比Task.Run性能要好,因为Task.FromResult只是创建了一个包装已计算任务的任务,而Task.Run会将一个工作项在线程池进行排队,计算,返回.并且使用Task.FromResult在具有SynchronizationContext 程序中(例如WinForm)调用Result或wait()并不会死锁(虽然并不建议这么干) ❌对于预计算或普通计算的函数不应该这么写 public async Task RunAsync() { return await Task.Run(()=>1+1); } ☑️而应该使用Task.FromResult代替 public async Task RunAsync() { return await Task.FromResult(1 + 1); } 还有另外一种代替方法,那就是使用ValueTask类型,ValueTask是一个可被等待异步结构,所以并不会在堆中分配内存和任务分配,从而性能更优化. ☑️使用ValueTask static async Task Main(string[] args) { await AddAsync(1, 1); } static ValueTask AddAsync(int a, int b) { // 返回一个可被等待的ValueTask类型 return new ValueTask(a + b); } 注: ValueTask结构是C#7.0加入的,存在于Sysntem,Threading.Task.Extensions包中 ValueTask ValueTask 3.避免使用Task.Run()方法执行长时间堵塞线程的工作 长时间运行的工作是指在应用程序生命周期执行后台工作的线程,如:执行processing queue items,执行sleeping,执行waiting或者处理某些数据,此类线程不建议使用Task.Run方法执行,因为Task.Run方法是将任务在线程池内进行排队执行,如果线程池线程进行长时间堵塞,会导致线程池增长,进而浪费性能,所以如果想要运行长时间的工作建议直接创建一个新线程进行工作 ❌下面这个例子就利用了线程池执行长时间的阻塞工作 public class QueueProcessor { private readonly BlockingCollection _messageQueue = new BlockingCollection(); public void StartProcessing() { Task.Run(ProcessQueue); } public void Enqueue(Message message) { _messageQueue.Add(message); } private void ProcessQueue() { foreach (var item in _messageQueue.GetConsumingEnumerable()) { ProcessItem(item); } } private void ProcessItem(Message message) { } } ☑️所以应该改成这样 public class QueueProcessor { private readonly BlockingCollection _messageQueue = new BlockingCollection(); public void StartProcessing() { var thread = new Thread(ProcessQueue) { // 设置线程为背后线程,使得在主线程结束时此线程也会自动结束 IsBackground = true }; thread.Start(); } public void Enqueue(Message message) { _messageQueue.Add(message); } private void ProcessQueue() { foreach (var item in _messageQueue.GetConsumingEnumerable()) { ProcessItem(item); } } private void ProcessItem(Message message) { } } 🔔线程池内线程增加会导致在执行时大量的进行上下文切换,从而浪费程序的整体性能, 线程池详细信息请参考CLR第27章 🔔Task.Factory.StartNew方法中有一个TaskCreationOptions参数重载,如果设置为LongRunning,则会创建一个新线程执行 // 此方法会创建一个新线程进行执行 Task.Factory.StartNew(() => { }, TaskCreationOptions.LongRunning); 4.避免使用Task.Result和Task.Wait()来堵塞线程 使用Task.Result和Task.Wait()两个方法进行阻塞异步同步化比直接同步方法阻塞还要MUCH worse(更糟),这种方式被称为Sync over async 此方式操作步骤如下 1.异步线程启动 2.调用线程调用Result或者Wait()进行阻塞 3.异步完成时,将一个延续代码调度到线程池,恢复等待该操作的代码 虽然看起来并没有什么关系,但是其实这里却是使用了两个线程来完成同步操作,这样通常会导致线程饥饿和死锁 🔔线程饥饿(starvation):指等待时间已经影响到进程运行,如果等待时间过长,导致进程使命没有意义时,称之为饿死 🔔死锁(deadlock):指两个或两个以上的线程相互争夺资源,导致进程永久堵塞, 🔔使用Task.Result和Task.Wait()会在winform和ASP.NET中会死锁,因为它们SynchronizationContext具有对象,两个线程在SynchronizationContext争夺导致死锁,而ASP.NET Core则不会产生死锁,因为ASP.NET Core本质是一个控制台应用程序,并没有上下文 ❌下面的例子,虽然都不会产生死锁,但是依然具有很多问题 async Task RunAsync() { // 此线程ID输出与UI线程ID不一致 Debug.WriteLine("UI线程:"+Thread.CurrentThread.ManagedThreadId); return await Task.Run(() => "Run"); } string DoOperationBlocking() { // 这种方法虽然摆脱了死锁的问题,但是也导致了上下文问题,RunAsync不在以UI线程调用 // Result和Wait()方法如果出现异常,异常将被包装为AggregateException进行抛出, return Task.Run(() => RunAsync()).Result; } } private async void button1_Click(object sender, EventArgs e) { Debug.WriteLine("RunAsync:" + Thread.CurrentThread.ManagedThreadId); Debug.WriteLine(DoOperationBlocking()); } public string DoOperationBlocking2() { // 此方法也是会导致上下文问题, // GetAwaiter()方法对异常不会包装 return Task.Run(() => RunAsync()).GetAwaiter().GetResult(); } 5.建议使用await来代替continueWith任务 在async和await,当时可以使用continueWith来延迟执行一些方法,但是continueWith并不会捕捉`SynchronizationContext `,所以建议使用await代替continueWith ❌下面例子就是使用continueWith private void button1_Click(object sender, EventArgs e) { Debug.WriteLine("UI线程:" + Thread.CurrentThread.ManagedThreadId); RunAsync().ContinueWith(task => { Console.WriteLine("RunAsync returned:"+task.Result); // 因为是使用的continueWith,所以线程ID与UI线程并不一致 Debug.WriteLine("ContinueWith:" + Thread.CurrentThread.ManagedThreadId); }); } public async Task RunAsync() { return await Task.FromResult(1 + 1); } ☑️应该使用await来代替continueWith private async void button1_Click(object sender, EventArgs e) { Debug.WriteLine("UI线程:" + Thread.CurrentThread.ManagedThreadId); Debug.WriteLine("RunAsync returned:"+ await RunAsync()); Debug.WriteLine("UI线程:" + Thread.CurrentThread.ManagedThreadId); } public async Task RunAsync() { return await Task.FromResult(1 + 1); } 6.创建TaskCompletionSource 对于编写类库的人来说TaskCompletionSource是一个具有非常重要的作用,默认情况下任务延续可能会在调用try/set(Result/Exception/Cancel)的线程上进行运行,这也就是说作为编写类库的人来说必须需要考虑上下文,这通常是非常危险,可能就会导致死锁' 线程池饥饿 *数据结构损坏(如果代码异常运行) 所以在创建TaskCompletionSourece时,应该使用TaskCreationOption.RunContinuationAsyncchronously参数将后续任务交给线程池进行处理 ❌下面例子就没有使用TaskCreationOptions.RunComtinuationsAsynchronously, static void Main(string[] args) { ThreadPool.SetMinThreads(100, 100); Console.WriteLine("Main CurrentManagedThreadId:" + Environment.CurrentManagedThreadId); var tcs = new TaskCompletionSource(); // 使用TaskContinuationOptions.ExecuteSynchronously来测试延续任务 ContinueWith(1, tcs.Task); // 测试await延续任务 ContinueAsync(2, tcs.Task); Task.Run(() => { Console.WriteLine("Task Run CurrentManagedThreadId:" + Environment.CurrentManagedThreadId ); tcs.TrySetResult(true); }); Console.ReadLine(); } static void print(int id) => Console.WriteLine($"continuation:{id}\tCurrentManagedThread:{Environment.CurrentManagedThreadId}"); static async Task ContinueAsync(int id, Task task) { await task.ConfigureAwait(false); print(id); } static Task ContinueWith(int id, Task task) { return task.ContinueWith( t => print(id), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } ☑️所以应该改为使用TaskCreationOptions.RunComtinuationsAsynchronously参数进行设置TaskCompletionSoure static void Main(string[] args) { ThreadPool.SetMinThreads(100, 100); Console.WriteLine("Main CurrentManagedThreadId:" + Environment.CurrentManagedThreadId); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // 使用TaskContinuationOptions.ExecuteSynchronously来测试延续任务 ContinueWith(1, tcs.Task); // 测试await延续任务 ContinueAsync(2, tcs.Task); Task.Run(() => { Console.WriteLine("Task Run CurrentManagedThreadId:" + Environment.CurrentManagedThreadId); tcs.TrySetResult(true); }); Console.ReadLine(); } static void print(int id) => Console.WriteLine($"continuation:{id}\tCurrentManagedThread:{Environment.CurrentManagedThreadId}"); static async Task ContinueAsync(int id, Task task) { await task.ConfigureAwait(false); print(id); } static Task ContinueWith(int id, Task task) { return task.ContinueWith( t => print(id), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } 🔔TaskCreationOptions.RunContinuationsAsynchronously属性和TaskContinuationOptions.RunContinuationsAsynchronously很相似,但请注意它们的使用方式 7.建议使用CancellationTokenSource(s)进行超时管理时总是释放(dispose) 用于进行超时的CancellationTokenSources,如果不释放,则会增加timer queue(计时器队列)的压力 ❌下面例子因为没有释放,所以在每次请求发出之后,计时器在队列中停留10秒钟 public async Task HttpClientAsyncWithCancellationBad() { var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); using (var client = _httpClientFactory.CreateClient()) { var response = await client.GetAsync("http://backend/api/1", cts.Token); return await response.Content.ReadAsStreamAsync(); } } ☑️所以应该及时的释放CancellationSoure,使得正确的从队列中删除计时器 public async Task HttpClientAsyncWithCancellationGood() { using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10))) { using (var client = _httpClientFactory.CreateClient()) { var response = await client.GetAsync("http://backend/api/1", cts.Token); return await response.Content.ReadAsStreamAsync(); } } } 🔔设置延迟时间具有两种方式 1.构造器参数 public CancellationTokenSource(TimeSpan delay); public CancellationTokenSource(int millisecondsDelay); 2.调用实例对象CancelAfter() public void CancelAfter(TimeSpan delay); public void CancelAfter(int millisecondsDelay); 8.建议将协作式取消对象(CancellationToken)传递给所有使用到的API 由于在.NET中取消操作必须显示的传递CancellationToken,所以如果想取消所有调用的异步函数,那么应该将CancllationToken传递给此调用链中的所有函数 ❌下面例子在调用ReadAsync时并没有传递CancellationToken,所以不能有效的取消 public async Task DoAsyncThing(CancellationToken cancellationToken = default) { byte[] buffer = new byte[1024]; // 使用FileOptions.Asynchronous参数指定异步通信 using(Stream stream = new FileStream( @"d:\资料\Blogs\Task\TaskTest", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, 1024, options:FileOptions.Asynchronous)) { // 由于并没有将cancellationToken传递给ReadAsync,所以无法进行有效的取消 int read = await stream.ReadAsync(buffer, 0, buffer.Length); return Encoding.UTF8.GetString(buffer, 0, read); } } ☑️所以应该将CancellationToken传递给ReadAsync(),以达到有效的取消 public async Task DoAsyncThing(CancellationToken cancellationToken = default) { byte[] buffer = new byte[1024]; // 使用FileOptions.Asynchronous参数指定异步通信 using(Stream stream = new FileStream( @"d:\资料\Blogs\Task\TaskTest", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, 1024, options:FileOptions.Asynchronous)) { // 由于并没有将cancellationToken传递给ReadAsync,所以无法进行有效的取消 int read = await stream.ReadAsync(buffer, 0, buffer.Length,cancellationToken); return Encoding.UTF8.GetString(buffer, 0, read); } } 🔔在使用异步IO时,应该将options参数设置为FileOptions.Asynchronous,否则会产生额外的线程浪费,详细信息请参考CLR中28.12节 9.建议取消那些不会自动取消的操作(CancellationTokenRegistry,timer) 在异步编程时出现了一种模式cancelling an uncancellable operation,这个用于取消像CancellationTokenRegistry和timer这样的东西,通常是在被取消或超时时创建另外一个线程进行操作,然后使用Task.WhenAny进行判断是完成还是被取消了 使用CancellationToken :x: 下面例子使用了Task.delay(-1,token)创建在触发CancellationToken时触发的任务,但是如果CancellationToken不触发,则没有办法释放CancellationTokenRegistry,就有可能会导致内存泄露 public static async Task WithCancellation(this Task task, CancellationToken cancellationToken) { // 没有方法释放cancellationToken注册 var delayTask = Task.Delay(-1, cancellationToken); var resultTask = await Task.WhenAny(task, delayTask); if (resultTask == delayTask) { // 取消异步操作 throw new OperationCanceledException(); } return await task; } :ballot_box_with_check:所以应该改成下面这样,在任务一完成,就释放CancellationTokenRegistry public static async Task WithCancellation(this Task task, CancellationToken cancellationToken) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using (cancellationToken.Register(state => { // 这样将在其中一个任务触发时立即释放CancellationTokenRegistry ((TaskCompletionSource)state).TrySetResult(null); }, tcs)) { var resultTask = await Task.WhenAny(task, tcs.Task); if (resultTask == tcs.Task) { // 取消异步操作 throw new OperationCanceledException(cancellationToken); } return await task; } } 使用超时任务 :x:下面这个例子即使在操作完成之后,也不会取消定时器,这也就是说最终会在计时器队列中产生大量的计时器,从而浪费性能 pub
50000+
5万行代码练就真实本领
17年
创办于2008年老牌培训机构
1000+
合作企业
98%
就业率

联系我们

电话咨询

0532-85025005

扫码添加微信