libll++ blog

我是一个40多岁的程序员,生活在中国,北京。在1998-2013年之间,我一直用c语言开发类unix系统的服务器应用。 2013年,我打算陪我儿子度过一个夏季,有点空闲时间,我突然想试试c++。由于以前的开发背景,我没有boost相关的知识背景, 但是c++11已经被标准化了。我想我没必要从最原始开始,就从c++11开始吧。c++的思维模式跟c语言有很大的差别, 在此期间我经历了很长时间的思维冲突,反复的思考和困惑,甚至现在有时候也是这样。

libll++是我这段时间学习开发库,发布在:https://github.com/storming/libllpp.git.

如果你对libll++有任何建议,可以发email给我:storm@vip.163.com

2013-9-1


2013-12-25 page allocator

很久没有更新博客了,也好像很久没有写程序了,最近忙一大堆家庭琐事快让我忘记自己是个程序员了。我感觉我好像在向做好一个 厨师而努力。最近,我在家熬高汤,做面包,还有其它很多很多的好吃的。我做鱼比较拿手,哈哈。

page allocator是个以页为单位的分配器。libll++里有很多的策略型分配器,还有一些与内存分配紧密有关的模块,它们基本都是 分配若干chunk作为操作对象,这些chunk一般是一个或者多个操作系统的page。这些分配的特性程两极化,有时候具有较大的持久性, 有时候则相当的频繁,比如在一个过程中就可能有若干page申请和释放。比如说在io级,可能会用page级的内存分配来管理,这可以 极大的提升性能,但是当一次io结束就会迅速的释放掉。这对page allocator提出比较严酷的性能要求,它的性能关乎到整个类库的 性能。

libll++里有个参照libapr的pool,libapr的apr_pool.c里有个page allocator,我也使用了一些年。但是它的一些特点另外 始终很烦恼,至少作为一个通用级的page allocator有些令人不舒服。首先这个allocator在每次分配page级chunk的时候内嵌埋入 一个chunk head,在我要分配page的时候需要自行计算减掉这个head所带来的开销,否则不能做到页对齐。其次,它的底层使用 malloc,众所周知malloc本身又在内存块的头尾有上下文。除非我按照约定成俗的malloc上下文大小在做一次减法,我在系统级是 无法真正分配出一个page的。但是约定成俗并不是标准,我并不能真正的依靠它。如果不考虑malloc的上下文开销,malloc本身又是 以page来分配内存的,最终会产生很多很多的浪费。在我使用这个东西的这些年里,我一直对它感觉不爽。

但是真正促使我去寻找新的出路的原因是在封装这部分的时候,我review了它的代码。它用order高达20的一个数组来加速它的检索。 同时用一个max_order来记录最大的order,这令我对它产生了更大的不爽。想象一下,我们偶尔分配了一个60K的内存,当然是通过 它的pool,或许是因为就是想简单的读入一个文件,这种apr pool里是提倡的,毕竟是瞬时操作,然后释放。释放后,这块内存稳稳 的蹲在这个数组的最高order上,当我们申请chunk的时候如果没有合适尺寸就会爬表上去最终分配这个60K的家伙。同时,为了维护 max_order又要爬表回来。由于有时候pool的瞬时性,这个pool可能会被迅速释放,这家伙又跑到了原来的那个位置。如此反复。我 称之为”卷帘窗”效应。这让整个系统的弹性变得脆弱,并且让使用者在分配上战战兢兢,当然如果他真的理解的话。

几个月前我开始思考并编码一个新的page allocator来替代这个令我极不舒服的设计。在之前的blog里曾经说过,我计划在未来写 一个真正的应用级的slub分配算法,它也需要一个纯粹的page allocator。这时候,我需要坐下来仔细想想这个东西的适应性。我的 第一反应是应该放弃malloc底层分配模式,为此我重新翻阅了libumem的代码,并且最终下了决心。如果以mmap作为基础分配方式,这 需要多做点工作,不过还是值得的。


2013-11-19 突然发现自己不知道怎么写Windows程序了!

大昆是我的儿子,长得身高体壮,但是在壮硕的身体里是颗儿童的心,很善良很单纯,很淘气。即恨其不争,有爱其单纯可爱。 11月初,儿子的学习任务就交到了我的手上。第一周很痛苦,老师天天让我去学校,不是这不对就是那不对。我也认识到是该让大昆 转型了。10岁之前我不太管他,童年嘛就该快乐。他马上要11岁了,他应该改变了。

大昆的语文没有任何问题,他具有超强的记忆能力,背课文很轻松,读几遍就能背下来。他平时也爱读闲书,他的乐趣不是出去玩, 而是在家看喜欢的书。数学的问题也不是太大,他的脑力绝对够用,只是懒于思维。头几天,有几道思维题,我引导他去思变,他顺利 过关。最大的难题是英语,在老家英语的教育并不是太重视,上课听不懂就自我放弃了。我突然发现北京小学毕业,课本上有接近3000个 单词,这几乎就是英语3级水平。OH,my god。

我面对的任务是在不到一年的时间里让一个顽皮的孩子单词量达到英语3级水平,这实在是太难了。我需要周密的安排。首先学英语, 在初期不可避免的需要大量书写。他那一笔狗爬字,如果现在大量书写势必是越写越难。他没有找到轻松书写的办法,为此我花费了差不多 3周的时间只是让他练习书写。大昆有一点挺好,他比较听我的话,这个过程虽然不顺利,但是现在基本完成了。说实在的,现在我写的 不如他写得好。大昆说我:乌鸦落在猪身上。我吐血。

我想我没法一下子让他学会所有的枯燥的语法。我的学习英语过程也不是学习语法开始的,我是从语感开始的,我在初中的时候能够 背诵6本书的任何一段。无论是语法还是语感,短期都不会取得显著的提高,我突然想起他超强的记忆力,那就背单词吧。那些题目 就是堆,也可以用单词量堆过去。于是我开始天天敦促他背单词。对于书写来说,背单词显然是他所乐于接受的。

慢慢的,单词越背越多,记得多了忘记的也多,我也有点茫然。于是我想给他写个程序,来完成我的想法,帮助他背单词。我的想法 很简单,维护个词库,如果没有某个单词就从网上抓,当然最重要的是发音。我希望用英语考英语,而不是用汉语考英语。不需要什么数据库, 简单的XML就够了。考试的方式要灵活,最好是他自己就能完成测验。

这时候,我开始规划用什么玩意写这个东西。我突然茫然了。Web方式?php、jsp、asp、mysql?我只是想要个简单的应用,随时方便 修改,来完成我的想法。这些“隔靴止痒”的方式好像不太灵光,OH每次给我儿子上课还得开个HTTP Server?算了。Delphi?这个我在 15年前是超级熟悉的,Win8下还能用么?能找到安装光盘么?VC,这个应该还能用,我需要重新切换到MFC的类库上,这需要至少一周 的时间。C#?VS开发环境太大了,实在是懒得安装,实在不明白为什么开发个windows小程序需要那么大的系统和漫长的安装时间。 QT?噢,需要vs。GLib?我还不如去写VC。突然,我发现我找不到一个和手的工具写windows应用。我发现最近5年,我写的唯一的windows应用 是个控制台程序。当时,出于恶劣兴趣写了个类似外挂的东西,在wow外面写了个控制台程序,在wow里写了个1000多行的脚本。每晚, 我操纵我的猫德蹲在boss后面,抓挠。在yaya里炫耀自己的输出手法多好,呵呵,这确实很那个啥。

琢磨半天,我在我的电脑里发现了flex builder。半年前曾经用它写过一个简单的as3应用,那就flash air做吧。尽管需要熟悉新的 类库,但是总比从新开始好吧。儿子在边上写作业,我琢磨这个玩意。

写UI程序真是让人很放松,没有什么泄漏的警惕,也没有什么高级概念的封装。随便写写,看看原始类文档,继续做。就是有点琐碎, 这里需要缝缝补补,那里需要缝缝补补。我觉得造作UI的规划可能比写程序还要费劲,怎么操作合理呐?我当年彻底扔掉这一块,现在 都不后悔。

程序写出来了,儿子非常感兴趣,玩的一塌糊度。这几天经常跟我说,爸爸咱们背单词吧。他对电子产品的敏锐度,超乎寻常。我在今年 尽量不让他接触电子产品。NO手机,NO电脑,NO Pad。这么个简单的程序,他自己考自己能玩2个小时。老婆云:龙生龙凤生凤,老鼠的 孩子会打洞,我直接当机。

做了这么多年的程序员,我第一次这么近的面对我的需求者。儿子提出不满意的地方,我就说:好滴,爸爸明天改。

2013-10-26 C++0x的右值和右值引用

最近实在是状态不佳,总是没心情写这个Blog。在c++0x的学习方面,经过这段时间的实践和阅读原始规范,尽管有些概念我还不能 深入简出的用通俗语言准确的描述出来,但是熟练准确的操纵它们还是没有问题的。简单的说它对我来说失去了神秘感,我对它失去 了兴趣。至于说libll++本身,我总觉得它跟c++有点格格不入,不过,它还是艰难的在这个方面前行。最近,各方面逐渐能够形成一个 体系了,但是烦恼也不少。libll++的functor、closure在操作上我还是很满意的,灵活性应该不差。但是由于它使用tuple_apply进行 参数展开,一旦出错它喷起错误来也是停可怕。

右值和右值引用是c++0x引入的核心概念,在这段学习时间里对它的认知是个渐进的过程,每每认为自己理解它了,每每过段时间否定自己 的认知。到现在,我还是无法用准确的语言来描述它,这里我使用FAQ的方式阐述一下我对它的理解。

Q: 什么是右值?

A: 你无法直接操作的值,可能是个临时对象,也可能是个常量。

int n = 10;
int m = n;

等号左边的值一定是个可地址化的东西,赋值的本质是给一块内存或者一个寄存器赋值。我们可以给一个变量赋值但是无法给一个常量赋值。 一个常量永远只能出现在等号的右边。一个函数返回临时对象也永远只能出现在等号的右边。

string foo();
string str = foo();

右值就是那些永远只能出现在等号右边的值。有一种判断一个”量”是不是右值很好方法:这个”量”有没有名字!

函数foo()返回的这个临时string实例就是个没有名字的量,存在但没名字。我们写程序只能操纵有名字的变量,没名字的我们无从干涉。干涉不了的就是右值。 常量10,我们也无从干涉它,你能把它改成11么?哈哈!

struct foo {
	static constexpr int num = 100;
};

如果显然是个常量别名,有名字,但是右值。

Q: 右值是怎么产生的?

A: 只有两种途径,1、常量;2、函数返回的临时变量。

增补:仔细思考了下,觉得这个回答并不准确。它简化了问题,有较强的实际操作性,但不准确严谨。

编译器在进行表达式解析的时候,会生成一些临时变量,为了说明问题这里假设__tmp开头的变量是那些临时变量。下面的这段代码编译器可能 是这样处理的。

int n = 1;
int m = 2;
int k = n++ + m + 3;

__tmp_1 = m + 3;
__tmp_2 = n++;
__tmp_3 = __tmp_2 + __tmp_1;
k = __tmp_3;

如果画成语法树,所有的__tmp变量都是非叶子节点。这些变量由编译器生成,最后都优化掉了,它们在程序里都没有名字,我们也无从触碰到它们。 这些临时变量和常量就是我们所说的右值。但是有一种情况下,我们可以间接的触碰到它们,那就是函数调用。上面的例子如果改成:

int n = 1;
int m = 2;
foo(n++ + m + 3);

__tmp_1 = m + 3;
__tmp_2 = n++;
__tmp_3 = __tmp_2 + __tmp_1;
foo(__tmp_3);

foo的参数实际上是个编译器生成的临时变量,这时候是我们唯一能接触到它的时机。前面的例子k是个标量,我们无论如何也接触不到这些临时量。 但是如果k是个对象(class),通过重载赋值实际的结果也是在调用一个函数。

template <typename _T>
void check(_T &&a) {
    cout << std::is_rvalue_reference<decltype(a)>::value << endl;
}

这是一个右值引用检测模板函数。

int n = 1;
check(n);		// result = 0
check(n + 1);		// result = 1
check(n++);		// result = 1
check(++n);		// result = 0

