Безопасность потоков в С++

threads-road

Допустим, вы пишете конвейер, в котором 2 потока, используя общий буфер, обрабатывают данные. Поток-producer эти данные создает, а поток-consumer их обрабатывает (Producer–consumer problem). Следующий код представляет собой самую простую модель: с помощью std::thread мы порождаем поток-consumer, a создавать данные мы будем в главном потоке.

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

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

Допустим, функция consume() бросает исключение. Поскольку это исключение генерируется в дочернем потоке, поймать и обработать его в главном потоке нельзя1 . Если во время развертывания стека дочернего потока не нашлось подходящего обработчика исключения, будет вызвана функция std::terminate(), которая по-умолчанию вызовет функцию abort(). Иными словами, если не обработать исключение в потоке, порожденном объектом thr, то программа завершит свою работу с ошибкой.

С функцией produce() немного сложнее. Допустим, эта функция генерирует исключение. Первое, что хочется сделать, это обернуть тело main() в try-catch блок:

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

std::thread

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

Прежде чем перейти к решению нашей задачи, давайте вкратце вспомним как работает std::thread.

1) конструктор для инициализации:

При инициализации объекта std::thread создается новый поток, в котором запускается функция fn с возможными аргументами args. При успешном его создании, конкретный экземпляр объекта начинает представлять этот поток в родительском потоке, а в свойствах объекта выставляется флаг joinable.
Запомним: joinable ~ объект связан с потоком.

2) Ждем конца выполнения порожденного потока:

Этот метод блокирует дальнейшее выполнение родительского потока, до тех пока не будет завершен дочерний. После успешного выполнения, объект потока перестает его представлять, поскольку нашего потока больше не существует. Флаг joinable сбрасывается.

3) Немедленно «отсоединяем» объект от потока:

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

4) Деструктор:

Деструктор уничтожает объект. При этом если, у этого объекта стоит флаг joinable, то вызывается функция std::terminate(), которая по умолчанию вызовет функцию abort().
Внимание! Если мы создали объект и поток, но не вызвали join или detach, то программа упадет. В принципе, это логично — если объект до сих пор связан с потоком, то надо что-то с ним делать. А еще лучше — ничего не делать, и завершить программу (по крайней мере так решил комитет по стандарту).

Поэтому при возникновении исключения в функции produce(), мы пытаемся уничтожить объект thr, который является joinable.

Ограничения

Почему же стандартный комитет решил поступить так и не иначе? Не лучше было бы вызвать в деструкторе join() или detach()? Оказывается, не лучше. Давайте разберем оба этих случая.

Допустим, у нас есть класс joining_thread, который так вызывает join() в своем деструкторе:

Тогда, прежде чем обработать исключение, мы должны будем подождать завершения работы дочернего потока, поскольку join() блокирует дальнейшее выполнение программы. А если так получилось, что порожденном потоке оказался в бесконечный цикл?

Хорошо, мы выяснили, что join() в деструкторе лучше не вызывать (до тех пор пока вы не уверены, что это корректная обработка события), поскольку это блокирующая операция. А что насчет detach()? Почему бы не вызвать в деструкторе этот неблокирующий метод, дав главному потоку продолжить работу? Допустим у нас есть такой класс detaching_thread.

Но тогда мы можем прийти к такой ситуации, когда порожденный поток пытается использовать ресурс, которого уже нет, как в следующей ситуации:

Таким образом, создатели стандарта решили переложить ответственность на программиста — в конце концов ему виднее, как программа должна обрабатывать подобные случаи. Исходя из всего этого, получается, что стандартная библиотека противоречит принципу RAII — при создании std::thread мы сами должны позаботиться о корректном управлении ресурсами, то есть явно вызвать join или detach. По этой причине некоторые программисты советуют не использовать объекты std::thread. Так же как new и delete, std::thread предоставляет возможность построить на основе них более высокоуровневые инструменты.

Решение

Одним из таких инструментов является класс из библиотеки Boost boost::thread_joiner. Он соответствует нашему joining_thread в примере выше. Если вы можете позволить себе использовать сторонние библиотеки для работы с потоками, то лучше это сделать.

Другое решение — позаботиться об это самому в RAII-стиле, например так:

В случае, если вы собираетесь отделить поток от объекта в любом случае, лучше сделать это сразу же:

Ссылки:

Александр Петров специально для «Типичного программиста»

  1. На самом деле можно явно передать исключение в другой поток с помощью move-семантики.