async/await使用深入详解

C#

浏览数:158

2019-5-12

AD:资源代下载服务

async和await作为异步模型代码编写的语法糖已经提供了一段时间不过一直没怎么用,由于最近需要在BeetleX webapi中集成对Task方法的支持,所以对async和await有了深入的了解和实践应用.在这总结一下async和await的使用,主要涉及到:自定义Awaitable,在传统异步方法中集成Task,异常处理等.

介绍

在传统异步方法处理都是通过指定回调函数的方式来进行处理,这样对于业务整非常不方便.毕竟业务信息和状态往往涉及到多个异步回调,这样业务实现和调试成本都非常高.为了解决这一问题dotnet推出了async和await语法糖,该语法可以把编写的代码编译成状态机模式,从而让开发员以同步的代码方式实现异步功能的应用.

应用

async和await的使用非常简单,只需要在方法前加上async关键字,然后await所有返回值为Task或ValueTask的方法即可.大概应用如下:

        async void AccessTheWebAsync()
        {
            var client = new HttpClient();
            var result = await client.GetStringAsync("https://msdn.microsoft.com");
            Console.WriteLine(result);
        }

以上是HttpClient的一个简单应用,它和传统的同步调用有什么不同呢?如果用同步GetString那线程回等待网络请求完成后再进行输出,这样会导致线程资源一直浪费在那里.使用await后,当线程执行GetStringAsync后就会释放出来,然后由网络回调线程来触发后面的代码执行.当然还有一种情况就是GetStringAsync同步完成了当线程就会马上执行Console.WriteLine(result);其实不管那一种情况下都不会让线程等待在那里浪费资源.

自定义Awaitable

一般情况下async和await都是结合Task来使用,因此可能有人感觉async和await是因Task而存在的;其实async和await是一个语法糖,通过它和相应的代码规则来让编译器知道怎样做,但这个规则并不是Task;正确的来说Task是这规则的一种实现,然后应用在大量的方法上,所以自然就使用起来就最普遍了.如果感觉Task太繁琐使用起来比较重的情况下是完全可以自己实现这个规则,这一规则实现起来也很简单只需要简单地实现一个接口和定义一些方法即可:

    public interface INotifyCompletion
    {
        void OnCompleted(Action continuation);
    }

看上去是不是很简单,不过除了实现这一接口外,还需要定义一些固定名称的方法

    public interface IAwaitCompletion : INotifyCompletion
    {

        bool IsCompleted { get; }

        void Success(object data);

        void Error(Exception error);

    }

    public interface IAwaitObject : IAwaitCompletion
    {

        IAwaitObject GetAwaiter();

        object GetResult();

    }

在基础上再定义一下些行为就可以了,以上IAwaitObject就是实现一个Awaitable所需要的基础方法行为.不过Success和’Error’方法不是必需要.只是通过这些方法可以让外部来触发OnCompleted行为而已. 围绕接口实现Awaitable的方式也可以根据实际情况应用有所不同,只要需要确保基础规则实现即可,以下是针对SocketAsyncEventArgs实现的Awaitable

    public class SocketAwaitableEventArgs : SocketAsyncEventArgs, ICriticalNotifyCompletion
    {
        private static readonly Action _callbackCompleted = () => { };

        private readonly PipeScheduler _ioScheduler;

        private Action _callback;

        public SocketAwaitableEventArgs(PipeScheduler ioScheduler)
        {
            _ioScheduler = ioScheduler;
        }

        public SocketAwaitableEventArgs GetAwaiter() => this;

        public bool IsCompleted => ReferenceEquals(_callback, _callbackCompleted);

        public int GetResult()
        {
            Debug.Assert(ReferenceEquals(_callback, _callbackCompleted));

            _callback = null;

            if (SocketError != SocketError.Success)
            {
                ThrowSocketException(SocketError);
            }

            return BytesTransferred;

            void ThrowSocketException(SocketError e)
            {
                throw new SocketException((int)e);
            }
        }

        public void OnCompleted(Action continuation)
        {
            if (ReferenceEquals(_callback, _callbackCompleted) ||
                ReferenceEquals(Interlocked.CompareExchange(ref _callback, continuation, null), _callbackCompleted))
            {
                Task.Run(continuation);
            }
        }

        public void UnsafeOnCompleted(Action continuation)
        {
            OnCompleted(continuation);
        }

        public void Complete()
        {
            OnCompleted(this);
        }

        protected override void OnCompleted(SocketAsyncEventArgs _)
        {
            var continuation = Interlocked.Exchange(ref _callback, _callbackCompleted);

            if (continuation != null)
            {
                _ioScheduler.Schedule(state => ((Action)state)(), continuation);
            }
        }
    }