可以看到,我们间接触碰到了右值。但是不要高兴的太早,”值”(value)是由编译解释的,”量”(var)才是由程序员操纵的。无论左值还是右值 都是编译期的抽象概念,在实例化(变成”量”)之前你是无法真正触碰它的。实例化的变量都是左值,他们都有名字。

当函数check被展开的时候,参数a已经被具体化了,它可能是个右值引用但是是个”左值”。右值你是永远无法直接操作的,否则编译器就穿帮了。 你能够操纵的永远是”左值”。

右值的意义在于如果你的函数提供了move语意的重载,编译器调用这个重载。

说了这么多,其实目的只有一个,阐述右值的来源:这些产生了临时变量的表达式都是右值。但是move语意对于单纯的标量来说,实在是没有太大的意义。 一般只是针对逻辑更加丰富的类实现move语言。对于类的各种操作,一般都是函数。另外一个函数返回的object(标量和class),都是个右值。 所以把右值的产生说成常量和函数返回值,具有比较强的实践性,但是并不严谨。

Q: 什么是右值引用?

A: 可以引用右值的引用。比如说引用一个常量。

Q: 右值引用可以引用左值么?

A: 完全可以。

Q: 这么说右值引用是个万能引用?

A: 可以这么说!事实上也是。

Q: 这令人感觉神奇且困惑。

A: 到底是变量还是常量,编译器可以跟踪到。这不用担心,就算你对一个常量的右值引用取地址,它也可以在堆栈里造出个变量让你取地址。 简单的说,对于编译器来说,每次赋值都是不同的变量。常量对于程序员来说是个抽象概念,但是对于编译器来说是实实在在的一个 语法树节点。对常量进行引用,对于编译器来说是很顺其自然合情合理的。

Q: 难道引用不是指向一个内存地址么?

A: 以前的C++引用确实是指向一个内存地址,或者可以把它说成是内存地址的引用描述方式。但是c++0x让引用的概念更加宽泛了。 以前的C++是没法对引用进行引用的,但是c++0x可以,以前是没法对一个常量进行引用的但是c++0x可以。引用在c++0x中可以这么 理解:索引“那个东西”,或者是索引“某个东西”,或者干脆把它理解成alias。c++中不是可以用typedef对类型进行别名么? 引用时某个“量”的别名。

当然跟以前兼容,左值引用&还是指向一个内存地址,增加右值引用&&可以指向一个常量。用“别名”去替换地址的思维方式, 是比较精确的。

Q: 既然右值引用可以引用普通的变量,为什么我把普通变量直接给右值引用赋值会编译错误?

A: 右值引用并不是设计用来替代左值引用的, c++0x也不是用来完全替代以前的c++的,它需要向后兼容。在对它赋值的时候,需要强制转换。 但是你完全可以把一个普通变量赋值给右值引用,并且像个普通引用那样操作它。

Q: 如果右值引用为了可以引用常量而提出,但是我们已经有了const了,而且工作的很好。

A: 确实在类成员变量定义和函数体变量定义里基本不会看到右值引用,除非你非要这么写。而且也确实没有必须这么写的必要。 我们一般不会直接去定义和操作一个右值引用。

Q: 那它经常在什么场合出现?

A: 在函数参数列表里出现,这也是它唯一必须出现的地方。

Q: 什么样的参数需要右值引用?

A: 具有move语意的的函数需要右值引用。

Q: 什么是move语意?

A: 这是跟copy语意对应的一个语意。c++有copy构造和copy赋值语意。比如下面的string类:

struct string {
	char *_p;
	string() : _p() {}
	string(const char *p) {
		_p = ::strdup(p);
	};
	~string() {
		if (_p) {
			::free(_p);
		}
	}
	string &operator=(const string &x) {
		if (_p) {
			::free(_p);
			_p = nullptr;
		}
		if (x._p) {
			_p = ::strdup(x._p);
		}
		return *this;
	}
};
inline string operator+(const string &x, const char *y) {
	string tmp;
	tmp._p = ::strcat(x._p, y);
	return tmp;
}

int main() {
	std::string str;
	std::string some = "aaaaaa";
	str = some + "bbbbb";
	return 0;
}

这个string类声明了copy赋值,具体的操作就是释放旧的内存,申请内存并copy新的串的内容。some加上”bbbbb”后会产生一个新的string实例。 这个临时string实例在赋值给str的时候调用了string的copy赋值函数。这种实现方式在逻辑上是完全正确的,结果也是正确的,但是它有个 唯一的缺点就是运行效率不佳。最好的办法就是直接把tmp的_p和str的_p的内容互换。临时string实例tmp在赋值后会被自动析构,这样它的内容 互换给了str,并且析构释放了str的_p所指向的内存。这种语意c++0x称之为move语意,其实说成是swap语意可能更形象一些。

c++0x标准把move语意描述成”偷”。str”偷梁换柱”了some + “bbbbbb”产生的临时实例的内存。但是怎么在程序里描述这个语意呐?以前的c++ 把const type &作为第一个参数的构造函数或者operater=重载称为copy构造和copy赋值函数。在c++0x中第一个参数是type &&的构造函数 和operator=作为move构造和move赋值。

Q: 这是个怎么样个过程?c++0x支持”偷”操作?

A: c++本身不支持”偷”的具体操作,它只是提供了这么个语意,至于怎么”偷”每种类自己决定。就像c++提供了copy构造和copy赋值,至于说怎么 copy由类自己决定。

struct string {
	char *_p;
	string(string &&x) : _p() {
		std::swap(_p, x._p);
	}
};

在这个例子里类在move构造里交换了_p。其它的类的实现方式也可能是某种复制。比如说std::list的move构造就是把所有的element都pop出来,然后 push到新的链表中。

Q: std::swap是什么?

A: 跟个swap宏差不多,tmp(a), a = b, b = tmp。a需要具有move或者copy构造还有move或者copy赋值,b需要move或者copy赋值。看前面那段代码 就能推测出。你可能没有明确的提供copy或者move构造和赋值,但是编译器会帮你隐含生成的。

Q: 到底什么时候调用copy语意,什么时候调用move语意?

A: 参数是右值时调用move,左值时调用copy。上面的例子中字符串相加产生的临时对象你无法直接的左值引用来引用它,这就是右值, 使用move语意。而str = some;,some是个可以直接的引用的对象,使用copy语意。

Q: 这岂不是很难判断?

A: 不难,参考FAQ第一条,调用构造或者赋值的时候,传入的参数有没有名字。没名字就是move语意,否则copy语意。

Q: 我是个传统的程序员,我只声明了copy语意。会是什么情况?

A: 只会调用copy语意。在函数匹配的过程中,会有个降解判断过程。看一下std::decay,这就是最标准的函数匹配标准降解过程。无论是move 还是copy的调用都没有脱离函数重载匹配的范畴和原则。函数匹配首先会匹配最低品质,如果两个函数的参数品质相同,则选择最佳匹配。

struct foo {
	foo(foo &x) {}
	foo(const foo &x) {}
	foo(foo &&x) {}
};

如果不小心定义了foo(foo &x),任何情况都不会调用copy和move构造,因为这个构造的函数参数品质最低。move语意所操作的对象,由于不可 直接触碰,它本身就具有const语意。如果有move构造,根据最佳匹配,会调用move构造。否则,会调用copy构造。

Q: 噢,我对这些语意有点了解了。如果我想强制类的其它实例去”被偷”我该怎么做?

A: 最完美的方法就是使用std::move。比如:

string aaa = "abc";
string bbb = "bcd";
aaa = std::move(bbb);

这时候会调用move赋值,如果有的话。这种语句在程序体中有时候会出现,基本含义就是bbb不想活了,基本也是这样。这也是基本 语意。按照前面string的实现,aaa和bbb完成了一次互换。所以,不想活了也不是绝对的。

string aaa = "abc";
string &&r = std::move(aaa);
string bbb(r);

你把一个对象的实例赋值给一个右值引用,不会对这个对象产生任何影响。使用这个引用去构造其它对象或者赋值给其它对象, 不会调用move构造和赋值,而是调用copy构造和赋值。原始对象也不会被析构,它的生命周期跟传统c++一样。

这看上去很让人迷糊吧!在c++0x里一定要时刻记住右值和右值引用是两个概念。右值引用可不一定是右值,它大多数情况下是左值。 为什么?因为它有名字啊,你能操作它啊,你能触碰它啊!在上面的例子里,r虽然是右值引用但是它是个左值,用它去构造只会 调用copy构造。

尽管move后的对象没有消失(析构),但一般在程序里看到std::move(bbb);这样的代码就意味着bbb可能是个易变的或者你准备放弃它了。 它虽然不会被析构,但是它的内容可能会改变,它在后续程序中的作用已经是无关轻重了,其数据已经不可控了。

呵呵,这只是个心理暗示,你如果确信知道类的实际操作,你还是可以使用它。它也还是个类实例。

Q: 既然如此我该怎么写个move构造或者赋值呐?

A: 如果类成员是基础类型,或者叫做标量类型,int啊,指针啊,引用啊,你可以自由决定是copy还是swap还是毫无操作置之不理。 否则,尽量去调用它的move构造或者赋值。也就是使用一次std::move。例如。

