using System; using System.Globalization; using System.Web; using System.Web.Util; using System.Threading; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Debug = System.Web.Util.Debug; // // Welcome to the CacheManager class, CM for short. CM monitors private bytes for the // worker process. If the Private Bytes limit is about to be exceeded, CM will trim // the cache (as necessary), and induce a GC to prevent the process from recycling. // // A timer thread is used to monitor Private Bytes. The interval is adjusted depending // on the current memory pressure. The maximum interval is every 2 minutes, and the // minimum interval is every 5 seconds. // namespace System.Web.Hosting { internal class CacheManager: IDisposable { const int HIGH_FREQ_INTERVAL_S = 5; const int HIGH_FREQ_INTERVAL_MS = 5 * Msec.ONE_SECOND; const int MEDIUM_FREQ_INTERVAL_S = 30; const int MEDIUM_FREQ_INTERVAL_MS = 30 * Msec.ONE_SECOND; const int LOW_FREQ_INTERVAL_S = 120; const int LOW_FREQ_INTERVAL_MS = 120 * Msec.ONE_SECOND; const int MEGABYTE_SHIFT = 20; const long MEGABYTE = 1L << MEGABYTE_SHIFT; // 1048576 const int SAMPLE_COUNT = 2; const int DELTA_SAMPLE_COUNT = 10; private ApplicationManager _appManager; private long _totalCacheSize; private long _trimDurationTicks; private int _lastTrimPercent = 10; // starts at 10, but changes to fit workload private long _inducedGCMinInterval = TimeSpan.TicksPerSecond * 5; // starts at 5 seconds, but changes to fit workload private DateTime _inducedGCFinishTime = DateTime.MinValue; private long _inducedGCDurationTicks; private int _inducedGCCount; private long _inducedGCPostPrivateBytes; private long _inducedGCPrivateBytesChange; private int _currentPollInterval = MEDIUM_FREQ_INTERVAL_MS; private DateTime _timerSuspendTime = DateTime.MinValue; private int _inPBytesMonitorThread; private Timer _timer; private Object _timerLock = new object(); private long _limit; // the "effective" worker process Private Bytes limit private long _highPressureMark; private long _mediumPressureMark; private long _lowPressureMark; private long[] _deltaSamples; // a history of the increase in private bytes per second private int _idxDeltaSamples; private long _maxDelta; // the maximum expected increase in private bytes per second private long _minMaxDelta; // _maxDelta must always be at least this large private long[] _samples; // a history of the sample values (private bytes for the process) private DateTime[] _sampleTimes; // time at which samples were taken private int _idx; private bool _useGetProcessMemoryInfo; private uint _pid; private bool _disposed; private CacheManager() {} internal CacheManager(ApplicationManager appManager, long privateBytesLimit) { #if PERF SafeNativeMethods.OutputDebugString(String.Format("Creating CacheManager with PrivateBytesLimit = {0:N}\n", privateBytesLimit)); #endif // don't create timer if there's no memory limit if (privateBytesLimit <= 0) { return; } _appManager = appManager; _limit = privateBytesLimit; _pid = (uint) SafeNativeMethods.GetCurrentProcessId(); // the initial expected maximum increase in private bytes is 2MB per second per CPU _minMaxDelta = 2 * MEGABYTE * SystemInfo.GetNumProcessCPUs(); AdjustMaxDeltaAndPressureMarks(_minMaxDelta); _samples = new long[SAMPLE_COUNT]; _sampleTimes = new DateTime[SAMPLE_COUNT]; _useGetProcessMemoryInfo = (VersionInfo.ExeName == "w3wp"); _deltaSamples = new long[DELTA_SAMPLE_COUNT]; // start timer with initial poll interval _timer = new Timer(new TimerCallback(this.PBytesMonitorThread), null, _currentPollInterval, _currentPollInterval); } void Adjust() { // not thread-safe, only invoke from timer callback Debug.Assert(_inPBytesMonitorThread == 1); Debug.Assert(SAMPLE_COUNT == 2); // current sample long s2 = _samples[_idx]; // previous sample long s1 = _samples[_idx ^ 1]; // adjust _maxDelta and pressure marks if (s2 > s1 && s1 > 0) { // current time DateTime d2 = _sampleTimes[_idx]; // previous time DateTime d1 = _sampleTimes[_idx ^ 1]; long numBytes = s2 - s1; long numSeconds = (long)Math.Round(d2.Subtract(d1).TotalSeconds); if (numSeconds > 0) { long delta = numBytes / numSeconds; _deltaSamples[_idxDeltaSamples] = delta; _idxDeltaSamples = (_idxDeltaSamples + 1) % DELTA_SAMPLE_COUNT; // update rate of change in private bytes and pressure marks AdjustMaxDeltaAndPressureMarks(delta); } } lock (_timerLock) { if (_timer == null) { return; } // adjust timer frequency if (s2 > _mediumPressureMark) { if (_currentPollInterval > HIGH_FREQ_INTERVAL_MS) { _currentPollInterval = HIGH_FREQ_INTERVAL_MS; _timer.Change(_currentPollInterval, _currentPollInterval); } } else if (s2 > _lowPressureMark) { if (_currentPollInterval > MEDIUM_FREQ_INTERVAL_MS) { _currentPollInterval = MEDIUM_FREQ_INTERVAL_MS; _timer.Change(_currentPollInterval, _currentPollInterval); } } else { if (_currentPollInterval != LOW_FREQ_INTERVAL_MS) { _currentPollInterval = LOW_FREQ_INTERVAL_MS; _timer.Change(_currentPollInterval, _currentPollInterval); } } } } void AdjustMaxDeltaAndPressureMarks(long delta) { // not thread-safe...only invoke from ctor or timer callback Debug.Assert(_inPBytesMonitorThread == 1 || _timer == null); // The value of _maxDelta is the largest rate of change we've seen, // but it is reduced if the rate is now consistently less than what // it once was. long newMaxDelta = _maxDelta; if (delta > newMaxDelta) { // set maxDelta to the current rate of change newMaxDelta = delta; } else { // if _maxDelta is at least four times larger than every sample rate in the history, // then reduce _maxDelta bool reduce = true; long maxDelta = _maxDelta / 4; foreach (long rate in _deltaSamples) { if (rate > maxDelta) { reduce = false; break; } } if (reduce) { newMaxDelta = maxDelta * 2; } } // ensure that maxDelta is sufficiently large so that the _highPressureMark is sufficiently // far away from the memory limit newMaxDelta = Math.Max(newMaxDelta, _minMaxDelta); // Do we have a new maxDelta? If so, adjust it and pressure marks. if (_maxDelta != newMaxDelta) { // adjust _maxDelta _maxDelta = newMaxDelta; // instead of using _maxDelta, use twice _maxDelta since recycling is // expensive and the real delta fluctuates _highPressureMark = Math.Max(_limit * 9 / 10, _limit - (_maxDelta * 2 * HIGH_FREQ_INTERVAL_S)); _lowPressureMark = Math.Max(_limit * 6 / 10, _limit - (_maxDelta * 2 * LOW_FREQ_INTERVAL_S)); _mediumPressureMark = Math.Max((_highPressureMark + _lowPressureMark) / 2 , _limit - (_maxDelta * 2 * MEDIUM_FREQ_INTERVAL_S)); _mediumPressureMark = Math.Min(_highPressureMark , _mediumPressureMark); #if PERF SafeNativeMethods.OutputDebugString(String.Format("CacheManager.AdjustMaxDeltaAndPressureMarks: _highPressureMark={0:N}, _mediumPressureMark={1:N}, _lowPressureMark={2:N}, _maxDelta={3:N}\n", _highPressureMark, _mediumPressureMark, _lowPressureMark, _maxDelta)); #endif #if DBG Debug.Trace("CacheMemory", "AdjustMaxDeltaAndPressureMarks " + "delta=" + delta + ", _maxDelta=" + _maxDelta + ", _highPressureMark=" + _highPressureMark + ", _mediumPressureMark=" + _mediumPressureMark + ", _lowPressureMark=" + _lowPressureMark); #endif } } [SuppressMessage("Microsoft.Reliability", "CA2001:AvoidCallingProblematicMethods", Justification="Need to call GC.Collect.")] private void CollectInfrequently(long privateBytes) { // not thread-safe, only invoke from timer callback Debug.Assert(_inPBytesMonitorThread == 1); // The Server GC on x86 can traverse ~200mb per CPU per second, and the maximum heap size // is about 3400mb, so the worst case scenario on x86 would take about 8 seconds to collect // on a dual CPU box. // // The Server GC on x64 can traverse ~300mb per CPU per second, so a 6000 MB heap will take // about 10 seconds to collect on a dual CPU box. The worst case scenario on x64 would make // you want to return your hardware for a refund. long timeSinceInducedGC = DateTime.UtcNow.Subtract(_inducedGCFinishTime).Ticks; bool infrequent = (timeSinceInducedGC > _inducedGCMinInterval); // if we haven't collected recently, or if the trim percent is low (less than 50%), // we need to collect again if (infrequent || _lastTrimPercent < 50) { // if we're inducing GC too frequently, increase the trim percentage, but don't go above 50% if (!infrequent) { _lastTrimPercent = Math.Min(50, _lastTrimPercent + 10); } // if we're inducing GC infrequently, we may want to decrease the trim percentage else if (_lastTrimPercent > 10 && timeSinceInducedGC > 2 * _inducedGCMinInterval) { _lastTrimPercent = Math.Max(10, _lastTrimPercent - 10); } int percent = (_totalCacheSize > 0) ? _lastTrimPercent : 0; long trimmedOrExpired = 0; if (percent > 0) { Stopwatch sw1 = Stopwatch.StartNew(); trimmedOrExpired = _appManager.TrimCaches(percent); sw1.Stop(); _trimDurationTicks = sw1.Elapsed.Ticks; } // if (trimmedOrExpired == 0 || _appManager.ShutdownInProgress) { return; } // collect and record statistics Stopwatch sw2 = Stopwatch.StartNew(); GC.Collect(); sw2.Stop(); _inducedGCCount++; // only used for debugging _inducedGCFinishTime = DateTime.UtcNow; _inducedGCDurationTicks = sw2.Elapsed.Ticks; _inducedGCPostPrivateBytes = NextSample(); _inducedGCPrivateBytesChange = privateBytes - _inducedGCPostPrivateBytes; // target 3.3% Time in GC, but don't induce a GC more than once every 5 seconds // Notes on calculation below: If G is duration of garbage collection and T is duration // between starting the next collection, then G/T is % Time in GC. If we target 3.3%, // then G/T = 3.3% = 33/1000, so T = G * 1000/33. _inducedGCMinInterval = Math.Max(_inducedGCDurationTicks * 1000 / 33, 5 * TimeSpan.TicksPerSecond); // no more frequently than every 60 seconds if change is less than 1% if (_inducedGCPrivateBytesChange * 100 <= privateBytes) { _inducedGCMinInterval = Math.Max(_inducedGCMinInterval, 60 * TimeSpan.TicksPerSecond); } #if DBG Debug.Trace("CacheMemory", "GC.COLLECT STATS " + "TrimCaches(" + percent + ")" + ", trimDurationSeconds=" + (_trimDurationTicks/TimeSpan.TicksPerSecond) + ", trimmedOrExpired=" + trimmedOrExpired + ", #secondsSinceInducedGC=" + (timeSinceInducedGC/TimeSpan.TicksPerSecond) + ", InducedGCCount=" + _inducedGCCount + ", gcDurationSeconds=" + (_inducedGCDurationTicks/TimeSpan.TicksPerSecond) + ", PrePrivateBytes=" + privateBytes + ", PostPrivateBytes=" + _inducedGCPostPrivateBytes + ", PrivateBytesChange=" + _inducedGCPrivateBytesChange + ", gcMinIntervalSeconds=" + (_inducedGCMinInterval/TimeSpan.TicksPerSecond)); #endif #if PERF SafeNativeMethods.OutputDebugString(" ** COLLECT **: " + percent + "%, " + (_trimDurationTicks/TimeSpan.TicksPerSecond) + " seconds" + ", infrequent=" + infrequent + ", removed=" + trimmedOrExpired + ", sinceIGC=" + (timeSinceInducedGC/TimeSpan.TicksPerSecond) + ", IGCCount=" + _inducedGCCount + ", IGCDuration=" + (_inducedGCDurationTicks/TimeSpan.TicksPerSecond) + ", preBytes=" + privateBytes + ", postBytes=" + _inducedGCPostPrivateBytes + ", byteChange=" + _inducedGCPrivateBytesChange + ", IGCMinInterval=" + (_inducedGCMinInterval/TimeSpan.TicksPerSecond) + "\n"); #endif } } internal long GetUpdatedTotalCacheSize(long sizeUpdate) { if (sizeUpdate != 0) { long totalSize = Interlocked.Add(ref _totalCacheSize, sizeUpdate); #if PERF SafeNativeMethods.OutputDebugString("CacheManager.GetUpdatedTotalCacheSize:" + " _totalCacheSize= " + totalSize + ", sizeUpdate=" + sizeUpdate + "\n"); #endif return totalSize; } else { return _totalCacheSize; } } public void Dispose() { _disposed = true; Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (disposing) { // managed and unmanaged resources can be touched/released DisposeTimer(); } else { // the finalizer is calling, so don't touch managed state } } private void DisposeTimer() { lock (_timerLock) { if (_timer != null) { _timer.Dispose(); _timer = null; } } } private void PBytesMonitorThread(object state) { // callbacks are queued and can unleash all at once, so concurrent invocations must be prevented if (Interlocked.Exchange(ref _inPBytesMonitorThread, 1) != 0) return; try { if (_disposed) { return; } #if DBG Debug.Trace("CacheMemory", "\r\n\r\n***BEG** PBytesMonitorThread " + DateTime.Now.ToString("T", CultureInfo.InvariantCulture)); #endif // get another sample long privateBytes = NextSample(); // adjust frequency of timer and pressure marks after the sample is captured Adjust(); if (privateBytes > _highPressureMark) { // induce a GC if necessary CollectInfrequently(privateBytes); } #if DBG Debug.Trace("CacheMemory", "**END** PBytesMonitorThread " + "privateBytes=" + privateBytes + ", _highPressureMark=" + _highPressureMark); #endif } finally { Interlocked.Exchange(ref _inPBytesMonitorThread, 0); } } private long NextSample() { // not thread-safe, only invoke from timer callback Debug.Assert(_inPBytesMonitorThread == 1); // NtQuerySystemInformation is a very expensive call. A new function // exists on XP Pro and later versions of the OS and it performs much // better. The name of that function is GetProcessMemoryInfo. For hosting // scenarios where a larger number of w3wp.exe instances are running, we // want to use the new API (VSWhidbey 417366). long privateBytes; if (_useGetProcessMemoryInfo) { long privatePageCount; UnsafeNativeMethods.GetPrivateBytesIIS6(out privatePageCount, true /*nocache*/); privateBytes = privatePageCount; } else { uint dummy; uint privatePageCount = 0; // this is a very expensive call UnsafeNativeMethods.GetProcessMemoryInformation(_pid, out privatePageCount, out dummy, true /*nocache*/); privateBytes = (long)privatePageCount << MEGABYTE_SHIFT; } // increment the index (it's either 1 or 0) Debug.Assert(SAMPLE_COUNT == 2); _idx = _idx ^ 1; // remember the sample time _sampleTimes[_idx] = DateTime.UtcNow; // remember the sample value _samples[_idx] = privateBytes; #if PERF SafeNativeMethods.OutputDebugString(String.Format("CacheManager.NextSample: privateBytes={0:N}, _highPresureMark={1:N}\n", privateBytes, _highPressureMark)); #endif return privateBytes; } } }