查阅 MSDN 异步编程以了解更多

异步编程

在 C# 中,异步编程是一种编程范式,它允许程序异步执行任务,而不会阻塞主线程或其他任务的执行。在异步程序中,程序可以继续执行其他任务,而无需等待一个任务完成

优势

异步编程可以为需要执行 I/O 操作或其他耗时任务的应用程序提供显著的优势,例如访问数据库、下载文件或执行网络通信。通过使用异步编程,应用程序可以提高性能、响应性、可扩展性和资源效率,同时简化代码并提高容错性

误解

  1. 异步编程总是更快:虽然异步编程可以提高性能,但它并不能保证在所有情况下都能提供更快的执行速度,如果一个应用程序没有大量的 I/O 操作或耗时任务,使用异步编程可能不会提供任何性能优势,甚至可能引入不必要的复杂性

  2. 异步编程总是优于同步编程:虽然异步编程可以提高应用程序的可扩展性和响应性,但它可能并不总是最好的解决方案。异步编程可能会引入额外的复杂性,而且对于所有的用例可能都不是必需需要的

  3. 异步编程可以完全消除对线程的需求:虽然异步编程可以减少显式线程的需求,但它并不能完全消除它。异步编程仍然依赖于底层线程和线程池在后台执行任务

还有并不能完全消除锁和同步的需求、或认为异步容易调试等等

关键字

asyncawait

目标

支持读起来像一连串语句的代码,但会根据外部资源分配和任务完成时间以更复杂的顺序执行

内容

async

用以标记函数的关键字,例如:

public async Task TestAsync(){ }

但仅这还不是真正的异步函数

await

必须在 async 标记下的函数内使用的关键字,例如:

public async Task TestAsync()
{
	await Task.delay(1000);
	Console.WriteLine("TestDone");
}

asyncawait 一起使用才构成了异步函数

如此例所示,await 关键字将暂停 TestAsync 方法的执行,并返回不完整的 Task,在此期间,线程将返回到线程池,以便自己可用于另一个请求,await 完成后将恢复继续向下执行

但是,如何知道 await 已经完成呢?这里就需要用到 Task,可先了解一下此站另一个 blog - 线程

总结

  • 该操作不会在线程池线程运行

  • 如果仅使用 async 关键字,那么函数内容仍会以同步方式进行

  • 一般异步函数命名的最后结尾都是 Async

  • 帮助我们从异步操作中提取结果

  • 验证操作成功

  • 提供在异步方法中执行其余代码的延续

Task

Task 抽象类是在 C# 5.0 (.NET Framwork 4.0) 引出的概念

大多数情况下,都是返回 Task 或者 Task<T>,极少数情况会返回 void

因为 Task 返回类型可提供更加丰富的监视内容和可等待内容结果,而 void 没有这些,仅在异步事件处理程序使用 void 返回类型

优势

  1. 抽象类允许我们轻松的使用线程池和本地线程,且绝大多数 Task 都在属于线程池的后台线程中执行

  2. 异步操作的状态管理:Task 对象跟踪异步操作的状态,包括是否已经完成、是否已经取消、是否已经出错等等,开发人员可以通过访问 Task 的属性和方法来获取和操作异步操作的状态信息

  3. 异步操作的结果返回:Task 对象代表异步操作的最终结果,当异步操作完成时,它可以返回异步操作的结果或者抛出异常。异步方法的返回值通常是一个 Task 对象或者 Task<T> 对象,开发人员可以通过访问 Task 对象的 Result 属性或者使用 await 关键字来获取异步操作的结果

  4. 异步操作的协调和组合:Task 对象可以通过多种方式来协调和组合异步操作,例如使用 Task.WhenAllTask.WhenAny 方法来等待多个异步操作的完成或者组合多个异步操作的结果

  5. 并行和异步编程的支持:Task 对象可以帮助开发人员实现并行和异步编程模式,使用异步方法和异步操作可以提高应用程序的性能和响应性,避免阻塞 UI 线程或者阻塞 CPU

Task 对象不是线程,也不会创建线程,它只是代表了异步操作的状态和行为。异步操作的实际执行可能在线程池线程、IO 线程、UI 线程或者其它线程中执行,具体取决于异步操作的实现方式和上下文

比较

例一

public static void Main()  
{  
    double i = 0;  
    var mres = new ManualResetEventSlim(false);  
    ThreadPool.QueueUserWorkItem<ManualResetEventSlim>((_msg) =>   
    {   
		i = Math.Exp(1);   
        mres.Set();  
    }, mres, false);  
    
    mres.Wait();  
    Console.WriteLine(i);  

	// output:2.71828182845
}

上面这个例子首先创建了 ManualResetEventSlim API 与其中的 SetWait 方法来控制线程池运行,再创建线程池执行内容

现在,我们可以用如下 Task 来简化操作

public static void Main()  
{  
    var task = Task.Run(() => Math.Exp(1));  
    Console.WriteLine(task.Result);  
}

// output:2.71828182845

与用前一个 ThredPool 类方法相比,Task 省去了创建变量和控制线程发生的过程,且用前个方法不能返回类型,而此方法在这个例子中,返回了 Task<double> 类型(具有 Result 属性)

例二

in BaseClass.cs

public class CarBuilding  
{  
    private static Body BuildBody(int weight, int length, int width) => new Body(weight, length, width);  
    private static Engine BuildEngine(int horsePower) => new Engine(horsePower);  
    private static Suspension BuildSuspension(int supportedKg) => new Suspension(supportedKg);  
    private static Painting Paint(string color, int bodyArea) => new Painting(color, bodyArea);  

	private static void Test(Body body, IEnumerable<Suspension> suspensions, Engine engine)  
	{  
	    if (suspensions.Sum(s => s.SupportedKg) <= body.Weight || engine.Horsepower * 4 <= body.Weight)  
	        throw new ArgumentException("The car weighs too much");  
	}
    
    public static void Main()  
    {        
	    
    }  
}

public class Body  
{  
    public Body(int weight, int length, int width)  
    {        
	    Weight = weight;  
        Length = length;  
        Width = weight;  
        
        Console.WriteLine($"{Weight} and {Length} and {Width}");  
    }   
     
    public int Weight { get; set; }  
    public int Length { get; set; }  
    public int Width { get; set; }  
}  
  
public class Engine  
{  
    public Engine(int horsePower)  
    {        
	    Horsepower = horsePower;  
    }   
     
    public int Horsepower { get; set; }  
}  
  
public class Suspension  
{  
    public Suspension(int supportedKg)  
    {        
	    SupportedKg = supportedKg;  
	    
		Console.WriteLine(SupportedKg);
    } 
       
    public int SupportedKg { get; set; }  
}  
  
public class Painting  
{  
    public Painting(string color, int bodyArea)  
    {        
	    Color = color;  
        BodyArea = bodyArea;  
        
        Console.WriteLine($"{Color} and {BodyArea}");  
    }  
    
    public string Color { get; set; }  
    public int BodyArea { get; set; }  
}

Main 函数中添加以下内容:

public static void Main()  
{  
    Body body = null!;  
    var bodyThread = new Thread(() =>  
    {  
        body = BuildBody(100, 5, 2);  
    });    
    bodyThread.Start();  // Thread 方法
    
    var bodyTask = Task.Run(() => BuildBody(50,1,1));  // Task 方法

	// output: 
	// 100 and 5 and 100
	// 50 and 1 and 50
}

这里两个方法有着区别,Task 在这更具优势,上文也有提到(简洁、具有返回值)

继续往 Main 函数添加内容

public static void Main()  
{  
    Body body = null!;  
    var bodyThread = new Thread(() =>  
    {  
        body = BuildBody(100, 5, 2);  
    });    
    bodyThread.Start();  
    
    bodyThread.Join();  
    Painting painting;  
    var paintingThread = new Thread(() =>  
    {  
        painting = new Painting("red", body.Width * body.Length);  
    });}
    
	paintingThread.Start();
	paintingThread.Join();

	// output:
	// 100 and 5 and 100
	// red and 500

