第四章:CPU与线程分析

4.1 CPU使用率分析——别被数字骗了

CPU使用率,这个指标看起来简单,但坑特别多。我见过太多人盯着任务管理器里那个百分比,然后说「CPU才30%,系统很空闲啊」。嗯,真相往往没那么简单。

先说说CPU使用率的本质。它表示CPU处于忙碌状态的时间比例。但「忙碌」分两种:一种是真正在干活(用户态),另一种是在等资源(内核态)。我曾经排查过一个线上问题,CPU使用率只有15%,但请求延迟高得离谱。后来发现,大量线程在等待锁,CPU时间都花在了上下文切换上。

核心指标:

  • User %:应用程序实际执行代码的时间
  • System %:操作系统内核执行的时间(系统调用、中断等)
  • I/O Wait %:CPU等待磁盘/网络I/O完成的时间
  • Steal %:虚拟化环境下,被宿主机偷走的时间

我个人习惯用 perftop 命令看这些细分指标。在Windows上,perfmon 里的「Processor Information」计数器也能看到类似数据。你想想看,如果System%很高,说明系统调用频繁,可能是锁竞争或者线程切换太猛了。

4.2 线程池调优——别让线程成为瓶颈

线程池是.NET后端最常用的并发工具。但调优不当,它反而会成为性能杀手。我记得有一次帮客户优化一个API服务,QPS上不去,线程池里线程数飙到200多,CPU却只有40%。

为什么会这样?因为线程池默认的最小线程数太保守了。在.NET Core中,默认最小线程数等于CPU核心数。对于I/O密集型任务,这个值远远不够。

调优建议:

  • 对于I/O密集型应用(如数据库查询、HTTP调用),适当提高最小线程数
  • 对于CPU密集型应用,保持线程数接近核心数
  • 使用 ThreadPool.SetMinThreadsThreadPool.SetMaxThreads 调整

我曾经踩过一个坑:把最小线程数设得过高,结果大量线程在等待锁,反而导致性能下降。所以调优要结合具体场景,不能一刀切。

// 调整线程池示例
int workerThreads, completionPortThreads;
ThreadPool.GetMinThreads(out workerThreads, out completionPortThreads);

// 对于I/O密集型,建议设置为 CPU核心数 * 2 或更高
ThreadPool.SetMinThreads(Environment.ProcessorCount * 4, completionPortThreads);

注意:不要频繁调用 ThreadPool.SetMinThreads。它应该在应用启动时设置一次。运行时动态调整可能导致线程池行为不稳定。

4.3 异步编程与性能——async/await的正确姿势

异步编程是.NET性能优化的核心武器。但用不好,反而会拖慢系统。我见过最典型的错误:在同步方法里调用异步方法,然后用 .Result.Wait() 阻塞等待。这会导致线程池饥饿,甚至死锁。

异步编程的本质是什么?说白了,就是让线程在等待I/O时去做别的事。你想想看,一个线程如果卡在数据库查询上,它就不能处理其他请求。异步编程让这个线程「释放」出来,去处理其他请求。

4.3.1 async/await 最佳实践

  • 全程异步:从控制器到数据访问层,全部使用 async/await
  • 避免阻塞:不要用 .Result、.Wait()、Task.WaitAll()
  • 配置上下文:在库代码中使用 ConfigureAwait(false)
  • 控制并发:使用 SemaphoreSlim 限制并发数
// 正确的异步模式
public async Task<User> GetUserAsync(int id)
{
    // 使用 ConfigureAwait(false) 避免上下文捕获
    var user = await _dbContext.Users.FindAsync(id).ConfigureAwait(false);
    return user;
}

// 错误的做法——会导致死锁
public User GetUser(int id)
{
    // 在同步方法中阻塞异步调用
    return GetUserAsync(id).Result; // 危险!
}

性能对比:

场景 同步方式 异步方式 提升
100个并发请求 需要100个线程 只需几个线程 线程数减少90%+
内存占用 每个线程1MB栈空间 几乎无额外开销 内存节省显著
上下文切换 频繁切换 极少切换 CPU效率提升

4.3.2 并发控制——别让异步变成灾难

异步编程虽然好,但并发控制不当也会出问题。我曾经遇到一个案例:某个服务同时发起1000个HTTP请求,结果目标服务器被打挂了。嗯,这就是典型的「异步风暴」。

解决方案很简单:使用 SemaphoreSlim 限制并发数。

private static readonly SemaphoreSlim _throttler = new SemaphoreSlim(50);

public async Task ProcessBatchAsync(List<int> ids)
{
    var tasks = ids.Select(async id =>
    {
        await _throttler.WaitAsync();
        try
        {
            await ProcessSingleAsync(id);
        }
        finally
        {
            _throttler.Release();
        }
    });
    
    await Task.WhenAll(tasks);
}

小技巧:对于I/O密集型操作,并发数可以设置为目标服务能承受的最大值。对于CPU密集型操作,并发数建议不超过CPU核心数的2倍。

4.4 实战案例分析

最后分享一个我实际优化过的案例。一个电商API,高峰期CPU使用率80%,但响应时间超过5秒。通过分析发现:

  1. 线程池线程数飙到300+,上下文切换频繁
  2. 大量同步阻塞操作(.Result调用)
  3. 数据库查询没有使用异步方法

优化方案:

  • 将所有数据库操作改为异步
  • 调整线程池最小线程数为CPU核心数的4倍
  • 使用 ConfigureAwait(false) 减少上下文捕获
  • 对第三方API调用使用SemaphoreSlim限流

优化后,CPU使用率降到35%,响应时间降到800ms。你看,有时候问题不在硬件,而在代码本身。

避坑指南:我曾经在优化时犯过一个错误——把所有方法都改成异步,包括那些只做CPU计算的方法。结果性能反而下降了。因为异步本身有开销,对于纯CPU计算,同步方式反而更快。记住:异步只对I/O密集型操作有效。

好了,这一章的内容就到这里。CPU和线程的优化,说白了就是让资源用在刀刃上。下一章我们会聊内存管理,那又是一个大坑。到时候见。