以上是Kestrel内部实现的一个Awaitable,它的好处就是可以自己不停地复用,并不需要每次await都要构建一个Task对象.这样对于大量处理的情况下可以降低对象的开销减轻GC的负担来提高性能.

传统异步下实现async/await

其实自定义Awaitable就是一种传统异步使用async/await功能的一种实现,但对于普通开发人员来说对于状态不好控制的情况那实现这个Awaitable多多少少有些困难,毕竟还需要大量的测试工作来验证.其实dotnet已经提供TaskCompletionSource<T>对象来方便应用开发者在传统异步下简单实现async/await.这个对象使用起来也非常方便

        public Task<Response> Execute()
        {
            TaskCompletionSource<Response> taskCompletionSource = new TaskCompletionSource<Response>();
            OnExecute(taskCompletionSource);
            return taskCompletionSource.Task;
        }

构建一个TaskCompletionSource<T>对象返回对应的Task即可,然后在异步完成的地方调用相关方法即可简单实现传统异步支持async/await

taskCompletionSource.TrySetResult(response)

taskCompletionSource.TrySetError(exception)

在这里不得不说一下TaskCompletionSource<T>的设计,非要加个泛型.如果结合反射使用就有点蛋碎了,毕竟这个方法并不提供object设置,除非上层定义TaskCompletionSource<Object>但这样定义就失去了T的意义了….还好这个类可继承的给使用者留了一个后路.以下做了简单的封装让它支持object返回值传入

    interface IAnyCompletionSource
    {
        void Success(object data);
        void Error(Exception error);
        void WaitResponse(Task<Response> task);
        Task GetTask();
    }

    class AnyCompletionSource<T> : TaskCompletionSource<T>, IAnyCompletionSource
    {
        public void Success(object data)
        {
            TrySetResult((T)data);
        }

        public void Error(Exception error)
        {
            TrySetException(error);
        }

        public async void WaitResponse(Task<Response> task)
        {
            var response = await task;
            if (response.Exception != null)
                Error(response.Exception);
            else
                Success(response.Body);
        }

        public Task GetTask()
        {
            return this.Task;
        }
    }

异常处理

由于async/await最终编译成状态机代码,所以异常处理会和普通代码不同,一连串的async/await方法里,一般只需要在最顶的断层方法Try即可,一般这个断层的方法是async void,或Task.wait处;和传统方法异常处理不一样,如果再往上一层是无法Try住这些异常的,当现现这情况的时候往往就是未知异常导致程序死掉.以下是一个错误的处理代码:

        static void Main(string[] args)
        {
            try
            {
                Test();
            }
            catch (Exception e_)
            {
                Console.WriteLine(e_);
            }
            Console.Read();
        }

        static async void Test()
        {
            Console.WriteLine(await PrintValue());
        }

        static async Task<bool> PrintValue()
        {
            var value = await GetUrl();
            Console.WriteLine(value);
            return true;
        }

        static async Task<string> GetUrl()
        {
            var client = new HttpClient();
            return await client.GetStringAsync("https://msdn.microsoft.comasd");
        }

正确有效的Try地方是在Test方法里

        static async void Test()
        {
            try
            {
                Console.WriteLine(await PrintValue());
            }
            catch (Exception e_)
            {
                Console.WriteLine(e_);
            }
        }

        static async Task<bool> PrintValue()
        {
            var value = await GetUrl();
            Console.WriteLine(value);
            return true;
        }

        static async Task<string> GetUrl()
        {
            var client = new HttpClient();
            return await client.GetStringAsync("https://msdn.microsoft.comasd");
        }

一些注意事项和技巧

  1. 自定义async/await时候,默认都是由异步完成线程来触发状态机,但这里存在一个风险当这个触发状态机的代码是在锁范围内执行就需要特别小心,很多时候再次回归执行获取锁的时候就导致无法得到引起代码无法执行的问题.
  2. 在使用的await之前其实是可以先判断一下完成状态,如果是完成就没有必然引用await来处理状态机的工作,这样一定程度降低状态的执行和开销.
  3. 如果你的方法可以是同步完成,如一些内存操作那最好用ValueTask代替Task
  4. 其实反射里使用async/await也是非常方便的,只需要判断一下对象是否Awaitable,如果是就执行await处理状态机.

作者:smark