Ведение журнала общих файлов между потоками в С++ 11

Недавно я начал изучать C++ 11. Я изучал C/C++ в течение короткого периода времени, когда учился в колледже. Я пришел из другой экосистемы (веб-разработка), поэтому, как вы можете себе представить, я относительно новичок в C++.

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

  • Мой первый вопрос и просьба заключались бы в том, чтобы указать на любые плохие практики/ошибки, которые я упустил из виду (хотя код работает с VC 2015).
  • Во-вторых, и это то, что меня больше всего беспокоит, это то, что я не закрываю дескриптор файла, и я не уверен, что это вызывает какие-либо проблемы. Если это так, когда и как было бы наиболее подходящим способом закрыть его?
  • Наконец, поправьте меня, если я ошибаюсь, я не хочу «приостанавливать» поток, пока другой поток пишет. Я пишу построчно каждый раз. Есть ли случай, когда вывод в какой-то момент испортится?

Большое спасибо за ваше время, ниже приведен источник (в настоящее время в учебных целях все находится внутри main.cpp).

#include <iostream>
#include <fstream>
#include <thread>
#include <string>

static const int THREADS_NUM = 8;

class Logger
{

  public:
    Logger(const std::string &path) : filePath(path)
    {
        this->logFile.open(this->filePath);
    }

    void write(const std::string &data)
    {
        this->logFile << data;
    }

  private:
    std::ofstream logFile;
    std::string filePath;

};

void spawnThread(int tid, std::shared_ptr<Logger> &logger)
{

    std::cout << "Thread " + std::to_string(tid) + " started" << std::endl;

    logger->write("Thread " + std::to_string(tid) + " was here!\n");

};

int main()
{

    std::cout << "Master started" << std::endl;
    std::thread threadPool[THREADS_NUM];

    auto logger = std::make_shared<Logger>("test.log");

    for (int i = 0; i < THREADS_NUM; ++i)
    {
        threadPool[i] = std::thread(spawnThread, i, logger);
        threadPool[i].join();
    }

    return 0;
}

PS1: в этом сценарии всегда будет открыт только 1 дескриптор файла для потоков для регистрации данных.

PS2: В идеале дескриптор файла должен закрываться прямо перед выходом из программы... Следует ли это делать в деструкторе Logger?

ОБНОВЛЕНИЕ

Текущий вывод с 1000 потоков следующий:

Thread 0 was here!
Thread 1 was here!
Thread 2 was here!
Thread 3 was here!
.
.
.
.
Thread 995 was here!
Thread 996 was here!
Thread 997 was here!
Thread 998 was here!
Thread 999 was here!

мусора пока не вижу...


person syd619    schedule 23.11.2017    source источник
comment
Как отмечали многие люди (включая меня), ваши журналы запускаются последовательно, а не параллельно, потому что вы присоединяетесь сразу после создания каждого потока.   -  person aerkenemesis    schedule 23.11.2017
comment
Да, я пропустил это, вы, ребята, правы :)   -  person syd619    schedule 23.11.2017


Ответы (4)


Мой первый вопрос и просьба заключались бы в том, чтобы указать на любые плохие практики/ошибки, которые я упустил из виду (хотя код работает с VC 2015).

Субъективно, но код выглядит нормально для меня. Хотя вы не синхронизируете потоки (некоторые std::mutex в регистраторе помогут).

Также обратите внимание, что это:

std::thread threadPool[THREADS_NUM];

auto logger = std::make_shared<Logger>("test.log");

for (int i = 0; i < THREADS_NUM; ++i)
{
    threadPool[i] = std::thread(spawnThread, i, logger);
    threadPool[i].join();
}

бессмысленно. Вы создаете тему, присоединяетесь к ней, а затем создаете новую. Я думаю, это то, что вы ищете:

std::vector<std::thread> threadPool;

auto logger = std::make_shared<Logger>("test.log");

// create all threads
for (int i = 0; i < THREADS_NUM; ++i)
    threadPool.emplace_back(spawnThread, i, logger);
// after all are created join them
for (auto& th: threadPool)
    th.join();

Теперь вы создаете все потоки, а затем ждете их всех. Не один за другим.

