Skip to content

Latest commit

 

History

History
294 lines (222 loc) · 16.4 KB

lab06-threaded-server.md

File metadata and controls

294 lines (222 loc) · 16.4 KB
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) {
``` buffer.put('\n'); ```
``` } buffer << std::setw(2) << std::setfill('0') << std::hex << (int)bytes[i]; } ```

Затем данные можно вывести одним вызовом log():

    log("%s\n", buffer.str().c_str());
}

(@) Если ранее при загрузке двух файлов одновременно наблюдались искажения вывода, повторите эксперимент и убедитесь, что вывод корректен.

Контрольные задания

Эта ЛР защищается вместе с ЛР № 7.