怎麼寫執行緒安全的複製(移動)建構子及複製(移動)賦值運算子

會呼叫建構子就是還沒有東西需要被保護。

呼叫解構子時需要保護成員變數基本上就是設計錯誤,任何時候都不該在變數還被使用時銷毀它,這跟 thread-safe 本身沒有關係。

因此以上兩點不在本文範圍。

現假設有自訂類別如下,將其改為 thread-safe 要怎麼做呢?

class Foo {
public:
    // copy constructor
    Foo(const Foo& rhs) : id_(rhs.id_), ticks_(rhs.ticks_) {}

    // move constructor
    Foo(Foo&& rhs) : id_(rhs.id_), ticks_(std::move(rhs.ticks_)) {}

    // copy assignment
    Foo& operator=(const Foo& rhs) {
        if (this != &rhs) {
            id_ = rhs.id_;
            ticks_ = rhs.ticks_;
        }

        return *this;
    }

    // move assignment
    Foo& operator=(Foo&& rhs) {
        if (this != &rhs) {
            id_ = rhs.id_;
            ticks_ = std::move(rhs.ticks_);
        }

        return *this;
    }

public:
    void SetID(int id) {
        id_ = id;
    }

private:
    int id_;
    std::vector ticks_;
};

首先 #include <mutex>,並新增成員變數 mutable std::mutex m_mutex。

copy constructor 不能使用 initializer list 的方式初始化了,因為沒有辦法在 initializer list 鎖定 rhs。

Foo(const Foo& rhs) : id_(rhs.id_), ticks_(rhs.ticks_) {}

考慮以下程式碼:

Foo foo1;
foo1.SetID(1); // use thread 1 write foo1
Foo foo2(foo1); // use thread 2 read foo1

讀取 rhs 時,可能會有其他 thread 正在修改 rhs,因此複製建構子需要改成先將 rhs 的 mutex 鎖住,再讀取 rhs 並建構自己。

Foo(const Foo& rhs) {
    std::lock_guard<std::mutex> lock(rhs.m_mutex);
    id_ = rhs.id_;
    ticks_ = rhs.ticks_;
}

move constructor 也是一樣。

Foo(Foo&& rhs) {
    std::lock_guard<std::mutex> lock(rhs.m_mutex);
    id_ = rhs.id_;
    ticks_ = std::move(rhs.ticks_);
}

接下來討論 copy assignment 及 move assignment。

Foo& operator=(const Foo& rhs) {
    std::lock_guard<std::mutex> lockThis(m_mutex);
    std::lock_guard<std::mutex> lockRhs(rhs.m_mutex);
    if (this != &rhs) {
        id_ = rhs.id_;
        ticks_ = rhs.ticks_;
    }

    return *this;
}

一開始直覺可能會這樣寫,但這會有 deak lock,考慮以下程式碼:

Foo foo;
foo = foo;

自己賦值給自己,根據程式,先鎖定 this,再鎖定 rhs,但其實都是 foo,也就是同一條 thread 鎖定自己兩次會 dead lock,我們可以移動 lock 的位置,但請不要改成std::recursive_mutex,這會隱藏 lock 的錯誤使用:

Foo& operator=(const Foo& rhs) {
    if (this != &rhs) {
        std::lock_guard<std::mutex> lockThis(m_mutex); // step 1
        std::lock_guard<std::mutex> lockRhs(rhs.m_mutex); // step 2
        id_ = rhs.id_;
        ticks_ = rhs.ticks_;
    }

    return *this;
}

很好,lock 之前會先檢查是不是自己賦值給自己,但這還有其他問題,考慮以下程式碼:

Foo foo1, foo2;
foo1 = foo2; // thread 1
foo2 = foo1; // thread 2

現假設 thread 1 及 thread 2 的執行順序如下

1. thread1 執行 step1,鎖住 this 成功,也就是鎖住了自己 (foo1)。
2. thread2 執行 step1,鎖住 this 成功,也就是鎖住了自己 (foo2)。
3. thread1 執行 step2,鎖住失敗,因為 rhs 也就是 foo2 已被 thread 2 鎖住。
4. thread2 執行 step2,鎖住失敗,因為 rhs 也就是 foo1 已被 thread 1 鎖住。

至此 thread 1 及 thread 2 都在等待對方釋放鎖,dead lock。

怎麼修改呢?見下列程式粗體部分:

Foo& operator=(const Foo& rhs) {
    if (this != &rhs) {
        std::lock(m_mutex, rhs.m_mutex);
        std::lock_guard<std::mutex> lockThis(m_mutex, std::adopt_lock);
        std::lock_guard<std::mutex> lockRhs(rhs.m_mutex, std::adopt_lock);
        id_ = rhs.id_;
        ticks_ = rhs.ticks_;
    }

    return *this;
}

std::lock 可以將所有的 mutex 一起鎖定,如果某一個 mutex 鎖不成功就先 unlock,至於 std::adopt_lock 則是告訴 std::lock_guard 當執行到你這行 code 時,std::lock 已經鎖住了mutex,你就不用再重複鎖定免得 dead lock,但是你還是要負責在離開 block 時幫我自動解鎖。
 

如果你的編譯器支援 C++17 的話,有另一種替代寫法如下:

Foo& operator=(const Foo& rhs) {
    if (this != &rhs) {
        std::scoped_lock lockAll(m_mutex, rhs.m_mutex);
        id_ = rhs.id_;
        ticks_ = rhs.ticks_;
    }

    return *this;
}

move assignment 也是一樣的作法,不再贅述。

最後,如果你的 class 是使用讀寫鎖實作的話,也就是用 std::shared_mutex 配合 std::unique_lock 及 std::shared_lock,則 copy constructor 改成讀鎖:

Foo(const Foo& rhs) {
     std::shared_lock<std::shared_mutex> lock(rhs.m_mutex);
     id_ = rhs.id_;
     ticks_ = rhs.ticks_;
}

copy assignment 改成寫鎖加讀鎖:

Foo& operator=(const Foo& rhs) {
    if (this != &rhs) {
        std::unique_lock<std::shared_mutex> lockThis(m_mutex, std::defer_lock);
        std::shared_lock<std::shared_mutex> lockRhs(rhs.m_mutex, std::defer_lock);
        std::lock(lockThis, lockRhs);
        id_ = rhs.id_;
        ticks_ = rhs.ticks_;
    }

    return *this;
}

注意這時 lockThis 及 lockRhs 改成 std::defer_lock,也就是不獲得 mutex 的所有權,只負責表明是讀寫鎖,且負責離開作用域的釋放鎖,讓 std::lock 來執行真正的鎖定。

為什麼使用讀寫鎖要後呼叫 std::lock,而使用 std::lock_guard 時先呼叫 std::lock 呢?

其實只是因為使用讀寫鎖時,std::lock 要知道哪個是讀鎖,哪個是寫鎖而已,畢竟 std::lock 要知道它內部一個一個呼叫的是 std::shared_mutex 的 lock 還是 lock_shared,因此std::unique_lock 及 std::shared_lock 要寫在前,先表明型別。

move 版本也是一樣,只是非內建型別要用 std::move 包起來而已。

C++ the rule of five

在 C++ 11 之前有所謂 the rule of three,也就是自訂類別有寫自己的複製建構子、複製賦值運算子或解構子其中之一,應該將其他兩個也補上,因為通常編譯器自動產生的不會符合你需要的,不然你也不用自己寫了對吧。

在 C++ 11 後,因為多了移動語意,所以還要加上兩個特別的函式,也就是移動建構子和移動賦值運算子。

那要怎麼寫呢?假設有一個自訂類別 Foo,有兩個成員變數,一個是內建型別 int,另一個是自訂型別 std::vector

class Foo {
private:
    int id_;
    std::vector ticks_;
};

以下示範正確的宣告及實作方法。

首先是建構子,需不需要傳參數是根據此類別的需求而定,請至少寫一種,正確的寫法是使用 initializer list,也就是冒號 (:) 後面接成員變數的初始化,如果是在 body 內用賦值的,其實效率會慢,因為實際行為是先用 defualt 建構子建構出成員變數,再用複製賦值運算子拷貝一次:

class Foo {
public:
    // constructor
    Foo() : id_(0), ticks_() {}

    // constructor
    Foo(int id, const std::vector& ticks) : id_(id), ticks_(ticks) {}

    // constructor 不好的寫法,先用預設建構子建構出 int 及 std::vector,再用 = 賦值一次
    Foo(int id, const std::vector& ticks) {
        id_ = id;
        ticks_ = ticks;
    }

private:
    int id_;
    std::vector ticks_;
};

第二個是解構子,解構子很簡單,首先考慮此函式有沒有指標需要 delete,有的話建議改用 smart point,再來考慮此類別有沒有虛擬函式,有的話就加 virtual,沒有的話就不用加:

class Foo {
public:
    // destructor 沒有其他虛擬函式不用加上 virtual
    ~Foo() {}

    // 或

    // destructor 有其他虛擬函式就加上 virtual
    virtual ~Foo() {}

private:
    int id_;
    std::vector ticks_;
};

第三個是複製建構子和移動建構子,一樣要使用 initializer list 的寫法,要注意的是移動建構子,如果有成員變數不是內建型別,請使用 std::move 包起來,這樣才可以轉成 rvalue,因為具名的右值參考 (named rvalue reference) 其實是左值 (rvalue),也就是程式中的 Foo&& rhs:

class Foo {
public:
    // copy constructor
    Foo(const Foo& rhs) : id_(rhs.id_), ticks_(rhs.ticks_) {}

    // move constructor
    Foo(Foo&& rhs) : id_(rhs.id_), ticks_(std::move(rhs.ticks_)) {} // 雖然 rhs 型別是 Foo&&,但其實是左值,因此要用 std::move 強制轉換成右值

private:
    int id_;
    std::vector ticks_;
};

最後是複製賦值運算子及移動賦值運算子,要注意的是需要先檢查是不是自我賦值,也就是撇除程式中 x = x 或 x = std::move(x) 的寫法,再來移動賦值運算子一樣非內建型別要用 std::move 包起來:

class Foo {
public:
    // copy assignment
    Foo& operator=(const Foo& rhs) {
        if (this != &rhs) { // 檢查自我賦值
            id_ = rhs.id_;
            ticks_ = rhs.ticks_;
        }

        return *this;
    }

    // move assignment
    Foo& operator=(Foo&& rhs) {
        if (this != &rhs) { // 檢查自我賦值
            id_ = rhs.id_;
            ticks_ = std::move(rhs.ticks_); // 非內建型別用 std::move 包起來
        }

        return *this;
    }

private:
    int id_;
    std::vector ticks_;
};

注意 assignment 正確寫法要傳回自己的參考

Foo& operator=(const Foo&);

Foo& operator=(Foo&&);

不然沒辦法這樣賦值

foo1 = foo2 = foo3;

而跟內建型別的賦值行為不同,對使用你自訂類別的使用者來說不是好的使用體驗。

最後,新寫一個類別時不要怕麻煩,養成好習慣一開始寫好就可以避免未來很多 debug 的時間。

請為有參數的建構子及轉型運算子都加上 explicit

假設有一個 class 如下:

class Foo {
public:
    Foo(int i) : i_(i) {}

private:
    int i_;
};

constructor 有一個參數,我們正常的宣告它

Foo f(0);

一切都很好,直到我們有了一個函式,它接收一個 Foo 參數

void Func(Foo f)
{}

我們預期此函式會被這樣呼叫

Func(f);

但有人不小心寫成

Func(0);

你會很驚訝地發現竟然可以編譯成功,因為編譯器發現 Func 需要參數 Foo,而 0,這個 int 型別可以被傳進 Foo 的建構子以建構出 Foo,而編譯器就這麼幹了,這非常可能不是你所預期的行為。

但只要為建構子加上 explicit,告訴編譯器只能顯示建構,編譯器就不會隱式轉換讓上述函式呼叫通過編譯。

class Foo {
public:
    explicit Foo(int i) : i_(i) {}
    …
};

那麼建構子有哪些情況要加上 explicit 呢?

1. 建構子只有一個參數時。有兩個以上的參數不用加,因為不會只傳一個參數進函式就可以建構出 Foo。

class Foo {
public:
    Foo(int i, double d) : i_(i), d_(d) {} // 不用加 explicit,因為無法這樣呼叫 Func(0, 1.0)
    …
};

2. 有多個參數,但是第二個參數有參數預設值。這很好理解,因為只傳一個值,Func(0) 還是可以通過編譯。

class Foo {
public:
    explicit Foo(int i, double d = 1.0) : i_(i), d_(d) {} // 要加 explicit
    …
};

上述情況其實就隱含著如果只有一個參數,但是有參數預設值,或是有多個參數,但第一個參數就有參數預設值的情況,因為 C++ 是前面的參數有預設值,後面就都要有,這應該很好理解。

class Foo {
public:
    explicit Foo(int i = 0) : i_(i), d_(1.0) {} // 只有一個參數,且有預設值,要加
    explicit Foo(int i = 0, double d = 1.0) : i_(i), d_(d) {} // 有多參數,但第一個參數就有預設值,也要加
    …
};

曾經我就遇過這樣一個 bug,vc6 的 CString 建構子因為沒有宣告 explicit,有人想建構一個 “0” 字串,結果傳進的是整數 0,導致後來程式整個亂掉,為了你的肝,請養成好習慣為建構子加上 explicit。

對了,如果你記不起來規則,那只要是建構子不管幾個參數你都加 explicit 算了。

等等!

你以為這樣就結束了?

還有一種函式要加 explicit,那就是轉型運算子

class Foo {
public:
    explicit operator bool() {
        return true;
    }
    …
};

假設有一個 Func2,接收一個 bool 參數

void Func2(bool b)
{}

如果你不加,那這兩種用法都會通過編譯,但應該不是你要的行為

Func2(f);

if (f)
{
    …
}

但如果加了,只有

if (f)
{
    …
}

會通過編譯,而現在 Func2 呼叫需要明確轉型才行

Func2(static_cast<bool>(f));

Func2((bool)f);

這次終於是全部了。