Во-вторых, и это то, что меня больше всего беспокоит, это то, что я не закрываю дескриптор файла, и я не уверен, что это вызывает какие-либо проблемы. Если это так, когда и как было бы наиболее подходящим способом закрыть его?

И когда вы хотите закрыть его? После каждой записи? Это было бы избыточной работой ОС без реальной выгоды. Файл должен быть открыт в течение всего времени жизни программы. Поэтому нет причин закрывать его вручную вообще. При изящном выходе std::ofstream вызовет свой деструктор, закрывающий файл. При неизящном выходе ОС все равно закроет все оставшиеся дескрипторы.

Однако было бы полезно очистить буфер файла (возможно, после каждой записи?).

Наконец, поправьте меня, если я ошибаюсь, я не хочу «приостанавливать» поток, пока другой поток пишет. Я пишу построчно каждый раз. Есть ли случай, когда вывод в какой-то момент испортится?

Да, конечно. Вы не синхронизируете записи в файл, вывод может быть мусором. На самом деле вы можете легко проверить это сами: создайте 10000 потоков и запустите код. Очень вероятно, что вы получите поврежденный файл.

Существует множество различных механизмов синхронизации. Но все они либо безблокировочные, либо на основе блокировок (или, возможно, смешанные). В любом случае простой std::mutex (базовая синхронизация на основе блокировки) в Класс регистратора должен быть в порядке.

