C# 中的并发:如何让程序不再空闲等待
\n\n想象一下,你是一位主厨。你有三个炉灶、一个烤箱和一个搅拌器。如果你按顺序做所有事情——先切洋葱,再煎炒,然后打发奶油——那么这顿饭会拖到天荒地老。更高效的做法是:在切菜的同时预热烤箱,在炖肉的同时打发奶油。这就是并发——一种能够“同时”执行多个任务的能力,通过在任务间切换,确保资源不被闲置。
\n\n在 C# 中,实现并发有三种主要工具:经典的线程(Thread)、现代的 Task 配合 async/await,以及通过迭代器实现的协程概念。本文将逐一剖析每种方法,对比它们的性能,并说明何时使用哪种方法。如果你正在使用 C# 编写代码,并希望你的程序运行如飞而非缓慢爬行,那么你来对地方了。
\n\n线程(Thread):重型武器
\n\n线程是实现并发的最底层方式。每个线程都是程序内部的一个独立的“轻量级进程”,拥有自己的栈和上下文。在 C# 中创建线程非常简单:
\n\nusing System;\nusing System.Threading;\n\nclass Program\n{\n static void Main()\n {\n Thread thread = new Thread(DoWork);\n thread.Start();\n Console.WriteLine("主线程正在工作");\n thread.Join(); // 等待线程结束\n }\n\n static void DoWork()\n {\n for (int i = 0; i < 5; i++)\n {\n Console.WriteLine($"线程: {i}");\n Thread.Sleep(200);\n }\n }\n}\n\n这里我们创建了一个独立的线程,它从 0 数到 4,同时主线程输出一条消息。如果没有 Join,程序可能会在计数完成之前就结束。
线程的缺点
\n- \n
- 重量级。每个线程会预留大约 1 MB 的内存作为栈。创建 1000 个线程就意味着白白占用 1 GB 内存。 \n
- 同步复杂。为了防止两个线程破坏共享数据,你需要使用锁、互斥量和信号量。一旦出错,就会导致死锁或竞态条件。 \n
- 上下文切换。操作系统在切换线程时会消耗时间。如果线程数超过 CPU 核心数,这就会成为性能瓶颈。 \n
当你需要在多核处理器上实现真正的并行计算来处理繁重的计算任务时,线程是很好的选择。但对于输入/输出操作(如文件、网络、数据库),它们就显得过于笨重了。
\n\nTask 和 async/await:现代标准
\n\n从 .NET 4.5 开始,微软提供了一种优雅的替代方案——任务并行库(TPL)以及关键字 async/await。它们并非纯粹的线程,而是承诺:你告诉编译器“启动这个任务,当它完成时,返回结果”。在任务执行期间,线程不会被阻塞,而是返回到线程池中。
using System;\nusing System.Net.Http;\nusing System.Threading.Tasks;\n\nclass Program\n{\n static async Task Main()\n {\n Console.WriteLine("开始下载...");\n string data = await DownloadDataAsync();\n Console.WriteLine($"已下载: {data.Length} 个字符");\n }\n\n static async Task<string> DownloadDataAsync()\n {\n using HttpClient client = new HttpClient();\n string result = await client.GetStringAsync("https://example.com");\n return result;\n }\n}\n\n请注意:await 不会阻塞线程。当数据通过网络下载时,线程会返回到线程池,并可以处理其他请求。这对于 Web 服务器和 GUI 应用程序来说非常理想——界面不会卡顿。
底层是如何工作的?
\n当编译器看到 async 时,它会将方法转换为一个状态机。在每个 await 处,方法会“记住”它的当前状态