struct foo {
	foo2 *_p;
	string _str;
	foo(foo &&x) : _p(), _str(std::move(x._str) {
		std::swap(_p, x._p);
	}
}

上面这个例子算是最无害的标准操作了。在这里x是个左值,它有名字。它的子对象_str也是左值,你可以直接索引到它。 但这是个move构造,你应该希望_str也是执行move构造,所以你需要强制转换_str成为右值。

Q: c++编译器会像生成默认copy构造那样为程序生成默认move构造么?

A: 如果类符合可默认move构造条件的话,编译器会为它生成默认move构造。

Q: 什么是符合默认构造条件?

A: 没有定义copy构造/copy赋值/move赋值而且没有定义析构函数的类,就是符合条件的类。

Q: 默认move构造是怎么实现的?

A: 简单的内存copy。

Q: 如果把你上面提到的string类改造成可以默认move构造,岂不是会出现double free情况?

A: 确实是这样。其实,你使用这种默认的copy构造,也会double free。

Q: 我实在是不喜欢move语意,我能阻止编译器生成默认move构造么?

A: 可以。

string(string&&x) = delete;

Q: std::move看上去很神奇,它具体怎么弄的?它会不会影响效率?

A: 它就是进行了一次强制转换。它有个修饰符是constexp,这是编译器级的转换,而且很强烈。它基本是无害的,我只是担心你在move 构造和赋值的时候忘记使用它,这会导致你无法达到设计目标。比如前面的类foo的move构造,如果初始化_str的时候没有使用std::move, 就会用string的copy构造来初始化_str,这有可能会达不到你的设计目标。

Q: 尽管你说只是在move语意的构造和赋值函数中出现右值引用,但是经常在模板库的函数参数上看到很多右值引用&&

A: 这是右值引用的另外一个作用:完美参数传递,尽管模板函数上体现的是&&,最终结果一般不是右值引用。

Q: 什么是完美参数传递?

A: 这要提到const以及引用在模板参数中产生的负面效应。比如声明一个函数

template<typename _T>
void iniline foo(_T &f){}

如果我们传入一个常量1,就会出现问题。ok,一怒之下改成这样:

template<typename _T>
void iniline foo(_T f){}

对于一个正常的类,就会产生构造,如果是指针哪?更不可知。以前的c++一般要完成基于const的重载来解决这个问题。但是,问题是 模板套模板,有时候几乎是无穷无尽的,我怎么知道要重载多少个函数才够用?请注意,在这个例子里只是一个参数,如果是N个就是2的 N次方个函数需要重载。

所以,c++0x的模板一般声明称这样:

template <typename _T>
void inline foo(_T &&f){}

这个函数参数里的&&其实是模板参数类型推导的一部分。c++0x制定了一个规则:

a & & = a&;
a & && = a&;
a && & = a&;
a && && = a&&;

以前的c++是不容许引用引用的,c++0x可以引用引用,引用引用的规则就是上面4条。上面的示例函数的参数_T &&f的含义是这是个_T的右值引用。 _T是模板参数,这时候c++要完成参数展开和重载匹配。c++在进行模板函数参数展开的时候,如果传入的是左值它尽量使用左值引用。例如:

int n = 1;
foo(n);

这时候类型_T不是int,而是int&,根据上面的引用的引用规则,函数参数f的类型是int& && = int&。如果传入的是个常量,比如:

foo(1);

什么都不需要做,参数f本来就是个右值引用,这时候函数参数f的类型是int&&。这样一个函数就能把以前需要const重载成2个函数的任务完成了。 特别是如果N个参数,用1个函数就概括了2^N个的重载。这可以称之为完美参数。

或许有人会问,就算是你通过这种方式把参数传进来了并且保持了变量的原有本质(品质),但是在函数体内也会出现类型冲突啊!这不是白费功夫么? 确实存在这个问题。但是在很多重型模板库里,模板函数调用模板函数的情况是非常普遍的。这些参数当前模板函数并不想去解释它,这些参数会被 传递到下一个被调用的模板函数中。这就是函数参数传递。

c++0x用std::forward,把参数传递给下一个被调用的函数。例如:

template <typename _T>
void inline foo(_T &&f){
	foo2(std::forward<_T>(f));
}

Q: 引用的引用有点令人困惑,是类似指针的指针那样么?

A: 前面提到,c++0x的引用实际上是个抽象概念,引用可以解释为:alias。在引用的引用情况下就是别名的别名。

读者大怒,特么的,nnd,到底指向了什么东西?咋说呐,这有点像unix文件系统的软连接。int n就像是个文件,引用是对n的软连接,我们操作 引用跟操作n是一样的。引用的引用就是在软连接上再建一个软连接,其结果?引用的引用最终还是指向n。操作引用的引用跟操作引用和操作n是一样的。 所以不要把它想象成指针的指针。

这种软连接的逻辑在程序里是怎么体现的(汇编级)?这个软连接概念是编译器级的,由编译器来维护,在汇编级没有任何体现,该怎么操作n就怎么操作 n。

所以c++0x提出了一个规则来“折叠引用”到最简:

a & & = a&;
a & && = a&;
a && & = a&;
a && && = a&&;

这个规则的名称就叫引用折叠规则。

Q: std::forward的工作原理是什么?它是怎么实现参数传递的?

A: std::forward<_T>(f)的作用就把_T强制转换成_T&&。根据折叠规则,这种操作不会影响_T的原始品质。

Q: 关于std::forward的原理的解释令人迷惑。它好像什么都没做啊!那要它有什么用,还敲那么多字母!

A: 在大多数情况下确实是这样,大多数情况下就算是没有它,直接传递参数程序也会工作的很好。它的主要作用是保护move语意。 这里第三次提醒读者右值和右值引用是两个概念。右值的产生只有两种途径,1、一个常量,2、一个函数返回的临时对象。

template <typename _T>
void inline foo(_T &&f){
	_T obj(f);
}

struct object {
	object(){}
	object(const object&){}
	object(object &&) {}
};

object make_object(object obj) {
	return obj;
}


int main() {
	object obj;
	foo(make_object(obj));
	return 0;
}

这个foo函数在展开后,参数f确实是个右值引用。但是右值引用并不等于右值,它是个左值,因为它有名字。从这段代码来看,我们原始设计 是希望调用它的move构造,但是很可惜调用的是copy构造。

传入函数foo的是个右值,因为它是个函数返回值。经过foo函数展开后,变成了左值。但是如果把foo,写成:

template <typename _T>
void inline foo(_T &&f){
	_T obj(std::forward<_T>(f));
}

则会正确的调用object的move构造,为什么?因为forward是个函数,它返回的右值引用就可以作为右值来用。否则就会丢失右值语意。

这看上去超级复杂,但是在实际编程中,我们不需要考虑太多这些问题。c++0x的完美参数forward是个非常非常复杂的概念,但是使用上却是 最最简单的,远远比move语意要简单。那就是只要是模板函数参数,当向下传递的时候,你就老老实实的使用std::forward。这是绝绝对对 不会出错的。甚至,你是否理解参数传递原理都不重要,老老实实的向下forward就行了。

Q: std::forward会影响效率么?

A: 跟std::move一样,它们都有constexpr修饰。这是个编译器级的。

Q: 在这个参数传递的过程中,我需要注意什么么?

A: 完全不必要,只需要忠实的把所有参数forward下去,编译器就会去寻找一个最靠谱的匹配。

Q: 为什么我在编写模板的时候没问题,最后实例化的时候说我传递的参数无法转换成右值?

A: 如果是嵌套模板,参数已经固化,就不存在模板参数推导问题。这时候,参数就只是需要提供右值引用。比如

template<typename ..._Args>
struct foo {
	void operator()(_Args&&...args)	{
	}
};

这种情况下,实例化后的模板已经知道_Args是什么,这种写法的意思是所有的参数都是右值引用。会报错。其实,不是所有的模板函数在变参上都要写 右值的,你可以很自信的去掉它。直接写成:

template<typename ..._Args>
struct foo {
	void operator()(_Args...args)	{
	}
};

还是要记住,在模板函数参数推导中出现的&&是引用的右值引用。在推导和展开的过程中,通过引用的引用规则,其结果不一定是右值引用。上面这个例子 模板在展开的时候_Args的所有类型都已经确定了。这就不存在模板函数参数推导了,_Args&&...args的意思是所有的参数都是右值引用。

在这种情况下,如果我还是想使用函数参数推导,那么就需要开始一个新的模板推导,如下的_Args2

template<typename ..._Args>
struct foo {
	template <typename ..._Args2>
	void operator()(_Args2&&...args) {
	}
};

又一次把blog写成了长文,不过这些概念确实也比较复杂。我对这两个概念的理解,用了差不多2个月的时间才逐步完善到现在的程度。

总之把,c++是个利器,看你怎么用。我对c++感觉气馁,它的概念令人迷糊。坦率的说我一点都不喜欢它。


2013-10-10 当前版本的内存分配策略

经过这么多天的折腾,内存分配策略基本定稿了。这东西肯定不会像标准new和delete那么简单,好在现在的实现也不是太复杂,至少 从使用的角度来说。但是使用者需要了解每种分配器的特性,用的越多需要了解的就越多。

libll++当前的内存策略分为:内存分配器、分配器和对象构造特例。

内存分配器是分配内存的实体,这是个逻辑概念并不是真正的类,它可能是可实例化的类比如pool,也可能是一系列static 函数比如 malloc和free。总之它的任务就是分配或者释放内存。

分配器是对内存分配器的实例化封装。内存分配器的实现方式是多种多样的,有可实例化类也有malloc这样的静态函数组成的,它们的初始化 参数也各不相同,分配内存和释放内存的参数也可能不太。比如函数free只有一个参数void *,但是libll++的free的默认语意是 void free(void *p, size_t)。甚至在以后多线程的模式下,需要实现内存分配的每线程特例。因此,我们需要一个统一的封装来完成 内存分配的统一接口。

struct some_allocator {
	void *alloc(size_t);
	void free(void *p, size_t);
};

对于需要分配内存的类,要么它们自己选择一个最适合其特性的allocator,要么由模板参数传入一个allocator类型。他们本身或者其内嵌类会 继承这个allocator来完成内存分配。所以这些allocator需要根据自己的特性提供必要的copy constructor或者move constructor,或者delete掉 某些constructor。总之,常规分配内存情况下我们会使用分配器而不是内存分配器。

对于由static构成的memory allocator,需要根据每种方案编写单独的allocator,比如malloc_allocator。对于可实例化的memory allocator, 我们选择封装它的指针来完成allocator,这是个模板,allocator_bind。

allocator还有2个可选函数,_new和_delete。在通用类里是没有的,它们主要是完成具体类集合的类工厂。

当前,libll++有以下几种static allocator。

malloc_allocator,它是对std::mallocstd::free的封装。简而言之,最后的选择。

new_allocator,它的实现方式不是使用::malloc也不是::new。在以前的blog中,我曾经提到过我有在libll++中实现一个slub分配器的想法。 尽管现在这不是当务之急,但是我还是留下了其相关伏笔。在当前的libll++为slub分配器预留了cache概念。当前的cache本质是个pool cache, 它从pool的global实例中分配内存,使用sized bins array来记录free的内存。它分配的内存没有边界标志,它依赖libll++的默认free语意, 也就是free的时候需要传入内存size。为了完成new的语意,new_allocator使用跟malloc一样的策略,在分配出的内存前面埋入一个size_t, 用来保存内存的大小。

mallocator,这是libll++的默认分配器。它也使用cache来完成内存分配,但是它不保存内存块大小。这个size需要程序员自己维护。把mallocator 作为默认allocator,表达了一种态度,libll++的设计目标是为了效率。

分配器必须传入size,这意味着一个问题:这种分配器不能准确的释放有虚析构的函数,析构可以虚,但是size没法虚。虚析构在比较深的class tree 中是必须的,比如说UI系统。比如说一个window的子window,各种各样,它们使用虚函数和虚析构来完成逻辑组织。在高性能服务器应用中,类层次 则是相对扁平,横向数量可能很大,但是层次不多。这时候虚析构只是浮云。libll++的默认分配器allocator是个typedef,如果需要强调虚析构, 需要把它指向new_allocator或者malloc_allocator。

最后,我们需要一个方式来构造类。libll++是个类库,它不可能去overload operator new和delete。它使用一系列的模板函数来完成这个工作。

construct函数用于构造一个类,它本质就是placement new。

destroy函数,它调用class的析构函数。

_alloc函数,它是allocator的argument forward的终端。在c++0x中,为了解决&const&和pointer参数的完美传递引入了forward,参数总是这么 forward来forward去,最后需要一个终端来确定。

_free函数,一方面完成free的终端,另外一方面它通过sfinae判断allocator是否有free成员函数。

_alloc_free以及construt.h文件中的所有函数一样,它们不只是针对分配器,也适用于内存分配器。这是一大堆具有sfinae能力的模板函数。 只要具有基本语意,他们就能工作。

struct some_allocator {
	void *alloc(size_t);
	void free(void *p, size_t);
};

_new_delete是对等的,它们都是针对对象和allocator的。它们首先判断对象_T是否有static _newstatic _delete,如果有就调用它。这有点 类似c++的标准中对象的operator new,但是它不只是分配内存,它本质是个factory。然后,它判断allocator是否有_new成员函数,如果有它用 allocator的_new去构造,这是个allocator级的factory。最后,它调用allocator的alloc分配内存,用placement new去构造类。

_delete需要注意的一点是,它传入的参数是void*。对于虚析构我还是比较担心的,这强制使用者明确指定需要_delete的类型。当然,这并不影响 虚析构的正常使用。

今天是个令人高兴的日子,我的结婚纪念日。我每年只穿一次西装,每年的今天。写完今天的blog,我去庆祝了。:)


2013-10-8 C++中内存操作的不适应以及对模板编程手法的幼稚

在上一篇博文中我简述了我对内存分配的认知历史,在写之前我估计会是很大篇幅,不过还是没想到会写这么长。每个部分都是简单的提几句就草草结束了, 毕竟我不想把它写成小说。

最近感觉很懒惰、懈怠,一方面因为每当我开始写程序的时候楼上的装修声音就在不停的轰炸我的大脑,另一方面这段时间中我陷入了深深的困惑之中。 我想在c++中实现内存操作的多样性,同时又不想让操作太复杂。在我实现了几个基础pool之后,我尝试用类工厂去实现类的构造和析构管理。 简而言之就是分配器提供内存,类工厂根据分配器的特性去构造和析构对象,从而来满足构造逻辑的多样性。

template <typename _T, typename _Allocator>
struct some_factory {
	template <typename ..._Args>
	static _T *create(_Allocator*, _Args&&...) {}
	static void destroy(_T *p) {};
};

刚开始的时候,逻辑上和开发上都还顺畅。我在这个基础上开发完成了timer manager和reactor,这时候我突然想实现一个全异步的socket或pipe的 管理机制,异步accept,异步connect,异步read,异步write,异步linger close。这种全异步机制在socket开发上往往很有益处,并且可以作为 libll++的一个总结节点,来验证一下系统开发到现在各个模块、逻辑和概念的契合程度。

在写这个模块开发过半的时候,我突然感觉虽然内存管理的逻辑比较清晰,扩展性也很强,但是操作上过于繁琐。我感觉不顺畅、不舒服, 好像有什么地方不对劲。

在用c开发相同逻辑的时候,一切显得简单而自然,没有太多需要过分关注的地方。但是我设计这套c++内存管理机制,却显得非常复杂。我对此非常 不满意,我在想这么用c++岂不是没有困难制造困难也要用么?于是我的手离开了键盘,与其这样做下去还不如不做。现在改可能还有机会 改好。以后改?呵呵,代码展开后,如果改基础逻辑太困难了。

