線程安全的對象生命管理 筆記

編寫線程安全的類不是一個困難的事情,用同步原語保護內部狀態就可,但是對象的生與死不能由對象資深擁有的mutex互斥器來保護。如何避免對象析構時候可能存在的race condition(競態條件),是c++多線程編程的基本問題,可以藉助boost庫的shared_ptr和weak_ptr完美解決。這也是實現線程安全的Observer模式的必備技術

當析構函數遇到多線程

與其他面向對象的語言不通,c++要求程序員自己管理對象的生命週期,在多線程環境下會十分困難,當一個對象被多個線程同時看到的時候,那麼對象的銷燬時間就會變得模糊不清,可能出現競態條件。

在即將析構一個對象的時候,從何而知是否有別的線程正在執行這個對象的成員函數?

如何保證在執行成員函數期間,對象不會在另一個線程裏被析構

在調用某個對象的成員函數之前,如何知道這個對象是否還或者,它的析構函數會不會恰巧執行到一半?

解決這些竟態問題是c++多線程編程面臨的基本問題。文本試圖用shared_ptr一勞永逸的解決這些問題,解決c++多線程編程的精神負擔

1.1.1線程安全的定義

多線程同時訪問的時候,其表現出正確的行爲。

無論操作系統如何調用這些線程,無論這些線程的執行順序如何交織

調用端代碼無需額外的同步或者其他協調動作

c++大多數class都不是線程安全的,包括std::string 、std::vector、std::map等,因爲這些class通常需要在外部加鎖才能提供給多線程來訪問

MutexLock 與 MutexLockGuard

爲了方便以後討論,先約定兩個工具類,相信每個c++多線程程序的人都實現過我使用過類似的功能類。

MutexLock封裝臨界區,這是一個簡單的資源類,封裝互斥器的創建和銷燬,在windows上是struct CRITI-CAL_SECTION是可重入的;在linux下是pthread_mutex_t默認是不可重入的。MutexLock一般是別的class的數據成員。

一個線程安全的counter示例

編寫單個線程安全的class不算太難,只需要同步原語保護其內部狀態。例如下面這個簡單的計數器類Counter:

1.2對象的創建很簡單

一個線程安全的class應該滿足下面三個條件

對象的構造要做到線程安全,唯一的要求就是在構造期間不要泄露this指針,即不要在構造函數中註冊任何回調函數;
也不要在構造函數中把this傳遞給跨線程的對象
幾遍在構造函數的最後一行也不行

之所以這樣規定是因爲在構造函數執行期間對象還沒有被初始化完成,如果this被泄露給了其他對象,那麼別的線程有可能訪問這個辦成品,造成難以預料的後果。

不要這麼做

#include <iostream>
class Observable
{
public:
    void register_(Observable* x);
    virtual ~Observable();
    //純虛函數是在聲明虛函數時被“初始化”爲0的函數。聲明純虛函數的一般形式是 virtual 函數類型 函數名 (參數表列) =0;
    virtual void update()=0;
};

class Foo : public Observable{
public:
    Foo(Observable* s)
    {
        s->register_(this); // 錯誤,非線程安全
    }
    virtual void update();

};
int main() {
    std::cout << "Hello, World!" << std::endl;
    return 0;
}

這說明一個二段式構造,即構造函數initialize有時會是一個好辦法,這雖然不符合c++的教條,但是在多線程下別無選擇。另外,既然允許二段式構造,那麼構造函數不必主動拋出異常,調用方靠initialize的返回值來判斷對象是否構造成功,這個功能簡化錯誤處理。

即使是最後一行也不要泄露this,因爲foo有可能是個基類,積累先於派生類構造,執行玩Foo::Foo()的最後一行代碼還會繼續執行派生類的構造函數,這時候most-derived class 的對象還處於構造中,仍然不安全。

相對來說,對象的構造做到線程安全還是比較容易的,畢竟曝光少,回頭率爲0.析構的線程安全就不那麼簡單了,這是本章關注的重點。

1.3 銷燬太難

對象析構,這在單線程裏不構成問題,最多需要避免空懸指針和野指針。

問題來了空懸指針是什麼
空懸指針是指向已經被銷燬的對象或者已經被回收的地址
情況一:

{
    char *dp = NULL;
    {
        char c;
        dp = &c;
    }
}

情況二:

#include <stdlib.h>

void func()
{
    char *dp = (char *)malloc(A_CONST);
    free(dp);         //dp變成一個空懸指針
    dp = NULL;        //dp不再是空懸指針
    /* ... */
}

情況三:

int * func ( void )
{
    int num = 1234;
    /* ... */
    return &num;
}

num是基於棧的變量,當func函數返回,變量的空間將被回收,此時獲得的指針指向的空間有可能被覆蓋。,在這裏真心推薦去看滴水逆向教程的函數棧方面的介紹真的是非常非常的好,文章從彙編寄存器的角度介紹了整個過程

野指針:

沒有被初始化的指針被叫做野指針

int func()
{
    char *dp;//野指針,沒有初始化
    static char *sdp;//非野指針,因爲靜態變量會默認初始化爲0
}

在多線程程序中存在太多的竟態條件。對一般成員函數而言,做到線程安全的辦法是他們順次執行,而不要併發執行,讓每個成員函數的臨界區不重疊。這是顯而易見的,不過有一個隱含條件或許不是每個人都想到的:成員函數用來保護臨界區的互斥器必須是有效的。而析構函數破壞了這一個假設,他會把mutex成員變量銷燬。!!!!!

1.3.1 mutex 不是辦法

mutex只能保證函數一個接一個的執行,考慮下面的代碼,它試圖用互斥鎖來保護析構函數:

Foo::~Foo()
{
    MutexLockGuard lock(mutex_);
}

void Foo::update()
{
    MutexLockGuard lock(mutex_);
}

此時,有A、B兩個線程能夠看到Foo對象x,線程A即被銷燬x,而線程B正在準備調用x->update。

線程A:

delete x;
x = NULL;

線程B:

if(x)
{
    x->update();
}

儘管線程A在銷燬對象之後把指針設置爲了NUll,儘管線程B在調用x的成員函數之前檢查了指針x的值,但是還是無法避免一種race condition:

1.線程A執行到了析構函數的(1)處,已經持有了互斥鎖,即將繼續往下執行。
2.線程B通過了if(x)的檢查,阻塞在(2)處。

接下來會發生什麼,只有天知曉。因爲析構函數會把mutex_銷燬,那麼(2)的位置可能會永遠阻塞下去,或者出現core dump,或者發生其他更糟糕的情況。

這個例子說明了delete對象之後把指針設置爲NULL根本沒用,如果一個程序要用這個來防止二次釋放,說明邏輯出了問題

1.3.2作爲數據成員的mutex不能保護析構

前面的例子已經說課,作爲class的數據成員mutexLock只能用於同步本class的其他數據成員的讀和寫,它不能保護安全的析構。因爲mutexLock成員的聲明期最多與對象一樣長,而析構動作可說是發生在對象身故之後。另外對於積累對象,那麼調用到積累析構函數的時候,派生類的對象部分已經析構了,那麼積累對象應有的mutexlock不能保護整個析構過程。再說,析構函數本來也不需要保護,因爲只有別的線程都訪問不到這個對象的時候,析構纔是安全的,否則會發生竟態條件。

另外如果同事讀寫一個class 的兩個對象,有潛在的死鎖可能性。比方說swap這個函數:

void swap(Counter& a,Counter &b)
{
    MutexLockGuard aLock(a.mutex_); // potential dead lock
    MutexLockGuard bLock(b.mutex_);
    int64_t value = a.value_;
    a.value_ = b.value_;
    b.value_ = value;
}

如果線程A執行了swap(a,b) 同時線程B執行了swap(n,a);就有可能出現死鎖。operator=() 也是類似的道理。

Counter& Counter::operator=(const Counter& rhs) 
{
    if(this == &rhs)
    {
        return *this;
    }

    MutexLockGuard myLock(mutex_);
    // potential dead lock
    MutexLockGuard itsLock(rhs.mutex_);
    value_ = rhs.value_; // 改成 value_ = rhs.value() 會死鎖
    return *this;
}

一個函數如果要鎖住相同類型的多個變量,爲了始終按相同的順序加鎖,我們可以比較mutex對象的地址,始終先加鎖地址比較小的mutex

一個函數如果要鎖住相同類型的多個對象,爲了保證始終按相同的順序加鎖,我
們可以比較 mutex 對象的地址,始終先加鎖地址較小的 mutex。

1.4線程安全的 Observer 有多難

一個動態創建的對象是否還活着,光看指針是看不出來的(引用也一樣看不出
來)。指針就是指向了一塊內存,這塊內存上的對象如果已經銷燬,那麼就根本不能看出來的我寫了一個很簡單的例子:

#include <iostream>
int main() {
    void* ptr = malloc(100);
    free(ptr);
    if(ptr)
    {
        printf("111\n");
    }
    return 0;
}

無法很輕鬆的判斷指針是否存活

一個十分簡單的做法就是隻創建不銷燬。程序使用一個對象來暫存用過的對象,下一次申請新的對象的時候,如果對象有存貨,就重新利用現有的對象,否則就再創建一個,當對象被使用完了,不是直接釋放掉,而是放回到池子裏。這個辦法雖然有很多缺點,但是至少能夠避免指針失效的問題。

這解決問題的辦法有以下問題:

對象池的線程安全,如何安全的完整的把對象放到池子裏,防止出現部分放回的竟態?

全局共享數據引發的lock contention,這個集中化對象池會不會把多線程併發操作串行化。

如果共享對象的類型不止一種,那麼重複實現對象池還是使用類模板?

會不會造成內存泄露或者分片?

當然我們還可以使用代理模式來處理,只需要給對應的對象加入計數器,使用一個代理對象來申請或者釋放對象

最後我們可以使用c++11的智能指針 他是一個神奇,效率也很高,因爲他可以確保指針在沒有使用的情況下被釋放,管理起來讓我們方便很多

下面我引自c++11 的智能指針內容

在c++ 中,動態內存的管理是通過一堆運算符來完成的:new,在動態內存中爲對象分配空間並返回一個指向這個對象的指針,我們可以選擇對對象進行初始化:delete,接受一個動態對象的指針,銷燬對象,並釋放與之關聯 的內存。

動態內存的使用很容易出問題,因爲確保在正確的時間釋放內存是及其困難的。有時候會忘記釋放內存

爲了更加容易的動態使用內存,新的標準庫使用兩種智能指針來管理動態對象。

智能指針有兩類,shared_ptr允許許多個指針指向同一個對象:unique_ptr則獨佔指向的對象。標準庫還定義了一個名字叫weak_ptr的伴隨類,他是一種弱引用,指向shared_ptr所管理的對象。這三種類型都在memory頭文件中。

shared_ptr 類

類似vector,智能指針也是模板。因此,當我們創建一個智能指針的時候,必須提供額外的信息-------指針可以指向的類型。與vector一樣,我們使用尖括號給出類型,之後是所定義的這種只能指針的名字:

12.1.1 shared_ptr類

類似vector,智能指針也是模板。因此當我們創建一個智能指針的時候,必須提供額外的信息------指針可以指向的類型。與vector一樣,我們在尖括號內給出類型,之後是鎖定義的這種智能指針的名字

默認情況下是null

#include <stdio.h>
#include <memory>
#include <iostream>
using namespace std;
class A{

};

int main()
{
    shared_ptr<A> d;
    if(d)
    {
        cout<<"not null"<<endl;
    }else{
        cout<<"null"<<endl;
    }
    return 0;
}

shared_ptr和unique_ptr都支持的操作.

shared_ptr<T> sp 空智能指針,可以指向類型爲T的對象
unique_ptr<T> sp

p   將p作爲一個對象判斷,如果p是一個對象,則爲true
*p  解引用p,獲得它的指定對象

p->mem 等價於*p

swap(p,q) 交換p和q的指針
p.swap(q)

shared_ptr獨有的操作

make_shared<T>(args) 返回一個shared_ptr,指向一個動態分配的類型爲T的對象。使用args初始化此對象

shared_ptr<T>p(a) p是shared_ptr q的拷貝;此操作會遞增q中的計數器

p=q p和q都是都是share_ptr,所保存的指針必須相互轉換。此操作,會遞減p的引用計數,遞增q的引用計數;若p的引用計數變爲0,則將其管理的原內存釋放

p.unique 若p.user_count()爲1,返回true,否則 返回false
p.user_count 返回共享對象智能指針的數量

make_shared函數

