抽象工厂模式

内容

  1. 抽象工厂模式之前
  2. 引入抽象工厂
  3. 抽象工厂模式概述
  4. 解决方案

抽象工厂模式之前

工厂方法模式通过引入工厂等级结构,解决了简单工厂模式中工厂类职责太重的问题。
但由于工厂方法模式中的每个工厂只生产一类产品,可能会导致系统中存在大量的工厂类,势必会增加系统的开销。
此时,我们可以考虑将一些相关的产品组成一个“产品族”,由同一个工厂来统一生产,这就是我们本文将要学习的抽象工厂模式的基本思想。

案例-界面皮肤库的初始设计

某软件公司欲开发一套界面皮肤库,可以对Java桌面软件进行界面美化。为了保护版权,该皮肤库源代码不打算公开,而只向用户提供已打包为jar文件的class字节码文件。用户在使用时可以通过菜单来选择皮肤,不同的皮肤将提供视觉效果不同的按钮、文本框、组合框等界面元素,其结构示意图如图。

image-20220426083347741

该皮肤库需要具备良好的灵活性和可扩展性,用户可以自由选择不同的皮肤,开发人员可以在不修改既有代码的基础上增加新的皮肤。

该软件公司的开发人员针对上述要求,决定使用工厂方法模式进行系统的设计,为了保证系统的灵活性和可扩展性,提供一系列具体工厂来创建按钮、文本框、组合框等界面元素,客户端针对抽象工厂编程,初始结构如图所示。

img

  • 在图中,提供了大量工厂来创建具体的界面组件,可以通过配置文件更换具体界面组件从而改变界面风格。但是,此设计方案存在如下问题:
    • 当需要增加新的皮肤时,虽然不要修改现有代码,但是需要增加大量类,针对每一个新增具体组件都需要增加一个具体工厂,类的个数成对增加,这无疑会导致系统越来越庞大,增加系统的维护成本和运行开销;
    • 由于同一种风格的具体界面组件通常要一起显示,因此需要为每个组件都选择一个具体工厂,用户在使用时必须逐个进行设置,如果某个具体工厂选择失误将会导致界面显示混乱,虽然我们可以适当增加一些约束语句,但客户端代码和配置文件都较为复杂。

如何减少系统中类的个数并保证客户端每次始终只使用某一种风格的具体界面组件?这是该公司开发人员所面临的两个问题,显然,工厂方法模式无法解决这两个问题,别着急,将要介绍的抽象工厂模式可以让这些问题迎刃而解。

引入抽象工厂

在工厂方法模式中具体工厂负责生产具体的产品,每一个具体工厂对应一种具体产品,工厂方法具有唯一性,一般情况下,一个具体工厂中只有一个或者一组重载的工厂方法。但是有时候我们希望一个工厂可以提供多个产品对象,而不是单一的产品对象,如一个电器工厂,它可以生产电视机、电冰箱、空调等多种电器,而不是只生产某一种电器。为了更好地理解抽象工厂模式,我们先引入两个概念:

  • 产品等级结构:产品等级结构即产品的继承结构,如一个抽象类是电视机,其子类有海尔电视机、海信电视机、TCL电视机,则抽象电视机与具体品牌的电视机之间构成了一个产品等级结构,抽象电视机是父类,而具体品牌的电视机是其子类。
  • 产品族:在抽象工厂模式中,产品族是指由同一个工厂生产的,位于不同产品等级结构中的一组产品,如海尔电器工厂生产的海尔电视机、海尔电冰箱,海尔电视机位于电视机产品等级结构中,海尔电冰箱位于电冰箱产品等级结构中,海尔电视机、海尔电冰箱构成了一个产品族。

产品等级结构与产品族示意图如图所示:

img

在图中,不同颜色的多个正方形、圆形和椭圆形分别构成了三个不同的产品等级结构,而相同颜色的正方形、圆形和椭圆形构成了一个产品族,每一个形状对象都位于某个产品族,并属于某个产品等级结构。图中一共有五个产品族,分属于三个不同的产品等级结构。我们只要指明一个产品所处的产品族以及它所属的等级结构,就可以唯一确定这个产品。

