高性能服务器架构 第三篇

来源:互联网 发布:淘宝可以无条件退货吗 编辑:程序博客网 时间:2024/04/30 01:17

转自:http://www.doserv.com/article/2012/0831/5299117.shtml

 

内存分配(Memory Allocator)

申请和释放内存是应用程序中最常见的操作, 因此发明了许多聪明的技巧使得内存的申请效率更高. 然而再聪明的方法也不能弥补这种事实: 在很多场合中,一般的内存分配方法非常没有效率。所以为了减少向系统申请内存,我有三个建议.

建议一是使用预分配. 我们都知道由于使用静态分配而对程序的功能加上人为限制是一种糟糕的设计. 但是还是有许多其它很不错的预分配方案. 通常认为,通过系统一次性分配内存要比分开几次分配要好,即使这样做在程序中浪费了某些内存. 如果能够确定在程序中会有几项内存使用,在程序启动时预分配就是一个合理的选择. 即使不能确定,在开始时为请求句柄预分配可能需要的所有内存也比在每次需要一点的时候才分配要好. 通过系统一次性连续分配多项内存还能极大减少错误处理代码. 在内存比较紧张时,预分配可能不是一个好的选择,但是除非面对最极端的系统环境,否则预分配都是一个稳赚不赔的选择.

建议二是使用一个内存释放分配的lookaside list(监视列表或者后备列表). 基本的概念是把最近释放的对象放到链表里而不是真的释放它,当不久再次需要该对象时,直接从链表上取下来用,不用通过系统来分配. 使用lookaside list的一个额外好处是可以避免复杂对象的初始化和清理.

通常,让lookaside list不受限制的增长,即使在程序空闲时也不释放占用的对象是个糟糕的想法. 在避免引入复杂的锁或竞争情况下,不定期的“清扫"非活跃对象是很有必要的. 一个比较妥当的办法是,让lookaside list由两个可以独立锁定的链表组成: 一个"新链"和一个"旧链".使用时优先从"新"链分配,然后最后才依靠"旧"链. 对象总是被释放的"新"链上。清除线程则按如下规则运行:

1. 锁住两个链

2. 保存旧链的头结点

3. 把前一个新链挂到旧链的前头

4. 解锁

5. 在空闲时通过第二步保存的头结点开始释放旧链的所有对象

使用了这种方式的系统中,对象只有在真的没用时才会释放,释放至少延时一个清除间隔期(指清除线程的运行间隔),但同常不会超过两个间隔期. 清除线程不会和普通线程发生锁竞争. 理论上来说,同样的方法也可以应用到请求的多个阶段,但目前我还没有发现有这么用的.

使用lookaside lists有一个问题是,保持分配对象需要一个链表指针(链表结点),这可能会增加内存的使用. 但是即使有这种情况,使用它带来的好处也能够远远弥补这些额外内存的花销.

第三条建议与我们还没有讨论的锁有关系. 先抛开它不说. 即使使用lookaside list,内存分配时的锁竞争也常常是最大的开销. 解决方法是使用线程私有的lookasid list, 这样就可以避免多个线程之间的竞争. 更进一步,每个处理器一个链会更好,但这样只有在非抢先式线程环境下才有用. 基于极端考虑,私有lookaside list甚至可以和一个共用的链工作结合起来使用.

锁竞争(Lock Contention)

高效率的锁是非常难规划的, 以至于我把它称作卡律布狄斯和斯库拉(参见附录). 一方面, 锁的简单化(粗粒度锁)会导致并行处理的串行化, 因而降低了并发的效率和系统可伸缩性; 另一方面, 锁的复杂化(细粒度锁)在空间占用上和操作时的时间消耗上都可能产生对性能的侵蚀. 偏向于粗粒度锁会有死锁发生,而偏向于细粒度锁则会产生竞争. 在这两者之间,有一个狭小的路径通向正确性和高效率,但是路在哪里?

由于锁倾向于对程序逻辑产生束缚,所以如果要在不影响程序正常工作的基础上规划出锁方案基本是不可能的. 这也就是人们为什么憎恨锁,并且为自己设计的不可扩展的单线程方案找借口了.

几乎我们每个系统中锁的设计都始于一个"锁住一切的超级大锁",并寄希望于它不会影响性能,当希望落空时(几乎是必然), 大锁被分成多个小锁,然后我们继续祷告(性能不会受影响),接着,是重复上面的整个过程(许多小锁被分成更小的锁), 直到性能达到可接受的程度. 通常,上面过程的每次重复都回增加大于20%-50%的复杂性和锁负荷,并减少5%-10%的锁竞争. 最终结果是取得了适中的效率,但是实际效率的降低是不可避免的. 设计者开始抓狂:"我已经按照书上的指导设计了细粒度锁,为什么系统性能还是很糟糕?"

在我的经验里,上面的方法从基础上来说就不正确. 设想把解决方案当成一座山,优秀的方案表示山顶,糟糕的方案表示山谷. 上面始于"超级锁"的解决方案就好像被形形色色的山谷,凹沟,小山头和死胡同挡在了山峰之外的登山者一样,是一个典型的糟糕爬山法;从这样一个地方开始登顶,还不如下山更容易一些。那么登顶正确的方法是什么?

首要的事情是为你程序中的锁形成一张图表,有两个轴:

图表的纵轴表示代码. 如果你正在应用剔出了分支的阶段架构(指前面说的为请求划分阶段),你可能已经有这样一张划分图了,就像很多人见过的OSI七层网络协议架构图一样.

图表的水平轴表示数据集. 在请求的每个阶段都应该有属于该阶段需要的数据集.

现在,你有了一张网格图,图上每个单元格表示一个特定阶段需要的特定数据集. 下面是应该遵守的最重要的规则:两个请求不应该产生竞争,除非它们在同一个阶段需要同样的数据集. 如果你严格遵守这个规则,那么你已经成功了一半.

一旦你定义出了上面那个网格图,在你的系统中的每种类型的锁就都可以被标识出来了. 你的下一个目标是确保这些标识出来的锁尽可能在两个轴之间均匀的分布, 这部分工作是和具体应用相关的. 你得像个钻石切割工一样,根据你对程序的了解,找出请求阶段和数据集之间的自然"纹理线". 有时候它们很容易发现,有时候又很难找出来,此时需要不断回顾来发现它. 在程序设计时,把代码分隔成不同阶段是很复杂的事情,我也没有好的建议,但是对于数据集的定义,有一些建议给你:

如果你能对请求按顺序编号,或者能对请求进行哈希,或者能把请求和事物ID关联起来,那么根据这些编号或者ID就能对数据更好的进行分隔.

有时,基于数据集的资源最大化利用,把请求动态的分配给数据,相对于依据请求的固有属性来分配会更有优势. 就好像现代CPU的多个整数运算单元知道把请求分离一样.

确定每个阶段指定的数据集是不一样的是非常有用的,以便保证一个阶段争夺的数据在另外阶段不会争夺.

如果你在纵向和横向上把"锁空间(这里实际指锁的分布)" 分隔了,并且确保了锁均匀分布在网格上,那么恭喜你获得了一个好方案. 现在你处在了一个好的登山点,打个比喻,你面有了一条通向顶峰的缓坡,但你还没有到山顶. 现在是时候对锁竞争进行统计,看看该如何改进了. 以不同的方式分隔阶段和数据集,然后统计锁竞争,直到获得一个满意的分隔. 当你做到这个程度的时候,那么无限风景将呈现在你脚下.

 

原创粉丝点击