Потоки

Что такое поток?

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

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

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

Потоки SFML или std::thread?

В своей последней версии (2011) стандартная библиотека С++ предоставила набор классов для работы с потоками. В то время когда была написана SFML, ещё не существовало стандарта С++11 и не было стандартного пути для создания потоков. На момент релиза SFML 2.0, большинство компиляторов ещё не поддерживало новый стандарт С++11.

Если вы работаете с компилятором поддерживающим новый стандарт и его заголовок <thread>, то забудьте про класс SFML и используйте его — это будет намного лучше. Если же вы работаете с компилятором не поддерживающим новый стандарт C++11 или планируете распространять свой код и сделать его полностью переносимым, тогда использование потоков, реализованных через SFML, будет хорошим решением.

Создание потока с помощью SFML

Хватит болтать, давайте уже посмотрим на код. Класс для реализации потока в SFML называется sf::Thread. Вот пример того, как его можно использовать:

#include <SFML/System.hpp>
#include <iostream>

void func()
{
    // эта функция запускается после вызова thread.launch()

    for (int i = 0; i < 10; ++i)
        std::cout << "I'm thread number one" << std::endl;
}

int main()
{
    // создаём поток с функцией func() в качестве входной точки
    sf::Thread thread(&func);

    // запускаем поток
    thread.launch();

    // главный поток при этом продолжает работу...

    for (int i = 0; i < 10; ++i)
        std::cout << "I'm the main thread" << std::endl;

    return 0;
}

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

system-thread-mixed

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

— Функция не метод класса с одним аргументом:

void func(int x)
{
}

sf::Thread thread(&func, 5);

— Метод класса:

class MyClass
{
public:

    void func()
    {
    }
};

MyClass object;
sf::Thread thread(&MyClass::func, &object);

— Функтор (функция-объект):

struct MyFunctor
{
    void operator()()
    {
    }
};

sf::Thread thread(MyFunctor());

Этот последний пример, с использованием функтора, один из самых действенных, поскольку можно взять функтор любого вида, и, тем самым, сделать класс sf::Thread  совместимым с функциями, виды которых напрямую не поддерживаются. Это свойство особенно интересно с учётом поддержки в C++11 лямбда-выражений и std::bind.

// с лямбда-выражением
sf::Thread thread([](){
    std::cout << "I am in thread!" << std::endl;
});
// c std::bind void func(std::string, int, double) { } sf::Thread thread(std::bind(&func, "hello", 24, 0.5));

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

class ClassWithThread
{
public:

    ClassWithThread()
    : m_thread(&ClassWithThread::f, this)
    {
    }

private:

    void f()
    {
        ...
    }

    sf::Thread m_thread;
};

Если вам нужно создать свой экземпляр sf::Thread после создания вашего объекта, вы можете так же задержать его создание с помощью размещения этого экземпляра в куче используя оператор new. (Если честно, то я не уверен что абсолютно правильно перевёл это предложение.. поэтому предоставляю оригинал:  If you really need to construct your sf::Thread instance after the construction of the owner object, you can also delay its construction by allocating it on the heap with new.)

Запуск потока

После создания экземпляра sf::Thread, его необходимо запустить с помощью функции launch().

sf::Thread thread(&func);
thread.launch();

launch() вызывает функцию, которую вы передали в конструктор нового потока, и тут же заканчивает свою работу, что бы вызывающий поток мог продолжить свою работу.

Остановка потока

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

sf::Thread thread(&func);

// запуск потока
thread.launch();

...

// блокирование выполнения, пока поток не завершит работу
thread.wait();

Функция wait() неявно вызывается деструктором sf::Thread, так что поток не может остаться в живых (и  выйти их под контроля). Имейте это ввиду когда будите управлять вашими потоками.

Пауза для потока

sf::Thread не имеет функции которая могла бы позволить одному потоку приостановить работу другого, единственный способ это осуществить заключается в том, что бы поток который хотим приостановить, приостанавливать изнутри. Это можно сделать используя функцию sf::sleep() :

void func()
{
    ...
    sf::sleep(sf::milliseconds(10));
    ...
}

sf::sleep() принимает один аргумент — время сна.  Подробнее про время можно почитать в уроке «Обработка времени».
Обратите внимание на то, что таким способом вы можете усыпить любой поток, даже главный.

sf::sleep() это наиболее эффективный способ усыпления потока: пока поток спит, он не нагружает процессор. Пауза основана на активном ожидании, в то время как пустой цикл while потреблял бы 100% CPU, не делая при этом ничего. Однако, имейте ввиду, что продолжительность сна является лишь примерной и в зависимости от операционной системы она будет более или менее точной. Не стоит полагаться на эту функцию для очень точного расчёта времени.

