Effective Modern C++ 條款16 使const成員函數成爲線程安全函數

使const成員函數成爲線程安全函數

如果我們在數學領域工作,我們可能發現一個表示多項式的類會對我們很便利。。在這個類中,一個計算多項式的根的函數是很有必要的,也就是求多項式等於0時字母的值。這個函數不會改變多項式,所以我們把它聲明爲const

class Polynomial {
public:
    using RootsType =            // 該數據結構存儲
      std::vector<double>;   // 多項式等於0時的根
    ...
    RootsType roots() const;
    ...
};

計算多項式的根這個操作很耗時,所以我們只有在不得已的情況下才使用它。如果我們真的進行了計算,那麼我們肯定不想計算第二次了,因此我們把計算結果緩存起來,然後roots函數返回緩存的值。這裏是最基本的方法:

class Polynomial {
public:
    using RootsType = std::vector<double>;

    RootsType roots() const
    {
        if (!rootsAreValid)  {   // 如果無緩存值
           ...         // 計算根,把結果存在rootVals
           rootsAreVaild = true;
        }
        return rootVals;
    }
private:
    mutable bool rootAreValid{ false };   // 括號初始化看條款7
    mutable RootsType rootVals{};
};

從概念上講,roots函數不會改變多項式對象,但是,它的計算並緩存行爲,需要修改rootVals和rootAreValid。這是一個mutable典型使用例子,也就是爲什麼mutable是成員變量聲明的一部分。

現在我們來想象一下兩個線程併發調用一個多項式對象的roots函數:

Polynomial p;
...
/****---- Thread 1 ----****/          /****---  Thread 2   ----****/
auto rootsOfP = p.roots();            auto valsGivingZero = p.roots();` 

這用戶的代碼是合情合理的。roots函數是一個const成員函數,那意味着函數表示的是一個讀操作,多線程下不帶同步原語使用讀操作是安全的。至少我們的假設是這樣的。在這個例子中,它不安全,因爲在root裏面,其中一個或所有的線程都有可能試圖改變成員變量rootsAreValid和rootVals。這意味着這用戶代碼可以有不同的線程不同步地讀寫相同的內存,那很明顯就是數據競爭(data race)了,這樣代碼就有未定義行爲。

問題其實是roots函數被聲明爲const,它卻不是線程安全的。在這裏使用const聲明,在C++11和C++98都是正確的(取得多項式的跟根本不需要改變多項式對象的值),所以我們要矯正的是線程安全的缺乏。

解決這問題最簡單的辦法其實就是平常的方法:使用mutex

class Polynomial {
public:
    using RootsType = std::vector<double>;

    RootsType roots() const
    {
        std::lock_guard<std::mutex> g(m);    // 加鎖

        if (!roorsAreValid) {
            ...
            rootsAreValid = true;
        }
        return rootVals;
    }                                                              // 解鎖
private:
    mutable std::mutex m;
    mutable bool rootsAreValid{ false };
    mutable RootsType rootVals{};
};

std::mutex對象m被聲明爲mutable,因爲加鎖和解鎖mutex都是non-const成員函數(即會改變mutex對象的值或狀態),然後在roots內(const成員函數),m可以被看作是個const對象(因爲const成員函數承諾不會改變成員變量的值或狀態)。

值得注意的是std::mutex是一個只可移動類型(move-only type,即只可移動,不可拷貝的類型),把m添加到多項式對象的副作用是多項式失去了被拷貝的能力,但它仍可以被移動。


在一些情況中,互斥鎖殺傷力太強。例如,如果你需要計數一個成員被調用了多少次,那麼用一個std::atomic計算器(原子操作類型,看條款40)將會減少很多開銷(實際上是否會減少開銷需要看你機器上的互斥鎖實現)。這裏是你使用一個std::atomic變量來計數:

class Point {    // 二維座標
public:
    ...
    double distanceFromOrigin() const noexcept  // noexcept看條款14
    {
        ++callCount;    // 原子遞增
        return  std::sqrt((x * x) + (y * y ));
    }
private:
    mutable std::atomic<unsigned> callCount{ 0 };
    double x, y;
};

std::mutex一樣,std::atomic也是隻可移動類型,所以Point對象也只是可以移動。


因爲操作std::atomic變量的開銷通常會比獲取鎖和釋放鎖要小,你可能就會重度依賴std::atomic。例如,在一個類中緩存一個計算耗時長的int,你可能會試圖用一對std::atomic變量來代替互斥鎖:

class Widget {
public:
    ...
    int magicValue() const
    {
        if (cacheValid) return cachedValue;
        else {
            auto val1 = expensiveComputation1();
            auto val2 = expensiveComputation2();
            cachedValue = val1 + val2;
            cacheValid = true;
            return cachedValue;
         }
    }
private:
    mutable std::atomic<bool> cacheValid;
    mutable std::atomic<int> cachedValue;
};

這代碼可以運行,但有時候它會運行得很困難,考慮以下:

  • 一個線程調用Widget::magicValue,看到cacheValid的值是false,然後進行兩個昂貴的計算,然後把它們的和安置在cachedValue。
  • 在這個時刻,第二個線程調用Widget::magicValue,也看到cacheValid的值是false,因此執行與第一個線程相同的昂貴計算。(“第二個線程”可能是其它好幾個線程。)

這樣的行爲與緩存的目的背道而馳。對調cachedValue和cacheValid賦值語句可以消除這個問題,但結果依舊糟糕:

Class Widget {
public:
    ...
    int magicValue() const
    {
        if (cacheValid) return cachedValue;
        else {
            auto val1 = expensiveComputation1();
            auto val2 = expensiveComputation2();
            cacheValid = true;
            return cachedValue = val1 + val2;
        }
    }
    ...
};

想象一下cacheValid的值爲false,然後:

  • 一個線程調用Widget::magicValue函數,然後執行到cacheValid設置爲true。
  • 在這個時刻,第二個線程調用Widget::magicValue函數,然後檢查cacheValid,看到它爲true,儘管第一個線程還沒賦值cachedValue,但該線程將cachedValue返回。因此返回的值是不正確的。

原因這裏講。如果只是一個變量或者一個存儲單元需求同步,那麼使用std::atomic就足夠了,但是你有兩個或者更多的變量和存儲單元需要以一個單元的形式操作,你應該使用互斥鎖。使用互斥鎖的Widget::magicValue代碼是這樣的:

class Widget {
public:
    ...
    int magicValue() const
    {
        std::lock_guard<std::mutex> guard(m);

        if (cacheValid) return cachedValue;
        else {
            auto val1 = expensiveComputation1();
            auto val2 = expensiveComputation2();
            cachedValue = val1 + val2;
            cacheValid = true;
            return cachedValue;
         }
    }
private:
    mutable std::mutex m;
    mutable int cachedValue;
    mutable bool cacheValid{ false };
};

現在告訴你,本條款出以多線程會併發執行對象的const成員函數的設想爲依據的。如果你寫的const成員函數不是基於這種情況——你能保證不超過一個線程執行對象的成員函數——那麼函數的線程安全無關緊要。例如,一些類的成員函數是專門爲單個線程設計的,那麼這種成員函數是否線程安全不重要,在這種情況下,你可以避免互斥鎖和std::atomic的開銷,以及它們帶來的只能移動的副作用。不過,這種免疫線程的情況越來越不常見,它似乎還會慢慢減少。const成員函數十有八九會經受併發執行,那就是爲什麼你應該確保你的const成員函數是線程安全的。


總結

需要記住的2點:

  • const成員函數做到線程安全,除非你能肯定它們決不在併發語境中使用。
  • 使用std::atomic變量可能比互斥鎖提供更好的性能,不過它們只適用於單一變量和單一存儲單元。
發佈了18 篇原創文章 · 獲贊 43 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章