一、为什么你的C#程序跑得比蜗牛还慢?
作为一个老码农,我见过太多新手写的C#代码效率低得让人抓狂。比如有个同事写的循环处理10万条数据,硬是让用户等了5分钟。其实很多时候不是电脑不行,而是我们写代码的方式有问题。就像开车一样,同样的路程,老司机知道怎么避开拥堵路段,而新手可能只会一脚油门一脚刹车。
举个例子,很多人喜欢用下面这种方式来拼接字符串:
// 低效的字符串拼接方式
string result = "";
for (int i = 0; i < 10000; i++)
{
result += i.ToString(); // 每次循环都会创建新字符串对象
}
这种写法的问题在于,每次拼接都会创建一个新的字符串对象,内存分配和垃圾回收会严重影响性能。正确的做法是使用StringBuilder:
// 高效的字符串拼接方式
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
sb.Append(i.ToString()); // 只在内存缓冲区操作
}
string result = sb.ToString();
二、集合操作的那些坑
集合操作是另一个常见的性能瓶颈。很多人对List情有独钟,不管什么场景都用List,这就像用大炮打蚊子,不仅浪费资源,效果还不好。
比如查找操作,List的Contains方法是线性查找,时间复杂度是O(n),而HashSet的Contains方法是O(1)。看下面这个例子:
// 低效的查找方式
List<int> list = Enumerable.Range(0, 100000).ToList();
bool exists = list.Contains(99999); // 需要遍历整个列表
// 高效的查找方式
HashSet<int> hashSet = new HashSet<int>(Enumerable.Range(0, 100000));
bool existsFast = hashSet.Contains(99999); // 直接通过哈希值定位
另一个常见问题是频繁的集合扩容。List在添加元素时,如果容量不足会自动扩容,这个过程需要创建新数组并复制数据。我们可以预先设置合适的容量:
// 预先设置容量避免频繁扩容
List<int> optimizedList = new List<int>(100000);
for (int i = 0; i < 100000; i++)
{
optimizedList.Add(i); // 不会发生扩容操作
}
三、数据库访问的优化之道
数据库访问是程序性能的关键点之一。很多性能问题都出在不当的数据库操作上。最常见的问题就是N+1查询问题。
假设我们有一个订单系统,需要查询订单及其明细:
// 低效的N+1查询方式
var orders = dbContext.Orders.Take(100).ToList();
foreach (var order in orders)
{
// 每次循环都会执行一次数据库查询
var details = dbContext.OrderDetails.Where(d => d.OrderId == order.Id).ToList();
// 处理订单明细...
}
这种写法会导致101次数据库查询(1次获取订单,100次获取明细)。正确的做法是使用Include或Join一次性加载关联数据:
// 高效的查询方式
var optimizedOrders = dbContext.Orders
.Include(o => o.OrderDetails) // 一次性加载关联数据
.Take(100)
.ToList();
// 或者使用投影查询只获取需要的字段
var projectedOrders = dbContext.Orders
.Join(dbContext.OrderDetails,
o => o.Id,
d => d.OrderId,
(o, d) => new { Order = o, Detail = d })
.Take(100)
.ToList();
另一个常见问题是过度获取数据。很多时候我们只需要几个字段,却查询了整个实体:
// 低效的数据获取方式
var products = dbContext.Products.ToList(); // 获取所有字段
var names = products.Select(p => p.Name).ToList();
// 高效的投影查询
var productNames = dbContext.Products
.Select(p => p.Name) // 只查询需要的字段
.ToList();
四、异步编程的正确姿势
异步编程可以显著提高程序的响应能力,但如果使用不当反而会降低性能。最常见的错误是滥用async/await。
比如下面这种"假异步"代码:
// 假异步,实际上没有真正并行
public async Task ProcessDataAsync()
{
await Task1(); // 等待第一个任务完成
await Task2(); // 然后才开始第二个任务
await Task3(); // 然后才开始第三个任务
}
正确的做法是让可以并行执行的任务真正并行:
// 真正的并行执行
public async Task ProcessDataOptimizedAsync()
{
var task1 = Task1(); // 立即启动任务
var task2 = Task2(); // 立即启动任务
var task3 = Task3(); // 立即启动任务
await Task.WhenAll(task1, task2, task3); // 等待所有任务完成
}
另一个常见错误是在同步方法中调用异步方法时使用.Result或.Wait(),这可能导致死锁:
// 危险的同步调用异步方法方式
public string GetData()
{
return GetDataAsync().Result; // 可能导致死锁
}
// 正确的做法是让调用链保持异步
public async Task<string> GetDataWrapperAsync()
{
return await GetDataAsync();
}
五、内存管理的艺术
C#虽然有垃圾回收机制,但不代表我们可以不关心内存使用。不当的内存使用会导致频繁的GC,影响程序性能。
比如对象池技术可以重用对象,减少内存分配:
// 使用对象池减少内存分配
ObjectPool<BigObject> pool = new DefaultObjectPool<BigObject>(
new DefaultPooledObjectPolicy<BigObject>(),
maxSize: 100);
// 从池中获取对象
BigObject obj = pool.Get();
try
{
// 使用对象...
}
finally
{
// 使用完毕后归还到池中
pool.Return(obj);
}
另一个常见问题是闭包导致意外的对象保留:
// 可能导致内存泄漏的闭包
void SetupTimer()
{
var bigObject = new BigObject(); // 大对象
Timer timer = new Timer(_ =>
{
// 闭包捕获了bigObject,导致它不会被释放
Console.WriteLine(bigObject.ToString());
}, null, 1000, 1000);
}
正确的做法是避免不必要的闭包,或者在不需要时解除引用:
// 安全的定时器用法
void SetupSafeTimer()
{
Timer timer = null;
timer = new Timer(_ =>
{
// 处理逻辑...
// 不再需要时释放
timer.Dispose();
}, null, 1000, Timeout.Infinite); // 只执行一次
}
六、实战案例分析
让我们看一个综合优化的例子。假设我们有一个需求:处理10万条用户数据,计算每个用户的得分并保存结果。
初始版本可能是这样的:
// 初始低效版本
public void ProcessUsers()
{
var users = dbContext.Users.ToList(); // 1. 加载所有用户
foreach (var user in users)
{
// 2. 为每个用户计算得分(耗时操作)
var score = CalculateScore(user);
// 3. 更新用户得分
user.Score = score;
dbContext.SaveChanges(); // 每次更新都保存
}
}
这个实现有几个明显问题:
- 一次性加载所有用户,内存压力大
- 每次更新都调用SaveChanges,数据库操作频繁
- 单线程处理,没有利用多核CPU
优化后的版本:
// 优化后的高效版本
public async Task ProcessUsersOptimizedAsync()
{
const int batchSize = 1000;
var options = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount };
// 分批处理用户
for (int i = 0; ; i += batchSize)
{
// 1. 分批加载用户
var users = await dbContext.Users
.Skip(i)
.Take(batchSize)
.AsNoTracking() // 不需要变更跟踪
.ToListAsync();
if (users.Count == 0) break;
// 2. 并行计算得分
Parallel.ForEach(users, options, user =>
{
user.Score = CalculateScore(user);
});
// 3. 批量更新
await dbContext.BulkUpdateAsync(users); // 使用批量操作扩展方法
}
}
这个优化版本:
- 分批处理,降低内存压力
- 使用并行处理,利用多核CPU
- 使用批量操作减少数据库往返
- 禁用不需要的变更跟踪
七、性能优化的黄金法则
经过这么多案例,我总结出几条性能优化的黄金法则:
- 测量第一:优化前一定要先测量,找到真正的瓶颈。不要凭直觉优化。
- 二八定律:80%的性能问题来自20%的代码,找到关键路径重点优化。
- 权衡取舍:优化往往需要权衡,比如用内存换速度,要考虑业务场景。
- 可读性优先:不要为了微小的性能提升牺牲代码可读性,除非确实必要。
- 渐进优化:不要一开始就追求极致优化,先保证正确性,再逐步优化。
记住,最好的优化往往来自架构层面的改进,而不是微观层面的调优。有时候换个算法,或者重新设计数据流,比抠那几毫秒的代码优化要有效得多。
最后提醒一点:优化后的代码一定要有相应的性能测试,确保优化确实有效,并且不会引入新的问题。性能优化是一个持续的过程,不是一劳永逸的工作。
评论