乐趣区

关于.net:记一次-NET-某电商交易平台Web站-CPU爆高分析

一:背景

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

退出移动版