2

记一次 .NET 某电商交易平台Web站 CPU爆高分析

 3 years ago
source link: https://www.cnblogs.com/huangxincheng/p/14820833.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

1. 讲故事

已经连续写了几篇关于内存暴涨的真实案例,有点麻木了,这篇换个口味,分享一个 CPU爆高 的案例,前段时间有位朋友在 wx 上找到我,说他的一个老项目经常收到 CPU > 90% 的告警信息,挺尴尬的。

既然找到我,那就用 windbg 分析呗,还能怎么办。

二: windbg 分析

1. 勘探现场

既然说 CPU > 90%,那我就来验证一下是否真的如此?


0:359> !tp
CPU utilization: 100%
Worker Thread: Total: 514 Running: 514 Idle: 0 MaxLimit: 2400 MinLimit: 32
Work Request in Queue: 1
    Unknown Function: 00007ff874d623fc  Context: 0000003261e06e40
--------------------------------------
Number of Timers: 2
--------------------------------------
Completion Port Thread:Total: 2 Free: 2 MaxFree: 48 CurrentLimit: 2 MaxLimit: 2400 MinLimit: 32

从卦象看,真壮观,CPU直接被打满,线程池里 514 个线程也正在满负荷奔跑,那到底都奔跑个啥呢? 首先我得怀疑一下这些线程是不是被什么锁给定住了。

2. 查看同步块表

观察锁情况,优先查看同步块表,毕竟大家都喜欢用 lock 玩多线程同步,可以用 !syncblk 命令查看。


0:359> !syncblk
Index SyncBlock MonitorHeld Recursion Owning Thread Info  SyncBlock Owner
   53 000000324cafdf68          498         0 0000000000000000     none    0000002e1a2949b0 System.Object
-----------------------------
Total           1025
CCW             3
RCW             4
ComClassFactory 0
Free            620

我去,这卦看起来很奇怪, MonitorHeld=498 是什么鬼??? 教科书上都说: owner + 1 , waiter + 2,所以你肉眼看到的总会是一个奇数,那偶数又是个啥意思? 查了下神奇的 StackOverflow,大概总结成如下两种情况:

这种情况比中彩还难,我也坚信不会走这种天罗运。。。

  • lock convoy (锁护送)

前段时间我分享了一篇真实案例: 记一次 .NET 某旅行社Web站 CPU爆高分析 ,它就是因为 lock convoy 造成的 CPU 爆高,果然世界真小,又遇到了。。。为了方便大家理解,我还是把那张图贴上吧。

看完这张图你应该就明白了,一个线程在时间片内频繁的争抢锁,所以就很容易的出现一个持有锁的线程刚退出,那些等待锁的线程此时还没有一个真正的持有锁,刚好抓到的dump就是这么一个时间差,换句话说,当前的 498 全部是 waiter 线程的计数,也就是 249 个 waiter 线程,接下来就可以去验证了,把所有线程的线程栈调出来,再检索下 Monitor.Enter 关键词。

从图中可以看出当前有 220 个线程正卡在 Monitor.Enter 处,貌似丢了29个,不管了,反正大量线程卡住就对了,从堆栈上看貌似是在 xxx.Global.PreProcess方法中设置上下文后卡住的,为了满足好奇心,我就把问题代码给导出来。

3. 查看问题代码

还是用老命令 !ip2md + !savemodule


0:359> !ip2md 00007ff81ae98854
MethodDesc:   00007ff819649fa0
Method Name:  xxx.Global.PreProcess(xxx.JsonRequest, System.Object)
Class:        00007ff81966bdf8
MethodTable:  00007ff81964a078
mdToken:      0000000006000051
Module:       00007ff819649768
IsJitted:     yes
CodeAddr:     00007ff81ae98430
Transparency: Critical
0:359> !savemodule 00007ff819649768 E:\dumps\PreProcess.dll
3 sections in file
section 0 - VA=2000, VASize=b6dc, FileAddr=200, FileSize=b800
section 1 - VA=e000, VASize=3d0, FileAddr=ba00, FileSize=400
section 2 - VA=10000, VASize=c, FileAddr=be00, FileSize=200

然后用 ILSpy 打开问题代码,截图如下:

尼玛,果然每个 DataContext.SetContextItem() 方法中都有一个 lock 锁,完美命中 lock convoy

