C# 13个高频特性误用避坑指南:异步、异常与资源管理实战
说个真事上个月做代码评审一份不到五百行的代码里我连续看到了三种上面列出的写法问题。一位写了三年 C# 的同事问我说“代码能跑、测试也过了为什么你要打回”这个问题其实特别值得回答。C# 的很多特性不是“能不能用”的问题而是“用得对不对”的问题——语法层面全对语义层面全错最后线上出问题时连排查方向都是歪的。这篇文章要聊的就是我在实际项目里最常看到的 13 个 C# 特性误用。它们覆盖异步、异常、资源管理、集合、字符串、LINQ、反射这几块高频区。如果你写过一段时间 C#多半会中招一两个如果你是刚入门不久这些内容可以直接当成避坑清单来用。1. 先搞明白这些特性为什么会被用错1.1 语法能跑不等于语义正确先说一个很关键的认识C# 是一门“语法友好”的语言它不会在你写throw ex;的时候报错也不会在你把async void用在非事件方法上时给出红色波浪线。编译器关心的是类型对不对、语法通不通它很少会替你想“这个异常堆栈后续好不好查”“这个资源到底什么时候释放”。所以很多人写代码时判断标准是“编译过了就完了”而不是“这样写在运行时边界条件下是否安全”。这就是误用最根本的来源。举个最简单例子ListT和T[]都能通过索引取值语义却完全不同。数组是固定大小的连续内存ListT是动态扩容的集合类型。如果只是“图方便”统一用ListT在一些热路径上就会产生不必要的扩容拷贝和 GC 压力。编译器不会拦你但性能会拦你。1.2 我把误用分成三类典型来源这几年代码评审做下来我发现 C# 特性误用基本逃不出三种来源。第一类是从其他语言带过来的旧习惯。比如写 Java 的人习惯把所有异常都e.printStackTrace()之后继续跑写 C 的人习惯在析构函数里做资源清理。这些习惯移植到 C# 里就很容易变成throw ex;或者IDisposable不释放的写法。第二类是“复制粘贴的模板代码”。很多框架生成的代码模板本身就是演示性质的并不适合直接搬到生产环境。比如事件处理器里默认给出async void新手一看“官方模板也这么写”于是全项目都是async void。第三类是过度依赖代码提示。Visual Studio 和 Rider 的智能提示太好用导致很多开发者在没有理解机制的情况下就接受了补全结果。比如输入try补全出来的 catch 块基本都带ex参数于是顺手就throw ex;了——原始堆栈没了排查问题的时候只能对着错误日志干瞪眼。理解了这三类来源再往下看具体的 13 个误用你会更清楚每个问题是在哪个环节“跑偏”的。2. 异步、异常与资源三大高频致命习惯这一部分我放在最前面因为它们造成的后果最严重而且经常是线上问题级别的。语法上几乎挑不出毛病运行起来却处处要命。2.1 把 async void 当普通异步方法用先看一段很常见的代码private async void LoadData() { var data await _service.GetDataAsync(); Bind(data); }按钮点击事件里这样写看起来挺正常。问题在于async void除了事件处理器之外几乎不该出现在任何地方。原因很简单async void方法返回的是void调用方拿不到Task自然也没法await。你调用它之后它内部什么时候执行完、有没有抛异常外部一概不知。更麻烦的是异常处理——async void方法内的异常不会像async Task那样被捕获进返回的 Task而是直接抛到当前线程的同步上下文上。在 UI 程序里这可能直接导致进程崩溃在服务端异常会飞到UnhandledException整个请求管道根本无法感知。正确写法是返回Taskprivate async Task LoadDataAsync() { var data await _service.GetDataAsync(); Bind(data); }调用处用await LoadDataAsync();。哪怕调用方不关心返回值也尽量保持async Task。如果是事件处理器比如按钮的Click外部框架要求签名必须是async void那就在方法内部把所有逻辑包进try/catch至少确保异常不会逃逸到同步上下文。我个人的做法是代码评审时把async void当成一条红线。除了 UI 事件处理器和极少数的生命周期回调比如某些框架要求一律不允许出现。这条规则救过我好几次尤其是在对接第三方推送、消息队列的时候async void会让异常变得完全不可观测。2.2 用 throw; 而不是 throw ex;这段代码我几乎每次写异常处理专题都会提因为错误日志里被它坑过太多次catch (Exception ex) { Log(ex); throw ex; // 错误重置了堆栈 }throw ex;会在当前代码行重新抛出一个异常对象同时把异常的StackTrace重置为“从这一行开始”。也就是说真正出错的原始调用位置被覆盖了。拿到日志时你只看到 catch 块所在的方法名前面几层调用、入参是什么、是哪一行触发的全都没有了。正确写法是一行throw;catch (Exception ex) { Log(ex); throw; // 保留原始堆栈 }如果确实需要包装异常比如把底层的SqlException转成业务的OrderCreateException那要把原始异常作为innerException传进去catch (SqlException ex) { throw new OrderCreateException(订单创建失败, ex); }这样上层既能拿到业务层面的错误信息也能通过InnerException一路查到最底层的堆栈。还有一个稍微冷门但非常实用的点如果你需要在重新抛出之前等一会儿比如异步方法中先记录日志再抛出同时又希望完整保留原始堆栈可以考虑ExceptionDispatchInfocatch (Exception ex) { var info ExceptionDispatchInfo.Capture(ex); Log(ex); info.Throw(); // 不会重置堆栈 }这个方法在“延迟重新抛出”的场景特别有用。ExceptionDispatchInfo.Capture在捕获时就把当前堆栈快照下来了之后不管从哪里调用Throw()抛出的都是原始堆栈。2.3 using 变量的释放时机比你预想的长C# 8 引入了using声明写法很多人特别喜欢因为它少了一层大括号using var conn new SqlConnection(_connString); var cmd conn.CreateCommand(); // ...执行逻辑看起来清爽但它有个容易被忽略的点using var的作用域是整个方法块不是到“最后一次使用”就结束。也就是说你在方法中间打开了一个连接后面还有大段代码没有执行完这个连接会一直保持打开状态直到方法退出。这在两种场景下特别容易出问题。一种是长方法前面 open 了一个SqlConnection中间又调了好几个服务最后才返回结果。实际上连接资源一直占着没释放数据库连接池轻松被打满。另一种是迭代器或异步流IEnumerablestring ReadLines() { using var reader File.OpenText(data.txt); while (reader.ReadLine() is { } line) { yield return line; } }这里的using var要到整个迭代器被枚举完才释放。如果调用方只Take(1)那reader会一直开着直到 GC 介入。正确做法是用显式的using块using (var reader File.OpenText(data.txt)) { while (reader.ReadLine() is { } line) { yield return line; } }using块在块结束的位置就释放资源行为更可控。我的经验是using var适合方法很短、逻辑集中、一眼能看到作用域范围的场景。方法一长或者涉及yield、await、分支语句多的情况一律回到using块。另外一个小知识点多个using变量的释放顺序是按声明逆序来的。比如先声明conn再声明transaction释放时先释放transaction再释放conn。这个顺序通常是对的但如果你在两个变量之间存在依赖关系比如transaction依赖conn反过来释放就会有问题。手动块显式控制顺序会更稳妥。2.4 事件不是公开委托字段不能随意赋值这个误用我在不少老项目里见过。有人为了方便把事件直接写成一个公开的委托字段public EventHandler DataChanged; // 错误看起来用起来都差不多都是、-实际差别很大。字段版本是完全公开的任何外部代码都能直接obj.DataChanged null;把所有订阅者清掉也能直接obj.DataChanged?.Invoke(...)触发回调。等于把事件的发布权、取消权全部暴露出去封装性不复存在。正确写法是event关键字public event EventHandler? DataChanged;加了event之后外部代码只能做或-不能直接赋值。编译器会把字段访问包装成add和remove两个访问器底层即使仍然是个多播委托外部也拿不到直接操作它的入口。还有一个常见问题订阅了事件之后忘了退订。如果是短生命周期对象订阅长生命周期对象的事件比如页面订阅全局服务的事件页面销毁时没有-那全局服务会一直持有着对页面的引用页面永远无法被 GC 回收内存泄漏就是这么来的。写 UI 程序时还有个更隐蔽的坑——重复订阅。同一个方法在同一个事件上用添加两次事件触发时方法会执行两次。关键在于-的行为它会移除多播委托链中最后一个匹配项。如果你加了两遍只减一遍还是会重复触发。代码评审时碰到事件订阅和退订总是成对出现的地方我都会多看两眼计数逻辑。3. 类型、集合与字符串数据表达里的性能暗坑这一部分不像异常和异步那么致命但恰恰因为它们“不会出大事”所以最容易被忽视日积月累就成了线上性能问题。3.1 数组 vs 集合别只凭习惯选先说结论数组和集合没有谁绝对更好选错才是问题。数组的核心特征是固定长度、连续内存。正因如此它有几个天然优势。索引访问是 O(1) 且没有额外方法调用开销内存局部性好CPU 遍历时缓存命中率高可以用Buffer.BlockCopy这类底层拷贝配合SpanT可以零拷贝切片。这些特性让数组在图像处理、字节流解析、高性能计算场景里几乎不可替代。ListT的核心特征是动态扩容。它的内部其实也是数组只是在容量不够时创建一个更大的数组并拷贝过去默认扩容策略是翻倍。频繁 Add 的数据如果没提前预留容量会产生多次数组分配和拷贝GC 压力不小。一个很实际的选型思路数量固定不变用数组。比如一年 12 个月、通道固定 8 个。只做遍历和随机访问用数组或IReadOnlyListT。需要频繁增删改用ListT。需要在中间插入、删除ListT也是 O(n)考虑用链表或直接用其它数据结构。方法对外返回时尽量暴露IReadOnlyListT或数组不要把内部ListT直接丢出去否则调用方可以随意修改你的内部状态。还有一个容易踩的坑是数组声明方式。int[,]是多维数组int[][]是交错数组。多维数组在语法上更方但每一行的长度必须一致交错数组是数组的数组每一行可以不同。从性能看交错数组访问开销通常更低因为 CLR 对一维数组的边界检查做了更多优化。如果在图像或矩阵计算里需要性能优先int[][]而不是int[,]。3.2 用 ToLower() 比较字符串是最常见的低效写法先看这段if (input.ToLower() success)问题在于ToLower()会创建一个全新的字符串对象。在高频路径上字符串对象分配不仅占用内存还会增加 GC 压力。更重要的是ToLower()是依赖当前文化的在某些语言区域下大小写映射规则不同于英语结果可能和预期完全不同。正确做法是if (string.Equals(input, success, StringComparison.OrdinalIgnoreCase))StringComparison.OrdinalIgnoreCase比较时不做文化转换也不产生中间字符串性能和语义都可靠。判断字符串相等时通常用Ordinal更合适需要做用户可读的排序、搜索时再考虑CurrentCulture或InvariantCulture。类似的常见误用还有if (str.Length 0) // 可以但没有 IsNullOrEmpty 直观 if (str.Trim().Length 0) // 错误Trim 也会创建新字符串空值判断直接用string.IsNullOrWhiteSpace(str)不要Trim()之后再判断。Trim()不仅分配字符串还会把原字符串里的空格信息丢掉你本来可能只是想判断是不是空的结果白白创建了一个新实例。字符串拼接也是重灾区。循环里写s item;在数据量大的时候会产生大量中间字符串因为 string 是不可变类型每次都会创建新对象。正确做法是StringBuilder或者用string.Join批量拼接。3.3 可变 struct值语义下的一地鸡毛C# 里struct是值类型赋值、传参、从集合取值都会复制一份。这个特性本身没问题但很多人在定义自己的struct时习惯像写类一样给它可变的属性public struct Point { public int X { get; set; } public int Y { get; set; } }然后就会遇到各种“灵异事件”。最典型的是通过ListT索引修改字段var points new ListPoint(); points.Add(new Point { X 1, Y 2 }); points[0].X 10; // 编译错误为什么编译错误因为points[0]返回的是Point的副本对副本的修改不会写回列表编译器干脆禁止这种操作。但换成数组就编译通过了var array new Point[1]; array[0].X 10; // 可以因为数组索引器返回的是引用这导致同一个“给元素赋值”的操作在不同容器里行为还不一样特别容易让人困惑。更隐晦的问题出在foreach或GetHashCode场景。如果你在struct里重写了GetHashCode并且里面访问了可变字段当这个struct被放入HashSet或作为Dictionary键之后字段一变哈希值就变了整个集合就废了——再也找不到这个项了。我的建议很明确struct尽量设计成不可变的。字段、属性都只读需要修改时返回新实例。C# 提供了readonly struct和readonly修饰成员配合in参数能避免不必要的拷贝。如果这个类型确实需要频繁修改状态那就用class别跟值语义对着干。3.4 、Equals、ReferenceEquals先想清楚比的是什么相等性判断看起来简单其实是 C# 里最容易出歧义的话题之一。基础规则是是静态运算符最终行为取决于编译时类型Equals是虚方法行为取决于运行时类型ReferenceEquals永远比较引用是否相同。三者可以各不相同。一个经典误用场景是自定义类没有重写Equals却在比较两个“内容相同”的对象var a new User { Id 1, Name Tom }; var b new User { Id 1, Name Tom }; if (a b) // false因为是引用比较这并不是“错误”但很多新手以为会走值比较。解决方法是如果可以接受牺牲可变性直接用record定义数据类public record User(int Id, string Name);record默认实现基于属性的值相等性、Equals、GetHashCode会一起协调工作省去大量手写代码。另一个容易踩的坑是ReferenceEquals配合值类型。值类型在使用ReferenceEquals时会先装箱装箱后的对象是一个新引用所以int i 10; ReferenceEquals(i, i); // 永远 false这不是你代码写错了而是语义上就不能这么用。最后提醒一点重写了Equals必须同时重写GetHashCode。HashSet、Dictionary先比较哈希码再确认Equals。如果两个对象内容相同但哈希码不同它们会被当成两个键去重和查找都会失效。record也自动处理了这一点这也是为什么我推荐数据类首选record。4. LINQ、空值与元数据现代特性容易跑偏的地方越是方便的高级特性越容易在“我不知道它实际是这么运行时”的情况下被用错。LINQ、空值合并、字符串插值、反射这些单独看都是好设计但用错场景就是另一回事了。4.1 LINQ 是延迟执行的不是调用时就出结果很多人在学习 LINQ 时只记住了“一个查询语句”忽略了它背后的延迟执行模型。var query _db.Users.Where(u u.Age 18);这一行并不会立即执行。Where返回的是一个可枚举的查询描述对象只有在被枚举时——比如foreach、.ToList()、.Count()——才会真正访问数据源并执行逻辑。这意味着两个隐藏问题。第一个是重复枚举同一个query如果被foreach两次数据库查询就会执行两次。对于IQueryable这种表达式树两次执行完全不同性能影响非常明显。解决方法是如果查询结果需要多次使用先.ToList()或.ToArray()冻结一次。第二个问题是用Count()来判断“是否有元素”。Count()会完整枚举整个序列如果数据量大代价很高。而Any()在找到第一个元素后就返回true。实际上只要确认数据源是IEnumerable而不是ICollectionAny()的性能优势就非常明显。还有一个排查起来特别痛苦的场景IQueryable 在using块的 DbContext 之外被枚举。因为查询是延迟的DbContext已经被释放了结果真正枚举时才抛异常堆栈看起来跟查询定义点完全没关系。解决办法是尽量在DbContext生命周期内完成枚举或者统一在仓储层返回ListT而不是IQueryableT。4.2 ?? 和 ?. 组合使用时有个常见误解?.和??是现代 C# 处理空值的好帮手但组合使用时要注意语义差异。string name person?.Name; int length person?.Name?.Length ?? 0;这两行看起来没问题。但如果你在??右侧放了一个有副作用的调用比如一个方法你要清楚它并不是“惰性到使用时才调用”而是在左侧求值为 null 的那一刻就会调用。更深一层的误解在于?.[]、?.Invoke()这些变体组合。比如var result GetValue()?.Compute() ?? DefaultValue();如果Compute()返回 null整个表达式也会走??的右侧。有时这不是你想要的。你真正想表达的可能是GetValue()为 null 用默认值但Compute()计算出来为空也应该保留。这时就要分开写不能靠连续?.一把梭。还有一个在 Unity 或某些 ORM 实体类里特别常见的坑重载了运算符的类型用 null判断可能出现偏差。这时用is null更可靠因为is null是模式匹配不会调用任何运算符重载。比较推荐的习惯是开启可空引用类型Nullable。项目文件里加Nullableenable/Nullable编译器会帮你标出可能为空的引用。看到警告后不要直接忽略应该用一个清晰的策略处理比如?? throw new ArgumentNullException(nameof(x))而不是默默把 null 往下传。4.3 字符串插值不是 Format 的替代品字符串插值$在可读性上确实碾压string.Format但在某些场景里它不是替代品而是性能陷阱。最典型的就是日志logger.LogInformation($用户 {userId} 登录成功);这段代码在日志级别被过滤掉之前插值字符串就已经被完整构建出来了userId的ToString()也已经执行。如果这是高频路径字符串分配和 GC 压力都会上来。正确做法是用日志库的模板语法logger.LogInformation(用户 {UserId} 登录成功, userId);Serilog 这类日志库会把{UserId}当成结构化占位符延迟到真正需要输出日志时才格式化而且还能保留结构化字段方便后续检索和分析。另一个差异是文化。插值表达式使用的CultureInfo.CurrentCulture和string.Format一样如果要生成不受当前环境影响的字符串需要使用FormattableString接口处理或者显式ToString(IFormatProvider)。这一点在处理金额、日期时尤其重要同一个时间在不同区域下可能格式完全不同。最后给一个实践建议循环里的字符串拼接不要用$连续做几十次改用StringBuilder。插值本质是一次性格式化用来发送单个消息没问题反复构建消息体就是对内存的浪费。4.4 foreach 闭包捕获循环变量的坑闭包捕获循环变量这个问题在 C# 5 之后已经解决了一大半。C# 5 开始foreach每次迭代的迭代变量是全新的所以下面这段代码是对的var actions new ListAction(); foreach (var i in Enumerable.Range(0, 10)) { actions.Add(() Console.WriteLine(i)); } foreach (var action in actions) { action(); // 输出 0 到 9 }但如果你用的是for循环或者从旧版本项目迁移过来的老代码问题还在var actions new ListAction(); for (int i 0; i 10; i) { actions.Add(() Console.WriteLine(i)); } foreach (var action in actions) { action(); // 全部输出 10 }原因很好理解for循环的迭代变量i在整个循环过程中是同一个变量所有 lambda 捕获的是同一个引用循环结束后i的值是 10于是所有 lambda 打印的都是 10。解决办法是在循环体里复制一份局部变量for (int i 0; i 10; i) { var j i; actions.Add(() Console.WriteLine(j)); }在 LINQ 查询中也有同样的坑尤其是把循环变量放进Where、Select这类闭包表达式时。排查这类问题有一个技巧看到循环体里有 lambda、异步调用或者 LINQ 查询先停下检查它捕获了哪些外部变量。这个习惯能帮你省掉很多看着“莫名其妙”的 bug。4.5 Attribute 是元数据不是魔法注释C# 里的Attribute中文叫“特性”它和你脑袋里想的“语言特性”不是一回事但恰好是标题最容易让人误解的地方。Attribute的本质是附加在类型、方法、属性、程序集等元素上的元数据。它本身不执行任何代码也不会改变行为。它只是一段编译期写入程序集元数据表的信息在运行时通过反射读取它你才能决定要不要做点什么。常见误用有三种。第一种是把它当成魔法注释写上去之后期待某个框架自动生效。比如自定义了一个[Permission(admin)]但代码入口处没有反射读取PermissionAttribute那这个特性永远不会产生任何效果。它不是[Obsolete]那种被编译器特殊照顾的“内置魔法”你得自己写解析逻辑。第二种是频繁反射读取特性导致性能问题。反射读特性本身有开销如果每个请求都会调用GetCustomAttribute高并发下 CPU 消耗会非常明显。我之前写过一个接口权限校验模块最初版本就是在每个接口请求时扫描方法特性QPS 上来之后 CPU 直飙。后来改成启动时一次性把所有接口的Attribute缓存到一个DictionaryMethodInfo, Attribute[]性能问题直接消失。第三种是忽略AttributeUsage和继承语义。默认情况下单独一个Attribute只能标记在一个位置AttributeTargets.All上且允许出现多次不对默认AllowMultiple false同一元素上不能出现两个相同特性。如果你的业务确实需要重复标记必须显式设置AttributeUsage(AttributeTargets.Method, AllowMultiple true)。Inherited属性控制子类是否能继承父类方法上的 Attribute默认是true但实际行为跟继承的方法是否被重写还有关系不能想当然。另外要注意Attribute 的构造函数参数必须是编译期常量。你写[MyAttribute(DateTime.Now)]编译器直接报错字符串、整数、枚举、Type 这些才允许传入。这个限制本身就是为了保证元数据在编译期就能计算出来别在 Attribute 里放运行时的复杂计算逻辑。结语一个小习惯回头看看这 13 个问题其实没有一个是什么高深的理论。它们全是“语法能过、测试能绿但上了生产才暴露”的典型。我个人在这些年的实操中养成了一个小习惯每写完一段代码尤其是在写异常处理、异步、LINQ 查询、字符串操作、事件订阅这块时会刻意问自己一句“如果这段代码一年后出了问题我能从日志里还原出当时的现场吗”throw;和throw ex;的区别async Task和async void的区别延迟执行和立即执行的区别……这些看似不起眼的细节在关键时刻决定了你是花五分钟定位问题还是花两天从一堆残缺日志里猜答案。代码评审时多留意一分线上排查时就能少熬夜几个晚上。