当系统所提供的工厂生产的具体产品并不是一个简单的对象,而是多个位于不同产品等级结构、属于不同类型的具体产品时就可以使用抽象工厂模式。抽象工厂模式是所有形式的工厂模式中最为抽象和最具一般性的一种形式。抽象工厂模式与工厂方法模式最大的区别在于,工厂方法模式针对的是一个产品等级结构,而抽象工厂模式需要面对多个产品等级结构,一个工厂等级结构可以负责多个不同产品等级结构中的产品对象的创建。当一个工厂等级结构可以创建出分属于不同产品等级结构的一个产品族中的所有对象时,抽象工厂模式比工厂方法模式更为简单、更有效率。抽象工厂模式示意图如图所示:

image-20220426084423742

在图中,每一个具体工厂可以生产属于一个产品族的所有产品,例如生产颜色相同的正方形、圆形和椭圆形,所生产的产品又位于不同的产品等级结构中。如果使用工厂方法模式,图所示结构需要提供15个具体工厂,而使用抽象工厂模式只需要提供5个具体工厂,极大减少了系统中类的个数。

抽象工厂模式概述

抽象工厂模式为创建一组对象提供了一种解决方案。与工厂方法模式相比,抽象工厂模式中的具体工厂不只是创建一种产品,它负责创建一族产品。抽象工厂模式定义如下:

抽象工厂模式(Abstract Factory Pattern):提供一个创建一系列相关或相互依赖对象的接口,而无须指定它们具体的类。抽象工厂模式又称为Kit模式,它是一种对象创建型模式。

在抽象工厂模式中,每一个具体工厂都提供了多个工厂方法用于产生多种不同类型的产品,这些产品构成了一个产品族,抽象工厂模式结构如图

image-20220426090141713

该模式包含的角色

  1. AbstractFactory(抽象工厂)
    • 它声明了一组用于创建一族产品的方法,每一个方法对应一种产品。
  2. ConcreteFactory(具体工厂)
    • 它实现了在抽象工厂中声明的创建产品的方法,生成一组具体产品,这些产品构成了一个产品族,每一个产品都位于某个产品等级结构中。
  3. AbstractProduct(抽象产品)
    • 它为每种产品声明接口,在抽象产品中声明了产品所具有的业务方法。
  4. ConcreteProduct(具体产品角色)
    • 它定义具体工厂生产的具体产品对象,实现抽象产品接口中声明的业务方法。

典型代码

在抽象工厂中声明了多个工厂方法,用于创建不同类型的产品,抽象工厂可以是接口,也可以是抽象类或者具体类,其典型代码如下所示:

1
2
3
4
5
6
class AbstractFactory
{
public:
AbstractProductA createProductA() = 0; //工厂方法一
AbstractProductB createProductB() = 0; //工厂方法二
}

具体工厂实现了抽象工厂,每一个具体的工厂方法可以返回一个特定的产品对象,而同一个具体工厂所创建的产品对象构成了一个产品族。对于每一个具体工厂类,其典型代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ConcreteFactory1 : public AbstractFactory
{
public:
//工厂方法一
AbstractProductA createProductA()
{
return new ConcreteProductA1();
}
//工厂方法二
AbstractProductB createProductB()
{
return new ConcreteProductB1();
}
}

前文案例完整解决方案

该公司开发人员使用抽象工厂模式来重构界面皮肤库的设计,其基本结构如图所示:

image-20220426091002353