上面内容用的 Thread 写法写出两个子线程运行的方法,下面写一个 Task 写法

public static void Main()  
{  
    var bodyTask = Task.Run(() => BuildBody(50, 5, 2));  
    var paintingTask = bodyTask.ContinueWith(  
        task => Paint("red", task.Result.Width * task.Result.Length)  
    );
}

// output:
// 50 and 5 and 50
// red and 250

比较一下,用 Task 方法显而易见的简洁了,其中采用了链式编程,使用了 ContinueWith 方法,意思是执行 bodyTask 线程池,并开始函数内部委托的执行,使任务之间的依赖性清晰且定义明确

而且 ContinueWith 方法也提供第二个参数,如写法:task => Paint("red", task.Result.Width * task.Result.Length),TaskContinuationOptions.OnlyOnRanToCompletion,第二个参数就表明前一个线程池必须完成后才执行此委托

在 Main 函数中添加以下内容:

Thread 多个线程创建并执行写法

using System.Collections.Concurrent;
------------------------------------------------

public static void Main()  
{  
    var suspensions = new ConcurrentBag<Suspension>();  
    var suspensionThreads = Enumerable  
        .Range(0, 10)  
        .Select(i =>  
        {  
            var t = new Thread(() =>  
            {  
                suspensions.Add(BuildSuspension(40));  
            });            
            t.Start();  
            return t;  
        });  
        
    foreach (var suspThread in suspensionThreads)  
        suspThread.Join();  

	// output:40 40 40 ... 40 40 (10 个 40)
}

ConcurrentBag:表示线程安全、无序的对象集合

这个 API 就是为了解决多个线程池的性能问题

而用 Task 创建则无需此步骤,如下

public static void Main()  
{  
    var suspensionTasks = Enumerable  
        .Range(0, 10)  
        .Select(i => Task.Run(() => BuildSuspension(40)));  
    var suspensionsTask = Task.WhenAll(suspensionTasks)  
        .ContinueWith(task => task.Result.ToList());  
        
    // output:40 40 40 ... 40 40 (10 个 40)
}

同样省略了很多步骤,在里面使用了 WhenAll 方法,表明执行完所有内容并返回一个 Task<T> 类型

也可进一步优化为:Task.Factory.ContinueWhenAll(suspensionTasks, tasks => tasks.ToList());

Main 函数添加以下内容:

Thread 表示线程抛出异常

public static void Main()  
{  
    Exception? thrownException = null;
      
    var body = new Body(1,1,1);  
    IEnumerable<Suspension> suspensions = new Suspension[1];  
    var engine = new Engine(1);  
    
    var testing = new Thread(() =>  
    {  
        try  
        {  
            Test(body, suspensions, engine);  
        }        
        catch (Exception exc)   
        {   
            thrownException = exc;  
        }    
        });    
        testing.Start();  
  
    testing.Join();  
    if (thrownException is not null)  
        throw thrownException;  
}

只有当线程抛出异常,才会使 thrownException 有值,没有异常则是 null

Tasks 方法同样有内置工具检测异常,如下

public static void Main()  
{  
    var body = new Body(1,1,1);    
	IEnumerable<Suspension> suspensions = new Suspension[1];    
	var engine = new Engine(1);   
	try   
	{  
        var testingTask = Task.Run(  
            () => Test(body, suspensions,engine)  
        );    
    }    
    catch (AggregateException exc)  
    {        
	    throw exc.InnerExceptions[0];  
    }
}

在这种情况下,我们只重新抛出第一个内部异常,但我们也可以使用AgggregateException.Handle() 方法处理它

TAP

Task-based Asynchronous Pattern 基于 Task的异步编程模型

了解任务代表异步方法的执行而不是结果非常重要,Task 有几个属性,指示操作是否成功完成(状态、已完成、取消、错误)

死锁

一般出现在 UI 或 ASP.NET 程序中

内容

static object lock1 = new object();  
static object lock2 = new object();  
  
public static void Main()  
{  
    Thread t1 = new Thread(DoWork1);  
    Thread t2 = new Thread(DoWork2);  
    t1.Start();  
    t2.Start();  
    t1.Join();  
    t2.Join();  
    Console.WriteLine("Done.");  
}  
  
