写在前面

与C/C++所使用的,传统的基于线程的并行模式不同,C#实现了丰富的并发编程模型,其中以异步模型最为流行。

本文中我们重点讨论C#在发展过程中出现的几种异步编程模型:

  • Async Programming Model(APM)
  • Event-based Async Pattern (EAP)
  • Task-based Async Pattern(TAP)
  • async/await语法糖

异步编程入门

同步模式是最常见,也是最被人熟知的编程模型,每一个任务按顺序执行,前一个任务执行完之后才会执行下一个任务。

异步编程和同步编程不同,程序的执行流程是由“事件”所驱动的。异步编程有两种实现方式,回调与future模式。

回调函数在Javascript中被大量使用,相信大家也都不会陌生。但是大量的回调函数会让代码失去可读性,陷入“Callback hell”。

Promise模式是回调函数的一种“包装”。我们使用一个占位符来表示“未来”将会产生的一个异步处理结果。

这个占位符在不同的语言/框架里面有不同的名字,其定义也不尽相同:

  • Task - C#
  • Deferred - Python Twisted
  • Promise - Javascript

在任务结束后,会触发绑定在这个占位符上定义的回调函数,继续预定义好的逻辑。

举个例子,同步模型就是你:

宅家想吃饭 -> 下楼买饭 -> 上楼 -> 吃饭 -> 打游戏看漂亮小姐姐。

而异步模型呢,就是:

宅家想吃饭 -> 手机叫外卖 -> 拿到了外卖定单(拿到占位符 或 注册回调)-> 打游戏看漂亮小姐姐 -> 外卖小哥把饭送上门(启动回调) -> 吃饭 -> 继续打游戏看漂亮小姐姐。

虽然从上面看,异步模型比同步模型要复杂一些。但是它却节省了耗时的“上下楼买饭”的时间,让你可以分配更多的时间用来看漂亮小姐姐。这和我们写程序时的思路是一致的,节省动辄十几几十毫秒耗时的IO时间,将更多的时间用在CPU上。

Thread Based Parallel

基于线程的并发模型是比较传统的并发模型了,基本上所有的现代编程语言都会支持。C#中Thread的用法与Java类似,这里就不做展开。

与此同时,C#还支持Thread pool,用来运行"long-running processor-bound tasks"。

直接操作线程也许是中年程序员的必修课,但是手动管理线程会给程序带来额外的负担。所以各种模型与框架应运而生,试图降低并发编程的复杂度。

Async Programming Model (APM)

在我们熟悉的async/await语法糖出现之前,C#中使用APM来表示异步操作。虽然这是一种上古时期的回调模式语法,但是现在有很多库仍旧支持这种风格。如Azure Storage SDK中的CloudTable.BeginExecute等一系列函数。

下面是一个简单的使用APM模式的代码范例:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Remoting.Messaging;
using System.Runtime.Remoting.Proxies;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApplication6
{
    class Program
    {
        public delegate int AsyncInvoke();

        static void Main(string[] args)
        {
            var SI = new AsyncInvoke(MyCall);
            var ar = SI.BeginInvoke(MyCallback, null);

            Console.WriteLine("Main()");
            while (!ar.AsyncWaitHandle.WaitOne(1000))
            {
                Console.WriteLine("Main() waiting...");
            }
            Console.WriteLine("Main() done");

            Console.ReadLine();
        }

        static int MyCall()
        {
            for (int i = 0; i < 5; i++)
            {
                Console.WriteLine("running...");
                Thread.Sleep(500);
            }
            Console.WriteLine("done");
            return 42;
        }

        static void MyCallback(IAsyncResult iResult)
        {
            var result = iResult as AsyncResult;
            var si = (AsyncInvoke) result.AsyncDelegate;
            int ret = si.EndInvoke(result);
            Console.WriteLine(ret);
        }
    }
}

运行结果如下:

我们可以看到: 1. 我们将MyCall()“包装”在一个delegate中,然后调用BeginInvoke函数实现异步执行。这个delegate的执行不会阻塞main thread。 2. 一个异步执行的delegate可以有一个回调,这个回调在delegate执行完后被触发。 3. 我们可以"long-polling"等待一个异步调用执行完。这里的“执行完”不包括条目2提到的回调(小心race condition!)。 4. 异步执行的结果可以通过delegate.EndInvoke(IAsyncResult)函数获取到异步调用的结果。 5. 由条目3我们可以知道,在callback函数中获取异步调用的结果是最合适的。

所以APM风格的代码写起来非常像javascript中的回调写法,如果逻辑复杂的话,维护起来会是一个大坑。

Event Asynchronous Pattern (EAP)

EAP是回调函数的另一种封装。

我们来看下面的代码:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApplication2
{
    class StringEventArgs : EventArgs
    {
        public string Content { get; set; }
    }

    class Solution
    {
        private event EventHandler<StringEventArgs> _eventHandler;

        public Solution()
        {
            _eventHandler += Handle1;
            _eventHandler += Handle2;
        }

        public void Run()
        {
            _eventHandler?.Invoke(this, new StringEventArgs()
            {
                Content = "123"
            });
        }

        void Handle1(object sender, StringEventArgs args)
        {
            Console.WriteLine("Handle1 " + args.Content);
            Thread.Sleep(1000);
        }
        void Handle2(object sender, StringEventArgs args)
        {
            Console.WriteLine("Handle2 " + args.Content);
            Thread.Sleep(1000);
        }
    }
    class Program
    {
        static void Main(string[] args)
        {
            Solution sol = new Solution();
            sol.Run();
            Console.WriteLine("Done");
            Console.ReadLine();
        }
    }
}

运行结果如下:

我们可以看出,所有的函数都执行在同一个线程上。并且Handle1Handle2顺序执行。

在实际工程中,我们fire event的代码可以在不同的线程,之后EventHandler,也就是callback函数会被调用。

Event同时支持BeginInvokeEndInvoke函数,也就意味着我们可以异步的fire相应的回调。但是注意此时我们只能注册唯一的回调,因为BeginInvoke只能有一个目标回调(原理:在同一时间同一线程只能有一个函数调用)。

Task Asynchronous Pattern (TAP)

在.NET 4.0(现在已经到6.0了哦),C#引入了TPL,Task Parallel Library。Task的目标是统一C#中的不同异步编程风格。

TPL中的Task非常像JS中的promise和twisted中的deferred,是“未来会完成的操作的结果”的占位符。

我们来看一段简单代码:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApplication3
{
    class Program
    {
        static void Main(string[] args)
        {
            Task t = Task.Run(() =>
            {
                Console.WriteLine("Task start");
                Thread.Sleep(2000);
                Console.WriteLine("Task end");
            });

            while (!t.IsCompleted)
            {
                Console.WriteLine("Main(): Task is running...");
                Thread.Sleep(1000);
            }

            Console.WriteLine("Done");
            Console.ReadLine();
        }
    }
}

这段代码我们前面提到的APM和EAP风格的代码有明显的不同,TAP更易读,并且保持了控制流的完整性。不像APM需要在EndInvoke函数中获取返回值以及进行后续操作,也不像EAP一样需要根据不同的event声明不同的回调。

Task也可能“串起来”,实现多级回调的机制。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ConsoleApplication4
{
    class Program
    {
        static void Main(string[] args)
        {
            var t0 = Task.Run(() => 0);
            var t1 = t0.ContinueWith((antecedent) =>
            {
                Console.WriteLine(antecedent.Result);
                return antecedent.Result + 1;
            });
            var t2 = t1.ContinueWith((antecedent) =>
            {
                Console.WriteLine(antecedent.Result);
                return antecedent.Result + 1;
            });
            var t3 = t2.ContinueWith((antecedent) =>
            {
                Console.WriteLine(antecedent.Result);
                return antecedent.Result + 1;
            });

            Console.ReadLine();
        }
    }
}

