Управление потоками

Автор работы: Пользователь скрыл имя, 17 Июня 2013 в 11:58, реферат

Описание работы

Допустим, вы решили написать параллельную программу, а конкретно - использовать несколько потоков. Как в этом случае нужно поступить? Как запустить потоки, как узнать что поток завершился, и как отследить его выполнение? Средства, появившиеся в стандартной библиотеке с выходом нового стандарта С++11, позволяют относительно просто решить эти задачи. Для решения сложных задач библиотека позволяет построить то, что нужно из простейших кирпичиков.

Файлы: 1 файл

Управление потоками.docx

— 46.24 Кб (Скачать файл)

  

     void f(int i, string const& s); 

     

    void oops(int some_param) 

    { 

         char buffer[1024]; 

         sprintf(buffer, "%i", some_param);  

         std::thread t(f, 3, buffer); 

         t.detach();     

}

 

В данном случае в новый  поток передаётся указатель на buffer, и есть все шансы, что выход  из функции oops произойдёт раньше, чем  буфер будет преобразован к типу string в новом потоке. В таком случае мы получим неопределённое поведение. Решение заключается в том, что необходимо выполнить преобразование в string до передачи buffer конструктору std::thread.  

    

     void f(int i, string const& s); 

     

    void oops(int some_param) 

    { 

         char buffer[1024]; 

         sprintf(buffer, "%i", some_param);  

         std::thread t(f, 3, string(buffer)); // позволяет избежать "висячего" указателя  

         t.detach();     

     В данном случае проблема была в том, что мы положились на неявное преобразование указателя на buffer к ожидаемому типу первого параметра string, а конструктор std::thread копирует переданные значения "как есть".

 

Передача владения потоком 

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

     Именно здесь и оказывается полезной поддержка std::thread семантики перемещения. В примере ниже создаётся два потока выполнения, владение которыми передаётся между тремя объектами std::thread: t1, t2, t3. 

    

     void some_function(); 

    void some_other_function();

 

 

     std::thread t1(some_function); // 1 

    std::thread t2 = std::move(t1); // 2 

    t1 = std::thread(some_other_function); // 3  

    std::thread t3; // 4 

    t3 = std::move(t2); // 5 

    t1 = std::move(t3); // 6 

     

     Сначала создаётся поток (1) и связывается с объектом t1. Затем владение явно передаётся объекту t2 в момент его конструирования путём вызова std::move() (2). В этот момент с t1 уже не связан никакой поток выполнения: поток, в котором исполняется функция some_function, теперь связан с t2. 

     Далее создаётся ещё один поток, который связывается с временным объектом типа std::thread (3). Для последующей передачи владения объекту t1 уже не требуется явный вызов std::move(), так как владельцем является временный объект, а передача владения от временных объектов производится автоматически. 

     Объект t3 конструируется по умолчанию (4), а это означает, что в момент создания с ним не связывается никакой поток. Владение потоком, который в данный момент связан с t2, передаётся объекту t3 (5). После этих перемещений t1 оказывается связан с потоком, исполняющим функцию some_other_function, t2 не связан ни с каким другим потоком, а t3 связан с потоком, исполняющим функцию some_function. 

     Последнее перемещение (6) передаёт владение потоком, исполняющим some_function, обратно объекту t1, в котором исполнение этой функции началось. Однако теперь с t1 уже связан поток, поэтому вызывается std::terminate(), и программа завершается - нельзя просто "прихлопнуть" поток, присвоив новое значение объекту std::thread, который им управляет.

 

Задание количества потоков во время выполнения

В стандартной библиотеке С++ есть функция std::thread::hardware_concurrency(), которая справляется с этой задачей. В многоядерной системе это может быть, например, количество процессорных ядер. Возвращаемое значение - всего лишь оценка; более того, функция может возвращать 0, если получить требуемую информацию невозможно. Однако эту оценку можно применить с пользой для разбиения задачи на несколько потоков.

 

Идентификация потоков  

     Идентификатор потока имеет тип std::thread::id, и получить его можно двумя способами. Во-первых, идентификатор потока, связанного с std::thread, возвращает get_id() этого объекта. Если с std::thread не связан никакой поток, то get_id() возвращает сконструированный по умолчанию объект типа std::thread::id, что следует интерпретировать как "не поток". Идентификатор текущего потока можно получить с помощью std::this_thread::get_id(), которая также определена в заголовке <thread>.   

     Объекты типа std::thread::id можно без ограничений копировать и сравнивать, в противном случае они вряд ли могли бы играть роль идентификаторов. Если два объекта типа std::thread::id равны, то либо они представляют один и тот же поток, либо оба содержат значение "не поток". Если же два таких объекта не равны, то либо они представляют разные потоки, либо один представляет поток, а другой содержит значение "не поток". 

     Библиотека Thread Library не ограничивается сравнением идентификаторов потоков на равенство, для объектов типа std::thread::id определён полный спектр операторов сравнения. Это позволяет использовать их в качестве ключей ассоциативных контейнеров, сортировать и сравнивать любым интересующим способом.  

    Идентификаторы потоков можно было бы так же использовать как ключи ассоциативных контейнеров, если с потоком нужно ассоциировать какие-то данные, а другие механизмы, например поточно-локальная память, не подходят. Например, управляющий поток мог бы сохранить в таком контейнере информацию о каждом управляемом потоке. Другое применение подобного контейнера - передавать информацию между потоками.  

     Идея заключается в том, что в большинстве случаев std::thread::id вполне может служить обобщённым идентификатором потока и лишь, если с идентификатором необходимо связать какую-то семантику (использовать как индекс массива), может понадобиться другое решение. Можно даже выводить std::thread::id в поток вывода: 

    cout << std::this_thread::get_id();

 

Разделение данных между  потоками

 

 

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

 

Проблемы разделения данных между потоками 

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

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

     Рассмотрим двусвязный список, в котором каждый узел содержит указатели на следующий и предыдущий узел. Один из инвариантов формулируется так: если "указатель на следующий" в узле А указывает на узел В, то "указатель на предыдущий" в В указывает на А. Чтобы удалить узел из списка, необходимо обновить узлы по обе стороны от него, так чтобы они указывали друг на друга. После обновления инвариант оказывается нарушен и остаётся таковым пока не будет обновлён узел по другую сторону. После того, как удаление завершено инвариант снова выполняется.  

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

 

Гонки 

     В параллельном программировании под состоянием гонки понимается любая ситуация, исход которой зависит от относительного порядка выполнения операции в двух или более потоках - потоки конкурируют за право выполнить операции первыми. Как правило, ничего плохого в этом нет, потому что все исходы приемлемы, даже если взаимный порядок может меняться. Например, если два потока добавляют элементы в очередь для обработки, то неважно, какой элемент будет первым добавлен, лишь бы не нарушались инварианты системы. Проблема возникает, когда гонка приводит к нарушению инварианта. В стандарте С++ также определён термин гонка за данными, означающий ситуацию, когда гонка возникает из-за одновременной модификации одного объекта. 

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

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

 

Устранение проблематичных состояний гонки 

     Существует несколько способов борьбы с проблематичными гонками. Простейший из них - снабдить структуру данных неким защитным механизмом, который гарантирует, что только поток, выполняющий модификацию, может видеть промежуточные состояния, в которых инварианты нарушены; с точки зрения других потоков, обращающихся к той же структуре данных, модификация либо ещё не начиналась, либо уже закончилась.  

     Другой вариант - изменить дизайн структуры данных и её инварианты, так чтобы модификация представляла собой последовательность неделимых изменений, каждое из которых сохраняет инварианты. Этот подход называют программированием без блокировок и реализовать его очень трудно. 

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

     Самый простой механизм защиты разделяемых данных из описанных в стандарте С++ - это мьютекс.

 

Защита разделяемых  данных с помощью мютексов 

     Итак, у нас есть структура данных, например связанный список, и мы хотим защитить его от гонки и нарушения инвариантов. Для решения данной задачи больше всего подходит примитив синхронизации, который называется мьютекс. Перед тем как обратиться к структуре данных, программа захватывает мьютекс, а по завершении операций с ней освобождает его. Библиотека Thread Library гарантирует, что если один поток захватил некоторый мьютекс, то все остальные потоки, пытающиеся захватить тот же мьютекс, будут вынуждены ждать, пока удачливый конкурент не освободит его. В результате все потоки видят согласованное представление разделяемых данных, без нарушения инвариантов.  

     Мьютексы - наиболее общий механизм защиты данных в С++, но панацеей они не являются.

 

Использование мьютексов  в С++ 

     В С++ для создания мьютекса следует сконструировать объект типа std::mutex, для захвата мьютекса служит функция-член lock(), а для освобождения - unlock(). Вызывать эти функции напрямую не рекомендуется, вместо этого в стандартной библиотеке имеется шаблон класса std::lock_guard, который реализует идиому RAII - захватывает мьютекс в конструкторе и освобождает в деструкторе, - гарантируя тем самым, что захваченный мьютекс обязательно будет освобождён.  

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

 

Структурирование  кода для защиты разделяемых данных  

     Для защиты данных с помощью мьютекса недостаточно просто "воткнуть" объект std::lock_guard в каждую функцию-член класса. На некотором уровне проверить наличие таких отбившихся указателей легко - если ни одна функция-член не передаёт вызывающей программе указатель или ссылку на защищённые данные в виде возвращаемого значения или выходного параметра, то данные в безопасности. Но стоит капнуть чуть глубже, как выясняется, что не всё так просто. Недостаточно просто проверить, что функции-члены не возвращают указатели и ссылки вызывающей программе, нужно ещё убедиться, что такие указатели и ссылки не передаются в виде входных параметров вызываемыми ими функциям, которые вы не контролируете. Что, если такая функция сохранит где-то указатель или ссылку, а потом какой-то другой код обратится к данным, не захватив предварительно мьютекс? 

Информация о работе Управление потоками