Прерывание программы (SIGINT) во время вызова condition_variable::wait() с последующим вызовом exit() приводит к ее зависанию.

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

#include <iostream>
#include <csignal>
#include <mutex>
#include <condition_variable>
#include <thread>

class Application {
    std::mutex cvMutex;
    std::condition_variable cv;
    std::thread t2;
    bool ready = false;

    // I know I'm accessing this without a lock, please ignore that
    bool shuttingDown = false;

public:
    void mainThread() {
        auto lock = std::unique_lock<std::mutex>(this->cvMutex);

        while (!this->shuttingDown) {
            if (!this->ready) {
                std::cout << "Main thread waiting.\n" << std::flush;
                this->cv.wait(lock, [this] () {return this->ready;});
            }

            // Do the thing
            this->ready = false;
            std::cout << "Main thread notification recieved.\n" << std::flush;
        }
    };

    void notifyMainThread() {
        std::cout << "Notifying main thread.\n" << std::flush;
        this->cvMutex.lock();
        this->ready = true;
        this->cv.notify_all();
        this->cvMutex.unlock();
        std::cout << "Notified.\n" << std::flush;
    };

    void threadTwo() {
        while(!this->shuttingDown) {
            // Wait some seconds, then notify main thread
            std::cout << "Thread two sleeping for some seconds.\n" << std::flush;
            std::this_thread::sleep_for(std::chrono::seconds(3));
            std::cout << "Thread two calling notifyMainThread().\n" << std::flush;
            this->notifyMainThread();
        }

        std::cout << "Thread two exiting.\n" << std::flush;
    };

    void run() {
        this->t2 = std::thread(&Application::threadTwo, this);
        this->mainThread();

    };

    void shutdown() {
        this->shuttingDown = true;
        this->notifyMainThread();
        std::cout << "Joining thread two.\n" << std::flush;
        this->t2.join();
        std::cout << "Thread two joined.\n" << std::flush;
        // The following call causes the program to hang when triggered by a signal handler
        exit(EXIT_SUCCESS);
    }
};

auto app = Application();
int sigIntCount = 0;

int main(int argc, char *argv[])
{
    std::signal(SIGINT, [](int signum) {
        std::cout << "SIGINT recieved!\n" << std::flush;
        sigIntCount++;
        if (sigIntCount == 1) {
            // First SIGINT recieved, attempt a clean shutdown
            app.shutdown();
        } else {
            abort();
        }
    });

    app.run();

    return 0;
}

Вы можете запустить программу онлайн, здесь: https://onlinegdb.com/Bkjf-4RHP

В приведенном выше примере показано простое многопоточное приложение, состоящее из двух потоков. Основной поток ожидает переменной условия, пока не будет получено уведомление и this->ready не будет установлено на true. Второй поток просто обновляет this->ready и периодически уведомляет основной поток. И, наконец, приложение обрабатывает сигнал SIGINT в основном потоке, где оно пытается завершить работу без ошибок.

Проблема:

Когда запускается SIGINT (через Ctrl+C), приложение не закрывается, несмотря на вызов exit() в Application::shutdown().

Вот что я думаю, происходит:

  1. Основной поток ожидает уведомления, поэтому он заблокирован this->cv.wait(lock, [this] () {return this->ready;});
  2. Принимается сигнал SIGINT, и вызов wait() прерывается сигналом, в результате чего вызывается обработчик сигнала.
  3. Обработчик сигнала вызывает Application::shutdown(), который впоследствии вызывает exit(). Вызов exit() зависает на неопределенное время, потому что он пытается выполнить некоторую очистку, которую нельзя выполнить, пока не возобновится вызов wait() (я не уверен в этом).

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

  • Когда я удаляю вызов exit() в Application::shutdown() и позволяю main() вернуться, программа завершается без проблем.
  • Когда я заменяю вызов exit() на abort(), который меньше влияет на очистку, программа завершается без проблем (это указывает на то, что процесс очистки, выполняемый функцией exit(), приводит к зависанию).
  • Если SIGINT отправляется, когда основной поток не ожидает переменную условия, программа завершается без проблем.

Вышеупомянутое является лишь примером проблемы, с которой я сталкиваюсь. В моем случае мне нужно вызвать exit() в shutdown(), а shutdown() нужно вызвать из обработчика сигнала. Пока мои варианты выглядят так:

  • Переместите всю передачу сигналов в выделенный поток. Это было бы сложно сделать, так как потребовалось бы переписать код, чтобы я мог вызывать Application::shutdown() из другого потока, а не того, которому принадлежит экземпляр Application. Мне также нужен способ вывести основной поток из вызова wait(), вероятно, добавив некоторое условие OR к предикату.
  • Замените вызов exit() вызовом abort(). Это будет работать, но приведет к тому, что стек не будет раскручен (в частности, экземпляр Application).

