title | lang |
---|---|
Лабораторная работа № 6. \ Асинхронное взаимодействие на базе потоков |
ru |
- Научиться создавать асинхронные серверы на основе потоков.
- Пронаблюдать типовые особенности реализации и работы таких приложений.
Во многих случаях желательно, чтобы сервер несколько клиентов одновременно, а не по очереди, как это делает программа из ЛР № 5. Например, посетители web-сайта не должны перед открытием страницы ожидать, пока другой пользователь получит от сервера свою. Одновременное обслуживание нескольких соединений называется асинхронным режимом работы.
Из курса системного ПО известен способ выполнять в программе несколько задач одновременно: потоки, или нити (threads). Достоинство этого подхода к асинхронности — простота: принципиально нужно лишь запускать цикл обработки запросов клиента в отдельном потоке, а в основном потоке продолжать прием новых подключений. Де-факто необходимо также, как в любом многопоточном приложении, обеспечить отсутствие гонок (races) при работе с общими ресурсами (разделяемыми, shared). Например, разделяемым ресурсом может быть список активных пользователей или банально стандартный поток вывода сообщений.
Наиболее просто отсутствие гонок обеспечить, если с разделяемым ресурсом в каждый момент времени сможет работать только один поток, а прочие потоки будут бездействовать в ожидании своей очереди. Из курса системного ПО же известно, что это решается примитивами синхронизации, конкретно — критической областью. Потоки пытаются войти в критическую область (временно захватить ее и выполнить часть кода), но преуспеть может лишь один поток, остальные же при попытке захвата переходят в состояние ожидания, пока критическая область не будет освобождена.
Одновременное обслуживание нескольких клиентов выгодно не только удобством, но и большей утилизацией канала связи, так как обычно сервер способен отдавать данные быстрее, чем клиент — принимать их. Однако совокупная скорость передачи, разумеется, ограничена каналом, и если один клиент способен полностью его задействовать, нескольким клиентам придется делить канал между собой.
Образец сервера (Windows, 32 бита):
lab06-threaded-server.exe
.
Средства для работы с потоками на C++ находятся в <thread>
:
#include <thread>
(@) Вынесите цикл обработки запросов клиента (все, что делается после
успешного accept()
) в отдельную функцию, которая имеет вид:
void serve_requests(SOCKET client, const sockaddr_in& peer);
(@) Вызывайте эту функцию не напрямую из той точки, где ранее располагался
цикл, а в новом потоке:
std::thread{serve_requests, client, peer}.detach();
std::thread{...}
создает новый объект, представляющий собой поток, в котором
будет выполняться функция serve_requests()
(первый аргумент), вызванная
с параметрами client
и peer
(оставшиеся аргументы).
У нового объекта сразу же вызывается метод detach()
. Если этого не сделать,
то по завершении итерации цикла приема подключений созданный объект будет
уничтожен (так как окончилась его область видимости), при том что функция
serve_requests()
в потоке не завершилась — такое стечение обстоятельств
приведет к краху программы. После detach()
же удаление объекта не повлияет
на выполняющийся поток.
(@) Проверьте способность сервера обслуживать несколько клиентов сразу.
Занесите в отчет вывод сервера. Порядок проверки:
* запустите один экземпляр сервера;
* подключитесь к нему одновременно двумя экземплярами клиента;
* убедитесь, запросы обоих клиентов обслуживаются (например, /list
);
* запустите параллельную загрузку клиентами больших файлов.
Последний пункт требует организовать передачу, которая шла бы несколько секунд и дольше. Самый простой способ — передавать крупный файл. Если подходящего нет, в Windows 7 и выше его можно создать через PowerShell (вызывается из меню «Пуск»), например, размером 100 МБ:
[io.file]::Create("big.file").SetLength(100MB).Close
Организуем на сервере подсчет средней скорости передачи файла клиенту. Для этого будем делить количество уже переданных байтов файла на время, прошедшее с начала передачи. Чтобы вывод сервера оставался читаемым и компактным, будем печатать статистику не чаще раза в секунду.
Функции для работы со временем в C++ — в заголовочном файле <chrono>
и в пространстве имен std::chrono
, а не std
. Одним из ключевых понятий
стандартной библиотеку являются часы (clock) — источник временных отметок
(time points). Для замеров скорости выберем std::chrono::steady_clock
,
которые будем называть Clock
, а их временную отметку — Time
.
Эти часы отличаются тем, что их время равномерно и монотонно возрастает,
с ним удобно работать, как это принято в физике. («Обычное» календарное время
отсчитывается значительно сложнее.)
using Clock = std::chrono::steady_clock;
using Time = std::chrono::time_point<Clock>;
(@) Напишите вспомогательную функцию, которая выдает количество миллисекунд,
прошедших со времени start_time
до текущего.
Получить текущее время выбранных часов можно функцией now()
:
uint32_t
get_elapsed_ms(Time start_time) {
const auto current_time = Clock::now();
Временные отметки можно вычитать, получая длительность (duration):
const auto time_elapsed = current_time - start_time;
Далее требуется представить длительность в нужных единицах (миллисекундах) и получить их количество (count):
const auto elapsed_milliseconds =
std::chrono::duration_cast<std::chrono::milliseconds>(time_elapsed);
return elapsed_milliseconds.count();
}
(@) В функции serve_file()
перед началом собственно передачи файла засекайте
текущее время, время последнего показа статистики (last_time
)
и количество переданных байтов bytes_sent
.
const auto start_time = std::chrono::steady_clock::now();
auto last_time = start_time;
uint32_t bytes_sent = 0;
(@) Успешной отправки очередной порции данных увеличивайте bytes_sent
на количество переданных байтов.
(@) После передачи же очередной порции проверяйте, не прошло ли с момента последнего показа статистики секунды. Если прошла, отображайте скорость и запоминайте текущее время как время последнего показа.
const auto current_time = Clock::now();
if (get_elapsed_ms(last_time) > 1000) {
std::clog << "info: transferring at "
<< 1000 * bytes_sent / get_elapsed_ms(start_time)
<< " bytes/sec\n";
last_time = current_time;
}
(@) После окончания передачи безусловно выводите среднюю скорость:
std::clog << "info: done transferring at "
<< 1000 * bytes_sent / get_elapsed_ms(start_time)
<< " bytes/sec\n";
(@) Подключитесь к серверу одним клиентом и загрузите крупный файл. Зафиксируйте в отчете вывод сервера.
(@) Подключитесь к серверу двумя клиентами и загрузить крупный файл одновременно, также зафиксировав вывод сервера в отчете.
**Примечание.** Вывод *может* выглядеть некорректно, как будто сообщения
для разных клиентов смешиваются (так и есть).
Отметим, что использовать среднюю скорость на протяжении сеанса на практике нужно с осторожностью, так как скорость может меняться в широких пределах. Наиболее ярко это можно наблюдать при загрузке крупных torrent´ов, например, дистрибутивов Linux и другого лицензионного ПО. В лабораторной работе средняя скорость используется как самая простая для расчета.
До сих пор потоки сервера работали без синхронизации с двумя разделяемыми ресурсами:
- стандартным потоком вывода ошибок (
stderr
илиstd::clog
); - глобальным буфером функции
inet_ntoa()
.
Предлагается взаимодействовать с ними только через функции готового модуля
sync.h
, который можно сохранить в каталог проекта и включить
директивой #include
в программу.
Модуль объявляет g_lock
— глобальную переменную-мьютекс (критическую область):
using Mutex = std::mutex;
static Mutex g_lock;
Функция void log(const char* format, ...)
работает так же,
как fprintf(stderr, ...)
, однако ее безопасно вызывать из нескольких потоков.
Также она предваряет вывод служебным номером (ID) потока, из которого вызвана.
Функция std::string endpoint_to_string(const sockaddr_in& address)
преобразует адрес в строку и потокобезопасна. Вообще говоря, вместо нее
следовало бы использовать inet_ntop()
или InetNtop()
, однако они доступны
лишь с Windows 8 или *nix.
Заслуживает внимание первая строка обеих функций:
Guard guard{g_lock};
Тип Guard
объявлен так:
using Guard = std::lock_guard<Mutex>;
Здесь применяется упоминавшийся в курсе «Технологии программирования»
подход RAII (захват ресурса есть инициализация [переменной]).
В данном случае при создании объекта guard
типа Guard
происходит вход
в критическую область, а при его удалении (по завершении функции) — выход.
Поскольку компилятор не может забыть удалить объект, программист не может
забыть выйти из критической области, если, например, из функции появятся новые
выходы. Без RAII нужно было бы вручную вызывать g_lock.lock()
в начале
функции и g_lock.unlock()
— перед каждым выходом (return
), а если забыть
это сделать, при следующей попытке входа в критическую область любой поток
окажется заблокирован навсегда.
(@) Замените весь вывод в консоль, кроме как в функциях ask_endpoint()
и hex_dump()
, на потокобезопансые вызовы log()
.
(@) Адаптируйте функцию hex_dump()
.
Чтобы не печатать ID потока у каждого байта, можно накапливать вывод
в потоке типа std::ostringstream
:
void
hex_dump(const void* data, size_t size) {
std::ostringstream buffer;
const auto bytes = reinterpret_cast<const uint8_t*>(data);
for (size_t i = 0; i < size; i++) {
if ((i + 1) % 16 == 0) {
Затем данные можно вывести одним вызовом log()
:
log("%s\n", buffer.str().c_str());
}
(@) Если ранее при загрузке двух файлов одновременно наблюдались искажения вывода, повторите эксперимент и убедитесь, что вывод корректен.
Эта ЛР защищается вместе с ЛР № 7.