于是我陷入了一种我不太喜欢的状态,想不明白但是忍不住还是想,每天有点昏昏欲睡的感觉。每天毫无目的的翻看一些open source代码, 但是又不太能读的进去,有点浮躁。开发的过程中有时候会出于这种状态,每年几次吧。毕竟我不是很聪明,往往需要很长的时间来化解心中的难题。 好在我比较固执,找不到舒服的方法我就不会放弃,早晚会找到的。我知道最终我可能封装6-8种常用内存管理机制,它们的出发点各不相同, 侧重也各不相同。如何用统一的方式来管理他们,同时又能保持它们的各异性,最重要的是要简单,这是个困难的问题。

在翻看c++0x的allocator代码的时候,我突然找到点灵感。作为一个c程序员每当在设计函数的时候,我总是直觉把allocator作为参数传入。要么 是把allocator在构造里传入,对象持有这个allocator,要么是在某个函数里传入。而c++0x中stl使用allocator的方式是继承而不是传入。当然, allocator的私有数据还是需要传入的,它使用allocator的copy constructor来完成。这极大的简化了逻辑上的复杂度,而且不影响操作的多样性, 封装方面简化了很多。原来的模式是:object->container->factory->allocator,现在是object->container(aka allocator)。

如果是个常规应用逻辑,使用copy构造是很直觉的事情。但是在通用封装上,如何在高性能情况下也考虑copy构造,我感觉以前我缺乏这方面的思维。 c++0x的allocator实质上是个factory,它是围绕对象来展开的。如果需要分配其它类型的对象,它需要rebind。这不是我需要的东西,首先这 增加了设计的复杂度,其次有些allocator是分配固定大小的分配器,rebind只会掩盖问题。这个版本的libll++的allocator还是针对内存的, 我想如果我需要一个特定的对象的factory可以派生新的allocator或者增加一层概念,但是这毕竟出现的几率很少。

class some_allocator {
	void *alloc(size_t);
	void free(void *p, size_t);
	template <typename _T>
	_T _new() {};
	template <typename _T>
	void _delete(void *p);
};

new和delete是可选项,一般会使用全局_new和_delete,这2个函数会根据sfine判断来决定是调用allocator的_new(如果存在),还是默认实现。 allocator的_new和_delete由于是模板函数,可以实现大多数类工厂的功能。这样,我们并没有都是类工厂的能力。

最近3个月我一直跟c++0x的模板较劲,很多不太涉及到模板的类我一般都能很快的完成,代码也不太需要后续的修改,很快能够稳固下来。 但是深度模板相关类则改了又改,显然无论在思想上还是编程手法上甚至对这方面的语言掌握我都很幼稚。

在一个月前很多模板我可以抄来用,但是自己却写不出来。显然,我还没有培养出解答这种“奥数题”的思维方式。new相关和closure相关模板, 我记不清改了多少次,20次还是40次?我觉得至少我有1个月在写这个玩意。closure根据要捕获参数和functor类型不同,类型和大小都不用。 所以,我们无法简单的new和delete它们。c++中functor的多样性,导致closure的多样性。以前的closure需要在模板级进行大量的overload, 涉及到closure的类也需要overload大量的模板级函数。经过不懈的努力,closure的模板函数虽然没有减少,甚至更多,但是外部使用相关 类的模板函数减少到1个。最近2周,我终于可以分析问题并根据自己的设计意图,随意的剖解出模板实方式,g++的模板实现代码的阅读也变得 很轻松,对此我很得意。我爱人问我,你每天在鼓弄些什么?答曰:学习一个非常操蛋的语言。虽然我很笨,但是我用了3个月才达到一个基本 目标(随意的操纵语言特性),这个语言确实太操蛋了。

在closure的各种实验中,我发现通过move constructor可以复制lambda,这相当的有趣。这意味着async closure可以使用lambda。 为此我在closure的make和_new中,判断functor是否是rvalue reference来决定是否使用move constructor模板。这很有趣,但是。。。。 如果不小心传入一个常规类的右值引用,也会被构造。对等的,如果左值引用传入一个lambda,在async closure情况下也可能是个巨大的bug。 如果,有办法知道这是个lambda表达式就好了。

各种问题虽然很多,但是逐渐清晰起来,对此我感觉很开心。


2013-9-14 我的内存分配简史

作为一个程序员,有时候我感觉写程序还是蛮有乐趣的。每当我写完一个小小的构件,如果我觉得它还不错,就会有种满足感,自我陶醉几个小时。 但是这肯定与内存分配不相干。内存溢出,缓冲溢出,泄漏,coredump,琐碎,难于调试,难于审计,战战兢兢,等等几乎所有软件相关的负面 词汇都和内存分配有关。它是软件开发黑暗面的当之无愧的老大,如果我们不喜欢线程我们可以不用,但是内存分配。。。

在软件开发过程中,内存分配是我最不愿意讨论的问题。所以,在这个blog中,内存分配也是我最不喜欢写的部分。要完整阐述libll++的内存分配 策略,这需要一个非常巨大的篇幅。这些策略不是libll++独创的,它们已经存在十几甚至几十年了,我只是在libll++这个实验性工程里尝试在c++的 环境下实现他们。我磨蹭了好几天才开始写这个部分,主要是出于懒惰和对这部分内容的讨厌。后来想了想,如果不写这部分内容这个blog就难于 进行下去,只好开始分段去写,每天写一点点。

为了避免问题的复杂化,内存分配相关内容尽量的避免牵扯到多线程问题。本质上,libll++到现在并不是个多线程库,不过它对多线程也并不是完全 没有自己的理念,这些内容或许在以后会逐渐展开。

92-95年,这是我上大学的几年,我一直在玩asm。各种不着调的tsr程序,dos内核剖析,还有51单片机。那个时候,谁要是问我内存分配,我估计 我会很茫然,what?

大学毕业后,曾经有那么2年写各种单片机程序。inter,moto,philips等等各种系列的芯片。如果这时候有人跟我提到内存分配,我会苦着脸说: 大哥,就这么点内存你分配个毛线啊?这时候,估计某硬件工程师会把脑袋伸过来,没问题我可以给你扩点外存,你要多少K的?我估计我肯定会一脚 把他踹回去,就你设计的板子的电磁兼容性?旁边有人用下手机,没准ram就错乱了。ram我都没法相信,我还能相信什么?呵呵,开玩笑,当时 我们是个快乐的团队。那个哥们只是经常忘记给片子接地,他设计的板子初期就像长毛的面包,上面到处都是飞线。为了排除到底是硬件问题还是 软件问题,我特意给他们设计了个刑具。一个2米高的箱子,里面布满了接触器来产生电磁干扰,还有电炉子来产生高温高湿的环境。每当板子从 工厂发回来,装配好,我们就玩这个游戏。想像一下,夏天,在一个没有空调的屋子里,守着个80多度的大箱子,无论硬件工程师还是软件, 轮流值班,24小时不间断,而且它还会卡卡的不停的响,相当的销魂。在相当长的一段时间,大家每天早上就趴在日志服务器上,对于所谓的对错 反倒不关心了。

在大学毕业后,我接触的第一个语言不是什么 严肃语言,是foxpro。简单的说是脚本语言。那个时候,我买了自己的第一块硬盘,也接触了更多 可安装的编程语言。之后,好像是VB。对于脚本语言我后来还用过perl(这个一直在用,估计永生用),PB,java, python,lua,c#,AS3。 除了perl,大多是为了生计。lua可能也是个特例,我针对他写了大量的脚本,只是因为我喜欢魔兽世界。脚本语言,或者我称之为bcode语言, 有其优势,也有其问题。当然了,这些语言都宣传不需要考虑变量和内存问题。

其实,这往往要看应用的规模和设计目标是什么。如果你用java写一个简单的应用,没人会在意。但是你用它写个重负荷应用,这就需要比较高超的技巧, 这些技巧至少有一部分的关键在于内存处理,你需要有计划的操纵gc。脚本语言其实也是存在内存泄漏的,尽管在语言级别上让程序员脱离了直接 内存控制,但是在逻辑级别还是存在内存泄漏的可能性。比如说2个孤儿互相索引。大多数的gc是无法解决这个问题的,或者说不想解决这个问题, 这导致内存的控制块更大,gc的效率更低。lua的gc能够处理这种情况,但是lua依然会存在逻辑泄漏问题,而且还相当的普遍。AS3经常被用来编写 网页游戏的客户端,避免内存泄漏控制gc更加平滑一直是个需要小心谨慎对待的问题。

这之后的很多年里,面对的是纯粹的c语言环境。在内存分配方面,c和c++没有太多的不同,它们都使用malloc作为内存分配的实现方式。

void *malloc(size_t size);
void free(void *p);

现在glibc一般使用dlmalloc作为malloc的实现方式。dlmalloc显然是经过了精心的优化,它的分配性能显然不是最快的,但是它非常的平均(或者叫平衡)。 具体dlmalloc的算法大家可以查阅去相关文档。malloc分配的内存块的前后两端都有边界标志,大小最少是2个size_t。malloc分配的内存最小空间是2个 指针大小,在32位系统就是8个字节,因为传统malloc的free list是个双向链表。这与想像中的按照对齐方式分配不同,小于8字节均按8字节分配而不是 4个字节。所以分配大量的小块内存是非常浪费和低效的分配方式。另外,内存写操作无论是上溢出还是下溢出,都会弄脏边界标志,导致malloc错乱。 内存是个全局资源,多线程环境下,内存分配必须是个原子操作。众多的线程频繁的分配释放内存,会频繁的争抢内存锁,导致系统性能下降。

频繁的分配和释放内存还会导致另外一个后果,那就是内存碎片。内存分配算法在释放的时候总是尝试合并free的内存空间,在分配的时候总是尝试使用现有的 free空间。找到一块适当大小的内存用来分配一般有两种算法:best-fit和fast-fit。best-fit理论上内存碎片最少,比如dlmalloc就是best-fit。 如果找不到恰好大小的内存块,就会尝试找个更大的内存块拆分成两块。如果这种拆分,不能很好的在free的时候合并,就会产生无法再分配的 细小内存块,这就是内存碎片。内存碎片也是受管理的内存节点,它们等待合并等待分配,但是这没有发生。这导致malloc管理的内存节点越来越多, 在分配内存的时候,找到一个合适的大小内存块的运算成本就会越来也高,最终导致内存分配效率的瀑布式的下降。dlmalloc显然在这方面做了 优化,但是它也付出了额外的开销,它不是特别快,但是分配时间消耗相当的平稳。

在这里要提一下服务器开发中经常提到的“时间累积性效应”。程序刚开始运行的时候状态非常良好,但是随着时间的推移,1天,2天,1周后应用的 各种性能开始大幅度的下降。这面往往有内存分配的因素在里面。

上面说的只是malloc的一些基本特性,这些并不是程序员对它有恐惧感的根源,根源在于malloc和free必须要成对出现。分配的内存必须要释放,否则 就会出现内存泄漏。写个小程序这看上去无所谓,但是如果是个几万甚至几十万行的大系统,到处都是malloc和free,这就是个灾难,甚至最终到了 不可控制的地步。在服务器开发中,程序core dump不是最可怕的,耐心排查还是能够解决的。但是如果应用内存持续而稳定的增长,则是相当的让人 绝望。

c++在这方面并没有任何改进,只不过malloc变成了new,free变成了delete。new的过程分成两步,malloc内存,在这个内存上调用构造。delete也是两步 调用析构,free内存。在c语言中,并没有明确的定义你应该怎么分配和释放内存,malloc和free只是2个约定成俗的函数,不是标准。但是c++明确的定义 了new和delete的接口规则。好消息是c++提供了placement new,这让我们可以用自己的方式申请内存。坏消息是delete只接受一个参数,一个指针;我们 去覆盖这个函数毫无意义,因为我们不知道指针所指的内存是怎么产生的,是malloc产生的还是自定义内存池产生的?delete的这种接口也是可以理解的, 父类指针指向子类,delete的时候编译器不可能知道这个指针指向的到底是父类还是子类,这主要是c++没有rtti(run-time type iden)。这样在delete的 运行时,delete并不知道要释放的内存的确切大小,它把所有相关的操作委托给下面的内存分配层。在释放内存前,它调用指针类型的析构函数,如果这是个 虚析构则会调用子类的析构函数。所以使用placement new,而且不是用malloc分配的内存,那么就不可能用delete去释放,你需要自己去调用析构函数, 自己去释放内存。

前面说到在c语言程序里到处出现malloc和free是个灾难,在c++里到处new和delete其实也是个灾难,因为他们本质上是一致的。好消息是我们可以在c++的 析构函数里释放相关分支的子内存,坏消息是这需要一个仔细的规划,同时频繁的析构调用会导致效率下降。不过不知道为什么,new和delete可能 比较时髦一些,这种关键字在c++里是大量频繁的出现的。

malloc存在很多开发上的问题,但是这并不是malloc本身的问题。这是过多过于频繁使用它的问题,或者说是使用的策略出了问题。很多聪明而睿智的程序员 开始从另外一个角度来思考这个问题,这就引入了内存池的概念。内存池是个很宽泛的概念,一个“池”拿出内存再放回去,看上去malloc也符合这个定义。 这里要讨论的是一种狭义内存池,我称之为策略型内存池。而malloc我称之为分配器,在后面要提到slab分配器也是属于分配器范畴。malloc非常全能, 但是全能的东西有广泛的能力同时也有广泛的弱点。策略型内存池则是针对某些特定的场合而设计的,功能有限,但是专精。为了简化,在这个blog中把 针对某种场合设计的策略型内存池称为内存池,把通用内存分配称为分配器。当然,为了特定目的设计的内存池很多,比如说脚本语言的gc就是个策略型 内存池。在这里,我只讨论那些常用到的,简单的,容易操纵的内存池。

这里我们举个例子,假如说我们要为某个应用实现一个配置管理系统。这只是个例子,我们当然可以用xml来完成这个事情,但是我们想自己创造个“轮子” 实现一个类似apache或者svn那样的unix风格的复杂配置系统。这个配置系统有很多的group和sub-group,还有很多的规则。设计的第一步是我们设计一个 配置文件语法级的规范,如果描述group和subgroup,如何描述规则,如何描述数字、字符串、列表,等等。然后我们要设计一系列的doc级别的class在内存 中描述这个语法树形成一个dom,然后进行解析。在解析的过程中,我们要在很多代码段中实例化很多很多的不同种类的类,然后在delete doc的时候,逐个 释放这些class实例。那么这里面到底有多少种类呐?这个不确定,要根据dom的规模来确定。而且,这些dom class不是孤立的,这往往是个树形结构,需要 树、链表等数据结构把他们关联起来。对于一个严谨的文件解析系统来说,还需要很多其它的辅助类,比如说我们要记录每个token的位置,一旦出现错误 要给使用者一个友好的提示。这里面有多少个类实例呐?不知道,我只能说很多很多。还可能存在一些潜在的内存分配,比如说std::string,std::list如果你用 了这些class的话。

一个看上去简单的设计,经过一分析立刻复杂起来了。如果我们逐个去操纵分配和释放,这是个强度非常高的工作。这些class尽管功能不同,分配时间不同, 但是它们都具有两个共同点,第一在从属关系上他们本质上都属于最终分析出来的document,第二从生命周期来说他们也都从属于document。

针对这种设计需求,就产生了多次分配,一次性释放的内存池,比如说apr pool。这种内存池,每次从内存分配层分配一个较大的chunk,然后在这个chunk上 紧密排列地分配更小的内存单元,如果剩余的空间不够就再次分配新的chunk。这些pool分配的内存块不再具有边界标志,分配的内存是对齐后的实际大小, 也就是说这种pool不具备释放某个具体内存块的能力。它只是用链表来维护chunk,然后在chunk上连续的分配内存。当需要释放内存的时候,不是释放某个 内存块,而是释放整个pool。这个过程就是把所有的chunk释放回内存分配层。

把这种内存池用到上面的例子就显得非常简单了。一个pool从属于一个document,在解析的时候,你可以尽情的在pool上进行分配展开语法树而不必考虑释放问题。 当document生命周期结束的时候,我们就像用链锯锯断大树那样从根上释放掉pool,而不用考虑某个具体内存块的释放。

这种内存分配方式有很多很多的好处。首先,它申请的是chunk而不是具体的某个小块内存,这些chunk都是以page为单位的,一般最小是8k或4k的倍数。这样整齐的 内存大小非常易于合并,甚至很多情况是用mmap直接映射的。一般情况下,库会有一个专门的分配器来分配这些chunk,我称之为“页分配器”(以页 为单位分配内存的分配器)。在chunk上分配更细小的内存而且不用考虑具体内存的释放,避免了内存块的额外维护开销,这种分配器不惧怕细小内存块, 甚至可以说粒度越小越适合它。在多线程系统下,每个pool是独立的与线程无关,真正需要互斥的是在分配chunk的时候,也就是说在页分配器上进行互斥, 这极大的放大了互斥操作的粒度,提高了系统的效率。在释放内存的时候,效率也是相当的高,跟释放成百上千的小内存相比,释放几个chunk是相当轻松的。 还有一个潜在的好处,oh,我忘记了释放pool,这就不是简单的内存缓慢增长问题了,而是暴涨的问题了,每个pool最少也是8k啊,这有利于尽早发现问题。 排查起来也比较容易,pool的粒度如此之大,它不可能像malloc分配细小内存那样,到处创建pool到处析构pool。

这东西看上去不错,但是在使用的时候有约束条件,还记得上面提到的document的从属类的特点么?所有的展开类实际上从属于pool的宿主, 另外它们的生命周期是一致的,这是这种pool只能多次分配只能一次性释放决定的。在做这种设计的时候,我们需要仔细的对对象进行分类划分,来更好 的利用它。这看上去约束性很强,但是在实际应用中我们往往能找到一个合理利用它的方式。

有人可能疑问,8k?这是不是太浪费了?这个问题确实存在,但是这要看你怎么看待它。首先,它分配的内存去掉了边界标志,这在大量细小内存分配上 已经非常的节省空间了。其次,要看它的粒度。它的粒度如此之大,显然不是针对某个细小设计目的而设计的,它一般存在于系统的根对象或者大粒度对象上。 比如说,一个网络应用这个pool往往在session上。一个应用有1万个连接就已经是超级优异的了,假如很不幸在pool上我们整好在最后一个chunk上只用了8个字节, 也就是说我们浪费了整个chunk,这浪费了多少内存呐?80k,这对于一个UI应用来说都是毛毛雨。另外一个方面能稳定完成的应用才是好应用,有所得必有所失, 一个成熟的程序员必须要学会权衡。最后,我们要看它的生命周期。我们经常在网络应用上用这东西。 想象一下,我们接收一个数据包,在pool里进行解包展开,中间处理的时候可能还要分配一些内存来完成逻辑,给请求返回数据包的时候,我们并不想计算 数据包的具体大小这可能会因为计算错误导致内存溢出,我们通过pool来自动计算完成它,在我们把返回数据推送后再释放pool。它的生命周期是函数调用级的, 它会浪费内存么?对于一个程序员来说,它看到一个协议处理函数传入一个pool,他知道这是“协议session”级的,他就可以放心大胆的用,不必考虑释放问题。 这会极大减轻程序开发的负担。

另外一个问题是,并不是所有的事情都是相同生命周期,相同从属的。好在这种pool都会有sub pool能力,father pool可以构造child pool,当father pool释放的 时候,child pool也会释放,当然也可以单独释放child pool。

我接触的第一个这种类似的pool是obstack,那是10几年前的事情了,大约是98年。我不是计算机科班出身,大学毕业后我开始认真的学一些计算机系必须要学的课程。 我买了本编译原理书,自学编译原理。在没有任何帮助的情况下,在当时资料也不充足的情况下这是个非常困难的事情。很多东西看不懂,我就开始 读gcc的源代码,那时候的gcc的语法解析和词法解析还是用yacc和lex做的(现在则完全是手写了),这很幸运,解决了我的很多书上看不懂的难题。 那时候obstack只是gcc的符号表的实现方式,现在它已经是glibc标准里的一部分了。

obstack基于chunk分配,小内存分配,自增长,除了没有sub pool它基本符合pool的所有定义。同时它的特性,导致它在协议打包上具有独特的优势。

apr pool是我接触第二个比较欣赏的pool。在实际应用中,被广泛的使用。在这个类库里引用了apr pool作为基础pool。在以后的blog中会详细描述这2个pool。

这两种pool,在c语言环境下使用的比较广泛,很多应用中都有类似的实现。但是在c++中,它们的使用却有点困难,一次性释放导致析构函数无法调用。 我在c++的内存处理上感觉困惑。我们只能操纵构造,不能操纵析构。但是析构是合法语法,程序员会去写。如果要求程序员用一个新的逻辑,这会产生困惑。 噢,对了,还有虚析构。delete死死的困住了c++的内存分配模式,如果你想引入一些有趣的想法,你只能说服程序员放弃析构。这对于习惯了这些的人来说 不只是造成困惑还有代码的不确定性。

其实在java的程序设计上,已经阐述了一种本质,这种本质是根语言无关的。尽量保持从属关系的单向性和纯洁性,全局资源需要明确的释放。但是oop的模式性 太强,让这一切看上去很教条。

现在的c++11向2个方向发展,一个方向就是纯模板化,总之别提怎么分配内存就行。另外一个方面,很多人在提倡gc。这本身就阐述了内存分配的无奈。能改么? 改不了了,这东西很多年了,想继续提升不改行么?或者干脆,就脚本化吧,用gc。

libll++在析构的问题上使用的是slotsig。在apr pool上有一个析构链表。在我使用c语言编程的时候,我把它去掉了,我认为它影响了c语言的纯粹性。作为一个 程序员我有责任去处理一些细节问题,再说pool已经干掉了太多的内存分配问题,这已经很好了。但是在c++中,我把它重新找了回来,我的想法是你要是觉得 你确认有些系统资源或者关键引用需要跟pool一起处理,那么你就把你的处理挂上去。毕竟,我们也没法调用析构函数了。

这些pool看上去很好,但是无法覆盖所有的应用。比如说memcache,这种pool就完全的无能为力。松散,扁平,频繁,无法预测的内存分配,完全无法规划pool。如果 单纯用malloc会导致内存分配效率的急剧下降。memcache的某个文档上说,当服务运行一周后,系统性能下降了一半。这时候,它引入了slab分配器。

slab分配器起源于solaris的kernel,linux kernel也包含这一分配器。slab分配的本质很简单,但是它系统相关的一些特性让它显得很复杂,比如说 每cpu队列。也就是说slab这个词无关的一些内容让这个分配器很难阅读,在这里我不想讨论cpu队列,不想讨论着色。着色,其实,倒是很简单,但是也搞不清 能有多大的系统提升。

slab分配器的基本组织单位是cache,比如说我们想搞一个timer manager,这需要timer manager的内部管理节点,一般都是红黑树节点,我们就声明一个 timer_node cache。slab分配器也会一次性分配一个以页为单位的chunk,来存放所有的内存块。这有点像sgi stl的内存池,把一个页切成相同大小的碎片。

分配内存的时候,就是从chunk上取出一个内存块,如果chunk里的内存块都分配出去了,就再申请一个chunk。这跟前面提到的pool没有区别。

这些chunk,就是slab。之所以叫slab分配器,不是在于分配,是在于它的释放机制,slab分配器的算法是围绕释放进行的。slab分配器把这些slab分类成3组, full(所有内存块都分配出去了),partial(部分分配出去),empty(完全没有分配)。其实empty不是必须的,empty情况下完全可以释放回page allocator。 这就像裝鸡蛋篮子,分配器尽量用那些利用率高的篮子,那些利用率不高的逐渐被腾空,释放回系统。这就是它的释放策略,slab级的释放。

slab分配器的每cpu队列是个相当销魂的东西。我猜测这东西叫cache的原因就在于此,它把这种机制概括为:子弹和弹夹。子弹就是要分配的内存块,一个内存 块在某个cpu上分配就会cache到某个cpu上。这种cache机制就叫弹夹,其实是个数组,很形象吧:)。如果弹夹里的子弹不够了,就会从slab分配层装填弹夹。 这极大的避免了互斥问题,只有在装填弹夹的时候才会出现互斥。但是,我们在大批量释放子弹的时候,slab分配器的数组机制就发飙了。

尽管slab分配器在操作系统kernel界大名鼎鼎,但是在应用开发上却不太有名。我知道的是memcache上有个所谓的实现,glib上有个gslice所谓实现,其实 这都不是真正的slab分配器实现。有个open source库叫libumem,这是原汁原味的slab分配器的应用级实现。

这么个大家都推崇的东西,为什么在应用开发中没有一个主流支持呐?我一直都很纳闷,现在也是。对此,我进行了一些猜想,对此也只是猜想,毕竟我没有 完整通透的把它读完过,尽管只是不到10000行代码,难度还是相当高的。除了它跟kernel相关的一些内容,它在普遍应用层上还是有些困难的。

首先,slab分配器是个比较重型的分配器。它的释放粒度是slab,如果应用的分配释放跟这种策略吻合,这当然相当的完美,不过不吻合,会出现大量的partial。 这会造成比较严重的内存浪费,malloc在较轻量级的应用则不会出现这种情况。slab一直宣扬自己不会产生内存碎片,从它的实现机制来说确实如此,但是同时 它产生了内存浪费。它的内存利用效率是建立在较大量的分配后基于统计概率论基础上的。比如说文件句柄相关数据结构,数量巨大且具有分配随机性,这时候 slab分配器的优势就体现出来了。

slab分配器有个重要的概念就是内存块的构造和析构,也就是在装填弹夹的时候调用某个函数来初始化这个内存块,这样可以避免反复初始化。 析构的概念与此类似,但是后来取消了。但是在应用层,特别是c++,看不到能够用到它的希望。我们不能直接调用构造函数,我们必须new,这个机制形同废柴。

最后一个问题,纯粹的slab分配器分配的内存是没有内存边界标志等meta data的,分配32字节就是32字节。在kernel中,物理地址可以直接映射到页, 页里保存了slab相关的上下文。但是应用层,不具备这种机制,libumem是通过一个hash表来完成的,这会导致性能的下降。如果用slab分配器去实现 malloc那样的函数接口,分配的内存必须要保存内存块大小等信息,libumem也是这样实现的。libumem在初始化的时候,建立了一系列的通用cache,从8字节 到8192字节,这是个数量非常巨大的cache群。libumem有个malloc的具体实现,甚至可以通过hook覆盖malloc。当用malloc分配的时候,libumem多分配1 个size_t,用来存放内存块的大小。然后找到相应的通用cache,进行cache级分配。释放则是个相反的过程。这些机制的开销跟malloc相比是非常巨大的。

无论如何,在重型应用中,我还是建议使用slab分配器,当然是类似libumem那样的原汁原味的slab分配器。linux的slub分配器优化了slab分每cpu队列, 如果能有应用层的slub实现真是令人期待。这种分配器对于多线程应用具有极强的隐形的性能提升。呵呵,尽管我不赞成滥用多线程。

如果时间容许,我真是很希望在libll++上实现一个slub分配器。现在的libll++,抽象了cache的概念,但是搭建在pool基础上,无法完成理想的内存回收。 在libll++中,我不甘心单纯的使用malloc,但是内存块级的分配还是需要的。因此,我还是引入了cache的概念,并且用pool作为它的实际分配器。 以后,我期盼能够自己实现一个slub分配器来替换这部分的代码。我想我在2014年,会去尝试完成一个完整的实现。

内存分配的简单介绍就暂时告一段落。这里面还有很多内容会分散在这个blog中,逐步讨论。


2013-9-11 slot and signal and rcode and module manager

用过QT的程序员都会对它的slot and signal机制有很深刻的印象。

我对slot and signal的理解是这样的:signal是个函数签名,定义了emit时需要传递的参数列表。slot本质上是个signal的一个具体实现。signal定义 了一个callback,slot实现了一个callback。把一个signal连接到slot的过程叫做connect。一个signal可以连接多个slot和signal,一个slot也可以连接多个 signal。简单的说就是signal定义了一个callback,并且有个数据结构(链表或者map)来引用相关的slot。而slot则是实现callback的具体函数或closure。 当signal emit的时候,则是把它引用的所有的slot都调用一遍,或者叫广播。

事件驱动的实现方式一般有两种,一种是消息队列(例:windows),一种是回调(例:gnome)。slot and signal在本质上也是一种回调。我个人感觉还是消息队列比较 稳妥一些(如果有这么个队列机制的话),callback则具有更好的可移植性和效率。其它一些语言环境也有类似的概念,比如AS3的addEventListener。不过, callback或者类似的封装管理都需要一个妥善的规划,否则就会出现event满天飞,程序员自己都很难确定程序的实际运行路径。

libll++用closure封装了callback,但是还缺少一个callback的管理机制,于是我把slot and signal概念引入到libll++中。一些类库使用interface和虚函数 的方式来设计事件驱动,比如说ACE的ACE_Reactor和ACE_TimerManager。我感觉这种实现方式耦合度太强且灵活性不足,比如前面提到的socket与timer的 那个例子,socket的不同时期timer的作用是不同的,插拔不同的slot到同一个signal上,会让代码显得更加清晰,耦合度更低且可拆卸。

libll++的slotsig设计秉承一贯的风格,尽量的简单实用和数据植入模式。直到现在libll++还没有提到它的内存管理规划,这导致在内存管理规划提出之前 的所有组件只能是全植入模式,也就是数据结构管理节点包含在数据里。signal connect signal,这会导致一个树形的广播关系,或者叫树广播。这种机制 很强大,特别是在UI系统里。不过这么强大的东西也很危险,如果规划的不好会反复的无意义的调用slot甚至导致无限递归。在这里的slotsig把这个功能去掉 了。

libll++的signal有2种,normal signal和once signal。

normal signal使用clist来管理所connect的slot。normal signal的slot是clist_entry和closure 的混合体,简单的说这个slot是个closure,它能够connect到signal上,它的构造函数参数跟closure完全一样。在95%的情况下,我们把slot声明称类成员就 足够用了。如果想动态new一个,那么你需要自己管理slot的内存,slotsig不负责内存方面的管理。

once signal是个简化的signal,它只能connect一次,也就是说它只保存一个closure,如果要connect新的必须要disconnect旧的。简单的说它就只是个 closure指针。尽管它很简单,但是在服务器的事件驱动应用中却占有绝大多数的出场率。

无论是哪种signal,它首先是个函数签名。

template <typename signature, bool __once = false>
class signal;

template <bool __once, typename ..._Args>
class signal<int(_Args...), __once> {}

上面的示例是signal的原型。__once说明这个signal是不是个once signal。可以看到,slotsig的signal定义了签名的返回值,是个int。 对于normal signal来说,它实质上是个callback list,这个返回值非常难于处理。当然,我们可以写个模板函数来遍历调用callback,然后传入一个 lambda之类的东西去接收返回值,但是lambda不能影响程序流程。

经过反复的思考,最终我把slot的返回类型定义为int。首先,int类型作为返回对于程序运行来说没有任何开销,一般这个返回值会被放到寄存器中返回 (x86就是eax寄存器)。其次,从这个节点开始libll++的函数使用统一的错误返回和错误处理方式。

libll++有错误状态返回的函数(包括类成员函数)均使用int作为返回类型。这个int,在libll++中称之为rcode(result code)。rcode == 0, 代表函数完全成功,无任何歧义。rcode < 0,代表函数失败,rcode的值是错误代码,相当于errno。rcode > 0,则是未定义的保留,可能是 其它信息,可能是某种警告和暗示,但是,不是错误。rcode的定义在rc.h中。

normal signal的emit函数现在的实现方式是如果callback返回小于0就返回。不久的以后他可能会是个模板,不如说传入一个lambda通过callback的返回值, 来返回一个int来决定是否继续遍历下去。

在现在的libll++中once signal的应用面是非常广泛的。不过,也有个normal signal的实例,module manager。这个模块管理器不太值得提倡,写它 纯粹是一种游戏心态。一个较大型的应用(呵呵比如说libll++,最后它不会很小的)经常会由很多个模块组成,这些模块或强或弱的有着一些依赖关系。 在程序初始化部分,我们需要按照正确的顺序逐个模块初始化,如果不成功就需要倒退回去。这种代码很烦人,也很容易出现疏漏。在c++中,我们经常用到 单例模式,一般都是滞后构造来保障正确的初始化关系。对于使用率超高的一些单例,那个NULL判断或者函数里隐含的static初始化判断看上去令人不爽。 我们希望程序启动的时候,能够简单的正确的直接的初始化。

在看到linux kernel的module管理后,我就也想有这么个机制。但是,很不幸,关键的问题是这不是个c++语言范畴,这是个连接器工作方式范畴, 简单的说这是未定义。在实际的编译工具链里这可能更复杂,collect2和ar之类的工具都会产生影响。如果用c++的方式来说,一个a.cppb.cpp都 有一个静态构造,那么是先调用a.cpp的还是先调用b.cpp的静态构造?这个不确定。我不想超越语言的界限,更低层的去干预gcc。

但是,从多年来使用gcc的经验来看,某些版本这个调用顺序与工程中源文件顺序相符,有些版本则是倒序,但是还没出现过随机调用的情况。只要不是 随机调用,这个事情就好办。正序和倒序我们可以通过程序直接推演出来,module.cpp和module_end.cpp完成了这个推演过程。

这样我们就有了一个不太值得提倡的模块管理器,每个模块在自己的源文件里初始化,并且通过源文件顺序来保障初始化顺序。

struct foo {
	int module_init() {
	}

	int module_exit() {
	}
}
ll_module(foo);

那么这个module管理器的本质是什么呐?module manager有2个signal,init_sig和exit_sig。ll_module是个宏,它把foo的2个成员函数以slot的 方式通过静态构造装配到signal上。

在libll++中封装slotsig不是心血来潮,是经过反复思考的结果。关键不在于callback管理,也不在于slotsig多么方便多么高级多么好,对此 我根本没有感觉。关键在于libll++的内存管理风格需要这么一个机制,在以后会提到slotsig在libll++内存管理风格中幕后所担任的重要角色。

欲知后事如何,请听下回分解:)


2013-9-9 closure and lambda and tuple_apply

记不清第一次接触closure这个词儿是什么时候了,不过应该是在学习某种脚本语言的时候看到的,lua、C#、AS3?这个单词的计算机类解释是“闭包”, 坦率的说当时没理解“闭包”的含义。我被这种高度抽象、深邃的中文解释迷惑了,我每每都是这么愚钝。我的应对方法就是放弃中文解释,closure就是closure。

我喜欢用c语言来解释现代计算机语言的高级概念,简单,直接。在服务器开发应用中,几乎所有的程序都会用到timer,有时候timer是唯一可依据的“轴”。 比如说一个socket server,accept一个connection进来,到close这个connection,需要一个timer始终跟随它。accept进来后多长时间内收到handshark包, 流控,keep heartbeat,关闭的时候linger close,这些都需要一个timer。下面示例是个简单的timer interface。

typedef int (*timer_handler_t)(struct timer *, struct timeval*, void *);
struct timer {
	rbtree_node node; //一般都是用红黑树来管理timer
	struct timeval expires;
	struct timeval interval;
	timer_handler_t handler;
	void *magic;
};
struct timer *timer_schedule(struct timeval *expires, struct timeval *interval, timer_handler_t handler, void *magic);

这种数据描述在c里面是非常常见的,特别是在事件驱动程序中,timer管理是个典型的事件驱动模式。timer_handler_t明显是个callback,在c 中往往一看到这种typedef就直接归类到callback,这类callback的最后一个参数基本都是void *。callback函数只是定义了方法,void *里放置 的是具体callback的context。比如说上面socket server的例子,context可能是某个peer的指针,或者创建了一个全局“秒级”timer,去回收某些资源, context是某些全局数据结构指针。或者函数知道context是什么,传入个NULL。这些context对timer manager来说是透明的,manager不去解释它,一般我会用 magic这个词来声明类似的变量。

如果把context和callback放到一个结构中,入下面的示例:

struct timer_closure {
	int (*handler)(struct timer *, struct timeval *, void *);
	void *context;
};
struct timer {
	rbtree_node node; //一般都是用红黑树来管理timer
	struct timeval expires;
	struct timeval interval;
	struct timer_closure closure;
};
struct timer *timer_schedule(struct timeval *expires, struct timeval *interval, timer_closure *closure);