Есть ли у меня другие варианты? Есть ли способ правильно прервать поток во время вызова std::condition_variable::wait() и выйти из программы из обработчика прерывания?


person John O'brien    schedule 27.09.2020    source источник


Ответы (2)


Как упомянул Игорь, вы мало что можете сделать в обработчиках сигналов. Однако вы можете работать с атомарными переменными без блокировки, поэтому вы можете изменить код для работы с этим.

Я добавил это и сделал несколько других изменений и прокомментировал предложенные мной изменения в коде:

#include <atomic>
#include <condition_variable>
#include <csignal>
#include <iostream>
#include <mutex>
#include <thread>

// Make sure the atomic type we'll operate on is lock-free.
static_assert(std::atomic<bool>::is_always_lock_free);

class Application {
    std::mutex cvMutex;
    std::condition_variable cv;
    std::thread t2;
    bool ready = false;

    static std::atomic<bool> shuttingDown;  // made it atomic

public:
    void mainThread() {
        std::unique_lock<std::mutex> lock(cvMutex);

        while(!shuttingDown) {
            // There is no need to check  if(!ready)  here since
            // the condition in the cv.wait() lambda will be checked
            // before it is going to wait, like this:
            //
            // while(!ready) cv.wait(lock);

            std::cout << "Main thread waiting." << std::endl; // endl = newline + flush
            cv.wait(lock, [this] { return ready; });
            std::cout << "Main thread notification recieved." << std::endl;

            // Do the thing
            ready = false;
        }
    }

    void notifyMainThread() {
        { // lock scope - don't do manual lock() / unlock()-ing
            std::lock_guard<std::mutex> lock(cvMutex);
            std::cout << "Notifying main thread." << std::endl;
            ready = true;
        }
        cv.notify_all(); // no need to hold lock when notifying
    }

    void threadTwo() {
        while(!shuttingDown) {
            // Wait some seconds, then notify main thread
            std::cout << "Thread two sleeping for some seconds." << std::endl;
            std::this_thread::sleep_for(std::chrono::seconds(3));
            std::cout << "Thread two calling notifyMainThread()." << std::endl;
            notifyMainThread();
        }
        std::cout << "Time to quit..." << std::endl;
        notifyMainThread();
        std::cout << "Thread two exiting." << std::endl;
    }

    void run() {
        // Installing the signal handler as part of starting the application.
        std::signal(SIGINT, [](int /* signum */) {
            // if we have received the signal before, abort.
            if(shuttingDown) abort();
            // First SIGINT recieved, attempt a clean shutdown
            shutdown();
        });

        t2 = std::thread(&Application::threadTwo, this);
        mainThread();

        // move join()ing out of the signal handler
        std::cout << "Joining thread two." << std::endl;
        t2.join();
        std::cout << "Thread two joined." << std::endl;
    }

    // This is made static. All instances of Application
    // will likely need to shutdown.
    static void shutdown() { shuttingDown = true; }
};

std::atomic<bool> Application::shuttingDown = false;

int main() {
    auto app = Application();
    app.run();
}
person Ted Lyngmo    schedule 28.09.2020
comment
Спасибо за ответ, Тед. Из любопытства, по какой причине следует избегать ручной блокировки (вызовы lock() и unlock())? Чтобы избежать проблем, которые могут возникнуть, если между вызовами возникнет исключение? Или есть другая причина? - person John O'brien; 02.10.2020
comment
@JohnO'brien Добро пожаловать! Да, это главная причина. Вторая причина для читателей кода, чтобы не застрять. При чтении кода с ручным управлением ресурсами я сразу же начинаю выяснять, не может ли он привести к утечке указанного ресурса. В вашем случае мне пришлось бы проверить, может ли notify_all() бросить. Ну нельзя, но все же. :-) Хороший тон ничего не упускать. Использование оболочки RAII даже в таких коротких функциях обеспечивает безопасность и простоту, а люди, читающие код, не будут тратить время на ненужные вещи. - person Ted Lyngmo; 02.10.2020

[support.signal]/3 Оценка является безопасной для сигналов, если только она не включает одно из следующих действий:

(3.1) — вызов любой стандартной библиотечной функции, за исключением простых блокировочных атомарных операций и функций, явно идентифицированных как безопасные для сигналов.
...

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

Ваша программа демонстрирует неопределенное поведение. Обработчик сигналов очень ограничен в том, что он может делать безопасно.

person Igor Tandetnik    schedule 27.09.2020