Pull to refresh

Утилизация «мусорщиком» сессий с истекшим сроком годности

Reading time11 min
Views3.5K

Введение

Технически это продолжение публикации Как не дать пользователю заснуть во время загрузки большого набора данных. Провожу "капитальный рефакторинг" корпоративной системы, которая используется 20 лет. Некоторые свои решения излагаю здесь в надежде, что кому-то пригодится, а также чтобы узнать что-то новое из комментариев.

Проблема

Как следует из упомянутой публикации, разрабатываемый сервер является "stateful" хотя бы потому, что загрузка данных для подкачки происходит между последовательными запросами клиента. Также полноценная сессия может понадобиться для каких-то других задач.

Соглашаясь с мнением в комментариях, что это ограничивает масштабируемость, всё же считаю, что инструмент должен соответствовать задаче. Для высоконагруженной системы с миллионами запросов в секунду важна распределённость, но за неё мы платим необязательностью предоставления актуальных данных в конкретный момент. Ни того ни другого нельзя сказать о системе, в которой при самых оптимистичных предположениях будет работать сотня операторов.

Так что предположим, что запрос на полноценные сессии всё же имеется. И эти сессии надо как-то утилизировать после окончания их срока жизни. Навскидку можно предложить следующие решения.

  • Запуск параллельной задачи при очередном запросе с клиента.

  • Запуск специального потока.

  • Использование таймера.

Всё же осмелюсь предложить ещё одну идею.

Идея решения

А давайте поручим убирать "мусор", то есть сессии с истекшим "сроком годности" сборщику мусора. Идея подписки на событие сборки мусора не моя, встречал её применение в демонстрационных целях и немного переработал на свой лад.

Мы вводим в систему источник события сборки мусора и каждую сессию на него подписываем. Когда событие происходит, сессия сама смотрит, стухла она или нет и в случае положительного ответа самоудаляется из словаря. Дальше в тексте будем называть сессию, на которую есть ссылка в словаре, присоединённой.

Класс-источник события сборки мусора GCNotifier.cs:

public static class GCNotifier
{
    private static GCEvent? _gcDone = null;

    /// <summary xml:lang="ru">
    /// Событие сборки мусора.
    /// </summary>
    public static event GCEvent? GCDone
    {
        add
        {
            if (_gcDone is null)
            {
                // При первой подписке создаём трекеры для каждого поколения
                // Так как на них нет ссылок, они сразу же попадают под 
                // утилизацию, но ведут себя при этом в зависимости от 
                // отслеживаемого ими поколения
                for(int i = 0; i <= GC.MaxGeneration; i++)
                {
                    new GenerationTracker(i);
                }
            }
            _gcDone += value;
        }
        remove
        {
            _gcDone -= value;
        }
    }

    /// <summary xml:lang="ru">
    /// Трекер для поколения сборки мусора
    /// </summary>
    private class GenerationTracker
    {
        private readonly int _trackedGeneration;

        public GenerationTracker(int trackedGeneration)
        {
            _trackedGeneration = trackedGeneration;
        }

        /// <summary xml:lang="ru">
        /// Финализатор позволяет доживать до соответствующего поколения
        /// </summary>
        ~GenerationTracker()
        {
            int currentGeneration = GC.GetGeneration(this);
            GCEvent? temp = Volatile.Read(ref _gcDone);
            if (currentGeneration == _trackedGeneration)
            {
                // Если есть подписчики, сигнализируем о событии
                temp?.Invoke(new GCEventArgs(currentGeneration));
            }
            if (temp is not null)
            {
                // Трекер поколения 0 порождается при сборке мусора в любом 
                // поколении
                // Трекер поколения 1 порождается при сборке мусора в 
              	// поколениях 1 и 2 и отсрочивает финализацию в поколении 0
                // Трекер поколения 2 никогда не финализируется
                if (_trackedGeneration <= currentGeneration 
                    && currentGeneration < GC.MaxGeneration)
                {
                    new GenerationTracker(_trackedGeneration);
                }
                else
                {
                    GC.ReRegisterForFinalize(this);
                }
            }
        }
    }
}