person freakish    schedule 23.11.2017
comment
Спасибо за ваш ответ, я отредактировал свой вопрос. файл должен оставаться открытым в течение всего времени жизни программы. - person syd619; 23.11.2017
comment
@Syd Тебе не нужно делать ничего особенного. std::ofstream деструктор закрывает для вас дескриптор. Он сработает, когда больше не будет ссылок на logger, т.е. при выходе из программы. - person freakish; 23.11.2017
comment
Хорошо, также я пробовал с 1000 потоков, но вроде нормально. - person syd619; 23.11.2017
comment
@Syd Это потому, что ваш исходный код не является многопоточным. Смотрите мое обновление. - person freakish; 23.11.2017
comment
О, я не понимал, что делаю это в отношении потоков! Вы правы, теперь каждая строка лога кажется случайной (имеется в виду порядок добавления в файл) - person syd619; 23.11.2017
comment
@Syd Порядок - это одно - вы не можете его предсказать, и в большинстве случаев это не имеет большого значения. Но более опасно то, что файл может быть поврежден, то есть записи могут перекрываться, например. Thread 1Thread 2 was here! was here!, безусловно, возможно. Это реальная проблема, вы не хотите этого. Это настоящая причина, по которой вам нужен мьютекс (или что-то в этом роде). - person freakish; 23.11.2017
comment
Мьютекс является точкой сериализации и приостанавливает потоки, поэтому мне он не кажется идеальным (в общем случае). У меня было бы несколько очередей для строк журнала разных потоков и поток в цикле, который берет из очередей и записывает на диск. - person AndrewBloom; 20.01.2020
comment
@AndrewBloom невозможно синхронизировать потоки без пауз, они есть даже в решениях без блокировки (часто скрытых как цикл занятости, но это все же пауза в том смысле, что поток не делает ничего значимого). У вас могут быть очереди, но тогда вы должны синхронизировать доступ к ним. Вы можете использовать какое-нибудь решение без блокировки, но (1) оно сложное, (2) оно по-прежнему приостанавливает потоки (хотя, возможно, на более короткий период времени) и (3) это преждевременная оптимизация: если вы на самом деле не измерили это ведение журнала является узким местом производительности. - person freakish; 20.01.2020
comment
@freakish, как вы сказали, вам понадобится решение без блокировки. (1) в случае один производитель - один потребитель, это не так сложно. проверьте, например, github.com /google/oboe/blob/master/samples/RhythmGame/src/main/ . У Boost тоже есть свои реализации. (2) Он по-прежнему приостанавливает потоки. Это не. Эти методы на самом деле используются в звуковом программировании, где вам нужно предсказуемое поведение, близкое к реальному времени. (3) это преждевременная оптимизация. Это не. Плохой дизайн против хорошего. - person AndrewBloom; 21.01.2020
comment
@AndrewBloom (1) очередь одного производителя и одного потребителя безумно бесполезна, и здесь это не так (2) она приостанавливается в том смысле, что потоки выполняют бессмысленную работу. Даже атомарные примитивы ждут друг друга, просто пауза происходит на уровне процессора, а не ОС. (3) это преждевременная оптимизация. Это не имеет абсолютно никакого отношения ни к какому дизайну, это деталь реализации, которую при необходимости можно заменить в любой момент. Точно: если нужно. Навряд ли. - person freakish; 21.01.2020
comment
@freakish, 1) безумно бесполезен на основании чего? вы можете заменить очередь с несколькими производителями и одним потребителем N очередью с одним производителем и одним потребителем, это обычная стратегия. 2) Я предлагаю вам изучить тему блокировки свободного программирования и подождать свободного программирования, прежде чем делать такие утверждения. Что именно остановило бы на уровне процессора? потоки, выполняющие бессмысленную работу? потоки не останавливаются, поэтому они могут делать все, что угодно (хотя, если программист бессмысленен, они могут выполнять бессмысленную работу:)) - person AndrewBloom; 21.01.2020
comment
3) Это плохой дизайн, потому что вы используете рабочие потоки для записи на диск. disk может использоваться другими процессами, поэтому обычно используется новый поток для операций ввода-вывода. Это плохой дизайн, потому что вы непредсказуемо блокируете рабочие потоки без веской причины для этого. Представьте, что вам нужно отладить функцию, и она становится случайным образом медленнее, потому что другой поток переполняет регистратор. Удачи с этим. Это плохой дизайн, потому что вы сильно сериализуете потоки, и это противоречит тому факту, что у вас обычно есть потоки для распараллеливания работы. - person AndrewBloom; 21.01.2020
comment
Кроме того, используя блокирующие очереди, имея очередь для каждого потока (представьте себе, например, карту очередей на основе thread_id), рабочий поток будет заблокирован только потоком регистратора, а не всеми остальными 999. Очевидно, что использование thread_pool, а не 1000 потоков, будет дают очень небольшое количество очередей. (1000 потоков --› возможно плохой дизайн!) - person AndrewBloom; 21.01.2020
comment
@AndrewBloom с использованием N очередей не является решением. Как вы будете это потреблять? Занятая петля, хотя все, все время? Это неэффективно и нет, это даже не близко к обычному. Обман не сработает. Что касается пауз процессора, я предлагаю вам прочитать о блокировке шины. Плюс занятая петля (часто встречающаяся в алгоритмах без блокировки) далека от того, чтобы я мог сделать что-то еще. Эти алгоритмы работают не так. Всегда измеряйте, никогда не выполняйте предварительную оптимизацию. - person freakish; 22.01.2020
comment
Плохой дизайн — это трата времени на сложные алгоритмы без учета того, что они вам действительно нужны. Кстати, даже при отсутствии блокировки вам нужны блокировки, чтобы приостановить потребитель, когда очередь пуста. - person freakish; 22.01.2020
comment
@freakish, позвольте мне уточнить: я не критикую ваш ответ на этот вопрос, так как этот вопрос больше представляет собой упражнение по кодированию для начинающего программиста. Я просто указал, что логгер в реальном сценарии делается совершенно по-другому. Теперь, отвечая на ваши вопросы: вы используете N очередей с циклом for. в псевдокоде: для каждого q в очередях: копировать содержимое в один массив: упорядочивать элементы по метке времени, затем записывать в файл. - person AndrewBloom; 23.01.2020
comment
Занятый цикл, хотя все... в чистой реализации без блокировки вы можете использовать std::this_thread::yield или this_thread::sleep_for, которые обычно передают управление операционной системе, которая переключает потоки. Если вы хотите использовать atomic, вы можете обернуть push очереди без блокировки и установить для atomic_bool значение true и уведомить. atomic_bool разбудит ожидающий поток регистратора с condition_variable. Я посмотрел на блокировку шины, люди говорят, что это не проблема, так как процессор Pentium Pro. Позвольте мне добавить, что, учитывая аппаратную специфику, это определенно преждевременная оптимизация. - person AndrewBloom; 23.01.2020
comment
Вы не контролируете эти элементы, и они могут изменяться без предварительного уведомления (у всего есть исключения, если вы разрабатываете встроенные системы, вы можете, например, рассмотреть ограничения hd). Я согласен с вами в отношении общей цитаты Всегда измеряйте, никогда не выполняйте предварительную оптимизацию, хотя и не в этом случае. Позвольте мне закончить отчет о комментарии из ОС Android в их исходных файлах регистратора, которые вы можете увидеть здесь: - person AndrewBloom; 23.01.2020
comment
chromium.googlesource.com/aosp/platform/ system/core/+/master/ в строках 147-153 * Потоки, которые активно пишут в этот момент, не сдерживаются * блокировкой и рискуют отбросить сообщения с кодом возврата * -EBADF. Лучше возвращать код ошибки, чем добавлять накладные расходы на * блокировку к каждому вызову записи журнала, чтобы гарантировать доставку. Кроме того, * любой, кто вызывает это, делает это, чтобы высвободить ресурсы ведения журнала и завершить работу, * для них это с невыполненными запросами журнала в других потоках * является неискренним использованием этой функции. - person AndrewBloom; 23.01.2020
comment
Ваша чистая реализация без блокировок либо потребляет все ядро ​​​​процессора (цикл занятости с выходом потока или без него, не имеет значения), либо имеет задержки (sleep_for, как долго?). Переменные условия требуют мьютексов (блокировок). Вы снова пытаетесь обмануть. Кроме того, теперь у вас есть сложное решение и все еще есть блокировки. Наконец: программирование без блокировок связано с аппаратным обеспечением. Да, когда вы делаете это, вы должны думать о таких вещах, как блокировка автобуса. Да, это сложно, чревато ошибками и вряд ли оно того стоит. Думаю, я закончил с дальнейшим обсуждением этого. - person freakish; 23.01.2020
comment
О, еще одна вещь, которую вы можете прочитать: stackoverflow.com/questions/43540943/ из-за наивного мнения, что без блокировки лучше. И нет, обычно вы не видите алгоритмы без блокировки в реальном мире, только в некоторых редких случаях. - person freakish; 24.01.2020
comment
@freakish, позволь мне еще раз перефразировать, чтобы ты понял. Вы предлагаете использовать мьютекс для защиты дисковой операции. Критические секции, охраняемые мьютексами, должны быть как можно короче, быстрее и предсказуемее. Это золотое правило использования мьютексов. Вы предлагаете охранять операцию с коротким кодом, но очень медленную и совершенно непредсказуемую. Это гигантская ошибка с точки зрения дизайна, и я потрясен, что вы не можете этого признать. Вы похоже не понимаете разные временные шкалы критических секций и атомарных операций. - person AndrewBloom; 24.01.2020
comment
Ваш охраняемый раздел может занять секунды, минуты или даже дни в зависимости от активности диска, атомарная операция занимает наносекунды. Это как сравнивать диаметр луковицы и диаметр Солнечной системы. Вы, кажется, игнорируете разницу между циклом занятости, например for(i=0; i<1000000; i++); и this_thread::yield, первый тратит ядро ​​​​на бесполезный подсчет, последний передает управление планировщику ОС. поток перепланируется, это означает, что на этом ядре будет выполняться другой поток. Кроме того, вы должны лучше читать ссылку, которую вы разместили, особенно комментарии Zulan. - person AndrewBloom; 24.01.2020