那么,timer_closure就是个传统意义的closure:一个带context的callback。有些解释说:一个有状态的callback。基本含义都是相同的。在第一个例子 中,本质上也是closure,有callback有context,只不过是分离传入的。从本质上来说所有的callback都是closure,区别只是有些context是默认已知的, 有些context是需要明确指定的。

上面讲的是c的closure实现,那么在脚本语言中closure会有不同么?没有本质的不同,比如说下面的as3代码。

public class foo {
	static public function dodo(var callback: function): void {
		callback();
	}
};
public class foo2 {
	public function doit(): void {
		//do someting
	}
}

var f: foo2 = new foo2;
foo::dodo(f.doit);

传入的是f.doit而不是foo2::doit,可以这么理解:它把foo2的实例和doit这个函数“打包”传给了foo::dodo。

c语言的closure看上去没什么不好,简单清晰,它从c存在开始就一直被广泛使用,当然了大多数时候没人叫它closure。 事件驱动这种最简单的程序模式,它的应用面却是非常非常广泛的。既然涉及到事件驱动,必须要讨论callback,或者其它 一些本质上无区别的名称上有区别的方式,closure, slot and signal, message queue(send模式)。在这段文字中, 我阐述了一些“嘲讽”和轻佻的“自嘲”。我讨厌过渡封装,如果一个简单的概念被封装的让人看不懂,这算是什么呐? 另外一方面我会在libll++里大量使用closure:噢,我在用c++,这必须要稍微高端点,让封装来的更猛烈点吧。 libll++是个实验性工程,我在暗示自己没必要搞得那么正规。

在c++11的世界里,closure有很多种实现方式。首先就是语言级支持的lambda。第一次看到这个东西让我兴奋异常, 这东西太cool了,然后就是一盆凉水。

自从在baidu和google上看到lambda,我就开始拷问g++,可能有一周的时间。想像一下,一个恶魔拿着鞭子反复的抽 g++ 4.7。我试图用个简单的方式来解释lambda,下面是个lambda代码:

template <typename _F>
void walk() {
	for (something) {
		f(v);
	}
}

int do_sum() {
	int sum = 0;
	walk([&](int n) {sum += n});
	return sum;
}

下面是我猜测的lambda的实现,c++环境下编译器去生成代码是相当普遍的,最早的就是copy构造。在g++里,lambda 是在基本语言环境下自动生成的。

int do_sum() {
	int sum = 0;
	class lambda_n {
		int &_sum;
	public:
		lambda_n(int& sum) : _sum(sum) {}
		void operator()(int n) {
			_sum += n;
		}
	} instance_n(sum);
	
	walk(instance_n);
	return sum;
}

在g++中,lambda是个函数内联类,至少在现在的版本是这样,它符合所有的函数内联类的特性。也就是说每个lambda是个类, 准确的说是个重载了()的functor类。而且这是个特殊的类,我们无法decltype它,你只能用auto去索引它,它的大小根据 它的捕获参数不同而不同。这让人很沮丧。

callback一般有两种模式,同步callback和异步callback。walk和do_sum这个例子就是个同步callback,context的生命周期 跟调用函数一致。异步callback的context的生命周期则是不确定的,比如说事件驱动。

lambda是可以赋值给function的。从语法角度,只要是函数可见域,lambda就可以捕获。我对于这种赋值是否依然生命周期具有局部性 没有继续研究下去。它违背了我的想法,我希望一个异步closure,我希望能够管控内存分配。就是lambda和function配合完成了 第一项,它也无法完成第二项,lambda是变长的内存怎么控制?

ok,有些人会说如果lambda不捕获任何变量,你可以把它当作一个静态函数用。我想说,不捕获变量,它还有什么用?我就 那么懒得写个函数么?

为此,在closure的道路上我首先排除了lambda。c++还是很丰富的还有function和bind。

在这里,我比较郁闷的是我很久很久没有跟踪c++的发展,特别是boost和looki。我只是2个月前开始知道他们,但是没有从源代码 级别去分析他们。c++11的很多类就够我看的了,function显然是boost的function的标准化版本。我对此不太理解,一个语言需要 定义这种级别的功能么?看上去像java。c++定义move copy和move assign,这很恰当。定义function,至少我是很难理解。

bind是个函数模板,它的返回类型未定义。function对于具体实现也差不多。我比较困惑,我希望精确控制内存和构造。 另外一个方面这是个实验性工程,我想去做个自己的“轮子”。于是,我开始设计libll++的closure。

在c++领域,callback有两种模式。在讨论这个之前,先讨论下函数签名,简单的说就是函数参数列表,不包括返回值。所有 callback的合法性是依据函数签名的。callback的一种方式是functor,这包括2个方式,一个是静态函数,另外一个是重载了 operator()的class,我们统称为functor。另外一种callback实现是类和类的成员函数,这个更加灵活且通用。还有一种方式 就是使用oop的虚函数,比如上面的timer的例子,timer_handler_t用虚函数来实现。在类库级封装上,我不太情愿用虚函数, 一虚阶虚,在声明的时候你就限制了自己。所以在拼凑和虚函数上我使用拼凑来解决问题,这个讨论以后会提到。

如果我们只是对functor或者class member进行封装,这不是很困难。关键的问题是我想实现复杂closure。对于functor,class instance是必须传入的,class member也是必须的。但是其它的参数怎么传入?就像lambda和bind。在我思考这个问题的时候, 第一反应就是typelist,在参考了tuple的文档和代码后,我相信这是个很好的解决方案。完全是编译时刻的实现。tuple就是个 typelist。

template <typename _Sig, typename ..._Params>
struct closure;

template <typename _R, typename ..._Args, typename ..._Params>
struct closure<_R(_Args...), Params...> {
	tuple<_Params...> _captures;
};

上面的代码就是我想像中的closure的最初原型。_R和_Args构成了函数签名,_Params则是closure捕获参数列表。这跟g++的 lambda内联类比较类似,不同的是它有语言级支持,可以单独的声明捕获变量,而我的closure只能用tuple。

但是,这里有个问题。我可以用tuple去存储捕获的变量,但是怎么把他们传递给callback函数?我在code stack上找到一个实现, 相当的有趣,我把它修改了一下并命名为tuple_apply放到了这个工程里。它的原型如下:

template<bool __back = false, typename _F, typename _T, typename ..._Args>
    static inline auto apply(_F && f, _T && t, _Args&&...args);

F是个functor,T是个tuple,_Args是调用的参数。这个函数的功能是调用_F,并不tuple中的元素展开到_Args的前面或者后面。 比如:

int foo(int a, int b, int c, int d);
tuple<int, int> t(1, 2);
tuple_apply::apply(foo, t, 3, 4);

实际的调用效果等同于foo(1, 2, 3, 4)。tuple_apply的设计核心就是定义一个语法级的递归,然后操纵编译器进行递归。 所有的typelist类似的编程基本都遵循这个方式。这很需要点空间想象能力,而且稍不注意就会把编译器搞得死循环或者 直接挂掉。有些文章把这种编程叫做meta program。

现在我们用tuple解决了参数捕获问题,用tuple_apply解决了参数传递问题。尽管这种方式看上去没有bind那么灵活,但是 在功能上是足够用了。下面是closure的使用例子:

struct foo {
	void operator()(int &a, int b) {
		return a += b;
	}
	void sub()(int &a, int b) {
		return a -= b;
	}
};

int main()
{
	foo f;
	int sum = 0;
	closure<void(int)>::instance<foo, int&> c(f, sum);
	c(1), c(2);
	
	closure<void(int)>::instance<foo, int&> c2(f, &foo::sub, sum);
	c2(1), c2(2);

	auto c3 = closure<void(int)>::make(f, sum);
	c3(1), c3(2);

	auto c4 = closure<void(int)>::make(f, &foo::sub, sum);
	c4(1), c4(2);
}

在这里我把所有捕获的变量展开在函数调用的列表的前部,这主要是考虑函数参数的默认值和变参等因素。closure实现 的功能与function和bind类似,在编程技巧上远远比不上bind。但是closure很简单,一个closure完成了类似function和 bind的组合功能。最关键的是我希望内存强控,关于了libll++的内存理念在后面会提到,这真传统 c++是完全不同的。呵呵,至少我不是个传统c++程序员,我没有传统。

对于c++封装来说,我觉得我又制造了个轮子。是不值得提倡的,唯一可以安慰的是这个轮子的制造时有目的的。


2013-9-3 libll++的list实现

对于libll++的基础数据结构的实现,我希望能够秉承以下基本原则:

  1. 尽量保持统一的接口

  2. 对于某类数据结构,尽量考虑它的各种使用情况,而不是只是提供一个粗粒度的统一实现。

  3. 尽量使用宿主去包含管理节点(或者叫数据植入),而不是使用频繁的2次内存分配。

  4. 尽可能使使用功能factory去拼凑,而不是用虚函数去规划。对于拼凑和规划的理解后续的blog会提到。

对于数据结构节点的植入,在前面已经提到过,如果某个数据结构曾经属于某个容器,它就具有了这个容器的基本属性。stl的list是申请一个内存作为节点, 被管理对象也需要分配内存,我称之为2次分配,内存分配的效率是很低的。根据结构植入理论,node就该属于数据结构,而不是单独分配。在libll++的基础 数据结构中,这种模式比比皆是。性能是一点点挤出来的,如果在基础层不去注意,后面再努力也是白搭。libll++虽然是个学习库,但是不是给那种浪漫 轻松的编程准备的,它要去挑战c的效率。

在c++中实现数据植入是个挺麻烦的事情。c++模板参数只接受编译时可确认的类型,类型,常量,还有类成员。类成员是实现这种想法的唯一选择, 但是它的描述相当的不方便,要知道成员类型,要知道类,要知道成员名。不过由于多重继承和c++根本没有定义内存布局,我也只能从c++合法语法角度 出发。总之,我比较抱怨模板的类成员参数描述不方便。

struct foo {
	list_entry _entry;
	clist_entry _centry;
};

ll_list(foo, _entry) alist;
ll_list(foo, _centry) blist;

由于libll++大量使用数据植入,为了简化模板参数,每个libll++的基础数据结构都有个宏来简化它。ll_list就是libll++的list声明简化宏。

前面提到了4种list版本,我不想放弃任何一个。在后面的数据结构中,我想复用前面的封装而不是单独再弄一个实现。简单的说,1把“手术刀”不够, 我需要4把,如果某一天我发现一个适合某种情况的手术刀,我就把它变成5把。

我希望一个统一的list描述方式,因为我不知道什么时候会出现第5把手术刀。另外,模板参数的复杂度,如果把这种描述都放到list本身上,会太复杂。 简单的想法是list是个模板,我想定义一个统一的模板参数列表,通过某些参数决定模板偏例来实现不同的list。经过反复考量,我把偏例的决定权交给 entry。list_entry, clist_entry,是2个不同的class。它们都有一个相同的constexpr来描述entry type。entry type来决定list的偏例实现。 就像上面的例子alist和blist是2个不同的链表。这看上去很诡异,但是它工作的很好。

这种模式在继承和多重继承上,都工作的相当好。比如foo2派生于foo并且多重继承其它类,放到alist或者blist里,完全没有问题。这得益于类成员地址 作为模板参数,如果是宏可能就不太行。

在list和其它基础数据结构中,用到了大量的offsetof_member和containerof_member。我们管理的元素是foo,而不是entry。在这种封装中,我感觉 很幸福,在c中我们用无穷无尽的宏去实现这个,而在c++中这一切看上去那么自然。

对于list的实现代码,我不想太具体去描述,篇幅有限。我只是阐述下它的设计初衷。那些代码都很简单,毕竟list也不是什么复杂的东西。


2013-9-2 list

昨天提到的member.h里的内容并不多。但是,这是我第一次大量开始使用模板、模板偏例和接触c++11,整个过程显得颇为曲折,改了很多版本最终是现在这个 样子。我想以后可能还会有所变化,这样发现更好的方式,哪怕是把整个库推翻了,也在所不惜。呵呵,毕竟这是个学习库。

在处理完member后,我就迫不及待的去开始动手写我的第一个实用数据结构封装,list。

list是个非常非常常用的数据组织方式,几乎每天都要用到,在所有数据结构中它的出场率我认为是最高的。在c语言中,对数据结构的操作往往比较细致。 这跟很多高级语言或者c++的stl有本质的不同。stl的list好像是个循环链表,也仅此而已。而在用c开发应用的时候,常用的list有4种。

slist,一个单纯的单向链表。