Защита общих данных

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

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

Самый основной (и часто используемый) примитив — мьютекс. Мьютекс расшифровывается как «взаимное исключение» (MUTual EXclusion): он разрешает только одному потоку в данный момент времени доступ  к коду, который его окружает. Давайте посмотрим, как они могут навести порядок в приведённом выше примере:

#include <SFML/System.hpp>
#include <iostream>

sf::Mutex mutex;

void func()
{
    mutex.lock();

    for (int i = 0; i < 10; ++i)
        std::cout << "I'm thread number one" << std::endl;

    mutex.unlock();
}

int main()
{
    sf::Thread thread(&func);
    thread.launch();

    mutex.lock();

    for (int i = 0; i < 10; ++i)
        std::cout << "I'm the main thread" << std::endl;

    mutex.unlock();

    return 0;
}

Этот код использует общий ресурс (std::cout), и, как мы видели, это производит не желательные последствия — всё смешалось в консоли. Что бы убедиться что текстовые сообщения не смешались произвольным образом, мы защитим соответствующие участки кода с помощью мьютексов. Первый поток, который достигает своей функции mutex.lock(), успешно блокирует мьютекс, при этом сразу же получая доступ к коду который выводит текст сообщений. Когда другой поток достигает mutex.lock(), то мьютекс всё ещё заблокирован и, таким образом, этот поток засыпает (так же с помощью sf::sleep(), потому процессор при этом не нагружается спящим потоком). Когда первый поток наконец разблокирует мьютекс, второй поток просыпается, блокирует мьютекс и выводит текст. Таким образом строки появляются в консоли без смешивания.

system-thread-orderedМьютексы не являются единственными примитивами которые вы можете использовать для защиты общих данных, но их обычно бывает достаточно для большинства случаев.

Защита мьютексов

Не волнуйтесь: мьютексы всегда потокобезопасные, нет необходимости защищать их. Но они не исключительно-безопасные (exception-safe)! Что произойдёт, если в то время пока заблокирован мьютекс, вызовется исключение? Он никогда не получит шанс на разблокирование и останется заблокированным навсегда. Все потоки которые будут пытаться вызвать блокировку, будут заблокированы навечно; всё ваше приложение может полностью зависнуть. Думаю вы понимаете на сколько это плохо.

Что бы убедиться, что мьютексы всегда разблокированы в том участке кода, где возможно срабатывание исключений, SFML предоставляет RAII класс что бы обернуть их: sf::Lock. Этот класс запирает мьютекс в своём конструкторе и отпирает его в деструкторе. Просто и эффективно.

sf::Mutex mutex;

void func()
{
    sf::Lock lock(mutex); // mutex.lock()

    functionThatMightThrowAnException(); // mutex.unlock() если эта функция выбрасывает исключение

} // mutex.unlock()

Обратите внимание на то, что sf::Lock может быть полезным с функциями имеющими несколько операторов возврата.

sf::Mutex mutex;

bool func()
{
    sf::Lock lock(mutex); // mutex.lock()

    if (!image1.loadFromFile("..."))
        return false; // mutex.unlock()

    if (!image2.loadFromFile("..."))
        return false; // mutex.unlock()

    if (!image3.loadFromFile("..."))
        return false; // mutex.unlock()

    return true;
} // mutex.unlock()

Типичные ошибки

Одну вещь программисты часто упускают из виду, заключается она в том, что поток не может жить без своего экземпляра sf::Thread. Следующий код часто встречается на форумах:

void startThread()
{
    sf::Thread thread(&funcToRunInThread);
    thread.launch();
}

int main()
{
    startThread();
    // ...
    return 0;
}

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

Так в чём же причина? Экземпляр sf::Thread является локальным для функции startThread() и поэтому, сразу же уничтожается при её завершении. Деструктор sf::Thread срабатывает и вызывает задержку wait(), как это было показано выше, в результате этого, главный поток оказывается заблокированным и ожидает завершения функции, запускаемой в потоке,  вместо того что бы продолжить работу параллельно ей.

И так, помните: вы сами должны управлять вашими экземплярами sf::Thread так, чтобы они существовали так долго, как работает функция, запускаемая в потоке.

1 Комментарий

  1. Виталий

    Крутая статья!

Оставить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Капча * Лимит времени истёк. Пожалуйста, перезагрузите CAPTCHA.

Этот сайт использует Akismet для борьбы со спамом. Узнайте как обрабатываются ваши данные комментариев.