译注:这篇文章可能又会引发 C++ 程序员的诸多不适,就做者本文所描述的问题来看,某些“C++的问题”实际上是能够有C++的解决方案的。请参阅侵入式和非侵入式容器。可是考虑到ZeroMQ是一个很底层的高性能网络库(ZeroMQ的目标是归入Linux内核中,这也应该是改用C的一大缘由,毕竟目前的ZeroMQ是用C++实现的),对错误处理、内存分配次数、并发效率等有着极高的要求,这些特定的限制每每不是全部的C++程序员所常见的应用场景。所以但愿各位在阅读时能多从做者的角度来考虑这些问题,而不是一味地批判做者的C++编程实践能力。html
在上一篇博文中,我已经讨论过了在须要进行严格错误处理的系统底层基础架构的开发中须要避免使用一些C++特性(异常、构造函数、析构函数)。个人结论是,当为C++加上了这样的使用限制后,用C来实现的话会使得代码更简短也更容易阅读。这么作的反作用是消除了对C++运行时库的依赖,而这不该该轻易地去掉,尤为是在嵌入式环境下。程序员
在这一篇博文中,我想从另外一个不一样的角度来探究这个问题。即:使用C++和C相比,在性能上有什么区别?理论上,这两种语言产生的程序性能应该是相同的。面向对象只不过是在过程式语言之上的语法糖而已,这使得代码对人类而言更加容易理解。人类大脑彷佛已经进化为一种天然的能力来处理以流程,关系等这类实体为主的对象。算法
每一个C++程序都能自动转换为等同的C程序——尽管这种说法理论上成立——但面向对象的观念使得开发者以特定的方式来思考并相应地以面向对象的方式来设计他们的算法和数据结构,而这反过来会对程序性能带来影响。编程
让咱们来比较一下,C++程序员要如何实现对象链表:缓存
注:假设包含在链表中的对象是不可赋值(non-assignable)的,由于这种状况下任何非简单的对象,好比持有大量内存缓冲区,文件描述符,句柄等这样的对象,若是对象是可赋值的,那么简单地使用std::list<person>就够用了,不会有任何问题。网络
1
2
3
4
5
6
|
class
person
{
int
age;
int
weight;
};
std::list <person*>
|
C程序员更倾向于按照以下的方式解决一样的问题:数据结构
1
2
3
4
5
6
7
8
9
10
11
12
13
|
struct
person
{
struct
person *prev;
struct
person *next;
int
age;
int
weight;
};
struct
{
struct
person *first;
struct
person *last;
}people;
|
如今,让咱们比较一下两种解决方案的内存模型:多线程
首先注意到的是C++的解决方案对比C来讲多分配了2倍的内存块。针对链表中的每一个元素,都要建立一个小的帮助对象。当程序中有许多容器时,这些帮助对象的总数就会扩散开来。好比,在ZeroMQ中建立和链接一个socket将致使数十次内存分配。而在我当前正在作的C版本中,建立一个socket只须要一次内存分配,链接时会再须要一次。架构
很明显,内存分配的次数会引发性能问题。分配内存所花费的时间多是可有可无的——在ZeroMQ中,这并非关键路径(请参阅关于ZeroMQ中关键路径的分析)——可是,内存使用量以及内存碎片带来的问题就很是重要了。这直接影响到CPU缓存是如何填充的,以及由此带来的缓存miss率。回顾一下,到目前为止对物理内存的访问是现代计算机上最慢的操做,这样就知道这种性能影响会有多严重了。并发
固然,这还没完呢。
实现方案的选择对算法的复杂度有着直接的影响。在C++版中,从链表中移除一个对象是O(n)的复杂度:
1
2
3
4
5
6
7
8
9
|
void
erase_person(person *ptr)
{
for
(std::list <person*>::iterator it = people.begin();
it != people.end(); it++)
{
if
(*it == ptr)
people.erase(it);
}
}
|
在C版本中,能够确保在常数时间内完成(简化版):
1
2
3
4
5
6
7
|
void
erase_person(
struct
person *ptr)
{
ptr->next->prev = ptr->prev;
ptr->prev->next = ptr->next;
ptr->next = NULL;
ptr->prev = NULL;
}
|
C++版本效率的低下是因为std::list的实现所致仍是因为面向对象的编程范式所致呢?让咱们深刻的探究这个问题。
C++程序员不会以C的方式来设计链表的真正缘由是由于这种设计破坏了封装的原则:“person”类的实现者必需要知道person的实例最终会存储到“people”链表中。此外,若是第三方开发者决定将其存储到另一个链表中时,就必须修改person的实现。这正是奉行面向对象编程的程序员所极力避免的。
可是,若是咱们不把prev和next指针放在person类内部,咱们就必须把它们放置在别的地方。因此,除了多分配一个帮助对象外没有别的办法了,这正是std::list<>所采用的作法。
此外,虽然帮助对象中包含有指向“person”对象的指针,但“person”对象却不能包含有指向帮助对象的指针。若是这么作了,那就破坏了封装的原则——“person”就必须知道包含本身的容器。结果就是,咱们能够将指向帮助对象(迭代器iterator)的指针转型为指向“person”,但反过来却不能够。这就是为何从std::list<>中移除一个元素须要遍历整个链表,换句话说,这就是为何须要O(n)的复杂度。
简单来讲,若是咱们听从面向对象的编程范式,咱们就没法实现一个全部操做都是O(1)的链表。若是要那么作就必须破坏封装的原则。
注:不少人都指出应该使用迭代器而不是指针。可是,假设某个对象须要被包含在10个不一样的链表中。你将不得不传递一个包含10个迭代器的结构体,而不是只传一个指针。此外,这并无解决封装的问题,只是把问题移到了别处而已。当你但愿将对象添加到一个新的容器类型中时,虽然你不用修改“person”的实现了,但你仍然不得不去修改包含迭代器的结构体。
这应该就是本文的结论了。可是这个主题实在太有意思了,我还想再问一个问题:这种低效究竟是源于面向对象的设计仍是说只是特定于C++语言呢?咱们可否设想以一种面向对象的编程语言来实现全部相关操做都为O(1)复杂度的链表呢?
要回答这个问题咱们必须理解问题的根本。而这个问题来自对术语“对象”的定义。在C++中“class”只是对C语言中“struct”的代名词,这两个关键字几乎能够互换使用。言下之意是指“对象”是一系列存储在连续内存空间中的数据集合。
这对于C++程序员来讲是想都不用想的问题。可是让咱们从不一样的角度来分析“对象”。
咱们说对象是一系列逻辑上相关联的数据的集合,在多线程程序中应该处于同一个临界区中受到保护。这必定义从根本上改变了咱们对程序架构的理解。下面这张图展现了C语言版的person/people程序,并标识出了数据域应该由一个链表级的临界区(黄色部分),仍是由元素级的临界区(绿色部分)来保护。
从面向对象的角度来看,这张图实在太诡异。“people”对象不只包含有“people”结构体内的字段,还包含有“person”结构体中的一些域(“prev”和“next”指针)。
可是出人意料的是,从技术角度来看这种分解却十分有道理:
1. 链表级的临界区保护着黄色部分的字段,这确保了链表的一致性。另外一方面,链表级的临界区并无对绿色部分的字段进行保护(“age”和“weight”),所以 容许对单独的数据进行修改而没必要锁住整个链表。
2. 黄色部分的字段应该只能由“people”类的方法来访问,尽管从内存布局上来看它们都是属于“person”结构体的。
3. 若是编程语言容许咱们在“people”类的内部声明黄色部分的字段,那么封装的原则就不会被打破。换句话说,将“person”添加到其它链表中时就不须要 对“person”类的定义进行修改。
最后,让咱们作一个概念性的实验,采用上述思想来扩展C++。请注意,咱们的目标不是为了提供一种完美的语言扩展设计,更多的是为了展现在C++中实现这种思想的可能性。
也就是说,让我引入一种“private in X”的语法结构。它可使用在类定义中,遵循“private in X”形式的数据成员在物理上(做者指的是按内存布局来看)属于结构体X的一部分,可是它们只能由被定义的类来访问:
1
2
3
4
5
6
|
class
person
{
public
:
int
age;
int
weight;
};
|
1
2
3
4
5
6
7
8
9
|
class
people
{
private
:
person *first;
person *last;
private
in person:
person *prev;
person *next;
};
|
个人结论是,若是ZeroMQ用C来实现的话,内存分配将更少,产生的内存碎片也更少。一些算法的复杂度将达到O(1),而不是O(n)或者O(logn)。
效率低下的问题不在于ZeroMQ的代码自己,也不是面向对象编程的固有缺陷,更多的是在于C++语言的设计上。固然,公平的说C++并非惟一,一样的问题也存在于大多数——若是不是所有的话——面向对象编程语言中。
英文原文:martin_sustrik 编译:伯乐在线— 陈舸