static void DoWork1()  
{  
    lock (lock1)  
    {        
	    Console.WriteLine("Thread 1 acquired lock1.");  
        Thread.Sleep(1000);  
        lock (lock2)  
        {            
	        Console.WriteLine("Thread 1 acquired lock2.");  
        }    
    }
}  
  
static void DoWork2()  
{  
    lock (lock2)  
    {        
	    Console.WriteLine("Thread 2 acquired lock2.");  
        Thread.Sleep(1000);  
        lock (lock1)  
        {            
	        Console.WriteLine("Thread 2 acquired lock1.");  
        }    
    }
}

在上面的代码中,定义了两个锁对象 lock1lock2,然后创建了两个线程 t1t2,分别在 DoWork1DoWork2 方法中获取锁。然后在这两个方法中互相等待对方释放锁,导致程序陷入死锁状态。运行程序后,程序将无法继续执行下去,需要手动终止程序。为了避免死锁,我们需要使用合适的同步和互斥机制,避免线程之间的互相等待

lock 代码块是 C# 中用于实现线程同步的机制。lock 关键字可以将一段代码块标记为“临界区”,确保同一时刻只有一个线程可以进入这个代码块,从而避免多个线程同时访问共享资源,导致数据竞争和不确定性的问题,在上述示例中进入 lock 代码块之前,线程会尝试获取 lock1 的锁,如果这个锁当前没有被其他线程占用,那么线程将获取到锁,并进入临界区代码。在临界区代码执行完成之后,线程会释放锁,以便其他线程可以获取锁进入临界区

也尽量减少使用 ResultGetResultWait 等方法(在某些地方适用,如控制台程序)

Result 属性为阻止属性。 如果你在其任务完成之前尝试访问它,当前处于活动状态的线程将被阻止,直到任务完成且值为可用。 在大多数情况下,应通过使用 await 访问此值,而不是直接访问属性

该函数内容需要阻止主线程而等待新开的线程先完成,这可能会导致死锁,这正是我们试图避免使用异步和等待关键字的事情

解决措施

  • 代码同步化,不争抢线程资源

  • 不使用 await 关键字

  • 使用 ConfigureAwait()

上下文

上下文(Context)是一种用于在不同线程之间共享数据的机制。上下文对象可以在一个线程中创建,并在另一个线程中使用,它可以是线程上下文、进程上下文、同步上下文、应用程序域上下文、执行上下文等等

指程序中当前执行代码的环境或状态。可以将上下文视为一个程序运行时的快照,其中包含了程序的当前状态、已定义的变量、对象以及正在执行的代码的位置等信息

当前上下文

通常也指异步上下文,它与线程池线程一起管理异步操作的执行。异步上下文是一种保存与异步操作相关的上下文信息的机制,包括线程 ID、SynchronizationContext 对象等。异步操作在等待其他操作完成时可能会保存当前上下文,以便可以在异步操作完成时恢复它。当前上下文与异步操作的执行是密切相关的,异步操作必须在正确的上下文中执行才能正常工作

理解

  • 如果使用的是 UI 线程,那么就是 UI 上下文

  • 如果使用的是 ASP.NET 请求响应,那么这就是 ASP.NET 请求上下文

  • 其余情况都是线程池上下文

当我们等待 Task 时,当等待决定暂停方法执行时,会捕获请求当前上下文。一旦方法准备好恢复执行,应用程序将从线程池中获取线程,将其分配给上下文,并恢复执行

在 ASP.NET Core 应用程序中没有 SynchronizationContext。ASP.NET Core 避免捕获上下文并排队,它所做的只是从线程池中获取线程并将其分配给请求,因此,应用程序要做的后台工作要少得多

示例