Соответствующий делегат и его аргумент GCEvent.cs:

public delegate void GCEvent(GCEventArgs args);

public class GCEventArgs: EventArgs
{
    public int Generation { get; init; }
    public GCEventArgs(int generation)
    {
        Generation = generation;
    }
}

Базовый класс сессии, которая это умеет GCManagedSession.cs:

public class GCManagedSession
{
    private DateTime _expirationTime = default(DateTime);
    private DateTime _startTime = default(DateTime);
    private object _key = null;
    private TimeSpan _lifeTime = TimeSpan.FromMilliseconds(-1);
    private ConcurrentDictionary<object, object>? _sessions = null;
    private object _lock = new object();

    /// <summary xml:lang="ru">
    /// Срок годности сессии
    /// </summary>
    public DateTime ExpirationTime
    {
        get
        {
            lock (_lock)
            {
                return _expirationTime;
            }
        }
    }

    /// <summary xml:lang="ru">
    /// Дата/время открытия сессии
    /// </summary>
    public DateTime StartTime
    {
        get
        {
            return _startTime;
        }
    }

    /// <summary xml:lang="ru">
    /// Текущая годность сессии
    /// </summary>
    public bool IsExpired
    {
        get
        {
            lock (_lock)
            {
                return _expirationTime <= DateTime.Now;
            }
        }
    }

    /// <summary xml:lang="ru">
    /// Ключ по которому сессия зарегистрирована в словаре сессий
    /// </summary>
    protected object Key => _key;

    /// <summary xml:lang="ru">
    /// Регистрирует в сессии словарь, ключ и время жизни
    /// Сессия должна перед вызовом уже находиться в словаре с 
  	/// предоставляемым ключом
    /// В этот момент сессия подписывается на события сборки мусора
    /// </summary>
    /// <param name="sessions"></param>
    /// <param name="key"></param>
    /// <param name="lifeTime"></param>
    /// <returns></returns>
    public bool TryConnect(ConcurrentDictionary<object, object> sessions, 
                           object key, TimeSpan lifeTime)
    {
        if (_sessions == null && sessions.TryGetValue(key, out object? self) 
            && Object.ReferenceEquals(self, this))
        {
            lock (_lock)
            {
                if (_sessions is null && sessions is not null 
                    && sessions.TryGetValue(key, out self) 
                    && Object.ReferenceEquals(self, this))
                {
                    _sessions = sessions;
                    _key = key;
                    _lifeTime = lifeTime;
                    _startTime = DateTime.Now;
                    _expirationTime = _startTime + _lifeTime;
                    GCNotifier.GCDone += GCEventHandler;
                    Connected();
                    return true;
                }
            }
        }
        return false;
    }

    /// <summary xml:lang="ru">
    /// Продлевает время жизни на заданную изначально величину
    /// </summary>
    public void Extend()
    {
        if (_expirationTime > DateTime.Now)
        {
            lock (_lock)
            {
                if (_expirationTime > DateTime.Now)
                {
                    _expirationTime = DateTime.Now + _lifeTime;
                }
            }
        }
    }

    /// <summary xml:lang="ru">
    /// На всякий случай, хотя бы для тестирования
    /// </summary>
    protected virtual void Connected()
    {

    }

    /// <summary xml:lang="ru">
    /// На всякий случай, хотя бы для тестирования
    /// </summary>
    protected virtual void Disconnected()
    {

    }

    /// <summary>
    /// Обрабатывает событие сборки мусора
    /// </summary>
    /// <param name="args"></param>
    private void GCEventHandler(GCEventArgs args)
    {
        if(IsExpired)
        { 
            GCNotifier.GCDone -= GCEventHandler;
            if(_sessions!.TryRemove(new KeyValuePair<object, object>(_key!, 
                                                                     this)))
            {
                Disconnected();
            }
        }
    }

}