最安全的分配和使用動態內存的方法是調用一個名爲make_shared的標準庫函數。次函數在動態內存中分配一個對象,並且初始化他,返回指向這個對象的shared_ptr。與智能指針一樣,他在memory裏。

當要用make_shared的時候,必須要指定想要創建的對象類型。我們可以使用make_shared進行賦值
#include <stdio.h>
#include <memory>
#include <iostream>
#include <cstring>

using namespace std;
class A{

};

int main()
{
    shared_ptr<int> data = make_shared<int>(42);
    cout<<*data<<endl;
    return 0;
}

shared_ptr的拷貝和賦值:

當進行拷貝或賦值的時候,每個shared_ptr都有一個關聯的的計數器,通常稱爲引用計數。

每一個shared_ptr都有一個引用計數,無論何時我們拷貝一個shared_ptr,計數器都會增加。例如當我們使用shared_ptr初始化另一個shared_ptr的時候,或將他作爲參數 傳遞給一個函數以及作爲函數值返回的時候,他所關聯的計數器都會增加。當我們給shared_ptr設置一個新值,或者shared_ptr離開作用域的時候計數器都會遞減。

一旦shared_ptr引用技術爲0,就會被自動釋放掉。

一段代碼:

#include <memory>
using namespace std;
int main()
{
    shared_ptr<int> q;
    auto r = make_shared<int>(42);
    r = q;
    printf("%d\n",*r);
}

我們發現出現了core dump 因爲 r=q,讓r的計數器減一,r的計數器爲0被釋放掉了

shared_ptr會自動釋放相關聯的內存

shared_ptr<Foo> factory(int arg)
{
    return make_shared<Foo>(arg);
}

使用動態內存出於三種原因:

1.程序不知道自己使用了多少對象
2.程序不知道所需要的準確類型
3.程序需要多個對象共享

12.1.3 new和shared_ptr結合使用

int main()
{
    auto data = shared_ptr<int>(new int(32));
    printf("%d\n",*data);
}

reset會重置計數器和值

int main()
{
    auto data = shared_ptr<int>(new int(32));
    data.reset();
    printf("%d\n",*data);
}

定義自己的析構函數

#include <memory>
using namespace std;

class Foo{

};
void end_data(Foo* a)
{
    printf("1111\n");
}

int main()
{
    shared_ptr<Foo> p1(new Foo,end_data);

    //使用定製的deleter創建shared_ptr

    return 0;
}

unique_ptr傳遞刪除器

#include <memory>
using namespace std;

class Foo{
public:
    Foo(int a)
    {

    }
};
void end_data(Foo* a)
{
    printf("1111\n");
}

int main()
{
    unique_ptr<Foo,void(*)(Foo*)> p1(new Foo(3),end_data);

    return 0;
}

注意 unique_ptr不能被拷貝或者賦值

weak_ptr不會改變對象shared_ptr的引用計數器,但是他可以讓你知道對象是否還活着。

很關鍵的兩個操作

#include <memory>
using namespace std;

class Foo{
public:
    int b;
    Foo(int a)
    {
        b = a;
    }

    ~Foo()
    {
        printf("333\n");
    }
};
void end_data(Foo* a)
{
    printf("1111\n");
}

int main()
{
    shared_ptr<Foo> p1 = make_shared<Foo>(3);
    weak_ptr<Foo> p2;
    p2 = p1;
    printf("%d\n",p2.expired());
    return 0;
}

不會改變引用計數,但是可以判斷對象是否存活

allocator分配n個未初始化的string

//全部釋放要while循環 data++

int main()
{
    allocator<Foo> alloc;
    Foo* data = alloc.allocate(10);
    alloc.construct(data,1);
    alloc.deallocate(data,10);
    return 0;
}

(3)allocator類算法

1)uninitialized_copy(begin,end,begin2);//將迭代器begin1end(尾後迭代器)所代表的輸入範圍copy到begin2開始的內存,begin2所指向的內存必須大於beginend所需的;

2)uninitialized_copy_n(begin,n,begin2);//從迭代器b指向的元素開始拷貝n個到begin2開始的內存空間

3)uninitialized_fill(begin,end,t);//在迭代器begin~end範圍內構建t的拷貝;

4)uninitialized_fill_n(begin,n,t);//從begin開始的內存構建n個t的拷貝;

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章