前言
开发高性能的C#应用程序时,我们常常将注意力集中在算法优化和架构设计上,却忽略了那些看似微不足道的代码细节。
事实上,真正拖慢你应用速度的,往往不是复杂的业务逻辑,而是那些"隐形杀手"——一些细微且常见的C#错误。这些模式不仅会显著降低性能,增加云服务成本,还会造成意想不到的垃圾回收(GC)压力。
本文将揭示10个由微软官方文档证实的性能陷阱,帮助你打造更快、更高效的C#应用。
正文
错误1:看似无辜的.ToList()?它正在吞噬你的内存
// 之前(低效——强制立即枚举并分配内存)
var activeUsers = GetUsers().Where(u => u.IsActive).ToList();
if (activeUsers.Any())
{
// 逻辑处理
}
// 更好的方式(延迟执行,避免双重枚举)
var activeUsersQuery = GetUsers().Where(u => u.IsActive);
if (activeUsersQuery.Any())
{
// 逻辑处理
}
注意:每个.ToList()都会分配内存并强制立即迭代。除非必须将数据具体化,否则请使用IEnumerable进行延迟处理。
错误2:在事件处理程序之外使用async void
// 之前(不好——无法等待或处理异常)
public async void SaveDataAsync()
{
await db.SaveChangesAsync();
}
// 更好的方式(异步方法始终返回Task)
public async Task SaveDataAsync()
{
await db.SaveChangesAsync();
}
注意:async void会破坏错误处理机制,且无法被等待——可能导致无声崩溃。除非在UI事件处理程序中(根据微软指南),否则请使用async Task。
错误3:在频繁循环中对值类型进行装箱操作
// 不好的方式
object sum = 0;
for (int i = 0; i < 10000; i++) sum = (int)sum + i;
// 好的方式
int sum = 0;
for (int i = 0; i < 10000; i++) sum += i;
注意:装箱操作会严重影响性能并增加GC压力。应保持值类型的值类型特性。
[Benchmark]
public void BoxingTest()
{
object sum = 0;
for (int i = 0; i < 10000; i++) sum = (int)sum + i;
}
原因:即使是5毫秒与50毫秒的实际差异,其影响也不容忽视。
错误4:在循环中过度使用字符串拼接
// 之前(缓慢——重复进行堆分配)
string result = "";
foreach (var word in words)
{
result += word + " ";
}
// 更好的方式(更快——最小化分配)
var builder = new StringBuilder();
foreach (var word in words)
{
builder.Append(word).Append(' ');
}
string result = builder.ToString();
// 最佳方式(零分配,使用Span<char>实现高性能)
Span<char> buffer = stackalloc char[1024];
var pos = 0;
foreach (var word in words)
{
word.AsSpan().CopyTo(buffer.Slice(pos));
pos += word.Length;
buffer[pos++] = ' ';
}
string result = new string(buffer.Slice(0, pos));
注意:字符串是不可变的;使用+=会产生大量分配。Span可避免堆压力和GC,非常适合快速、低延迟的循环。
错误5:在性能关键的API中忽略ValueTask
// 之前(效率较低)
public async Task<int> GetCachedValueAsync()
{
if (cache.HasValue)
return cache.Value;
return await ComputeValueAsync();
}
// 更好的方式
public ValueTask<int> GetCachedValueAsync()
{
if (cache.HasValue)
return new ValueTask<int>(cache.Value);
return new ValueTask<int>(ComputeValueAsync());
}
注意:当结果已可用时,ValueTask可避免堆分配。非常适合“缓存优先”的异步流程。
错误6:对字符串和数组进行切片时不使用AsSpan()或AsMemory()
// 之前(低效——分配子字符串)
string prefix = input.Substring(0, 5);
// 更好的方式(零分配——不复制子字符串)
ReadOnlySpan<char> prefix = input.AsSpan(0, 5);
注意:Substring()会分配内存,在循环中造成GC压力。Span在原地工作——零分配,在解析、文件处理和协议处理方面性能更佳。
错误7:不池化HttpClient或Regex等昂贵对象
// 之前(不好——导致套接字耗尽或每次都重新编译Regex)
var client = new HttpClient();
var match = new Regex(@"\d+").Match(input);
// 更好的方式(重用昂贵对象——线程安全且经过优化)
private static readonly HttpClient _httpClient = new HttpClient();
private static readonly Regex _regex = new Regex(@"\d+", RegexOptions.Compiled | RegexOptions.CultureInvariant);
注意:重新创建HttpClient或Regex会浪费资源。池化可避免套接字问题和重新编译,提高性能和可靠性。
错误8:阻塞异步代码(例如使用.Result、.Wait())
// 之前(危险——导致死锁和线程池饥饿)
var result = httpClient.GetAsync(url).Result;
// 更好的方式(非阻塞,全程异步)
var result = await httpClient.GetAsync(url);
注意:使用.Result或.Wait()进行阻塞可能导致死锁和线程饥饿。全程使用async/await可避免超时并确保可扩展性。
错误9:数据库或网络调用不使用批处理
// 之前(N+1查询或多个缓慢的网络调用)
foreach (var id in orderIds)
{
var order = await db.Orders.FindAsync(id);
results.Add(order);
}
// 更好的方式(批处理——一次往返而非多次)
var orders = await db.Orders.Where(o => orderIds.Contains(o.Id)).ToListAsync();
注意:循环中重复的I/O会导致缓慢、频繁的操作。正如微软所建议的,批处理可减少往返次数,改善延迟并提高可扩展性。
错误10:“即发即弃”的异步调用没有安全保障
// 之前(即发即弃——异常会被吞噬)
DoSomethingAsync();
// 更好的方式(附加到带有异常处理的安全后台任务)
_ = Task.Run(async () =>
{
try
{
await DoSomethingAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "后台任务失败。");
}
});
注意:未等待的异步调用可能会无声失败或导致服务崩溃。正如微软所建议的,将它们包装在带有try/catch的Task.Run中,或使用BackgroundService以确保安全执行。
总结
通过识别和修复这10个常见的C#性能陷阱,可以显著提升应用程序的性能和可靠性。从避免不必要的内存分配,到正确使用异步编程模式,再到优化I/O操作,每一个小的改进都能带来可观的性能提升。
记住,高性能的应用程序不仅仅是关于算法和架构,更在于对细节的关注和持续的优化。遵循微软官方的最佳实践,让大家的C#代码真正发挥其潜力。
关键词
C#、性能优化、内存分配、GC压力、LINQ、异步编程、装箱、字符串拼接、ValueTask、Span、HttpClient、正则表达式、阻塞调用、批处理、即发即弃任务
最后
如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。
也可以加入微信公众号 [DotNet技术匠] 社区,与其他热爱技术的同行一起交流心得,共同成长!
优秀是一种习惯,欢迎大家留言学习!
作者:寒冰
出处:mp.weixin.qq.com/s/3imd6IZWsY4topdA0TKdtw?scene=1&click_id=59
声明:网络内容,仅供学习,尊重版权,侵权速删,歉意致谢!