【层面一】C#语言基础和核心语法-03(泛型/集合/LINQ)
文章目录
- 1.泛型(Generics)
- 1.1 为什么需要泛型?
- 1.2 泛型的救世主思维:“类型参数化”
- 1.3 泛型的本质与原理
- 1.4 如何使用泛型
- 1.5 高级主题:协变与逆变
- 2.集合与 LINQ
- 2.1 集合-数据的容器
- 2.2 LINQ-数据查询语言
- 3.异步编程(Async/Await)
- 3.1 核心痛点:为什么需要异步编程?
- 3.2 核心比喻:异步编程就像点外卖
- 3.3 异步编程的本质与原理
- 3.4 如何正确地使用 Async/Await
1.泛型(Generics)
1.1 为什么需要泛型?
在泛型出现之前,我们主要用两种方式来编写“通用”的代码,但它们都有巨大的缺陷。
- 方式一:针对具体类型 - 重复的代码
假设我们需要一个能存放整数的盒子和一个能存放字符串的盒子:
public class IntBox
{public int Data { get; set; }
}public class StringBox
{public string Data { get; set; }
}// ... 还需要 BoolBox, PersonBox, 无穷无尽...
问题:代码重复。IntBox 和 StringBox 的逻辑完全一样,只是因为要存储的数据类型不同,我们就得写无数个几乎一模一样的类。这是维护的噩梦。
- 方式二:使用 object 类型 - 性能与安全性的灾难
为了解决重复问题,我们想到了 .NET 所有类型的基类:object。
public class ObjectBox
{public object Data { get; set; }
}// 使用
ObjectBox box1 = new ObjectBox();
box1.Data = 100; // 装箱(Boxing)发生!int 被包裹成 objectObjectBox box2 = new ObjectBox();
box2.Data = "Hello";// 取数据时,必须进行强制类型转换(Unboxing)
int myInt = (int)box1.Data; // 拆箱,正确
string myString = (string)box2.Data; // 正确// 但这是不安全的!
int crash = (int)box2.Data; // 编译通过,但运行时会抛出 InvalidCastException!
这种方式有两个致命缺点:
-
性能损耗:存储值类型(如 int)时会发生装箱(Boxing) 和拆箱(Unboxing) 操作,这是一个昂贵的性能开销。
-
**类型不安全:**编译器无法检查强制类型转换是否有效,错误只能在运行时暴露,极易导致程序崩溃。
1.2 泛型的救世主思维:“类型参数化”
泛型的思路非常直观:既然逻辑是一样的,只是数据类型不同,那我们为什么不把‘类型’本身也作为一个参数呢?
就像是一个万能模具。
-
没有泛型:你需要为生产“塑料猫”、“塑料狗”、“塑料汽车”分别制造一个专用的模具。
-
有了泛型:你只需要一个万能模具。使用时,你告诉它:“用塑料” -> 得到塑料猫;“用金属” -> 得到金属猫。“塑料”和“金属”就是类型参数。
这个万能模具,就是泛型类。而“塑料”、“金属”,就是类型参数 T。
1.3 泛型的本质与原理
- 它是如何工作的?- 运行时支持
泛型并非 C# 语言的“语法糖”。它是 CLR(公共语言运行时)的内置功能,其本质是:在运行时根据需要动态生成具体的类型。
-
当你定义泛型时:List<T>
CLR 并不会立即创建一个真正的类型。它只是记住这个蓝图:“有一个列表,它的元素类型是 T”。 -
当你使用泛型时:
List<int> intList = new List<int>(); List<string> stringList = new List<string>();
- CLR 遇到 List<int>:检查是否已生成过 int 版本的列表。如果没有,即时(JIT编译时) 地根据 List<T> 的蓝图,生成一个专用于 int 的具体类型。这个新类型中的 T 被全部替换为 int。
- 同理,它为 List<string> 生成另一个专用于 string 的具体类型。
这个过程被称为“泛型类型实例化”。
-
它带来的三大核心优势
- 类型安全:编译器在编译时就能进行严格的类型检查。
List<int> list = new List<int>(); list.Add(100); // ✅ 正确 list.Add("Hello"); // ❌ 编译错误!编译器直接报错,无法通过。 int num = list[0]; // ✅ 直接就是 int,不需要强制转换。
- 性能卓越:彻底消除了装箱和拆箱。
-
List 内部就是一个 int [ ] 数组。
-
Dictionary<string, int> 内部就是 string 和 int 的键值对。
-
值类型直接存储,引用类型直接存储引用,没有任何性能损耗。
- 代码复用:一份泛型代码,可以用于无限多种数据类型。.NET 标准库中的 List<T>, Dictionary<TKey, TValue>, Nullable<T> 等都是最好的例子,我们无需为自己定义的每一种类型都重写一遍集合类。
1.4 如何使用泛型
- 使用现有的泛型(消费者)
这是我们最常做的角色。使用 System.Collections.Generic 命名空间下的泛型集合是入门第一步。
// 泛型列表
List<string> names = new List<string>();
names.Add("Alice");
names.Add("Bob");
string first = names[0]; // 类型安全,直接返回 string// 泛型字典
Dictionary<int, string> employeeMap = new Dictionary<int, string>();
employeeMap.Add(1, "Alice");
employeeMap.Add(2, "Bob");
string employee = employeeMap[1]; // 类型安全// 泛型方法(LINQ 中大量使用)
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// Where<T> 和 ToList<T> 都是泛型方法,编译器通常能推断出 T 是 int
var evenNumbers = numbers.Where(n => n % 2 == 0).ToList();
-
创建自己的泛型(生产者)
-
创建泛型类/接口/结构
使用 <T> 语法来声明类型参数。// 定义一个自己的万能盒子 public class MyBox<T> // T 是类型参数 {public T Content { get; set; } // 使用 T 作为属性类型public bool IsContentEmpty(){// 我们可以把 T 当作一个已知类型来使用return Content == null || Content.Equals(default(T));} }// 使用 MyBox<int> box1 = new MyBox<int> { Content = 100 }; MyBox<string> box2 = new MyBox<string> { Content = "Hello" }; MyBox<Person> box3 = new MyBox<Person> { Content = new Person() };
-
创建泛型方法
即使类不是泛型的,方法也可以是泛型的。public class Utility {// 一个交换两个变量值的泛型方法public static void Swap<T>(ref T a, ref T b){T temp = a;a = b;b = temp;} }// 使用 int x = 10, y = 20; Utility.Swap(ref x, ref y); // 编译器推断出 T 是 intstring s1 = "foo", s2 = "bar"; Utility.Swap(ref s1, ref s2); // 编译器推断出 T 是 string
-
-
添加约束(让泛型更“懂事”)
默认情况下,T 是 object 类型,你只能调用 object 的方法(如 ToString())。但我们可以通过 约束 来要求 T 必须满足某些条件,从而可以在泛型代码中调用更多特定的方法。
// 要求 T 必须是实现了 IComparable 接口的类型
// 这样我们就可以在方法里调用 a.CompareTo(b)
public T Max<T>(T a, T b) where T : IComparable<T>
{return a.CompareTo(b) > 0 ? a : b;
}// 使用
int maxInt = Max(10, 20); // ✅ int 实现了 IComparable<int>
string maxStr = Max("A", "B"); // ✅ string 实现了 IComparable<string>
// Person maxPerson = Max(p1, p2); // ❌ 如果 Person 没实现 IComparable<Person>,编译就会报错!
常用约束:
-
where T : struct:T 必须是值类型。
-
where T : class:T 必须是引用类型。
-
where T : new():T 必须有一个无参构造函数(允许 new T())。
-
where T : SomeBaseClass:T 必须继承自某个基类。
-
where T : ISomeInterface:T 必须实现某个接口。
1.5 高级主题:协变与逆变
这是泛型中更高级的概念,主要为了增加泛型接口和委托的灵活性。
-
协变(Out):允许使用更派生的类型作为输出/返回值。
-
用 out 关键字修饰类型参数(如 IEnumerable<out T>)。
-
IEnumerable<string> 可以被赋值给 IEnumerable<object>,因为 string 是 object 的派生类,且 IEnumerable 只“输出” T(通过 GetEnumerator()),是安全的。
-
-
逆变(In):允许使用更基础的类型作为输入/参数。
-
用 in 关键字修饰类型参数(如 Action<in T>)。
-
Action<object> 可以被赋值给 Action<string>,因为如果一个方法能处理任何 object,它必然能处理 string,是安全的。
-
简单理解:协变和逆变允许我们在保证类型安全的前提下,让泛型接口和委托的赋值操作变得更加灵活。
总结
.NET 泛型的本质是 CLR 支持的、在运行时动态生成具体类型 的机制。
-
它解决了:代码重复、类型不安全、性能损耗(装箱/拆箱)三大痛点。
-
它的核心是:将类型参数化,编写一份通用代码(“万能模具”),适用于多种具体类型(“塑料”、“金属”)。
-
它的优势是:类型安全、高性能、代码复用。
-
它的应用无处不在:从集合类 (List<T>) 到 LINQ,从依赖注入容器到异步编程 (Task<T>),泛型是现代 .NET 生态的基石。
2.集合与 LINQ
2.1 集合-数据的容器
-
核心概念:为什么需要集合?
程序 = 数据结构 + 算法。绝大多数时候,我们都在处理一组数据,而不是单个数据。-
一个名字列表(List)
-
一个商品和其价格的映射(Dictionary<Product, decimal>)
-
一组唯一的用户ID(HashSet)
-
数组是简单的集合,但它一旦创建,大小就固定了,非常不灵活。.NET 集合框架提供了一系列功能丰富、灵活多变的数据结构,用于在各种场景下存储和操作一组对象。
- 集合的层次结构:接口的力量
.NET 集合的强大之处在于其基于接口的设计。所有集合都实现了一组通用的接口,这使得它们在使用上具有一致性。
最核心的接口是 IEnumerable<T>:
-
它只做一件事:暴露一个枚举器(IEnumerator<T>),该枚举器可以逐个返回集合中的元素。
-
它的核心方法:GetEnumerator()。
-
它的本质:它表示“我可以提供我的数据,让你能遍历我”。这是所有集合的基石,也是 LINQ 能够工作的前提。
// IEnumerable<T> 的简化定义
public interface IEnumerable<out T>
{IEnumerator<T> GetEnumerator();
}public interface IEnumerator<out T>
{T Current { get; } // 获取当前元素bool MoveNext(); // 移动到下一个元素void Reset(); // 重置枚举器
}
foreach 循环的本质就是基于这两个接口:
// 这段 foreach 循环...
foreach (var item in myCollection)
{Console.WriteLine(item);
}// ...会被编译器翻译成类似这样的代码:
var enumerator = myCollection.GetEnumerator();
try
{while (enumerator.MoveNext()) // 只要还有下一个元素{var item = enumerator.Current; // 获取当前元素Console.WriteLine(item);}
}
finally
{enumerator.Dispose();
}
其他重要接口:
-
ICollection<T>:继承了 IEnumerable<T>,增加了 Count, Add, Remove, Clear 等功能。表示一个可修改的集合。
-
IList<T>:继承了 ICollection<T>,增加了通过索引访问的功能([index])。
-
IDictionary<TKey, TValue>:表示键值对的集合。
- 常用集合及其特性
集合类型 | 主要接口 | 核心特性与用途 | 生活比喻 |
---|---|---|---|
List<T> | IList<T> | 动态数组。按索引访问极快(O(1))。在尾部添加元素快,在中间插入/删除慢(需移动后续元素)。最常用。 | 排队:可以快速找到第几个人(索引),但插队很麻烦。 |
Dictionary<TKey, TValue> | IDictionary<TKey, TValue> | 哈希表。通过键(Key)查找值(Value)极快(O(1))。元素无序。键必须唯一。 | 字典/电话簿:通过“名字”(Key)快速找到“电话号码”(Value)。 |
HashSet<T> | ISet<T> | 不含重复值的集合。基于哈希实现,判断是否包含某元素极快(O(1))。用于去重或快速存在性检查。 | 数学里的集合:{1, 2, 3},不允许重复元素。 |
Queue<T> | IEnumerable<T> | 先进先出(FIFO) 的队列。Enqueue(入队),Dequeue(出队)。 | 现实中的队列:先来的人先接受服务。 |
Stack<T> | IEnumerable<T> | 后进先出(LIFO) 的栈。Push(压栈),Pop(弹栈)。 | 一摞盘子:总是取最上面的那个,最后放上去的先被取走。 |
LinkedList<T> | ICollection<T> | 双向链表。在任意位置插入/删除都很快(O(1)),但按索引访问慢(O(n))。 | 寻宝游戏:每个线索指向下一个线索的位置。 |
选择指南:
-
需要快速按索引访问? -> List<T>
-
需要快速按键查找? -> Dictionary<TKey, TValue>
-
需要确保元素不重复? -> HashSet<T>
-
需要先进先出? -> Queue<T>
-
需要后进先出? -> Stack<T>
2.2 LINQ-数据查询语言
- 核心概念:声明式编程
想象一下,你想让助手从一堆文件中找出所有关于“财务”的PDF报告,按日期排序,并只要前5个。
-
命令式编程(如何做):你会一步步指挥他:“打开文件夹,遍历每个文件,检查扩展名是不是.pdf,再打开文件检查内容是否包含‘财务’这个词,然后把符合的文件记下来,再根据文件修改日期排序,最后从排序好的列表里取前5个…” (繁琐,易错)
-
声明式编程(做什么):你会直接告诉他:“帮我找一下最新的5份财务PDF报告。” (简洁,直观)
LINQ 就是 C# 中的声明式查询语言。你只需要告诉它你想要什么结果,而不需要关心它底层如何一步步去实现这个结果。
-
LINQ 的两种语法风格
-
查询语法(Query Syntax)- 类似 SQL
// 数据源 var numbers = new List<int> { 5, 4, 1, 3, 9, 8, 6, 7, 2, 0 };// 查询:从数字集合中找出小于5的偶数,并按大小排序 var query = from num in numbers // from 定义数据源和范围变量where num < 5 // where 过滤条件where num % 2 == 0 // 可以多个条件orderby num descending // orderby 排序select num; // select 选择结果foreach (var num in query) {Console.WriteLine(num); // 输出 4, 2, 0 }
这种语法可读性非常高,尤其对于复杂的多表连接查询。
-
方法语法(Method Syntax)- 基于扩展方法和 Lambda
// 同样的查询,用方法语法实现 var query = numbers.Where(num => num < 5) // Where 扩展方法,传入Lambda表达式作为谓词.Where(num => num % 2 == 0).OrderByDescending(num => num) // OrderByDescending 扩展方法.Select(num => num); // Select 扩展方法foreach (var num in query) {Console.WriteLine(num); // 输出 4, 2, 0 }
-
-
LINQ 的核心原理:延迟执行
这是 LINQ 最精妙也最重要的特性。
-
定义查询 != 执行查询。当你写一连串的 .Where(), .OrderBy(), .Select() 时,你只是在构建一个查询计划,并没有真正开始遍历数据源。
-
真正的执行时机是当你真正需要数据的时候,通常发生在:
-
迭代查询结果(foreach)
-
调用 ToList(), ToArray(), ToDictionary() 等方法
-
调用 Count(), First(), Single(), Max() 等聚合方法
-
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// 1. 定义查询(此时查询并未执行!)
var query = numbers.Where(n => {Console.WriteLine($"Checking {n}");return n % 2 == 0;
});Console.WriteLine("Query defined.");// 2. 现在开始执行查询!(触发点:ToList())
var evenNumbers = query.ToList();
// 输出:
// "Query defined."
// "Checking 1"
// "Checking 2"
// "Checking 3"
// "Checking 4"
// "Checking 5"// 如果再次执行查询,会再次遍历数据源
var count = query.Count(); // 再次输出 "Checking 1" ...
延迟执行的好处:
-
提高性能:可以组合多个查询操作,但最终只遍历数据源一次。
-
实时查询:如果数据源在查询定义后发生了改变,执行时会使用最新的数据。
- 常用 LINQ 操作符
-
筛选:Where (找出满足条件的元素)
-
投影:Select (将元素转换成另一种形式)
-
排序:OrderBy, OrderByDescending, ThenBy
-
分组:GroupBy (产生 IGrouping<TKey, TElement>)
-
连接:Join, GroupJoin (像 SQL 里的 JOIN)
-
聚合:Count, Sum, Average, Min, Max, Aggregate
-
元素:First, FirstOrDefault, Single, Last
-
集合:Distinct, Union, Intersect, Except
-
转换:ToArray, ToList, ToDictionary, OfType<T>, Cast<T>
总结与关系
-
集合是数据的容器:它们提供了存储和组织数据的不同数据结构(List, Dictionary, HashSet 等)。核心接口是 IEnumerable<T>,它允许遍历。
-
LINQ 是数据的查询工具:它提供了一套声明式的、统一的语法来查询任何实现了 IEnumerable<T> 的数据源(包括集合、数组、XML、数据库等)。
-
它们的关系:LINQ 查询以集合为输入,并通常以另一种形式的集合为输出。你可以将多个 LINQ 操作符链式调用,形成一个复杂的查询管道,对原始集合进行过滤、排序、投影、分组等操作,最终得到你想要的结果。
最终比喻:
-
集合就像原材料仓库(仓库里有货架、冷藏库、格子间等不同存储方式)。
-
LINQ 就像一位智能的仓库管理员。你只需要对他说:“帮我把仓库里所有过期日期大于下周的、产自山东的苹果,按价格从低到高排好,把它们的名字和价格列个表给我。” (声明式查询)
-
管理员(LINQ)会自己去仓库(集合)里,按照你的要求(查询操作符)把东西找出来、处理好,然后交给你最终的结果(新的集合或单个值)。
3.异步编程(Async/Await)
3.1 核心痛点:为什么需要异步编程?
想象一个场景:你去一家快餐店点餐。
-
同步编程(Synchronous):就像只有一个服务员。他收到你的点单后,自己跑到后厨去做汉堡,全程站在厨房里等待,直到汉堡做好后再回来为你服务。在这期间,他完全无法接待其他顾客。整个服务通道被一个耗时的任务完全阻塞。
-
异步编程(Asynchronous):就像一个有高效流程的餐厅。服务员收到你的点单后,将订单交给后厨,然后立即回来继续接待下一位顾客。后厨独立工作,当你的汉堡做好后,会通过叫号系统通知你来取(或者由另一个专人送来)。服务员(线程)的时间没有被浪费在等待上。
在程序中,这个“做汉堡”的耗时任务通常是:
-
I/O 密集型操作:读写文件、网络请求(调用API、访问数据库)、下载文件。这些操作的特点是需要等待外部设备,CPU大部分时间在空闲等待。
-
CPU 密集型操作:复杂的计算、图像处理、加密解密。这些操作的特点是CPU满负荷工作。
异步编程的核心目标就是:在等待耗时操作(尤其是I/O操作)完成的过程中,释放当前线程,让它去处理其他工作,从而最大限度地提高应用程序的吞吐量和响应能力。
3.2 核心比喻:异步编程就像点外卖
让我们用一个更贴切的比喻来理解 async/await 的关键角色:
-
你(调用方):想吃外卖。
-
async 方法(餐厅):你打电话下单的餐厅。餐厅接单后,给你一个订单号(Task<T>),代表一个“未来的餐食”(Future)。
-
await 关键字(等餐&做其他事):你不会傻傻地站在门口等外卖员来(阻塞)。你可以:
-
A. 真正异步(推荐):放下电话(释放线程)去看电视。外卖到了(操作完成),门铃响起(回调),你再去拿。
-
B. 伪异步(错误):.Result 或 .Wait() 就像你站在门口死死盯着马路,不让任何人进出(阻塞线程),直到外卖送到。这完全失去了点外卖的意义。
-
在这个比喻中:
-
async:修饰方法,声明“我这个方法内部包含异步操作,调用我会返回一个 Task(订单号)”。
-
await:用在异步操作前,意思是“等到这个异步操作完成,但在此期间,请释放当前线程回去干别的事”。
Task / Task<T>:代表一个异步操作,是那个“订单号”或“未来的结果”。Task 是无返回值的订单,Task<int> 是未来会有一个 int 结果的订单。
3.3 异步编程的本质与原理
- 状态机(State Machine):编译器的魔法
async/await 最大的魔力在于,它让你用写同步代码的思维方式(从上到下顺序执行)来写异步代码,但其底层完全是由编译器重构的。
当你写一个 async 方法时:
public async Task<string> GetHtmlAsync(string url)
{var client = new HttpClient();string html = await client.GetStringAsync(url); // <- 暂停点return html.ToUpper();
}
C# 编译器会做以下事情:
-
将方法拆解:它将你的方法拆分成多个片段,以 await 为界限。await 之前的代码是第一部分,await 之后的代码是第二部分。
-
生成一个状态机类:编译器会为你生成一个隐藏的、复杂的类(状态机)。这个类会记住方法的执行状态(比如局部变量的值)和当前执行到了哪个片段(比如是在 await 之前还是之后)。
-
处理 await:当执行到 await 时,状态机:
-
启动异步操作(如 client.GetStringAsync(url))。
-
立即返回一个 Task 给调用者。
-
订阅异步操作的完成回调。
-
然后!它就释放当前线程了!(如果是UI线程,它就回去处理点击事件;如果是线程池线程,它就回去处理其他请求)。
-
-
完成后恢复:当异步操作(网络下载)完成时,它的完成回调会通知状态机。状态机会抓取一个空闲的线程(可能是原来的线程,也可能是另一个新线程),然后从它上次离开的地方(await 之后)继续执行剩下的代码。
所以,async/await 的本质是编译器提供的“语法糖”,它自动为你构建了一个复杂的状态机,来处理异步操作的启动、挂起和恢复,让你无需手动处理繁琐的回调。
- 线程 vs. 异步:关键区别:
这是最大的误解!异步 != 多线程。
-
多线程:是关于使用多个CPU核心( worker)来同时执行多个计算任务(CPU密集型)。
-
异步:是关于避免线程被阻塞( I/O密集型),在等待时释放线程。
一个异步操作可能根本不占用任何线程!
在等待I/O操作(如网络请求、磁盘读写)时,是硬件设备(网卡、磁盘控制器)在工作,不需要CPU线程。.NET 运行时利用操作系统提供的 I/O 完成端口 等机制,在硬件工作完成后才通知运行时,再由运行时安排一个线程来处理后续逻辑。
异步编程的核心价值在于:用极少的线程(甚至是1个线程,如UI线程)处理大量并发的I/O操作。
3.4 如何正确地使用 Async/Await
- 黄金法则
-
Async All the Way:异步调用应该像病毒一样传播。如果一个方法是 async 的,那么调用它的方法也最好 async,一直延伸到顶层(如事件处理函数)。不要混合同步和异步。
-
避免使用 Task.Wait() 或 Task.Result:这在绝大多数情况下会导致死锁(尤其是在UI线程或ASP.NET旧版本中),因为它强制异步操作同步完成,阻塞了线程。
-
使用 ConfigureAwait(false):在库代码或非UI上下文中,如果你不关心后续代码在哪个原始上下文中恢复,使用 await task.ConfigureAwait(false)。这可以提高性能并避免潜在的死锁。但在UI应用中(如按钮点击事件里),你通常需要回到UI线程来更新UI,所以不能使用它。
- 代码示例对比
错误示范(同步阻塞,浪费线程):
// 在Web服务器中,这会阻塞一个宝贵的线程池线程
public string GetData()
{var client = new HttpClient();// .Result 会阻塞当前线程,直到下载完成string result = client.GetStringAsync("https://api.example.com/data").Result;return result;
}
正确示范(异步非阻塞,释放线程):
// async ALL THE WAY!
public async Task<string> GetDataAsync()
{var client = new HttpClient();// await 会释放当前线程,去处理其他请求string result = await client.GetStringAsync("https://api.example.com/data");return result;
}// 在ASP.NET Core Controller中调用
[HttpGet]
public async Task<IActionResult> Index()
{var data = await GetDataAsync(); // 这里也会释放请求线程!return View(data);
}
在ASP.NET Core中,当一个请求线程在 await 时被释放,它可以立即回去处理另一个新进来的请求。当异步操作完成后,任何空闲的线程池线程都可以接手继续处理后续工作。这使得服务器可以用很少的线程处理非常高的并发请求。
- 异常处理
异步方法的异常会被捕获并存储在返回的 Task 对象中。当你 await 这个 Task 时,异常会被重新抛出。
try
{await SomeAsyncMethodThatMightFail();
}
catch (Exception ex)
{// 在这里捕获异步方法中抛出的异常Console.WriteLine(ex.Message);
}
总结
-
为什么需要:解决I/O操作中的线程阻塞问题,极大提升应用程序的吞吐量和响应性。
-
本质是什么:是编译器提供的语法糖,其底层通过生成状态机来自动化异步操作的启动、挂起和恢复流程。
-
核心机制:await 关键字是“暂停点”,它会立即返回一个 Task,并在异步操作完成前释放当前线程。
-
关键区别:异步是关于I/O等待,线程是关于CPU计算。异步操作在等待期间可能不占用任何线程。
-
最佳实践:Async All the Way,避免 .Result/.Wait(),在库代码中考虑使用 ConfigureAwait(false)。