Предыстория


Есть у меня маленький и уютный 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 — поэтому класс статический и один на всех, поэтому точность не особо. Я вижу много мелочей, которые можно было бы сделать лучше, но… всегда есть чем заняться ещё, так что пока скорость я считаю вот так и считаю что это не самый плохой вариант.

Комментарии (9)


  1. berez
    31.08.2019 13:27
    +1

    А если нужна средняя скорость за последние 24 часа — всю память отсчетами будем занимать? :)
    Я ожидал, что в конце вы таки придете к вычислению скользящего среднего (как это, например, сделано в линуксовом load average).
    Минусы:
    1. Надо с равными интервалами вызывать функцию, пересчитывающую текущее значение.
    2. Пересчитать из «среднего за час» в «среднее за минуту» не получится: нужно заранее решить, средние значения за какой период нас интересуют.
    Плюсы:
    1. Не надо хранить N последних отсчетов.
    2. Код простой и быстрый.


    1. MonkAlex Автор
      31.08.2019 13:37

      Я не случайно описывал свой кейс в начале — я только на него и рассчитывал.
      Да, среднюю за сутки считать так не получится, но у меня и задачи такой не стояло =)

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

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

      ПС: по линуксовому — первый раз вижу, почитаю и подумаю, спасибо =)


  1. dimaaan
    31.08.2019 15:20

    А вы не пробовали просто добавить в HttpClient System.Net.Http.Handlers.ProgressMessageHandler?


    1. MonkAlex Автор
      31.08.2019 15:29

      Не пробовал.

      А можете пояснить, что это такое и что дает? По описанию кажется, что это аналог eventArgs у WebClient.DownloadProgressChanged, и там о скорости нет ничего.


      1. dimaaan
        31.08.2019 15:51

        Да, это аналог. Вычисления скорости остаются, но можно убрать GetBytesAsync:


        using (var handler = new ProgressMessageHandler())
        using (var client = HttpClientFactory.Create(handler))
        {
            handler.HttpReceiveProgress += (s, e) => 
                NetworkSpeed.AddInfo(e.BytesTransferred  - lastRecorded);
            // use client here to download file
        }


        1. MonkAlex Автор
          31.08.2019 15:53

          Вариант, спасибо =)

          Я просто сам `HttpClient` не использую, поэтому был не в курсе.


  1. VADemon
    01.09.2019 19:15

    Вы своё приложение на гигабите (с задержками) тестировали? Меня смущает буфер в 80КБ. В худшем случае может же ограничивать скорость передачи.


    1. MonkAlex Автор
      01.09.2019 20:44

      Гигабита у меня под рукой нигде нет.
      80кб — стандартная реализация MS, взято прямо оттуда же:

              //We pick a value that is the largest multiple of 4096 that is still smaller than the large object heap threshold (85K).
              // The CopyTo/CopyToAsync buffer is short-lived and is likely to be collected at Gen0, and it offers a significant
              // improvement in Copy performance.
              private const int _DefaultCopyBufferSize = 81920;


    1. DistortNeo
      31.08.2019 23:18

      Почему смущает? За один вызов Read вы все равно не сможете прочитать больше данных, чем содержится в приёмном буфере сокета. Значение по умолчанию в Windows — 64K.


      На самом деле можно читать и кусками меньшего размера — даже 8K для гигабита будет нормально. На производительности это не скажется. На гигабите будет около 15K IOPS на чтение и столько же на запись при работе с буферами по 8К.