Лабораторная установка

Мы будем в течение определённого времени совершать запросы с клиента на сервер из нескольких потоков. Сервер будет создавать какое-то количество объектов и возвращать статистику, которую мы будем показывать в интерфейсе клиента. Будет вариант с сессиями и без сессий, чтобы сравнить.

Чтобы испытать, как всё работает, создадим в VisualStudio Studio Community 2022 три проекта. Исходники доступны тут.

  • GCMSessionContract - библиотека классов, общих для сервера и клиента. Здесь находится класс с некоторыми константами и интерфейс класса для сбора и передачи статистики на клиента.

public class Constants
{
    public const string GCMSessionScheme = "GCMSessionScheme";
    public const string Session = "/session";
    public const string NoSession = "/noSession";
    public const string Clear = "/clear";

}


/// <summary xml:lang="ru">
/// Интерфейс классов для сбора статистики на сервере,
/// передачи её на клиента и отбражения там
/// </summary>
public interface IStat
{
    /// <summary xml:lang="ru">
    /// Количество запросов
    /// </summary>
    public long CountRequests { get; }

    /// <summary xml:lang="ru">
    /// Минимальная продолжительность запроса
    /// </summary>
    public TimeSpan MinRequestDuration { get; }

    /// <summary xml:lang="ru">
    /// Максимальная продолжительность запроса
    /// </summary>
    public TimeSpan MaxRequestDuration { get; }

    /// <summary xml:lang="ru">
    /// Средняя продолжительность запроса
    /// </summary>
    public TimeSpan AverageRequestDuration { get; }

    /// <summary xml:lang="ru">
    /// Текущее количество сессий в словаре
    /// </summary>
    public int CountSessions { get; }
    /// <summary xml:lang="ru">
    /// Максимальное количество сессий в словаре
    /// </summary>
    public int MaxCountSessions { get; }

    /// <summary xml:lang="ru">
    /// Среднее количество сессий в словаре
    /// </summary>
    public int AverageCountSessions { get; }

    /// <summary xml:lang="ru">
    /// Полное количество сессий, побывавших в словаре
    /// </summary>
    public int CountSessionsTotal { get; }

    /// <summary xml:lang="ru">
    /// Максимальное время, которое сессия прожила в словаре после истечения
    /// </summary>
    public TimeSpan MaxSessionOverlife { get; }

    /// <summary xml:lang="ru">
    /// Среднее время, которое сессия прожила в словаре после истечения
    /// </summary>
    public TimeSpan AverageSessionOverlife { get; }
}
  • GCMSessionServer - проект ASP.NET Core. В Program.cs Мы обрабатываем три маршрута с параметрами:

// Middleware для подсчёта запросов и возврата статистики
app.Use(async (context, next) =>
{
    DateTime start = DateTime.Now;
    await next(context);
    if(!context.Request.Path.ToString().StartsWith(Constants.Clear))
    {
        Stat.Instance.AddRequestDuration(DateTime.Now - start);
        await context.Response.WriteAsJsonAsync<IStat>(Stat.Instance);
    }
});

// Мапинг маршрута без сессий
app.MapGet($"{Constants.NoSession}/{{count=1000000}}", 
           async (HttpContext context, int count) =>
{
    Stat.Instance.Running = true;
    HardWorker.WorkHard(context, count);
    await Task.CompletedTask;
});