Первая серьезная ошибка — это сказать «это работает с MSVC, я не вижу мусора», тем более что это работает только потому, что ваш тестовый код сломан (ну, он не сломан, но он не параллелен, так что конечно нормально работает).

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

Цель не блокировать («приостанавливать») один поток, пока другой выполняет запись, недостижима, если вы хотите корректности, по крайней мере, если они одновременно пишут в один и тот же дескриптор. Вы должны синхронизироваться правильно (называйте это как хотите и используйте любой метод), иначе поведение будет неверным. Или, что еще хуже, он будет выглядеть правильно до тех пор, пока вы на него смотрите, и будет вести себя неправильно через шесть месяцев, когда ваш самый важный клиент использует его для многомиллионного проекта.

В некоторых операционных системах вы можете «обмануть» и обойтись без синхронизации, поскольку они предлагают системные вызовы с гарантиями атомарности (например, writev). Однако это не то, что вы можете подумать, это действительно тяжеловесная синхронизация, просто вы этого не видите.

Лучшая (более эффективная) стратегия, чем использование мьютекса или атомарной записи, может состоять в том, чтобы иметь один поток-потребитель, который записывает на диск, и помещать задачи журнала в параллельную очередь из нескольких потоков-производителей. тебе нравится. Это имеет минимальную задержку для потоков, которые вы не хотите блокировать, и блокировки там, где вам все равно. Кроме того, вы можете объединить несколько небольших операций записи в одну.

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

