Безопасность потоков C++ и экономия времени: почему поток с проверкой мьютекса иногда работает быстрее, чем без него?

Я новичок в использовании потоков в C++. Я прочитал основы о std::thread и mutex, и, похоже, я понимаю цель использования мьютексов.

Я решил проверить, действительно ли потоки так опасны без мьютексов (ну, я верю книгам, но предпочитаю видеть это своими глазами). В качестве тестового примера "что я не должен делать в будущем" я создал 2 версии одной и той же концепции: есть 2 потока, один из которых увеличивает число несколько раз (NUMBER_OF_ITERATIONS), другой уменьшает то же число на то же число раз, поэтому мы ожидаем увидеть то же число после выполнения кода, как и до него. Код прилагается.

Сначала я запускаю 2 потока, которые делают это небезопасным способом - без мьютексов, просто чтобы посмотреть, что может произойти. И после того, как эта часть закончена, я запускаю 2 потока, которые делают то же самое, но безопасным образом (с мьютексами).

Ожидаемые результаты: без взаимных исключений результат может отличаться от исходного значения, поскольку данные могут быть повреждены, если два потока работают с ним одновременно. Особенно это обычно для огромных NUMBER_OF_ITERATIONS - потому что вероятность повреждения данных выше. Так что этот результат я могу понять.

Также я измерил время, проведенное как "безопасными", так и "небезопасными" частями. Как и ожидалось, для огромного количества итераций безопасная часть тратит гораздо больше времени, чем небезопасная: на проверку мьютекса уходит некоторое время. Но для небольшого количества итераций (400, 4000) безопасное время выполнения детали меньше небезопасного. Почему это возможно? Это то, что делает операционная система? Или есть какая-то оптимизация компилятором, о которой я не знаю? Я провел некоторое время, думая об этом, и решил спросить здесь.

Я использую Windows и компилятор MSVS12.

Таким образом, возникает вопрос: почему безопасное выполнение части может быть быстрее, чем небезопасное выполнение первой части (для небольших NUMBER_OF_ITERATIONS < 1000*n)? Еще один: почему это связано с NUMBER_OF_ITERATIONS: для меньших (4000) "безопасная" часть с мьютексами быстрее, а для огромных (400000) "безопасная" часть медленнее?

main.cpp

#include <iostream>
#include <vector>
#include <thread>
#include <mutex>
#include <windows.h>
//
///change number of iterations for different results
const long long NUMBER_OF_ITERATIONS = 400;
//
/// time check counter
class Counter{
    double PCFreq_ = 0.0;
    __int64 CounterStart_ = 0;
public:
    Counter(){
        LARGE_INTEGER li;
        if(!QueryPerformanceFrequency(&li))
            std::cerr << "QueryPerformanceFrequency failed!\n";

        PCFreq_ = double(li.QuadPart)/1000.0;

        QueryPerformanceCounter(&li);
        CounterStart_ = li.QuadPart;
    }
    double GetCounter(){
        LARGE_INTEGER li;
        QueryPerformanceCounter(&li);
        return double(li.QuadPart-CounterStart_)/PCFreq_;
    }
};

/// "dangerous" functions for unsafe threads: increment and decrement number
void incr(long long* j){
    for (long long i = 0; i < NUMBER_OF_ITERATIONS; i++) (*j)++;
    std::cout << "incr finished" << std::endl;
}
void decr(long long* j){
    for (long long i = 0; i < NUMBER_OF_ITERATIONS; i++) (*j)--;
    std::cout << "decr finished" << std::endl;
}

///class for safe thread operations with incrment and decrement
template<typename T>
class Safe_number {
public:
    Safe_number(int i){number_ = T(i);}
    Safe_number(long long i){number_ = T(i);}
    bool inc(){
        if(m_.try_lock()){
            number_++;
            m_.unlock();
            return true;
        }
        else
            return false;
    }
    bool dec(){
        if(m_.try_lock()){
            number_--;
            m_.unlock();
            return true;
        }
        else
            return false;
    }
    T val(){return number_;}
private:
    T number_;
    std::mutex m_;
};

///
template<typename T>
void incr(Safe_number<T>* n){
    long long i = 0;
    while(i < NUMBER_OF_ITERATIONS){
        if (n->inc()) i++;
    }
    std::cout << "incr <T> finished" << std::endl;
}
///
template<typename T>
void decr(Safe_number<T>* n){
    long long i = 0;
    while(i < NUMBER_OF_ITERATIONS){
        if (n->dec()) i++;
    }
    std::cout << "decr <T> finished" << std::endl;
}

using namespace std;

// run increments and decrements of the same number
// in threads in "safe" and "unsafe" way
int main()
{
    //init numbers to 0
    long long number = 0;
    Safe_number<long long> sNum(number);

    Counter cnt;//init time counter
    //
    //run 2 unsafe threads for ++ and --
    std::thread t1(incr, &number);
    std::thread t2(decr, &number);
    t1.join();
    t2.join();
    //check time of execution of unsafe part
    double time1 = cnt.GetCounter();
    cout <<"finished first thr"  << endl;
    //
    // run 2 safe threads for ++ and --, now we expect final value 0
    std::thread t3(incr<long long>, &sNum);
    std::thread t4(decr<long long>, &sNum);
    t3.join();
    t4.join();
    //check time of execution of safe part
    double time2 = cnt.GetCounter() - time1;
    cout << "unsafe part, number = " << number << "  time1 = " << time1 << endl;
    cout << "safe part, Safe number = " << sNum.val() << "  time2 = " << time2 << endl << endl;

    return 0;
}

1 ответ

Решение

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

Очевидно, ваше молоко может меняться.

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

Суть в том, что при тестировании скорости алгоритма вы всегда должны отдавать предпочтение большим входным данным, а также повторять тесты одной и той же программы (желательно в случайном порядке!), Чтобы попытаться "сгладить" неровности во времени.

Другие вопросы по тегам