4. 真的就这样结束了吗?

本来准备汇报了,但想着500多个线程栈都调出来了,闲着也是闲着,干脆扫扫看吧,结果我去,意外发现有 134 个线程卡在 ReaderWriterLockSlim.TryEnterReadLockCore 处,如下图所示:

从名字上可以看出,这是一个优化版的读写锁: ReaderWriterLockSlim,为啥有 138 个线程都卡在这里呢? 真的很好奇,再次导出问题。



internal class LocalMemoryCache : ICache
{
    private string CACHE_LOCKER_PREFIX = "xx_xx_";

    private static readonly NamedReaderWriterLocker _namedRwlocker = new NamedReaderWriterLocker();

    public T GetWithCache<T>(string cacheKey, Func<T> getter, int cacheTimeSecond, bool absoluteExpiration = true) where T : class
    {
        T val = null;
        ReaderWriterLockSlim @lock = _namedRwlocker.GetLock(cacheKey);
        try
        {
            @lock.EnterReadLock();
            val = (MemoryCache.Default.Get(cacheKey) as T);
            if (val != null)
            {
                return val;
            }
        }
        finally
        {
            @lock.ExitReadLock();
        }
        try
        {
            @lock.EnterWriteLock();
            val = (MemoryCache.Default.Get(cacheKey) as T);
            if (val != null)
            {
                return val;
            }
            val = getter();
            CacheItemPolicy cacheItemPolicy = new CacheItemPolicy();
            if (absoluteExpiration)
            {
                cacheItemPolicy.AbsoluteExpiration = new DateTimeOffset(DateTime.Now.AddSeconds(cacheTimeSecond));
            }
            else
            {
                cacheItemPolicy.SlidingExpiration = TimeSpan.FromSeconds(cacheTimeSecond);
            }
            if (val != null)
            {
                MemoryCache.Default.Set(cacheKey, val, cacheItemPolicy);
            }
            return val;
        }
        finally
        {
            @lock.ExitWriteLock();
        }
    }

看了下上面的代码大概想实现一个对 MemoryCache 的 GetOrAdd 操作,而且貌似为了安全起见,每一个 cachekey 都配了一个 ReaderWriterLockSlim,这逻辑就有点奇葩了,毕竟 MemoryCache 本身就带了实现此逻辑的线程安全方法,比如:


public class MemoryCache : ObjectCache, IEnumerable, IDisposable
{
    public override object AddOrGetExisting(string key, object value, DateTimeOffset absoluteExpiration, string regionName = null)
    {
        if (regionName != null)
        {
            throw new NotSupportedException(R.RegionName_not_supported);
        }
        CacheItemPolicy cacheItemPolicy = new CacheItemPolicy();
        cacheItemPolicy.AbsoluteExpiration = absoluteExpiration;
        return AddOrGetExistingInternal(key, value, cacheItemPolicy);
    }
}

5. 用 ReaderWriterLockSlim 有什么问题吗?

哈哈,肯定有很多朋友这么问?😅😅😅,确实,这有什么问题呢?首先看一下 _namedRwlocker 集合中目前到底有多少个 ReaderWriterLockSlim ? 想验证很简单,上托管堆搜一下即可。


0:359> !dumpheap -type System.Threading.ReaderWriterLockSlim -stat
Statistics:
              MT    Count    TotalSize Class Name
00007ff8741631e8    70234      6742464 System.Threading.ReaderWriterLockSlim

可以看到当前托管堆有 7w+ 的 ReaderWriterLockSlim,这又能怎么样呢??? 不要忘啦, ReaderWriterLockSlim 之所以带一个 Slim ,是因为它可以实现用户态 自旋,那 自旋 就得吃一点CPU,如果再放大几百倍? CPU能不被抬起来吗?

总的来说,这个 Dump 所反应出来的 CPU打满 有两个原因。

  • lock convoy 造成的频繁争抢和上下文切换给了 CPU 一顿暴击。
  • ReaderWriterLockSlim 的百倍 用户态自旋 又给了 CPU 一顿暴击。

知道原因后,应对方案也就简单了。

  • 批量操作,降低串行化的 lock 个数,不要去玩锁内卷。
  • 去掉 ReaderWriterLockSlim,使用 MemoryCache 自带的线程安全方法。

更多高质量干货:参见我的 GitHub: dotnetfly

图片名称

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK