Привет, Хабр! Перевод статьи подготовлен в рамках курса "C++ Developer. Professional"


Один из участников моего семинара в рамках CppCon 2018 спросил меня: «Может ли std::thread быть прерван (interrupted)?». Мой ответ тогда был — нет, но это уже не совсем так. С C++20 мы можем получить std::jthread (в итоге все таки получили — прим. переводчика).

Позвольте мне развить тему, поднятую на CppCon 2018. Во время перерыва в моем семинаре, посвященному параллелизму, я побеседовал с Николаем (Йосуттисом). Он спросил меня, что я думаю о новом предложении P0660: Cooperatively Interruptible Joining Thread. На тот момент я ничего не знал об этом предложении. Следует отметить, что Николай является одним из авторов этого предложения (наряду с Хербом Саттером и Энтони Уильямсом). Сегодняшняя статья посвящена будущему параллелизма в C++. Ниже я привел общую картину параллелизма в текущем и грядущем C++.

Из названия документа Cooperatively Interruptible Joining Thread (совместно прерываемый присоединяемый поток) вы можете догадаться, что новый поток имеет две новые возможности: прерываемость (interruptible) и автоматическое присоединение (automatically joining, здесь и далее «присоединение» — блокировка вызывающего потока до завершения выполнения, результат вызова метода join() — прим. переводчика). Позвольте мне сначала рассказать вам об автоматическом присоединении.

Автоматическое присоединение

Это неинтуитивное поведение std::thread. Если std::thread все еще является joinable, то в его деструкторе вызывается std::terminate. Поток thr является joinable, если ни thr.join(), ни thr.detach() еще не были вызваны.

// threadJoinable.cpp

#include <iostream>
#include <thread>

int main(){
    
    std::cout << std::endl;
    std::cout << std::boolalpha;
    
    std::thread thr{[]{ std::cout << "Joinable std::thread" << std::endl; }};
    
    std::cout << "thr.joinable(): " << thr.joinable() << std::endl;
    
    std::cout << std::endl;
    
}

При выполнении программа терминируется.

Оба потока терминируются. На втором запуске поток th имеет достаточно времени, чтобы отобразить свое сообщение: «Joinable std::thread».

В следующем примере я заменяю хедер <thread> на "jthread.hpp" и использую std::jthread из грядущего стандарта C++.

// jthreadJoinable.cpp

#include <iostream>
#include "jthread.hpp"

int main(){
    
    std::cout << std::endl;
    std::cout << std::boolalpha;
    
    std::jthread thr{[]{ std::cout << "Joinable std::thread" << std::endl; }};
    
    std::cout << "thr.joinable(): " << thr.joinable() << std::endl;
    
    std::cout << std::endl;
    
}

Теперь поток thr автоматически присоединяется в своем деструкторе, если он все еще является joinable, как, например, в этом примере.

Прерывание std::jthread

Чтобы было от чего отталкиваться, позвольте мне продемонстрировать вам простой пример.

// interruptJthread.cpp

#include "jthread.hpp"
#include <chrono>
#include <iostream>

using namespace::std::literals;

int main(){
    
    std::cout << std::endl;
    
    std::jthread nonInterruptable([]{                                   // (1)
        int counter{0};
        while (counter < 10){
            std::this_thread::sleep_for(0.2s);
            std::cerr << "nonInterruptable: " << counter << std::endl; 
            ++counter;
        }
    });
    
    std::jthread interruptable([](std::interrupt_token itoken){         // (2)
        int counter{0};
        while (counter < 10){
            std::this_thread::sleep_for(0.2s);
            if (itoken.is_interrupted()) return;                        // (3)
            std::cerr << "interruptable: " << counter << std::endl; 
            ++counter;
        }
    });
    
    std::this_thread::sleep_for(1s);
    
    std::cerr << std::endl;
    std::cerr << "Main thread interrupts both jthreads" << std:: endl;
    nonInterruptable.interrupt();
    interruptable.interrupt();                                          // (4)
    
    std::cout << std::endl;
    
}

Я запустил в main два потока, nonInterruptable, который нельзя прерывать, и interruptable, который можно (строки 1 и 2). В отличие от потока nonInterruptable, поток interruptable, получает std::interrupt_token и использует его в строке 3, чтобы проверить, был ли он прерван: itoken.is_interrupted(). В случае прерывания в лямбде срабатывает return и, следовательно, поток завершается. Вызов interruptable.interrupt() (строка 4) триггерит завершение потока. Аналогичный вызов nonInterruptable.interrupt() не сработает для потока nonInterruptable, который, как мы видим, продолжает свое выполнение.

Вот более подробная информация о токенах прерывания (interrupt tokens), присоединяющихся потоках и условных переменных.

Токены прерывания

Токен прерывания std::interrupt_token моделирует совместное владение (shared ownership) и может использоваться для сигнализирования о прерывании, если токен валиден. Он предоставляет три метода: valid, is_interrupted, и interrupt.

 

itoken.valid() — true, если токен прерывания может быть использован для сигнализировании о прерывании

itoken.is_interrupted() — true, если был инициализирован с true или был вызван метода interrupt()

itoken.interrupt() — если !valid() или is_interrupted(), то вызов метода не возымеет эффекта. В противном случае, сигнализирует о прерывании посредством itoken.is_interrupted() == true. Возвращает значение is_interrupted()

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

std::jthread jthr([](std::interrupt_token itoken){
    ...
    std::interrupt_token interruptDisabled; 
    std::swap(itoken, interruptDisabled);     // (1)       
    ...
    std::swap(itoken, interruptDisabled);     // (2)
    ...
}

std::interrupt_token interruptDisabled не валиден. Это означает, что поток не может принять прерывание между строками (1) и (2), но после строки (2) уже может.

Присоединение потоков

std::jhread представляет собой std::thread с дополнительным функционалом, реализующим сигнализирование о прерывании и автоматическое присоединение. Для поддержки этой функциональности у него есть std::interrupt_token.

Новые перегрузки Wait для условных переменных

Две вариации wait wait_for и wait_until из std::condition_variable получат новые перегрузки. Они принимают std::interrupt_token.

template <class Predicate>
bool wait_until(unique_lock<mutex>& lock, 
                Predicate pred, 
                interrupt_token itoken);

template <class Rep, class Period, class Predicate>
bool wait_for(unique_lock<mutex>& lock, 
              const chrono::duration<Rep, Period>& rel_time, 
              Predicate pred, 
              interrupt_token itoken);

template <class Clock, class Duration, class Predicate>
bool wait_until(unique_lock<mutex>& lock, 
                const chrono::time_point<Clock, Duration>& abs_time, 
                Predicate pred, 
                interrupt_token itoken);

Новые перегрузки требует предикат. Эти версии гарантированно получают уведомления, если поступает сигнал о прерывании для переданного им std::interrupt_token itoken. После вызовов wait вы можете проверить, не произошло ли прерывание.

cv.wait_until(lock, predicate, itoken);
if (itoken.is_interrupted()){
    // interrupt occurred
}

Что дальше?

Как я и обещал в своей последней статье, следующая статья будет посвящена оставшимся правилам определения концептов (concepts).


Узнать подробнее о курсе "C++ Developer. Professional".

Смотреть запись демо-занятия по теме
«Области видимости и невидимости»: участники вместе с экспертом попробовали реализовать класс общего назначения и запустить несколько unit-тестов с использованием googletest.