文档章节

为什么我希望用C而不是C++来实现ZeroMQ(第二篇)

开心303
 开心303
发布于 2012/09/12 09:48
字数 2738
阅读 389
收藏 4

译注:这篇文章可能又会引起 C++ 程序员的诸多不适,就作者本文所描述的问题来看,某些“C++的问题”其实是可以有C++的解决方案的。请参阅侵入式和非侵入式容器。但是考虑到ZeroMQ是一个很底层的高性能网络库(ZeroMQ的目标是纳入Linux内核中,这也应该是改用C的一大原因,毕竟目前的ZeroMQ是用C++实现的),对错误处理、内存分配次数、并发效率等有着极高的要求,这些特定的限制往往不是所有的C++程序员所常见的应用场景。因此希望各位在阅读时能多从作者的角度来考虑这些问题,而不是一味地批判作者的C++编程实践能力。

上一篇博文中,我已经讨论过了在需要进行严格错误处理的系统底层基础架构的开发中需要避免使用一些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      编译:伯乐在线— 陈舸

© 著作权归作者所有

开心303
粉丝 142
博文 88
码字总数 106265
作品 0
闵行
CTO(技术副总裁)
私信 提问
加载中

评论(2)

egmkang
egmkang
因为list的迭代器是稳定的,所以可以保存下来
egmkang
egmkang
用std::list的话,person里面需要保存一个list的迭代器,这样方便快速删除.
当然也可以遍历删除.
nanomsg原来是zeroMQ作者用C重写的

据说性能是zeromq的3到4倍,值得研究一下 为什么我希望用C而不是C++来实现ZeroMQ

老盖
2013/10/23
3.6K
0
评估了zeromq 和nanomsg -- 两个凡是

本来比较偏好 C 开发的nanomsg 如果zeromq是C开发的, 毫无疑问zeromq 但是 nanomsg 验证不够 根据调查, zeromq胜出 虽然我非常讨厌C++

宏哥
2016/12/31
2.4K
3
为什么我希望用C而不是C++来实现ZeroMQ

开始前我要先做个澄清:这篇文章同Linus Torvalds这种死忠C程序员吐槽C++的观点是不同的。在我的整个职业生涯里我都在使用C++,而且现在C++依然是我做大多数项目时的首选编程语言。自然的,当...

鉴客
2012/05/18
17.4K
38
为什么我希望用C而不是C++来实现ZeroMQ(一)

开始前我要先做个澄清:这篇文章同Linus Torvalds这种死忠C程序员吐槽C++的观点是不同的。在我的整个职业生涯里我都在使用C++,而且现在C++依然是我做大多数项目时的首选编程语言。自然的,当...

开心303
2012/09/12
107
0
云风开发笔记(3) Redis, Google Protobuffer, ZeroMQ

这周的工作主要是写代码。 开发计划制定好后,我们便分头写代码去了。我们希望一期早点做出可以运行的东西来,一切都从简。整体的代码量并不多,如果硬拆成很多份让很多人来做的话,估计设计...

亚历山大痒
2013/03/16
3K
0

没有更多内容

加载失败,请刷新页面

加载更多

JAVA CAS单点登录之三:CAS代理模式演练

前言 JAVA CAS单点登录之一:搭建CAS服务器 JAVA CAS单点登录之二:CAS普通模式1演练 代理模式相相对上一节的普通模式,更加复杂了。但配置起来也会稍微有些差别。所谓难者不会,会者不难。如...

彬彬公子
40分钟前
5
0
Webfont 的兼容性问题[持续更新]

本文转载于:专业的前端网站➺Webfont 的兼容性问题[持续更新] 低版安卓手机的 webview 显示不了,另外黑莓手机显示出来是这样: 生成工具: 离线字体生成工具:webfont 在线字体生成平台:ico...

前端老手
44分钟前
4
0
调用链与日志关联的探索式查询

摘要:本文将就Observability中的日志聚合、分布式跟踪及具体应用中结合使用进行展开说明。 一、Observability Observability是一个最近几年开始在监控社区流行的术语。本文将Observability...

宜信技术学院
44分钟前
4
0
Java常见异常处理

异常是Java程序中经常遇到的问题,一个异常就是一个Bug,就要花很多时间来定位异常。 Java异常 (1)Throwable是Java异常的顶级类,所有的异常都继承于这个类。 (2)Error,Exception是异常...

daxiongdi
今天
4
0
Validator 常用注解

说明 Validator主要是校验用户提交的数据的合理性的,比如是否为空了,密码长度是否大于6位,是否是纯数字的,等等。那么在spring boot怎么使用这么强大的校验框架呢。 常用 [@null](https:...

五彩的颜色
今天
5
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部