Timsort事件告诉我们……

排序是计算机工作者最熟悉的问题,数据结构课程介绍了一批排序算法。但是,每个算法都有弱项,权衡利弊,传统上实际使用最多的还是快速排序或其变形。实际上,快速排序有三大缺点:糟糕的最坏时间复杂性(O(n^2)),不具有稳定性,且不具有适应性(或说具有反适应性,被处理数组越接近排序状态时排序越慢)。受到偏爱的原因就是它在实际使用中“比较快”,快就是好。


现在情况有些变化,因为出现了Timsort。这是一种正在崛起、使用越来越广泛的排序算法。有人说该算法源自Peter McIlroy的想法和设计,但实际上McIlroy只是对归并和插入技术的结合做了些理论分析,并没有给出实际算法。Tim Peters在2002年为Python开发基础设施时结合使用归并和插入,并辅以另外一些技术手段,实现了后来被人称为Timsort的排序算法(程序)。该算法的基本工作过程大致是:

  1.  扫描数组,确定其中的单调上升段和严格单调下降段,将严格下降段反转;
  2. 定义最小基本片段长度,短于此的单调片段通过插入排序集中为长于此的段;
  3. 反复归并一些相邻片段,过程中避免归并长度相差很大的片段,直至整个排序完成,所用分段选择策略可以保证O(n log n)时间复杂性。

