Подсчет скорости скачивания в вашем приложении

в 8:30, , рубрики: .net, C#, скорость загрузки

Предыстория

Есть у меня маленький и уютный pet-project, который позволяет качать файлы из интернета. Файлы при этом группируются и пользователю отображается не каждый файл, а некоторая группировка. И весь процесс скачивания (и отображение этого процесса) сильно зависел от данных. Данные при этом получались на лету, т.е. пользователь запускает на скачивание и нет никакой информации, сколько придётся качать в реальности.

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

И тут появляется пользователь с логичной проблемой — на большой группировке непонятно, почему прогресс еле ползёт — много файлов надо скачать или низкая скорость? Как я упоминал выше — количество файлов заранее неизвестно. Поэтому, я принял решение добавить счетчик скорости.

Анализ

Хорошая практика — посмотреть тех, кто уже решал аналогичную задачу, чтобы не изобретать велосипед. Разный софт закрывает этим разные задачи, но выглядит отображение довольно одинаково:

uTorrent DownloadMaster
uTorrent DownloadMaster

Ключевой момент, который я выделил для себя — в первую отображение скорости нужно на текущий момент времени. Не какая скорость была средней, не какая скорость в целом средняя с момента начала, а именно какова эта цифра на текущий момент. На самом деле это важно, когда дойду до кода — поясню отдельно.

Итак, нам нужна простая цифра вида 10 MB/s или что-то подобное. Как же нам её посчитать?

Теория и практика

Существующая реализация скачивания использовала HttpWebRequest и я решил не переделывать само скачивание — не стоит трогать работающий механизм.

Итак, начальная реализация без какого-либо подсчета:

            var request = WebRequest.Create(uri);
            var response = await request.GetResponseAsync();
            using (var ms = new MemoryStream())
            {
                await response.GetResponseStream().CopyToAsync(ms);
                return ms.ToArray();
            }

На уровне такого API реагировать можно только на полное скачивание файла, для небольших групп (или даже для одного файла) скорость фактически не посчитать. Идём за исходниками CopyToAsync, копипастим оттуда простую логику:

            byte[] buffer = new byte[bufferSize];
            int bytesRead;
            while ((bytesRead = await ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0)
            {
                await destination.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false);
            }

Теперь мы можем реагировать на каждый буфер, отданный нам по сети.

Итак, во первых, что мы делаем вместо коробочного CopyToAsync:

        public static async Task<byte[]> GetBytesAsync(this Stream from)
        {
            using (var memory = new MemoryStream())
            {
                byte[] buffer = new byte[81920];
                int bytesRead;
                while ((bytesRead = await from.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) != 0)
                {
                    await memory.WriteAsync(buffer, 0, bytesRead).ConfigureAwait(false);
                    NetworkSpeed.AddInfo(bytesRead);
                }
                return memory.ToArray();
            }
        }

Единственное, что реально добавлено — NetworkSpeed.AddInfo. И единственное, что мы передаем — количество скачанных байт.

Сам код для скачивания выглядит в итоге так:

            var request = WebRequest.Create(uri);
            var response = await request.GetResponseAsync();
            var array = await response.GetResponseStream().GetBytesAsync();

Вариант для WebClient

            var client = new WebClient();
            var lastRecorded = 0L;
            client.DownloadProgressChanged += (sender, eventArgs) =>
            {
                NetworkSpeed.AddInfo(eventArgs.BytesReceived - lastRecorded);
                lastRecorded = eventArgs.BytesReceived;
            };
            var array = await client.DownloadDataTaskAsync(uri);

Вариант для HttpClient

            var httpClient = new HttpClient();
            var content = await httpClient.GetStreamAsync(uri);
            var array = await content.GetBytesAsync();

Хорошо, половина задачи решена — мы знаем, сколько мы скачали. Переходим к скорости.

Согласно википедии :

Скорость передачи данных — объём данных, передаваемых за единицу времени.

Первый наивный подход

У нас есть объём. Время можно взять буквально запуска и получать разницу с DateTime.Now. Берем и делим?
Для консольных утилит типа curl такое возможно и имеет смысл.
Но если ваше приложение чуть сложнее, то буквально кнопка "пауза" резко усложнит вам жизнь.

Немного про паузу
Может я очень наивен, а может вопрос действительно не так прост — но пауза меня заставляет задумываться постоянно. Пауза при скачивании может вести себя минимум тремя способами:

  • прерывать закачку файлов, начинать заново после
  • просто не качать файл дальше, надеяться что сервер даст продолжить после
  • докачивать уже начатые файлы, не качать новые, качать новые после

Так как первые два приводят к потере уже скачанной информации, я у себя использую третий.
Чуть выше я обращал внимание, что скорость нужна именно на момент времени. Так вот, пауза это дело усложняет:

  • нельзя нормально посчитать, какой была средняя скорость, просто взяв объем на время
  • пауза может иметь внешние причины, которые поменяют скорость и канал (переподключение к сети провайдера, переключение на VPN, завершение uTorrent-a занявшего весь канал), что приведёт к изменению реальной скорости
    Фактически, пауза разделяет любые показатели на до и после неё. Это не влияет особо на код ниже, просто минутка забавной информации на подумать.

Второй наивный подход

Добавим таймер. Таймер каждый период времени будет брать всю свежую информацию о скачанном объеме и пересчитывать показатель скорости. А если таймер поставить в секунду, то вся полученная за эту секунду информация о скачанном объеме и будет равна скорости за эту секунду:

Реализация класса NetworkSpeed целиком

    public class NetworkSpeed
    {
        public static double TotalSpeed { get { return totalSpeed; } }

        private static double totalSpeed = 0;

        private const uint TimerInterval = 1000;

        private static Timer speedTimer = new Timer(state =>
        {
            var now = 0L;
            while (ReceivedStorage.TryDequeue(out var added))
                now += added;
            totalSpeed = now;
        }, null, 0, TimerInterval);

        private static readonly ConcurrentQueue<long> ReceivedStorage = new ConcurrentQueue<long>();

        public static void Clear()
        {
            while (ReceivedStorage.TryDequeue(out _))
            {
            }

            totalSpeed = 0;
        }

        public static void AddInfo(long received)
        {
            ReceivedStorage.Enqueue(received);
        }
    }

По сравнению с первым вариантом, такая реализация начинает реагировать на паузу — скорость снижается до 0 в ближайшую секунду после того, как перестают приходить данные снаружи.
Но, есть и минусы. Мы работаем с буфером в 80кб, а значит загрузка начатая в одной секунде, отобразится только в следующей. И при большом потоке параллельных загрузок такие погрешности в измерениях будут отображать что угодно — у меня разброс был до 30% от реальных цифр. Я бы может и не заметил, но превышение 100мбит выглядело слишком уж подозрительно.

Третий подход

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

Реализация чуть усложняется, но в целом ничего такого:

Реализация класса NetworkSpeed целиком

    public class NetworkSpeed
    {
        public static double TotalSpeed { get { return totalSpeed; } }

        private static double totalSpeed = 0;

        private const uint Seconds = 3;

        private const uint TimerInterval = 1000;

        private static Timer speedTimer = new Timer(state =>
        {
            var now = 0L;
            while (ReceivedStorage.TryDequeue(out var added))
                now += added;
            LastSpeeds.Enqueue(now);
            totalSpeed = LastSpeeds.Average();
            OnUpdated(totalSpeed);
        }, null, 0, TimerInterval);

        private static readonly LimitedConcurrentQueue<double> LastSpeeds = new LimitedConcurrentQueue<double>(Seconds);

        private static readonly ConcurrentQueue<long> ReceivedStorage = new ConcurrentQueue<long>();

        public static void Clear()
        {
            while (ReceivedStorage.TryDequeue(out _))
            {
            }
            while (LastSpeeds.TryDequeue(out _))
            {
            }

            totalSpeed = 0;
        }

        public static void AddInfo(long received)
        {
            ReceivedStorage.Enqueue(received);
        }

        public static event Action<double> Updated;

        private class LimitedConcurrentQueue<T> : ConcurrentQueue<T>
        {
            public uint Limit { get; }

            public new void Enqueue(T item)
            {
                while (Count >= Limit)
                    TryDequeue(out _);
                base.Enqueue(item);
            }

            public LimitedConcurrentQueue(uint limit)
            {
                Limit = limit;
            }
        }

        private static void OnUpdated(double obj)
        {
            Updated?.Invoke(obj);
        }
    }

Пара моментов:

  • на момент реализации не нашел готовой очереди с ограничением на количество элементов и взял её в интернете, в коде выше это LimitedConcurrentQueue.
  • вместо реализации INotifyPropertyChanged почему то Action, использование фактически одинаковое, причин не помню. Логика простая — показатель меняется, надо пользователей об этом уведомить. Реализация может быть любой, хоть IObservable, кому как удобнее.

И немного читабельности

API отдает скорость в байтах, для читаемости пригодится простой (взятый в интернете)

конвертер

    public static string HumanizeByteSize(this long byteCount)
    {
      string[] suf = { "B", "KB", "MB", "GB", "TB", "PB", "EB" }; //Longs run out around EB
      if (byteCount == 0)
        return "0" + suf[0];
      long bytes = Math.Abs(byteCount);
      int place = Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024)));
      double num = Math.Round(bytes / Math.Pow(1024, place), 1);
      return Math.Sign(byteCount) * num + suf[place];
    }

    public static string HumanizeByteSize(this double byteCount)
    {
      if (double.IsNaN(byteCount) || double.IsInfinity(byteCount) || byteCount == 0)
        return string.Empty;

      return HumanizeByteSize((long)byteCount);
    }

Напомню, что скорость в байтах, т.е. на 100мбитный канал должно выдать не более 12.5МБ.

Как это в итоге выглядит:

Скачивание образа ubuntu

Current speed 904,5KB/s
Current speed 1,8MB/s
Current speed 2,9MB/s
Current speed 3,2MB/s
Current speed 2,9MB/s
Current speed 2,8MB/s
Current speed 3MB/s
Current speed 3,1MB/s
Current speed 3,2MB/s
Current speed 3,3MB/s
Current speed 3,5MB/s
Current speed 3,6MB/s
Current speed 3,6MB/s
Current speed 3,6MB/s
...

Ну и несколько образов сразу

Current speed 1,2MB/s
Current speed 3,8MB/s
Current speed 7,3MB/s
Current speed 10MB/s
Current speed 10,3MB/s
Current speed 10MB/s
Current speed 9,7MB/s
Current speed 9,8MB/s
Current speed 10,1MB/s
Current speed 9,8MB/s
Current speed 9,1MB/s
Current speed 8,6MB/s
Current speed 8,4MB/s
...

Заключение

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

Хочется сказать спасибо Stack Overflow на русском и конкретно VladD-exrabbit — в хорошем вопросе хоть и есть половина ответа, но любые подсказки и любая помощь всегда двигают тебя вперёд.

Хочу напомнить, что это pet-project — поэтому класс статический и один на всех, поэтому точность не особо. Я вижу много мелочей, которые можно было бы сделать лучше, но… всегда есть чем заняться ещё, так что пока скорость я считаю вот так и считаю что это не самый плохой вариант.

Автор: MonkAlex

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js