// WinForms 示例程序(同样原理用于 WPF 程序).
private async void DownloadFileButton_Click(object sender, EventArgs e)
{
  // 因为我们使用了异步关键字 awiat,这个 UI 线程不会被文件下载阻止
  await DownloadFileAsync(fileNameTextBox.Text);

  // 由于我们恢复了 UI 上下文,可以直接调用 UI 元素
  resultTextBox.Text = "File downloaded!";
}
private static void button()  
{  
    Console.WriteLine($"main1 thread {Thread.CurrentThread.ManagedThreadId}");  
    TestConfigureAwait().Wait();  
    Console.WriteLine($"main2 thread: {Thread.CurrentThread.ManagedThreadId}");  
}  
  
private static async Task TestConfigureAwait()  
{  
    await Task.Run(() =>  
    {  
        Thread.Sleep(6000);  
        Console.WriteLine($"task thread: {Thread.CurrentThread.ManagedThreadId}");  
    }).ConfigureAwait(false); 
     
    Console.WriteLine($"testConfigureAwait thread: {Thread.CurrentThread.ManagedThreadId}");  
}

在这个示例中,是一个 UI 上下文内容,和使用了 ConfigureAwait(false) 来解决死锁问题

异常捕获

public static void Test()  
{  
    try  
    {  
        var result = SomeAsyncCode().Result;  
    }    
    catch (ArgumentException aex)  
    {        
	    Console.WriteLine($"Caught ArgumentException: {aex.Message}");  
    }
}  
  
private static async Task<string> SomeAsyncCode()  
{  
    await Task.Delay(10);  
    throw new ArgumentException("Oh noes!");  
}

在这个示例中我们尝试使用 try-catch 来捕捉异常,我们执行 SomeAsyncCode(),获取到它的 Result 属性。可以发现我们并没有进入到 catch 块。

这是因为,如果没有等待关键字,任务会吞噬异常。此外,不再有继续,操作也没有经过验证。如果继续执行此操作,将获得一个异常,但这将是映射异常,而不是我们从异步方法中抛出的异常

--- EXCEPTION #1/2 [ArgumentException]

在发生 ArgumentException 时,使用其他关键字尝试解决

在此示例,我们不能用 await 关键字来等待结果,我们使用 SomeAsyncCode().GetAwaiter().GetResult() 来捕捉,运行后结果正确,成功进入 catch

也可以使用 Global Error Handling 技术来解决或跟踪异常

控制台程序

在 C# 7.1 之前其实是不能对控制台程序对主入口 Main 函数标记为 async

原因

如果 await 看到可等待项尚未完成,那么它会异步发挥作用。它告诉 awaitable 在方法完成后运行剩余部分,然后从异步方法返回。当将方法的剩余部分传递给 await 时,awaiter 还将捕获当前上下文

异步方法将在完成之前返回给其调用者。这在 UI 应用程序(方法仅返回 UI 事件循环)和 ASP.NET 应用程序(该方法从线程返回,但使请求保持保留状态)中完美工作

对于控制台程序来说效果不太好:Main 函数返回到操作系统,因此程序退出

解决

控制台程序没有很大的必要使用异步,但为简单的一些操作演示,也是能解决的

让主线程强制等待异步完成,如以下示例:

static void Main(string[] args)
{
	MainAsync(args).GetAwaiter().GetResult();
	// or MainAsync(args).Wait()
}

static async Task MainAsync(string[] args)
{
	Bootstrapper bs = new Bootstrapper();
	var list = await bs.GetList();
}

主要就是 GetAwaiter 方法和 GetResult 方法,前者是获取到有 Task 标记的异步程序,后者是等待此有 Task 标记的异步程序结束

也可以直接使用 MainAsync(args).Wait()Wait 方法与上面类似,但上面的两个方法会避免一些 AggregateException 包装错误

总结

以上内容为搜罗实验整理,如有错误欢迎留言指正交流,如有内容变动会及时更新

更多参考:

OldNewDescription
task.Waitawait taskWait/await for a task to complete
task.Resultawait taskGet the result of a completed task
Task.WaitAnyawait Task.WhenAnyWait/await for one of a collection of tasks to complete
Task.WaitAllawait Task.WhenAllWait/await for every one of a collection of tasks to complete
Thread.Sleepawait Task.DelayWait/await for a period of time
Task constructorTask.Run or TaskFactory.StartNewCreate a code-based task