在图中,SkinFactory接口充当抽象工厂,其子类SpringSkinFactorySummerSkinFactory充当具体工厂,接口ButtonTextFieldComboBox充当抽象产品,其子类SpringButtonSpringTextFieldSpringComboBoxSummerButtonSummerTextFieldSummerComboBox充当具体产品。完整代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
//在本实例中对代码进行了大量简化,实际使用时,界面组件的初始化代码较为复杂,为了突出核心代码,在此只提供框架代码和演示输出。
//按钮接口:抽象产品
class Button
{
public:
virtual void display() = 0;
};
//Spring按钮类:具体产品
class SpringButton : public Button
{
public:
void display()
{
cout << "显示浅绿色按钮。" << endl;
}
};
//Summer按钮类:具体产品
class SummerButton : public Button
{
public:
void display()
{
cout << "显示浅蓝色按钮。" << endl;
}
};
//文本框接口:抽象产品
class TextField
{
public:
virtual void display() = 0;
};
//Spring文本框类:具体产品
class SpringTextField : public TextField
{
public:
void display()
{
cout << "显示绿色边框文本框。" << endl;
}
};
//Summer文本框类:具体产品
class SummerTextField : public TextField
{
public:
void display()
{
cout << "显示蓝色边框文本框。" << endl;
}
};
//组合框接口:抽象产品
class ComboBox
{
public:
virtual void display() = 0;
};
//Spring组合框类:具体产品
class SpringComboBox : public ComboBox
{
public:
void display()
{
cout << "显示绿色边框组合框。" << endl;
}
};
//Summer组合框类:具体产品
class SummerComboBox : public ComboBox
{
public:
void display()
{
cout << "显示蓝色边框组合框。" << endl;
}
};
//界面皮肤工厂接口:抽象工厂
class SkinFactory
{
public:
virtual Button* createButton() = 0;
virtual TextField* createTextField() = 0;
virtual ComboBox* createComboBox() = 0;
};
//Spring皮肤工厂:具体工厂
class SpringSkinFactory : public SkinFactory
{
public:
Button* createButton()
{
return new SpringButton();
}
TextField* createTextField()
{
return new SpringTextField();
}
ComboBox* createComboBox()
{
return new SpringComboBox();
}
};
//Summer皮肤工厂:具体工厂
class SummerSkinFactory : public SkinFactory
{
public:
Button* createButton()
{
return new SummerButton();
}
TextField* createTextField()
{
return new SummerTextField();
}
ComboBox* createComboBox()
{
return new SummerComboBox();
}
};

客户端

编写如下客户端测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<iostream>
using namespace std;
int main()
{
//使用抽象层定义
SkinFactory* factory;
Button* bt;
TextField* tf;
ComboBox* cb;
factory = new SpringSkinFactory;
bt = factory->createButton();
tf = factory->createTextField();
cb = factory->createComboBox();
bt->display();
tf->display();
cb->display();
}

编译并运行程序,输出结果如下

1
2
3
显示浅绿色按钮。
显示绿色边框文本框。
显示绿色边框组合框。

问题

该公司使用抽象工厂模式设计了界面皮肤库,该皮肤库可以较为方便地增加新的皮肤,但是现在遇到一个非常严重的问题:由于设计时考虑不全面,忘记为单选按钮(RadioButton)提供不同皮肤的风格化显示,导致无论选择哪种皮肤,单选按钮都显得那么“格格不入”。该公司的设计人员决定向系统中增加单选按钮,但是发现原有系统居然不能够在符合“开闭原则”的前提下增加新的组件,原因是抽象工厂SkinFactory中根本没有提供创建单选按钮的方法,如果需要增加单选按钮,首先需要修改抽象工厂接口SkinFactory,在其中新增声明创建单选按钮的方法,然后逐个修改具体工厂类,增加相应方法以实现在不同的皮肤中创建单选按钮,此外还需要修改客户端,否则单选按钮无法应用于现有系统。

怎么办?答案是抽象工厂模式无法解决该问题,这也是抽象工厂模式最大的缺点。在抽象工厂模式中,增加新的产品族很方便,但是增加新的产品等级结构很麻烦

抽象工厂模式的这种性质称为**“开闭原则”的倾斜性**。“开闭原则”要求系统对扩展开放,对修改封闭,通过扩展达到增强其功能的目的,对于涉及到多个产品族与多个产品等级结构的系统,其功能增强包括两方面:

  • 增加产品族:对于增加新的产品族,抽象工厂模式很好地支持了“开闭原则”,只需要增加具体产品并对应增加一个新的具体工厂,对已有代码无须做任何修改。
  • 增加新的产品等级结构:对于增加新的产品等级结构,需要修改所有的工厂角色,包括抽象工厂类,在所有的工厂类中都需要增加生产新产品的方法,违背了“开闭原则”。

