线程安全的对象生命管理 笔记

编写线程安全的类不是一个困难的事情,用同步原语保护内部状态就可,但是对象的生与死不能由对象资深拥有的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(b,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的拷贝;

猜你喜欢

转载自blog.csdn.net/qq_32783703/article/details/104346242
今日推荐