// Мапинг маршрута с сессиями
// Ключ сессии для простоты передаём в заголовке Authorization
app.MapGet($"{Constants.Session}/{{lifetimeSeconds=10}}/{{count=1000000}}", 
           async (HttpContext context, int lifetimeSeconds, int count) =>
{
    Session? session = null;
    int key = 0;
    object? sessionObject = null;

    Stat.Instance.Running = true;
    if (
        context.Request.Headers.Authorization.Count == 0
            || !int.TryParse(context.Request.Headers.Authorization[0]
            		.Substring(Constants.GCMSessionScheme.Length).Trim(), out key)
            || !sessions.TryGetValue(key, out sessionObject)
            || (sessionObject as Session).IsExpired
    )
    {
        session = context.RequestServices.GetRequiredService<Session>();
        key = Interlocked.Increment(ref _genSessionKey);
        sessions.TryAdd(key, session);
        session.TryConnect(sessions, key, 
                           TimeSpan.FromSeconds(lifetimeSeconds));
        Stat.Instance.AddSession();
    }
    else
    {
        session = sessionObject as Session;
        session.Extend();
    }

    context.Response.Headers.Authorization = 
      	$"{Constants.GCMSessionScheme} {key}";
    HardWorker.WorkHard(context, count);
    await Task.CompletedTask;

});

// Мапинг маршрута для сброса статистики
app.MapGet($"{Constants.Clear}", async (HttpContext context) =>
{
    sessions.Clear();
    Stat.Instance.Clear();
    await Results.Ok().ExecuteAsync(context);
});

В классе HardWorker расположен метод наполняющий лист какими-то ненужными объектами. Так мы влияем на частоту сборки мусора.

public static class HardWorker
{
    private const string GarbageNamePrefix = "Garbage #";
    public static void WorkHard(HttpContext context, int count)
    {
        List<Garbage> cats = new();
        for(int i = 0; i < count; i++)
        {
            cats.Add(new Garbage { Name = $"{GarbageNamePrefix}{i + 1}"});
        }
    }
}

public class Garbage
{
    public string Name { get; set; }
}

Класс Session унаследован от GCManagedSession. Мы выводим кое-что на консоль и сообщаем статистике при отсоединении сессии.

public class Session : GCManagedSession
{
    ~Session()
    {
        Console.WriteLine(
        	$"finalized: {Key} exp: {ExpirationTime}, now: {DateTime.Now}");
    }

    protected override void Disconnected()
    {
        base.Disconnected();
        Stat.Instance.RemoveSession(this);
        Console.WriteLine(
        	$"disconnected: {Key}, exp: {ExpirationTime}, now: {DateTime.Now}");
    }

    protected override void Connected()
    {
        base.Connected();
        Console.WriteLine($"connected: {Key}");
    }
}

Класс Stat реализует интерфейс IStat, также содержит методы для сбора статистики. Для краткости не буду приводить его полностью, можно при желании посмотреть в исходнике.

/// <summary xml:lang="ru">
/// Добавляет в статистику продолжительность запроса и устанавливает
/// ряд значений, касающихся запросов
/// </summary>
/// <param name="duration"></param>
public void AddRequestDuration(TimeSpan duration) { ... }

/// <summary xml:lang="ru">
/// Сообщает о факте присоединения сессии и устанавливает
/// ряд значений, касающихся сессий
/// </summary>
/// <param name="duration"></param>
public void AddSession() { ... }

/// <summary xml:lang="ru">
/// Сообщает о факте отсоединения сессии и устанавливает
/// ряд значений, касающихся сессий
/// </summary>
/// <param name="duration"></param>
public void RemoveSession(Session session) { ... }

/// <summary xml:lang="ru">
/// Сбрасывает накопленную статистику
/// </summary>
/// <param name="duration"></param>
public void Clear() { ... }
  • GCMSessionClient - проект WPF. Разметку рассматривать не будем, а в code-behind для простоты размещены методы, посылающие много запросов на сервер и отображающие статистику. Код для краткости привожу не полностью, он доступен в исходниках.

/// <summary>
/// Прерывает текущую серию запросов
/// </summary>
/// <returns></returns>
private async Task Stop()
{
		IsRunning = false;
    await Task.CompletedTask;
}