正因为抽象工厂模式存在“开闭原则”的倾斜性,它以一种倾斜的方式来满足“开闭原则”,为增加新产品族提供方便,但不能为增加新产品结构提供这样的方便,因此要求设计人员在设计之初就能够全面考虑,不会在设计完成之后向系统中增加新的产品等级结构,也不会删除已有的产品等级结构,否则将会导致系统出现较大的修改,为后续维护工作带来诸多麻烦。

总结

抽象工厂模式是工厂方法模式的进一步延伸,由于它提供了功能更为强大的工厂类并且具备较好的可扩展性,在软件开发中得以广泛应用,尤其是在一些框架和API类库的设计中,例如在Java语言的AWT(抽象窗口工具包)中就使用了抽象工厂模式,它使用抽象工厂模式来实现在不同的操作系统中应用程序呈现与所在操作系统一致的外观界面。抽象工厂模式也是在软件开发中最常用的设计模式之一。

  • 优点
    • 抽象工厂模式隔离了具体类的生成,使得客户并不需要知道什么被创建。由于这种隔离,更换一个具体工厂就变得相对容易,所有的具体工厂都实现了抽象工厂中定义的那些公共接口,因此只需改变具体工厂的实例,就可以在某种程度上改变整个软件系统的行为。
    • 当一个产品族中的多个对象被设计成一起工作时,它能够保证客户端始终只使用同一个产品族中的对象。
    • 对于增加新的产品族——很方便,无须修改已有系统,符合“开闭原则”。
  • 缺点
    • 对于增加新的产品等级结构——很麻烦,需要对原有系统进行较大的修改,甚至需要修改抽象层代码,这显然会带来较大的不便,违背了“开闭原则”。
  • 适用场景
    • 一个系统不应当依赖于产品类实例如何被创建、组合和表达的细节,这对于所有类型的工厂模式都是很重要的,用户无须关心对象的创建过程,将对象的创建和使用解耦
    • 系统中有多于一个的产品族,而每次只使用其中某一产品族。可以通过配置文件等方式来使得用户可以动态改变产品族,也可以很方便地增加新的产品族
    • 属于同一个产品族的产品将在一起使用,这一约束必须在系统的设计中体现出来。同一个产品族中的产品可以是没有任何关系的对象,但是它们都具有一些共同的约束,如同一操作系统下的按钮和文本框,按钮与文本框之间没有直接关系,但它们都是属于某一操作系统的,此时具有一个共同的约束条件:操作系统的类型。
    • 产品等级结构要求保持稳定,设计完成之后,不会向系统中增加新的产品等级结构或者删除已有的产品等级结构。

参考文献

1
[1] 刘伟. 设计模式.

读muduo有感_线程安全的对象生命期管理

内容

  1. 线程安全的定义
  2. 对象的创建
  3. 对象的销毁
  4. 线程安全的Observer
  5. 解决方案
  6. 陷阱

线程安全的定义

依据[JCP],一个线程安全的class应当满足以下三个条件:

  • 多个线程同时访问时,其表现出正确的行为。
  • 无论操作系统如何调度这些线程,无论这些线程的执行顺序如何交织。
  • 调用端代码无须额外的同步或其他协调动作。

依据这个定义,C++标准库里的大多数class都不是线程安全的,包括std::stringstd::vectorstd::map等。这些class通常需要在外部加锁才能供多个线程同时访问。

以Counter为例说明问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Counter : boost::nocopyable
{
public:
Counter() : value_(0)
{}
int64_t value() const;
int64_t getAndIncrease();
private:
int64_t value_;
mutable MutexLock mutex_;

};
int64_t Counter::value() const
{
MutexLockGuard lock(mutex_);
int64_t ret = value_++;
return ret;
}

这个class很直白,一看就明白,也容易验证它是线程安全的。每个Counter对象有自己的mutex_,因此不同对象之间不构成锁争用(lock contention)。如果是同一个Counter对象则不可同时访问value_++

注意到,其mutex_成员是mutable的,意味着const成员函数如Counter::value()也能直接使用non-constmutex_