可以参阅linux的queue.h的SLIST_相关宏。下面是个简单示例。

struct entry {
    	struct type *slh_first; /* first element */
}
struct head {
       	struct type *sle_next;  /* next element */
}

链表和节点都是一个指针,相当的简单和紧凑。它在使用上限制颇多,只能对链表头进行快速insert和remove操作,如果要删除某个中间节点必须要找到该节点 的上一个节点。用c++的语意来说它只能很好的完成push_front和pop_front。不过,我们应该满足,这么小的开销完成这些已经很不错了。但是在实际应用中 它的适用面相当的广泛。我随便举一些简单的例子。

单向链表往往具有堆栈的特性,当对它进行head操作的时候,数据是FILO的。程序递归的本质是建立在堆栈上的,这个堆栈不只是调用堆栈。有时候,我们可能 想避免调用递归,就会选择一个调用堆栈的替代堆栈。单向链表是个天然的良好替代品,简单,高效,开销小。

有时候,我们会把它用到内存回收算法上。由于任何内存分配器都要考虑对齐问题,比如malloc(1)实际分配的可用内存大小是sizeof(void*)。而slist的entry 只有一个指针,完全符合内存分配器的最小分配。下面的示例对相同大小的内存进行cache。

struct node {
	entry _entry;
};
head recycle_list;
void *alloc() 
{
	node *p = recycle_list.pop_front();
	if (!p) {
		p = malloc(some_size);
	}
	return p;
}
void free(void *p) 
{
	recycle_list.push_front((node*)p);
}

还有一些比较常规的想法,比如我们只对当前状态感兴趣,也就是head指向的那个元素,同时我们想在某个时机去追溯以前的元素。只要不是想直接remove一个 中间元素,slist都是非常不错的选择。在以后obstack的代码中,会看到这个分配器是怎么用slist去管理它的page列表的。一个简单的比喻来总结它,head 指向的是当前,entry的next指向的是过去。

slist的另外一个重要的出场场合是它是hash表的一个基础数据结构。

在这里要说一下使用链表基本意图,使用链表是一定要遍历它的,否则就不存在使用它的意义。但是遍历链表不等于用遍历去定位元素,这在 链表操作中是要尽量避免的。当然事情没有绝对的,如果理论上确认链表足够的短,直接遍历定位还是可行的。就像有些数据库相关文章说到, 在表足够小的时候,不要建索引,索引的维护反倒导致性能下降。我们在讨论元素定位的时候,还要讨论插入和删除时数据结构的效率。想象 一下,一个8元素有序数组2分法查找或者8元素avl查找,最差是3次比对看上去比链表要好很多,但是他们插入删除的开销则高很多。hash表 的散列在理论上决定了链表的长度不会很高,所以它往往是用链表作为散列后数据组织工具。不过在通常意义下,slist只是使用push_front, pop_front, for遍历,尽量不要考虑元素定位和中间元素删除。

有一个面试题,会经常出现在很多公司的考卷上:把一个单向链表反转。这其实就是在考单向链表head操作的stack特性。从原链表上pop_front, push_front到新的链表,就完成了反转过程。而且,没有额外开销。

stlist,一个可以尾部插入的slist增强版

可以参阅linux的queue.h的STAILQ_相关宏。它的entry跟slist完全一样,只是head不同。

struct head {
    	struct type *stqh_first;        /* first element */
    	struct type **stqh_last;        /* addr of last next element */
}

前面提到我们一般对链表的两端进行操作,尽量不去插入或者移除中间元素。这让链表具有了一个特性,时间有序性(我简称为时序性)。链表头部操作都是FILO, 有时候我们不满足于这种情况,我们希望FIFO,也就是实现队列。这需要在slist的基础上多出一个指针来记录队尾的状态。跟普通数据结构书籍上提到的 不同,这个实现的last指向了entry->next的地址,也就是说它是个指针的指针。如果是个空链表或者初始化态,last指向first的地址。这极大的提高 了尾部插入的性能。

slist在插入时序上是逆序,stlist的push_back则是正序,当然了push_front还是逆序。比如说一个xml解析,我们可以把xml node当作一个多叉树的 节点,node->children其实是个链表。如果我们想用单向链表来完成sibling关系的组织,为了保障节点的有序性stlist就是个很好的选择。这往往会 出现在sax解析中,我们需要自己去组织节点关系。

list,最常用且功能强大的list

可以参阅linux的queue.h的LIST_相关宏,它的head和slist完全一样,只是entry多了个指针。

struct entry {
	type *next;
	type **prev; /* addr of prev element's next, 有些代码会用ref这个词而不是prev */
};

list跟slist类似,在本质上只是个单向链表,但是它的entry多了个指针,让它具有了一个特殊的能力:自移除能力。由于prev指向的是上个element 的next指针的地址(如果是push_front,会指向head的first),所以element可以不去遍历list就能把自己从链表中移除出去。甚至element不需要 知道它属于哪个链表,只要它知道它在链表里就能把自己移除出去。这很有意思,slist的特性这里就不再说了,只是讨论remove self的使用。

enum {
	peer_closed,
	peer_connecting,
	peer_connected,
	peer_idle,
	peer_busy,
	peer_linger,
	peer_state_max,
};
struct peer_t {
	int state;
	entry entry;
};
list peer_lists[peer_state_max];
void update_peer_state(peer_t *peer, int state) {
	if (peer->state == state) {
		return;
	}
	list_remove(peer->entry);

	//do something

	peer->state = state;
	peer_lists[state].push_front(peer);
}

上面这个例子有时候用在高性能异步网络开发上。list_remove表现了2种能力,第一不去判断自己属于哪个链表,第二不去遍历去确定上一个是谁, 程序逻辑只是确定它肯定在某个链表里,直接把自己从链表中remove出去。这产生了极高的性能。

如果hash表使用list作为散列后数据结构,那么这个hash表就也具有这种特性。不需要通过key,element直接就能把自己从hash表中remove出去。在 网络开发中,每个peer一般都会有个逻辑id,比如说用户名。当侦测到对端关闭了连接,这时候我们需要清除这个context。常规想法是通过id去删除 相应的hash entry,但是list的这种特性可以让程序员直接完成这个操作而不必通过比对查找。这会产生极高的性能提升。

libll++的默认内存池来自于apr pool,在以后的blog中会提到它。apr pool的chunk管理使用的就是这种list,但是它把它的应用提到了一个新的 高度。一般list的初始化只是把first清0即可。每次push_front,element的prev实际是指向了first的地址。apr pool的chunk管理有个前提,就是 链表里至少要有一个chunk,它的实现示例代码如下:

void pool_init(pool *pool) {
	chunk *new_chunk = some alloc....
	new_chunk->next = null;
	new_chunk->prev = &new_chunk->next;
	pool->chunk_list = new_chunk;
}

这里prev不是指向了head的first,而是指向了自己的next。这导致它完成了一个单向循环链表,而且不丢失remove self能力。我第一次看到这种 设计,给我的第一感觉是老式的拨号盘电话。list的head也不再具有特定的意义,它只是标注最关心的当前的那个节点,也就是“主分配偏好”节点。 这是splay tree的单向循环链表版本,相当的让人叹为观止。

clist,一个万能的双向循环链表

前面3种链表的实现均来自于linux的queue.h,clist则是我随手写了一个实现。以前曾经看过stl的代码,它的list好像就是用循环链表写的。 循环链表不难理解,list和entry是同一个数据结构。

struct entry {
	struct entry *next;
	struct entry *prev;
};

typedef struct entry clist;

这样clist的first就是head->next,clist的last就是head->prev。head在初始化的时候,next和prev都指向自己。它可以push/pop front和 push/pop back,效率都很高。可以正向遍历也可以逆向遍历。在插入删除的时候,不需要判断NULL,效率很不错。它也可以高效完成 remove self。总之吧,它能完成list的所有功能。当然它的内存开销也是最大的,head和entry都是2个指针。

在用c开发的过程中,clist是个较少被用到的数据结构。一方面它的开销比较大,比list的head多一个指针。单个看不算什么,如果一个 很大的hash表,用clist就很可观了。clist的性能略低于list,不过几乎可以忽略不计,关键的问题在于clist需要特定的初始化。在c 里是没有构造函数的,如果忘记初始化你就悲剧了。而list的初始化则简单的多,清0就行了。

clist的出现场合往往是既要remove self,又要能够保障时序的正序。

某些特定的场合clist具有压倒性的优势。比如,基于生命周期session管理模式,这多用于网络应用。 比如,我们建立一个系统,这个系统最大能够cache 1000个session。用户下线后,session不是立刻被清除,而是cache起来。如果,用户 一段时间内重新登录,立刻启用cache的session。当然,用户数是要远远大于cache数的。当新用户登录后,cache已经满了,没有session可 分配给新用户,就会释放掉最不活跃的session。这就需要一个基于时序性的session活跃度记录。通常的做法是,用户有行为,就会把session 从cache list中摘除然后push_front到cache list中,为了高效完成这个功能必须要有remove self能力。cache list的back则是最不活跃 session。这需要cache list能够高效的检索tail元素,并且能够高效的remove它。这种场合下,clist就是舍我其谁的唯一选择。

今天总结

本想今天把list的开发过程写完,没想到只是介绍下以前常用的4种list就用了这么大的篇幅,看来只能下个blog再写完了。

我认为写程序的过程,首先是个数据结构选择的过程。很多类库的封装,隐藏了大量的细节,这是我很长时间以来 不太喜欢c++的一个重要原因。 任何一种结构都会有其所得也必然会有其所失。程序员需要去衡量和推敲每种结构的收益和代价。如果即取得了超高的收益,又避免了较高的 代价,这一般是技术飞跃。很可惜,技术飞跃是很难产生的,至少我还没在自己身上碰到过。


2013-9-1 member

对于c语言工程师来说,使用内存池和嵌入式的数据结构还有宏是相当习惯的事情。某种意义上,这种语法上的缺失保障了c语言程序的高效,就像拥有越少的人越努力。 下面的例子是个很标准的c语言链表声明。

struct foo {
	LIST_ENTRY(foo) _entry
}

这种链表相关宏,在linux的queue.h中。这跟STL的方式有较大的不同,STL是数据算法分离模式,而在c中往往是融合模式。 这在内存处理上有较大的优势。而STL则显得更加学术性。 这种模式阐述了一种本质,代码是静态的,只有数据是动态的。静态说明本质。如果你曾经属于某个容器,你就具有这个容器的特质。 在现实的开发中,这屡屡被验证,甚至几乎不会出现偏差。

如果要在c++中实现c的这种模式,难度是很大的。offsetof在g++中需要编译选项,container_of(这是linux kernel的宏)就更加困难了。

好在c++模板支持类成员地址,这给我的想法提供了一线生机。 对于开发者意图来说,如果用c++,我就不太情愿用宏,除非在语法上无法再简化了。 效率不是我担心的问题,到语法树级别,c和c++可能会有差别,但是差距不大。

为此,我在libll++里写的第一个文件就是member.h,它是一切class member相关操作的基础。 考虑到c++是个多重继承语言,在做这方面的操作的时候,需要有个清晰的认识。

/* typeof_member */
template <typename _T, typename _C>
_T typeof_member_helper(_T _C::*member);
#define typeof_member(x) decltype(ll::typeof_member_helper(x))


/* typeof_container */
template <typename _T, typename _C>
_C typeof_container_helper(_T _C::*member);
#define typeof_container(x) decltype(ll::typeof_container_helper(x))

类成员地址模板很麻烦,c++11里有那么多的aoto也不是空穴来风。这2个宏一个是提取类成员的类型,一个是提取类成员的宿主。 这是个很有意思的事情。

struct foo {
	some_entry _entry;
};

struct foo2 : foo {
	something
};

那么typeof_container(&foo2::_entry)是哪个?是foo,这个要注意。

offsetof_member和containerof_member是c++版的offsetof和container_of。我的核心想法是要在c++上实现c语言级的效率。 stl过于学术性,它的分离性,导致它的过分依赖内存分配效率。

member.h剩下的部分是个很大的宏,它用c++的sfinae去检测类成员类型是否存在。 在很少的情况下,会用到它。但是,我把它当作脑筋急转弯收录到member.h中。 里面的模板偏例应用相当高超,我的第一个模板偏例例子就是这个,我整整看了2天才看明白。 至于能够自己完成,则在一段时间以后。