/// <summary>
/// Начинает серию запросов с открытием сессий
/// В параметрах пути передаём время жизни сессии и количество элементов, 
/// которые будут сгенерированы на сервере
/// </summary>

/// <returns></returns>
private async Task Session()
{
		IsRunning = true;
    await Run($"{Constants.Session}/{_lifetimeSeconds}/{_count}");
}

/// <summary>
/// Начинает серию запросов без открытия сессий
/// В параметре пути передаём количество элементов, 
/// которые будут сгенерированы на сервере
/// </summary>
/// <returns></returns>
private async Task NoSession()
{
		IsRunning = true;
    await Run($"{Constants.NoSession}/{_count}");
}

/// <summary>
/// Общий метод для сессий и без
/// Очищаем статистику на сервере, запускаем сколько нужно потоков
/// которые делают по одному запросу по соответствующему пути
/// Таким образом, в случае с сессиями, каждый раз открывается новая и потом
/// без продления устаревает
/// </summary>
/// <param name="url"></param>
/// <returns></returns>
private async Task Run(string url)
{
		HttpClient httpClient = new HttpClient();
    httpClient.BaseAddress = new Uri(Server);
    HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, 
                                                        Constants.Clear);
    HttpResponseMessage response = await httpClient.SendAsync(request);
    _start = DateTime.Now;
    Task[] tasks = new Task[_countThreads];
    for (int i = 0; i < _countThreads; i++)
    {
    		tasks[i] = GetResponse(url);
    }
    		await Task.WhenAll(tasks);
}

/// <summary>
/// Сам запрос и отображение статистики
/// </summary>
/// <param name="url"></param>
/// <returns></returns>
private async Task GetResponse(string url)
{
    while (IsRunning)
    {
        HttpClient httpClient = new HttpClient();
        httpClient.BaseAddress = new Uri(Server);
        HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get,
                                                            url);
        HttpResponseMessage response = await httpClient.SendAsync(request);
        Stat? stat = await JsonSerializer.DeserializeAsync<Stat>(
            response.Content.ReadAsStream(),
                        new JsonSerializerOptions { 
                          PropertyNameCaseInsensitive = true });

        if (stat is not null)
        {
            await Dispatcher.BeginInvoke(async () =>
            {
                Stat.Clear();
                TimeSpan elapsed = DateTime.Now - _start;
                Stat.Add(new("time", elapsed));
                foreach (PropertyInfo pi in typeof(Stat).GetProperties())
                {
                    Stat.Add(new(pi.Name, pi.GetValue(stat)));
                }
                if(elapsed.TotalSeconds >= _durationSeconds)
                {
                    StopCommand.Execute(null);
                }
            });
        }
    }
}

Запустим сервер и клиент. Будем из 10 потоков в течение 60 секунд делать запросы на создание 1000000 объектов без сессий.

Получилось 374 запроса в среднем по 1.4 секунды.

Теперь запустим с теми же параметрами с сессиями живущими по 10 секунд. Видим, как и ожидали, что полное количество открытых сессий CountSessionsTotal равно количеству запросов CountRequests, 387. Количество одновременно присоединённых сессий стабилизировалось на уровне ~80, при этом каждая сессия в среднем является присоединённой после истечения около 1.8 секунд.

Также мы видим, что нет заметной разницы в производительности между работой с сессиями и без них.

Вывод

Использование сборщика мусора для инициирования утилизации истекших сессий представляется жизнеспособной идеей. Кажется, в этом случае проверка сессий на возможность утилизации происходит в "ритме" необходимой очистки памяти в конкретных условиях работы сервера, а не по воле разработчика или какой-то специальной настройки.

Обновление: благодарю @Politura - комментарий о MemoryCache оказался очень полезным! Проверил и решил так и сделать.

Tags:
Hubs:
+1
Comments5

Articles

Change theme settings