Когда данные попали хотя бы в буфер ОС, все в порядке, если только не произойдет неожиданное отключение питания. Это не относится к двум другим уровням кеша!
Если ваш процесс неожиданно завершает работу, освобождается его память, в том числе все, что кешируется в iostream и все, что кешируется в CRT. Поэтому, если вам нужна хоть какая-то надежность, вам придется либо регулярно очищать (что дорого), либо использовать другую стратегию. Такой стратегией может быть сопоставление файлов, потому что все, что вы копируете в сопоставление, автоматически (по определению) находится в буферах операционной системы, и если не отключится питание или не взорвется компьютер, оно будет записано на диск.

При этом существуют десятки бесплатных и легкодоступных библиотек ведения журналов (таких как, например, spdlog), которые очень хорошо справляются со своей задачей. На самом деле нет особых причин изобретать это конкретное колесо.

person Damon    schedule 23.11.2017

Привет и добро пожаловать в сообщество!

Несколько комментариев по коду и несколько общих советов.

  1. Не используйте нативные массивы, если в этом нет абсолютной необходимости.

  2. Устранение родного массива std::thread[] и замена его на std::array позволит вам выполнить цикл for на основе диапазона, который является предпочтительным способом перебора элементов в C++. std::vector также будет работать, так как вам нужно сгенерировать потоки (что вы можете сделать с std::generate в сочетании с std::back_inserter)

  3. Не используйте интеллектуальные указатели, если у вас нет особых требований к управлению памятью, в этом случае подойдет ссылка на выделенный стек регистратор (регистратор, вероятно, все равно будет жить в течение всей программы, поэтому нет необходимости в явном управлении памятью). ). В C++ вы пытаетесь максимально использовать стек, динамическое выделение памяти во многих отношениях происходит медленно, а общие указатели создают накладные расходы (уникальные указатели — это абстракции с нулевой стоимостью).

  4. Соединение в цикле for, вероятно, не то, что вам нужно, оно будет ждать ранее созданный поток и порождает еще один после его завершения. Если вам нужен параллелизм, вам нужен еще один цикл for для соединений, но предпочтительным способом будет использование std::for_each(begin(pool), end(pool), [](auto& thread) { thread.join(); }) или чего-то подобного.

  5. Используйте Основные рекомендации C++ и недавний стандарт C++ (текущий C++17), C++11 устарел, и вы, вероятно, захотите изучить современные вещи, а не учиться писать устаревший код. http://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines

  6. C++ — это не java, максимально используйте стек — это одно из самых больших преимуществ использования C++. Убедитесь, что вы наизусть понимаете, как работают стек, конструкторы и деструкторы.

person aerkenemesis    schedule 23.11.2017
comment
Вы можете использовать range for с собственными массивами. - person krzaq; 23.11.2017

Первый вопрос субъективен, так что кто-то еще хотел бы дать совет, но я не вижу ничего ужасного.

Ничто в стандартной библиотеке C++ не является потокобезопасным, за исключением некоторых редких случаев. Хороший ответ об использовании ofstream в многопоточной среде приведен здесь.

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

person Volodymyr Lashko    schedule 23.11.2017
comment
Почему закрытие файла не может быть проблемой? ОС все равно закроет все оставшиеся дескрипторы файлов при выходе. Кроме того, поскольку RAII не защитит вас от таких вещей, как SIGKILL, я вообще не вижу причин для его реализации. - person freakish; 23.11.2017
comment
std::ofstream закрывает файл при уничтожении, что автоматически происходит с членом Logger при уничтожении Logger, или что вы имеете в виду? - person bipll; 23.11.2017
comment
Конечно, деструктор ofstream вызовет close(). Я хочу сказать, что было бы полезнее знать RAII, чем просто найти решение для использования, в частности, ofstream. - person Volodymyr Lashko; 23.11.2017