尽管这个Counter毫无疑问是线程安全的,但是如果Counter是动态创建的,并通过指针来访问,则对象销毁的**竞态条件(race condition)**仍然存在。

当析构函数遇到多线程

与其他面向对象语言不同,Cpp要求程序员自己管理对象的生命期,这在多线程环境下显得尤为困难。

当一个对象能被多个线程同时看到时,那么对象的销毁时机就变得模糊不清,可能出现多种竞态条件

  • 在即将析构一个对象时,从何而知此刻是否有别的线程正在执行该对象的成员函数?
  • 如何保证在执行成员函数期间,对象不会在另一个线程被析构?
  • 在调用某个对象的成员函数之前,如何得知这个对象还活着?它的析构函数会不会碰巧执行到一半?

这些竞态条件问题是C++多线程编程面临的基本问题。

对象的创建

对象构造要做到线程安全,唯一的要求是在构造期间不要给其他对象泄露this指针(其自身创建的子对象除外)。即:

  • 不要在构造函数中注册任何回调;
  • 不要在构造函数中把this传给跨线程的对象;
  • 即便在构造函数的最后一行也不行。

之所以这样规定,是因为在构造函数执行期间,对象还没有完成初始化工作,如果这时this泄露给了其他对象(其自身创建的子对象除外),那么别的线程有可能访问这个半成品对象,这会造成难以预料的后果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <algorithm>
#include <vector>
#include <stdio.h>

class Observable;

class Observer
{
public:
virtual ~Observer();
virtual void update() = 0;

void observe(Observable* s);

protected:
Observable* subject_;
};

class Observable
{
public:
void register_(Observer* x);
void unregister(Observer* x);

void notifyObservers()
{
for (size_t i = 0; i < observers_.size(); ++i)
{
Observer* x = observers_[i];
if (x) {
x->update(); // (3)
}
}
}

private:
std::vector<Observer*> observers_;
};

Observer::~Observer()
{
subject_->unregister(this);
}

void Observer::observe(Observable* s)
{
s->register_(this);
subject_ = s;
}

void Observable::register_(Observer* x)
{
observers_.push_back(x);
}

void Observable::unregister(Observer* x)
{
std::vector<Observer*>::iterator it = std::find(observers_.begin(), observers_.end(), x);
if (it != observers_.end())
{
std::swap(*it, observers_.back());
observers_.pop_back();
}
}
1
2
3
4
5
6
7
8
9
10
/* 错误 */
class Foo : public Observer
{
public:
Foo(Observer * s)
{
s->register_(this);
}
virtual void update();
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 正确 */
class Foo : public Observer
{
public:
Foo();
virtual void update();
void observe(Observer * s)
{
s->register_(this);
}
};
Foo* pFoo = new Foo;
Observer * s = getSubject();
pFoo->observer(s); //二段式构造,或者直接写s->register_(pFoo);

二段式构造——即构造函数+initialize()——有时会是好办法,这虽然不符合C++教条,但是多线程下别无选择。

另外,既然允许二段式构造,那么构造函数不必主动抛异常,调用方靠initialize()的返回值来判断对象是否构造成功,这能简化错误处理。

即使是构造函数的最后一行,也不要泄露this指针,因为Foo有可能是个基类,基类先于派生类构造,执行完Foo::Foo()的最后一行代码还会继续执行派生类的构造函数,这时most-derived class的对象还处于构造中,仍然不安全。

对象的销毁

对象的析构,在单线程里不构成问题,最多需要注意避免空悬指针和野指针。

而在多线程程序中,存在了太多的竞态条件。对一般成员函数而言,做到线程安全的办法是让它们顺次执行,而不要并发执行(关键是不要同时读写共享状态),也就是让每个成员函数的临界区不重叠。这是显而易见的,不过有一个隐含条件或许不是每个人都能立刻想到:成员函数用来保护临界区的互斥器本身必须是有效的。而析构函数破坏了这一假设,它会把mutex成员变量销毁掉。悲剧啊!