当然我们还可以把回调写成一棵树状结构,然后一层一层的执行,不过生命是如此宝贵,我们并没有充分的理由要这么做。

async/await语法糖

async/await语法糖在C# 5.0中被引入,其目的是为了避免回调带来的代码复杂度。就像Twisted中的@inlineCallbacks一样,省去了defer复杂的回调链。

await中有一个"wait",说明这是一个“等待”操作。它等待的是相应的Task执行完成。

我们来看一段代码:

private async void DumpWebPageAsync(string uri)
{
    WebClient webClient = new WebClient();
    string page = await webClient.DownloadStringTaskAsync(uri);
    Console.WriteLine(page);
}

当代码执行到await一行时,当前函数会主动放弃当前的控制流。当使用await修饰的Task完成后,当前函数会从之前中断的地方继续执行。

这样做的好处是我们可以写出和同步版本非常相似的异步代码,只需要在必须的地方加上await关键字,提醒编译器这里是一个异步函数,需要额外的处理逻辑,但这一切都是对开发者透明的。

async/await干了什么

想弄清楚async/await到底干了什么,首先我们要想明白线程到底是什么、干了什么。

线程是进程内一条执行流的状态,其中包括了硬件状态(IP、Registers等)以及堆栈(栈上的局部变量和堆上的进程级内存)。那么如果我们想实现挂起/启动(Hibernating and Resuming),那么我们就要有一个机制来保存当前线程的运行状态。

所以当你写下了async/await关键字后,编译器在后面帮助你生成了状态保存和恢复运行上下文的代码。

async/await到底干了什么

想像我们有一个复杂的async函数,里面有很多个await调用,那就意味着这个函数中会有多次挂起/继续操作。同时我们还要维护这个函数的状态。如果我们是这个语法糖的设计者,我们会选择怎么样的手段来处理这个问题呢?

是的,状态机。async/await避免了代码的碎片化,它的解决方案并不是消灭了回调函数和Continuation Tasks,而是使用工具(编译器)来帮助人类进行重复劳动。当async函数从挂起中恢复时,会调用MoveNext函数(相信看过async函数那一长串的traceback的同学肯定对这个函数非常眼熟),MoveNext函数会在async函数第一次被调用以及从挂起中恢复时被调用。状态机保存了当前函数的执行状态,当MoveNext函数被调用时,会根据当前状态来判断接下来执行什么代码。

asycn/await到底TMD干了什么

由于async/awaic语法糖是在编译期才被翻译成相应的程序代码,所以我们只能使用IL反编译器来窥探编译器到底做了怎样的处理与优化。不过反编译器你懂得,生成的代码基本没法看,讲解起来也会非常晦涩。

幸好在99%的情况下,我们并不需要知道async/await是怎样被展开的。如果你确实对这个问题感兴趣,可以参考这篇文章:Async Await and the Generated StateMachine

几个常见的坑

await 与 锁

由于await会中断当前函数在当前线程的执行流,并且可能在恢复时,被指派到另外的线程。所以对await加锁明显是多此一举的。并且如果操作不当,还会造成死锁。

所以我们应该把await放到加锁的区域外。

lock (sync)
{    
    // Prepare for async operation
}

int myNum = await AlexsMethodAsync();

lock (sync)
{    
    // Use result of async operation
}

"There is an ... unfired Task between us"

一个async函数如果返回的是Task,那么它返回的一定是一个hot task,即已经被启动了的Task。

并且await只可能等待一个启动了的Task,否则await操作将会hang住,破坏程序既定的执行流。

使用TPL (Task parallel library)

Async methods are synchronous util needed.

如果我们想同时执行几个异步操作,使用for来遍历执行可不是一个好主意。因为这样函数执行流仍然是顺序执行相应的函数。

TPL提供了WhenAllWhenAny等函数,让我们可以有弹性的并发执行Task。

当然我们还可以使用PLINQ,不过这就是另外一个话题了。

参考链接


Comments

comments powered by Disqus