在实际算法中,上面几项工作交织在一起进行,还做了许多调整和优化。算法的详情见Python文档(https://hg.python.org/cpython/file/tip/Objects/listsort.txt)。


Timsort避免了快速排序的几个重要缺点:其最坏情况时间复杂性是O(n log n)(没有快速排序的极端慢速情况),具有稳定性和适应性。最重要的是,通过各种数据试验,结论是Timsort的性能极好,很多试验中它的表现优于快速排序。由于这些情况,Java从SE7开始用Timsort取代了原排序算法(这一替代并非向后兼容,可能导致已有程序崩溃),Android系统平台和GNU Octave也用Timsort作为标准排序函数。这一趋势还可能继续,会有更多的语言和系统采用(或改用)这种排序算法作为基础设施。


故事说到这里都是好消息,现在应该转到本文的主题了。


事情要从欧洲的几个形式化方法研究者说起。经过多年工作,这些研究者开发了一种专用于Java程序(实际上是JML程序,在普通Java程序里加入JML规范)的形式化验证工具KeY,并用KeY系统成功验证了一些程序,包括计数排序和基数排序等。为进一步检验KeY的价值,他们找到Java标准库的Timsort,觉得这是一个很合适的验证对象:它比较复杂,规模不是太大,而且正在作为Java语言的基础设施被广泛使用,其重要性毋庸置疑。研究者试着用KeY证明Timpsort的正确性,工作一段无法成功而转去检查原因,通过仔细分析发现Timpsort的基本归并循环不能维持证明中定义的不变式。进一步深入分析代码发现了一个错误。实际情况是,对一些形式比较特殊的数组,Java的Timsort排序函数必定崩溃。研究者检查了Python的原实现,发现也存在同样错误。他们提出了新的不变式,按这一不变式修改程序就能消除错误。详情见http://envisage-project.eu/proving-android-java-and-python-sorting-algorithm-is-broken-and-how-to-fix-it/。


这里简单说明错误的一些细节。前面说过,在实际算法里,单调分段的识别、构造(通过反转或插入排序)和归并交织进行,因此需要确定每步应归并的相邻分段。Tim Peters通过分析提出了一套选择准则(归并循环的不变式)。归并过程中需要用一个栈缓存待处理分段,显然,栈的最大可能项数与被排序数组的规模有关。为保证效率,Tim Peters根据循环不变式推算出栈的长度(类似于斐波那契序列的逆函数),在开始排序前分配空间。可惜的是,由于认识的局限性,Timsort执行中可能打破上述不变式,出现更多片段需要缓存的情况。当数组中数据具有某些特殊分布情况时,运行中会出现栈溢出(数组越界)。特别的,当时的程序在数组长度为65536时就可能出现栈溢出。Java移植代码直接采用原代码的计算规则,对同样的数组也会出问题。


有趣的是,Java SE7发布后有用户报告了这个错误,指出对某些数组会出现溢出(当时栈容量为20,被排序数组包含65536个元素),而且可重现。开发人员不知道如何修复,就把这时的栈容量扩充到24。后又有报告说对包含67108864个元素的数组,当时设置为40个元素的栈也可能溢出。开发人员再扩容,又发现对1073741824个元素的数组可能出现溢出情况。这次开发人员做了些分析,认为“85 is ridiculously large enough, good for an array with 2**64elements”,认为可以把栈长度设为85。错误报告到Python开发团队,Tim Peters认为这是个“低”优先级错误,硬件不支持出错情况的数组规模。后来其他开发者还是根据KeY研究者的建议改了程序,也修改了栈分配方法,把栈长度简单设置为85。实际上,该程序对更大的数组还是可能出错,但他们不想管了。


故事讲完了,它给我们提供了什么值得注意的启示呢?


首先,似乎很难把Timsort出现的问题归结为测试不充分。请设想一下,在不知错误的情况下,测试人员有可能做出能触发错误的用例吗?无论是人工设计还是随机生成,都不大可能造出正好能触发错误的65536个元素的数组。目前研究界和业界正在研究测试用例自动生成方法,都是设法根据代码拼凑出触发错误的用例,有关技术不可能解决这里的问题。实际上,对各种通用的底层基础模块和库模块,很难构造出足够丰富的测试用例集。


另一个事实也很有趣:用户报告Timsort有错后,开发人员完全无能力弄清错误的原因,也不知道应该怎样修改程序。实际中这种情况绝不罕见。接到错误报告后,有多少维护人员能找到错误的真正根源,从根本上修正错误?实际维护人员常常没有这种能力,只能简单地打补丁,就像在Timsort里把20改为24。进一步说,他们对修改的潜在影响也毫无预期。正因为此,我们看到复杂软件系统里的错误层出不穷,永无止境。可以想象,在我们深度依赖的各种基础设施中还隐藏着大量致命错误,不知道什么时候爆发出来。


Timsort的基本思想不太复杂,不难根据有关想法写出一个朴素实现,保证其正确性(能正确排序)、时间效率要求(O(n log n))和存储安全性(不出现数组越界等)。但这种朴素实现需要O(n) 栈空间。Tim Peters希望做出一个高度优化的程序,最重要的一个优化目标就是处理实际数据时能少用存储。所做优化导致完成不同工作的代码高度纠缠,也使Timsort主循环变得很复杂,需要维持一个很复杂的不变式。从Timsort的代码和说明中可以看到实现者做的优化,其中有许多很值得称道的想法。但也正是这些优化导致了隐藏很深的错误,这一事实再次印证了Knuth的名言:“不成熟的优化是万恶之源”。然而,为了底层模块和关键代码的效率,软件人员常常需要做这种代码调优。Timsort的事实再次说明,用一组测试用例来检查优化版本与原版本等价,这种常用技术并不可靠。


Tim Peters是国际知名的软件开发专家,经验非常丰富。从有关Timsort的文档中可以看到其工作态度之慎重,思考之认真和缜密。然而,即使如此有经验并下了如此功夫,也不能避免犯错误。专家们尚且如此,更不用说只有一般经验和专业水平的程序员了。随着程序变得越来越复杂,人工思考和推导的完整性和可信程度也会快速衰败。


Timsort的设计和实现也表现出循环不变式的重要作用。没有循环不变式的指导,人们根本不可能写出这样复杂的算法。Tim Peters充分理解这一点,他通过细致分析设计出主归并循环的“不变式”,并仔细论证。然而,如此认真工作还是没有避免错误。这个事实说明,开发经验、深入分析和认真工作,仍不足以支持复杂程序的开发活动。作为故事的另一面,我们也看到,计算机辅助的形式化分析和推导确实有效。KeY的研究者们并没有那么仔细地研究Timsort算法,他们只是按部就班地在Timsort代码里加入形式化规范,在辅助证明系统的帮助下做严格推导,就发现了错误并确定了解决方法。


Timsort的故事告诉我们,目前软件开发活动的技术基础依然非常脆弱。特别是对复杂的效率要求高的基础设施、底层软件和最常用的库,非常缺乏保证质量的有效技术手段和工具。即使是经过长期运行检验、维护、排除错误的软件,难免还存在各种隐藏很深的错误。这些年不时爆出的事件也说明了这种说法不是危言耸听。解决基础软件、底层软件和常用库的脆弱性问题,需要采用各种技术手段和其他手段。形式化方法、严格的推理和验证已经在这方面表现出重要的作用和价值,应该引起软件工作者的重视。
  • 9
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值