  • mutex不是办法。

mutex只能保证函数一个接一个地执行,考虑下面两个代码(并行),它试图用互斥锁来保护析构函数:

image-20220425141301184

此时,有A、B两个线程都能看到Foo对象x,线程A即将销毁x,而线程B正准备调用x->update()

image-20220425141415894

尽管线程A在销毁对象之后把指针置为了NULL,尽管线程B在调用x的成员函数之前检查了指针x的值,但还是无法避免一种竞态条件:

  1. 线程A执行到了析构函数的(1)处,已经持有了互斥锁,即将继续往下执行。
  2. 线程B通过了if(x)检测,阻塞在(2)处。

接下来会发生什么,只有天晓得。因为析构函数会把mutex_销毁,那么(2)处有可能永远阻塞下去,有可能进入“临界区”,然后core dump,或者发生其他更糟糕的情况。

这个例子至少说明delete对象之后把指针置为NULL根本没用,如果一个程序要靠这个来防止二次释放,说明代码逻辑出了问题。

  • 作为数据成员的mutex不能保护析构

前面的例子说明,作为class数据成员的MutexLock只能用于同步本class的其他数据成员的读和写,它不能保护安全地析构。因为MutexLock成员的生命期最多与对象一样长,而析构动作可说是发生在对象死亡之后(或者说死亡之时)。另外,对于基类对象,调用到基类析构函数的时候,派生类对象的那部分已经析构完毕了,那么基类对象拥有的MutexLock不能保护整个析构过程。

其实,析构过程本来也不需要保护,因为只有别的线程都访问不到这个对象时,析构才是安全的,否则会有竞态条件发生。

死锁

如果要同时读写一个class的两个对象,有潜在的死锁可能。比方说有swap()这个函数。

1
2
3
4
5
6
7
8
void swap(Counter & a, Counter & b)
{
MutexLockGuard aLock(a.mutex_);
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=()也是类似的道理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Counter& Counter::operator=(const Counter& rhs)
{
if(this == &rhs)return *this;
MutexLockGuard myLock(mutex_);
MutexLockGuard itsLock(rhs.mutex_);
/* 不要写成 value_ = rhs.value(),会死锁;
* rhs.value() -->
* MutexLockGuard lock(mutex_);
* int64_t ret = value_++;
* return ret;
*/
value_ = rhs.value_;
return *this;
}

一个函数如果要锁住相同类型的多个对象,为了保证始终按相同的顺序加锁,我们可以比较mutex对象的地址,始终先加锁地址较小的mutex。???

线程安全的Observer

一个动态创建的对象是否还活着,光看指针(或引用)看不出来。指针就是指向了一块内存,这块内存上的对象如果已经销毁,那么根本就不能访问(就像free(3)之后的地址不能访问一样),既然不能访问又如何知道对象的状态?换句话说,没有高效的办法判断一个指针是否是合法指针,这是C/C++指针问题的根源。(万一原址又创建了一个新的对象呢?再万一这个新的对象的类型异于老的对象呢?)

对象之间关系的三种主要类型

composition(组合/复合)、aggregation(聚合)、association(关联)。

  • 组合关系

组合关系在多线程里不会遇到什么麻烦,因为对象x的生命期由其唯一的拥有者owner控制,owner析构的时候会把x也析构掉。从形式上看,x是owner的直接数据成员,或者scoped_ptr/unique_ptr成员,抑或owner持有的容器的元素。

后两种关系在C++里比较难办,处理不好就会造成内存泄露或者重复释放。

  • 关联关系

关联是一种很宽泛的关系,它表示一个对象a用到了另一个对象b,调用了后者的成员函数。从代码形式上看,a持有b的指针或引用,但是b的生命期不由a单独控制

  • 聚合关系

聚合关系从形式上看与关联关系相同,除了a和b有逻辑上的整体与部分的关系。如果b是动态创建的并在整个程序结束前有可能被释放,那么就会出现前文提到的竞态条件。

如何避免访问失效对象

似乎有一个简单的解决方法:只创建不销毁。程序使用一个对象池来暂存用过的对象,下次申请新对象时,如果对象池里有存货就拿一个利用,否则就新建一个;对象用完之后不是直接释放掉而是放回池子里。这个办法虽然有很多缺点,但是却能避免访问失效对象的情况发生。

缺点和问题:

  • 对象池的线程安全,如何安全地、完整地把对象放回池子里,防止出现“部分放回”的竞态?(线程A任务对象x已经放回了,而线程B以为对象x还活着。)
  • 全局共享数据引发的lock contention,这个集中化的对象池可能会把多线程并发操作退化为串行。
  • 如果共享对象的类型不止一种,那么是重复实现对象池还是使用类模板呢?
  • 会不会造成内存泄漏与分片?因为对象池占用的内存只增不减,而且多个对象池不能共享内存。

Observer模式

回到正题上来,如果对象x注册了任何非静态成员函数回调,那么必然在某处持有了指向x的指针,这就暴露在了竞态条件下。

一个典型的场景是Observer模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* 观察者 */
class Observer //: boost::noncopyable
{
public:
virtual ~Observer();
virtual void update() = 0;
/* ... */
};
/* 观察目标 */
class Observable //: boost::noncopyable
{
public:
void register_(Observer * x); //因为和关键字冲突了,所以加个_
void unregister(Observer * x);
void notifyObservers()
{
for(Observer * x : observers_)
{
x->update();
}
}
private:
std::vector<Observer*> observers_;
}

当Observable通知每一个Observer时(x->update();),它从何得知Observer对象x还活着?要不试试在Observer的析构函数里调用unregister()来解注册?恐难奏效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Observer //: boost::noncopyable
{
public:
// 同前
void observe(Observable * s)
{
s->register_(this);
subject_ = s;
}
virtual ~Observer()
{
subject_->unregister(this);
}
Observable* subject_;
/* ... */
};

我们试着让Observer的析构函数去调用unregister(this),这里有两个竞态条件。其一:subject_->unregister(this)中如何得知subject_还活着?其二:就算subject_指向某个永久存在的对象,那么还是险象环生:

  1. 线程A执行到subject_->unregister(this)之前,还没有来得及unregister本对象。
  2. 线程B执行到x->update();,x正好指向是subject_->unregister(this)正在析构的对象。

这时悲剧又发生了,既然x所指的Observer对象正在析构,调用它的任何非静态成员函数都是不安全的,何况是虚函数(C++标准对在构造函数和析构函数中调用虚函数的行为有明确规定,但是没有考虑并发调用的情况。)。更糟糕的是,Observer是个基类,执行到subject_->unregister(this)时,派生类对象已经析构掉了,这时候整个对象处于将死未死的状态,core dump恐怕是最幸运的结果。

这些竞态条件似乎可以通过加锁来解决,但在哪儿加锁?谁持有这些互斥锁?似乎不是那么显而易见。要是有一个活着的对象能帮帮我们就好了,这个对象需要提供一个isAlive()之类的程序函数,告诉我们某个对象还在不在。可惜指针和引用都不是对象,它们是内建类型。(这时候就要引出来智能指针了)

不要使用原始指针

指向对象的原始指针(raw pointer)是坏的,尤其当暴露给别的线程时。Observable应当保存的不是原始的Observer*,而是一个能够分辨Observer对象是否存活的东西。类似地,如果Observer要在析构函数里解注册(这虽然不能解决前面提到的竞态条件,但是在析构函数中打扫战场还是应该的),那么subject_的类型也不能是原始的Observable*(因为解注册用到了Obervable,它的unregister成员函数)。

可以使用引用计数型智能指针,即shared_ptr。用一层间接性(二级指针)保证了避免释放空悬指针,也通过引用计数解决了释放对象期间的竞态条件问题。

一个万能的解决方案

引如另外一层间接性,用对象来管理共享资源,亦即handle/body惯用技法。用标准库中的一对“神兵利器”可助我们完美解决原始指针的问题。

share_ptr+weak_ptr

  • shared_ptr控制对象的生命期。
    • shared_ptr是强引用(想象成用铁丝绑住堆上的对象),只要有一个指向x对象的shared_ptr存在,该x对象就不会析构。
    • 当指向对象x的最后一个shared_ptr析构或reset()的时候,x保证会被销毁。
  • weak_ptr不控制对象的生命期,但是它知道对象是否还活着(想象成用棉线轻轻拴住堆上的对象)。
    • 如果对象还活着,那么它可以提升为有效的shared_ptr
    • 如果对象已经死了,提升会失败,返回一个空的shared_ptr。提升lock()行为是线程安全的。
  • shared_ptr/weak_ptr的“计数”在主流平台上是原子操作,没有用锁,性能不俗。
  • shared_ptr/weak_ptr的线程安全级别与std::string和STL容器一样。

孟岩在《垃圾收集机制批判》中一针见血地点出智能指针的优势:“C++利用智能指针达成的效果是:一旦某对象不再被引用,系统刻不容缓,立刻回收内存。这通常发生在关键任务完成后的清理时期,不会影响关键任务的实时性,同时,内存里所有的对象都是有用的,绝对没有垃圾空占内存。”

C++的内存问题很容易解决

C++里可能出现的内存问题大致有这么几个方面:

  1. 缓冲区溢出(buffer overrun)
  2. 空悬指针/野指针
  3. 重复释放(double delete)
  4. 内存泄漏(memory leak)
  5. 不配对的new[]/delete
  6. 内存碎片(memory fragmentation)

在这几种错误里边,内存泄漏的危害相对较小,因为它只是借了东西不还,程序功能在一段时间内还算正常;而其他如缓冲区溢出或重复释放等致命错误可能会造成安全性(security和data safety)方面的严重后果。

正确使用智能指针能很轻易地解决前面5个问题。解决第6个问题——内存碎片需要别的思路。

  • 缓冲区溢出
    • std::vector<char>/std::string或自己编写Buffer class来管理缓冲区,自动记住用缓冲区的长度,并通过成员函数而不是裸指针来修改缓冲区。
  • 空悬指针/野指针
    • shared_ptr/weak_ptr
  • 重复释放
    • scoped_ptr,只在对象析构的时候释放一次。
  • 内存泄漏
    • scoped_ptr,对象析构的时候自动释放内存。
  • 不配对的new[]/delete
    • new[]统统替换为std::vector/scoped_array

注意:scoped_ptrshared_ptrweak_ptr都是值语义。要么是栈上对象,或是其他对象的直接数据成员,或是标准库容器里的元素,即不会出现下面这种形式:shared_ptr<Foo>* pFoo = new shared_ptr<Foo>(new Foo);

现代的C++程序中一般不要出现delete语句,资源(包括复杂对象本身)都要通过对象(智能指针或容器)来管理,不要让程序员还要为此操心。

应用到Observer上

Observer模式的竞态条件的核心问题是被观察者如何探查观察者的生死,可以通过weak_ptr解决,只要让Observable保存weak_ptr<Observer>即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Observable
{
public:
void register_(weak_ptr<Observer x); //参数类型可用const weak_ptr<Observer>&
// void unregister(weak_ptr<Observer> x); //不需要了,已经有下边的代码帮我们解决把失效观察者从observers_中删除了。相应地,Observer的析构函数也不用调用Observable的unregister了。
void notifyObservers();
private:
mutable MutexLock mutex_;
std::vector<weak_ptr<Observer>> observers_;
using Iterator = std::vector<weak_ptr<Observer>>::iterator;
};
void Observable::notifyObservers()
{
MutexLockGuard lock(mutex_);
Iterator it = observers_.begin();
while(it != observers_.end())
{
shared_ptr<Observer> obj(it->lock()); //weak_ptr的lock函数,尝试提升为shared_ptr,这一步是线程安全的。
if(obj)
{
obj->update(); //没有竞态条件,因为obj在栈上,对象不可能在本作用域内销毁。
++it;
}
else
{
// 观察者对象已经销毁,从容器中删除weak_ptr,即做了unregister的工作。
it = observers_.erase(it);
}
}
}

经过把Observer*替换为weak_ptr<Observer>,部分解决了Observer模式的线程安全问题,但还有以下疑点。

  • 侵入性
    • 强制要求Observer必须以shared_ptr来管理
  • 不是完全线程安全
  • 锁争用(lock contention)
    • Observable的三个成员函数都用了互斥器来同步,这会造成register_