Предыстория
Есть у меня маленький и уютный pet-project, который позволяет качать файлы из интернета. Файлы при этом группируются и пользователю отображается не каждый файл, а некоторая группировка. И весь процесс скачивания (и отображение этого процесса) сильно зависел от данных. Данные при этом получались на лету, т.е. пользователь запускает на скачивание и нет никакой информации, сколько придётся качать в реальности.
Наивная реализация хоть какого-то информирования сделана простой — отображается прогресс скачивания как отношение количества скачанных к общему количеству. Информации для пользователя не много — просто ползущая полоска, но это уже лучше чем ничего, и это заметно лучше чем популярный нынче механизм загрузки без индикации прогресса.
И тут появляется пользователь с логичной проблемой — на большой группировке непонятно, почему прогресс еле ползёт — много файлов надо скачать или низкая скорость? Как я упоминал выше — количество файлов заранее неизвестно. Поэтому, я принял решение добавить счетчик скорости.
Анализ
Хорошая практика — посмотреть тех, кто уже решал аналогичную задачу, чтобы не изобретать велосипед. Разный софт закрывает этим разные задачи, но выглядит отображение довольно одинаково:
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();
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);
var httpClient = new HttpClient();
var content = await httpClient.GetStreamAsync(uri);
var array = await content.GetBytesAsync();
Хорошо, половина задачи решена — мы знаем, сколько мы скачали. Переходим к скорости.
Согласно википедии :
Скорость передачи данных — объём данных, передаваемых за единицу времени.
Первый наивный подход
У нас есть объём. Время можно взять буквально запуска и получать разницу с DateTime.Now
. Берем и делим?
Для консольных утилит типа curl такое возможно и имеет смысл.
Но если ваше приложение чуть сложнее, то буквально кнопка "пауза" резко усложнит вам жизнь.
Немного про паузу
Может я очень наивен, а может вопрос действительно не так прост — но пауза меня заставляет задумываться постоянно. Пауза при скачивании может вести себя минимум тремя способами:
- прерывать закачку файлов, начинать заново после
- просто не качать файл дальше, надеяться что сервер даст продолжить после
- докачивать уже начатые файлы, не качать новые, качать новые после
Так как первые два приводят к потере уже скачанной информации, я у себя использую третий.
Чуть выше я обращал внимание, что скорость нужна именно на момент времени. Так вот, пауза это дело усложняет:
- нельзя нормально посчитать, какой была средняя скорость, просто взяв объем на время
- пауза может иметь внешние причины, которые поменяют скорость и канал (переподключение к сети провайдера, переключение на VPN, завершение uTorrent-a занявшего весь канал), что приведёт к изменению реальной скорости
Фактически, пауза разделяет любые показатели на до и после неё. Это не влияет особо на код ниже, просто минутка забавной информации на подумать.
Второй наивный подход
Добавим таймер. Таймер каждый период времени будет брать всю свежую информацию о скачанном объеме и пересчитывать показатель скорости. А если таймер поставить в секунду, то вся полученная за эту секунду информация о скачанном объеме и будет равна скорости за эту секунду:
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мбит выглядело слишком уж подозрительно.
Третий подход
Второй вариант уже достаточно близок к правде, плюс у него ошибка наблюдалась больше на старте закачки, а не на всём жизненном цикле.
Поэтому, простое решение — брать в качестве показателя не цифру за секунду, а среднюю за три последние секунды. Три здесь — магическая константа, подобранная на глаз. С одной стороны хотелось приятное отображение роста и спада скорости, с другой — чтобы скорость была поближе к правде.
Реализация чуть усложняется, но в целом ничего такого:
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МБ.
Как это в итоге выглядит:
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)
dimaaan
31.08.2019 15:20А вы не пробовали просто добавить в HttpClient System.Net.Http.Handlers.ProgressMessageHandler?
MonkAlex Автор
31.08.2019 15:29Не пробовал.
А можете пояснить, что это такое и что дает? По описанию кажется, что это аналог eventArgs у WebClient.DownloadProgressChanged, и там о скорости нет ничего.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 }
MonkAlex Автор
31.08.2019 15:53Вариант, спасибо =)
Я просто сам `HttpClient` не использую, поэтому был не в курсе.
VADemon
01.09.2019 19:15Вы своё приложение на гигабите (с задержками) тестировали? Меня смущает буфер в 80КБ. В худшем случае может же ограничивать скорость передачи.
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;
DistortNeo
31.08.2019 23:18Почему смущает? За один вызов Read вы все равно не сможете прочитать больше данных, чем содержится в приёмном буфере сокета. Значение по умолчанию в Windows — 64K.
На самом деле можно читать и кусками меньшего размера — даже 8K для гигабита будет нормально. На производительности это не скажется. На гигабите будет около 15K IOPS на чтение и столько же на запись при работе с буферами по 8К.
berez
А если нужна средняя скорость за последние 24 часа — всю память отсчетами будем занимать? :)
Я ожидал, что в конце вы таки придете к вычислению скользящего среднего (как это, например, сделано в линуксовом load average).
Минусы:
1. Надо с равными интервалами вызывать функцию, пересчитывающую текущее значение.
2. Пересчитать из «среднего за час» в «среднее за минуту» не получится: нужно заранее решить, средние значения за какой период нас интересуют.
Плюсы:
1. Не надо хранить N последних отсчетов.
2. Код простой и быстрый.
MonkAlex Автор
Я не случайно описывал свой кейс в начале — я только на него и рассчитывал.
Да, среднюю за сутки считать так не получится, но у меня и задачи такой не стояло =)
В целом, счетчик сделан условно realtime, для отображения. Если нужна возможность нарисовать график, пересчитать в других единицах и прочее — надо куда то агрегировать уже посчитанные данные, т.е. делать дополнительную логику которая всё это сможет покрутить.
Качественный вызов таймера единственное что меня и самого смущает — он и на тредпуле, а тот может быть загружен, и не дает 100% гарантии что будет вызван вовремя.
С другой стороны, цифра просто будет немного колебаться, потери данных никакой не будет.
ПС: по линуксовому — первый раз вижу, почитаю и подумаю, спасибо =)