证明Android、Java和Python的排序算法已损坏(并说明如何修复)

Tim Peters开发了Timsort混合排序算法2002年。它巧妙地结合了合并排序和插入排序的思想,设计用于在实际数据上表现良好。TimSort最初是为Python开发的,但后来通过约书亚·布洛克(Java Collections的设计者也指出大多数二进制搜索算法都被破坏了). TimSort现在被用作Android SDK、Sun的JDK和OpenJDK的默认排序算法。鉴于这些平台的流行,这意味着使用TimSort进行排序的计算机、云服务和手机的数量已达数十亿。

快进到2015年。在我们成功验证了Java中的计数和基数排序实现之后(J.汽车。推理53(2),129-139)使用名为凯伊,我们正在寻找新的挑战。TimSort似乎符合要求,因为它相当复杂且被广泛使用。不幸的是,我们无法证明其正确性。更仔细的分析表明,这很简单,因为TimSort被破坏了,我们的理论考虑最终引导我们找到了找到错误的途径(有趣的是,该错误已经出现在Python实现中)。这篇博客文章展示了我们是如何做到的。

该论文具有完整的分析,并且在我们的网站.

更新(2017年8月):KIT小组使用KeY验证JDK的双透视快速排序实现用作整数或长数组的默认排序算法。TimSort是引用类型数组的默认值。

此博客帖子的结构

  1. Android、Java和Python中的TimSort错误
    1.1在Java中重现TimSort错误
    1.2 TimSort如何工作(原则上)?
    1.3 TimSort错误的演练
  2. 证明TimSort的正确性
    2.1验证系统KeY
    2.2修复及其正式规范
    2.3分析KeY的产量
  3. 建议修复Python和Android/Java Timsort错误
    3.1错误的Python merge_collapse函数
    3.2修正的Python merge_collapse函数
    3.3 Java/Android merge_collapse函数不正确
    3.4修正的Java/Android merge_collapse函数
  4. 结论-我们可以学到什么?

1.Android、Java和Python中的TimSort错误

那么错误是什么?你为什么不先自己复制呢?

1.1在Java中重现TimSort错误

git克隆https://github.com/abtools/java-timsort-bug.git光盘java-timsort-bugjavac*.java语言java测试TimSort 67108864

预期输出

线程“main”java.lang.ArrayIndexOutOfBoundsException异常:40位于java.util。推送运行(TimSort.java:413)在java.util。排序(TimSort.java:240)位于java.util。Arrays.sort(Arrays.java:1438)位于TestTimSort.main(TestTimSport.java:18)

演练视频

1.2 TimSort如何工作(原则上)?

TimSort是一种使用插入排序和合并排序的混合排序算法。

该算法通过查找连续的(不相交的)排序段(从这里称为“运行”),从左到右对输入数组重新排序。如果运行时间太短,则使用插入排序对其进行扩展。生成的运行的长度将添加到名为runLen(运行长度)。每当将新跑步添加到runLen(运行长度),名为mergeCollapse的方法将一直运行到中的最后3个元素runLen(运行长度)满足以下两个条件(“不变量”):

  1. runLen(运行长度)[n-2]runLen(运行长度)[n-1]+runLen(运行长度)【n】
  2. runLen(运行长度)[n-1]>runLen(运行长度)【n】 

这里n是runLen中最后一次运行的索引。其目的是检查前3个变量的不变量runLen(运行长度)事实上保证全部的运行满足了它。在最后,所有运行都被合并,生成输入数组的排序版本。

出于性能原因,为以下对象分配尽可能少的内存至关重要runLen,运行长度,但仍足以存储所有跑步记录。如果所有运行都满足不变量,则每次运行的长度将以指数形式增长(甚至比fibonacci更快:当前运行的长度必须严格大于接下来两次运行的长度之和)。由于运行不会重叠,因此只需要少量运行即可完全覆盖甚至非常大的输入数组。

1.3 TimSort错误的演练

下面的代码片段显示mergeCollapse的实现检查中最后3次运行的不变量runLen(运行长度).

私有的 空隙合并折叠(){  虽然(堆栈大小>1){int n=堆栈大小-2;  如果(n>0&&runLen[n-1]<=runLen[n]+runLen[n+1]){      如果(运行长度[n-1]<运行长度[n+1])n--;合并At(n);    }其他的 如果(runLen[n]<=运行长度[n+1]){合并At(n);    }其他的{中断;//已建立不变量    }  }}

不幸的是,这不足以确保所有运行都满足不变量。假设runLen(运行长度)在要合并Collapse的条目中包含以下内容:

120, 80, 25, 20, 30

在第一次循环迭代中,25和20被合并(因为25<20+30和25<30):

120, 80, 45, 30

在第二次迭代(现在n=3)中,确定最后3次运行满足了不变量,因为80>45+30和45>30,因此mergeCollapse终止。但是mergeCollapse已经完全恢复了不变量:它被打破了120,自120 <80 + 45.

我们的测试生成器网站利用这个问题。它生成了一个输入数组,其中包含许多短运行——太短,这意味着它们不满足不变量——最终导致TimSort崩溃。特别是,因为通过打破不变量,运行的长度可能会增长得比预期慢,超过运行长度.长度需要运行以覆盖整个输入数组,从而在上产生ArrayOutOfBoundsExceptionrunLen(运行长度).

2.证明TimSort的(in)正确性

我们发现,在尝试正式验证TimSort时,mergeCollapse的假定不变量被破坏。幸运的是,我们不仅发现了它坏了,还发现了如何修复它。最后,我们甚至成功地验证了一个正确的不变量。但让我们一步一步来。首先:我们对正式验证的意思是什么?它是如何做到的?

2.1验证系统KeY

由于许多请求,我们发布了一篇关于KeY的简短博客文章。

凯伊是用于顺序Java和JavaCard应用程序的演绎验证平台。它允许证明程序相对于给定规范的正确性。大致来说,规范包括先决条件,也称为需要条款和a后置条件,也称为确保条款。规范附加到方法实现,例如上面的mergeCollapse()。方法的规范也称为合同.

对于排序程序,前提条件可以简单地声明输入是非空数组,后置条件可以声明返回的数组是输入的排序排列。KeY通常证明的是:无论何时使用满足前提条件的输入调用正在验证的方法,该方法都会正常终止,在最终状态下,后置条件为true。这也称为完全正确性,因为可以确保终止。显然,OpenJDK的java.utils。Array.sort()不符合此约定,因为它对于某些输入异常终止。

此外,实例和类不变量用于指定字段值的一般约束。典型属性与数据一致性或边界值有关:

/*@私有不变量@runBase.length==runLen.length&&runBase!=runLen;@*/

这个不变量表示数组runBase和runLen的长度必须相等,并且这两个数组不能指向同一个数组实例,即它们没有别名。不变量的语义意味着每个方法在完成时不仅必须建立其契约的后置条件,还必须建立其(“this”)对象的不变量。

作为KeY中的规范语言Java建模语言使用(JML)。它包含纯Java表达式作为子语言,因此对Java程序员来说很容易学习。它在Java之外的主要扩展是量化表达式(\对于所有人T x,\存在T x)以及合同的合适关键词。JML规范附加到它们在.Java文件中所属的Java声明中。下面是一个用JML指定的Java方法的简单示例:

/*@私有normal_behavior@需要@n>=最小值;@确保@\结果>=最小值/2;@*/私有的 静止的整数/*@纯净的@*/最小运行长度(int n){断言n>=0;整数r=0;//如果任何1位移位,则变为1/*@循环_变量n>=最小值/2&&r>=0&&r<=1;@减少n;@可转让的 \什么都没有;@*/虽然(n>=最小值){r|=(n&1);n>>=1;}返回n+r;}

minRunLength()的约定要求调用方确保只使用大于或等于MIN_MERGE的值调用该方法。在这种情况下(仅在这种情况中),该方法确保它将正常终止(即既不发散也不抛出异常),并且返回的值至少与MIN_MERGE/2一样大。此外,该方法标记为纯净的这意味着该方法不会修改堆。

关键是KeY可以静态地证明该方法适用于任何给定输入。这怎么可能?KeY对正在验证的方法执行符号执行,即使用符号值执行该方法,以便考虑所有可能的执行路径。但这还不够,因为没有固定边界的循环的符号执行(例如mergeCollapse()中的循环,我们不知道stackSize的值)不会终止。为了使循环的符号执行有限,使用了不变推理。例如,上面的方法minRunLength()包含一个使用循环不变量指定的循环。不变量确保在每次循环迭代后,条件n>=MIN_MERGE/2&&r>=0&&r<=1成立,因此可以证明该方法的后置条件。这个减少注释通过提供一个值为非负且严格递减的表达式来证明循环的终止。这个可转让的子句列出了循环可能修改的堆位置。关键字\什么都没有表示没有修改堆位置。事实上:只有局部变量r和值参数n发生了变化。

总之,就正式验证而言,方法合同是不够的。有必要提供合适的循环不变量。要想出一个足够强大的不变量来确保所需的后置条件仍然有效,这可能是一件棘手的事情。如果没有工具支持和自动定理证明技术,就很难为非平凡程序找到正确的循环不变量。事实上,正是在这里,TimSort的设计者出错了。mergeCollapse循环在某些情况下会导致违反TimSort类不变量的以下部分(请参阅第1.3节TimSortbug的演练):

/*@私有不变量@   (\对于所有人整数i;0<=i&&i<堆叠尺寸-4;@运行长度[i]>运行长度[i+1]+运行长度[i+2]))@*/

这表明runLen[i]必须大于两个后续条目的大小(对于0(包括0)内的索引i和stackSize-4(不包括))。由于稍后在方法mergeCollapse上也不会恢复不变量,因此它也不会保留类不变量。因此,循环不变量并不像开发人员假设的那样强大。在KeY的帮助下,我们在正式验证尝试中发现了这一点。如果没有工具支持,几乎不可能做到这一点。

尽管JML与Java非常接近,但它是一种成熟的设计-契约语言,适用于Java程序的完整功能验证。

2.2修复及其正式规范

一个简化mergeCollapse应该满足的合同版本如下所示。

/*@需要@堆栈大小>0&&@runLen[stackSize-4]>runLen[stackSize-3]+runLen-[stackSize-2]@&&runLen[stackSize-3]>runLen[stackSize-2];@确保@   (\对于所有人整数i;0<=i&&i<stackSize-2;@运行长度[i]>运行长度[i+1]+运行长度[i+2])@&&runLen[stackSize-2]>运行长度[stackSize-1]@*/私有的 空隙合并折叠()

中的两个公式确保暗示当mergeCollapse完成时全部的游程满足第1.2节中给出的不变量。我们已经看到,mergeCollapse的当前实现(见第1.3节)并不满足上述合同,因此我们提供了以下与合同相关的固定版本:

私有的 空隙新建合并折叠(){虽然(堆栈大小>1){int n=堆叠大小-2;如果(n>0&&runLen[n-1]<=runLen[n]+runLen-[n+1]||n-1>0&&runLen[n-2]<=运行长度[n]+运行长度[n-1]){如果(运行时间[n-1]<运行时间[n+1])n--;}其他的 如果(n<0||runLen[n]>runLen[n+1]){打破; // 已建立不变量}合并At(n);}}

这个新版本的主要思想是检查不变量是否适用于最后4次跑步在runLen中,而不仅仅是最后3个。我们将确保这足以确保全部的运行满足mergeCollapse完成时的不变量。

证明mergeCollapse固定版本的契约的第一步是找到合适的循环不变量。下面的代码片段显示了循环不变量的简化版本。

/*@循环_变量@  (\对于所有人整数i;0<=i&&i<堆叠尺寸-4;@运行长度[i]>运行长度[i+1]+运行长度[i+2])@&&runLen[stackSize-4]>运行长度[stackSize-3])@*/

直观地看,这个循环不变量表示除最后4个之外的所有运行都满足不变量。将这一点与mergeCollapse的新循环只有在最后4次运行也满足时才终止(使用break语句)的观察结果结合起来,这可以确保所有运行都满足不变量。

2.3分析KeY的产量

当将mergeCollapse的固定版本、其契约和循环不变量作为输入给KeY时,系统象征性地执行循环并生成验证条件:其真值表示mergeCollappse契约得到满足的公式。以下公式(简化)显示了KeY产生的主要证明义务:

生成此验证条件是为了确保在循环终止时满足mergeCollapse的后置条件。这解释了括号中的三个公式:只有当这些公式为真时,才会执行终止循环的break语句。我们以半自动化的方式与KeY正式证明了该公式(以及所有其他验证条件)。在这里,我们简述了这个证明:

证明.公式runLen[stackSize-2]>runLen[stackSize-1]从mergeCollapse后置条件直接从n>=0==>运行长度[n]>运行长度[n+1].

我们证明了另一个公式,

\对于所有整数i;0<=i&&i<stackSize-2;runLen[i]>runLen[i+1]+runLen-[i+2]

根据i值的区分情况:

  • i<stackSize-4:来自循环不变量
  • i=stackSize-4:从n>1开始==>runLen[n-2]>runLen[n-1]+runLen[n]
  • i=stackSize-3:从n>0=>runLen[n-1]>runLen[n]+runLen[n+1]
  • i=stackSize-2:从n开始>=0==>runLen[n]>runLen[n+1]

上述证据表明合并折叠仅在以下情况下终止全部的游程满足不变量。

3.建议修复Python和Android/Java Timsort错误

我们对错误的分析(其中包括对合并折叠)在Java错误跟踪器中提交、审查和接受https://bugs.openjdk.java.net/browse/JDK-8072909.

这个错误至少存在于Java、OpenJDK和OracleJDK的Android版本中:所有这些都共享TimSort的相同源代码。此外,它也存在于Python中。接下来的两部分将并列显示原始版本和固定版本。

如前一节所述,修复背后的想法非常简单:检查runLen中最后4次运行的不变量是否成立,而不是仅最后3次运行的。

3.1错误的Python merge_collapse函数

Timsort for Python(使用PythonAPI以C语言编写)在中提供subversion存储库–算法也在http://svn.python.org/projects/python/trunk/Objects/listsort.txt

TimSort的Java版本是从原始CPython版本移植而来的。该版本还包含错误,用于最多包含2^64个元素的数组。然而,在当前的机器上,在Python版本中不可能触发out-of-bounds错误:它为runLen分配了85个元素,这足以(根据我们在全文中的分析)处理元素少于2^49的数组。相比之下,目前最强大的超级计算机http://en.wikipedia.org/wiki/Tianhe-2总共有大约2^50字节的内存。

/*MergeState的最大条目数*pending运行堆栈。
*这足以对大小约为的数组进行排序
*32*phi**MAX_MERGE_PENDING(最大值)
*其中φ=1.618。85足够大了,*适用于阵列带有2**64个元素。
*/
#定义MAX_MERGE_PENDING 85

合并_折叠(合并状态*毫秒)
{
   结构s_slice(切片)*第页=毫秒->未决;

断言(ms);
   虽然(毫秒->n个> 1) {
Py_size_t编号=毫秒->n个- 2;
       如果(n)> 0 &&p[数字]-1].len(长度)<=p[n].长度+p[数字]+1个].len){
           如果(p[n-1].len(长度)<p[数字]+1个].len)
               --n;
           如果(合并_ at(ms,n)< 0)
               返回 -1;
       }
       其他的 如果(p[n].len<=p[数字]+1个].len){
                如果(合并_ at(ms,n)< 0)
                       返回 -1;
       }
       其他的
           打破;
   }
   返回 0;
}

3.2修正的Python merge_collapse函数

合并_崩溃(合并状态*毫秒)
{
   结构s_slice(切片)*第页=毫秒->悬而未决的;

断言(ms);
   虽然(毫秒->n个> 1) {
Py_size_t编号=毫秒->n个- 2;
       如果(n)> 0  &&p[数字]-1].len(长度)<=p[n].长度+p[数字]+1个].len(长度)       ||(n-1>0&&p[n-2].len(长度)<=p[n].长度+p[数字]-1].len)){
           如果(p[n-1].len(长度)<p[数字]+1个].len)
               --n;
           如果(合并_ at(ms,n)< 0)
               返回 -1;
       }
       其他的 如果(p[n].len)<=p[数字]+1个].len){
                如果(合并_ at(ms,n)< 0)
                       返回 -1;
}
       其他的
           打破;
   }
   返回 0;
}

3.3 Java/Android merge_collapse函数不正确

与第3.1节中Python的错误相同

私有void mergeCollapse(){
while(堆栈大小>1){
int n=堆栈大小-2;
         如果(n>0&&runLen[n-1]<=runLen[n]+runLen-[n+1]){
if(运行长度[n-1]<运行长度[n+1])
n--;
合并At(n);
}else if(runLen[n]<=runLen[n+1]){
合并At(n);
}其他{
中断;//已建立不变量
           }
       }
   }

3.4修正的Java/Android merge_collapse函数

第3.2节中Python的等效修复
[更新26/2:我们更新了下面的代码,因为它来自论文的早期版本。旧代码是等效的,但包含冗余测试和不同的编码风格。有几个人注意到了–感谢您的反馈!]

private void newMergeCollapse(){
while(堆栈大小>1){
int n=堆栈大小-2;
      if((n>=1&&runLen[n-1]<=runLen[n]+runLen-[n+1])
||(n>=2&&runLen[n-2]<=runLen[n]+runLen-1]){
if(运行长度[n-1]<运行长度[n+1])
n--;
}else if(runLen[n]>运行长度[n+1]){
中断;//已建立不变量
           }
合并At(n);
}
   }

4.结论-我们可以学到什么

在尝试验证TimSort时,我们未能建立其实例不变量。通过分析原因,我们发现TimSort的实现中存在一个错误,导致某些输入出现ArrayOutOfBoundsException。我们建议对罪魁祸首方法进行适当的修复(不损失可测量的性能),并且我们已经正式证明修复实际上是正确的,并且此错误不再存在。

除了这个bug的直接问题之外,还可以从这个练习中得出一些观察结果。

  1. 正式方法通常被从业者归类为不相关和/或不可行。事实并非如此:我们发现并修复了每天数十亿用户使用的软件中的一个错误。如我们的分析所示,在没有正式分析和验证工具帮助的情况下,几乎不可能找到并修复此错误。它在Java和Python的核心库例程中已经存在多年了。早期出现的潜在错误本应得到修复,但实际上只会降低其发生的可能性。
  2. 尽管该错误本身不太可能发生,但很容易看出它如何用于攻击。主流编程语言核心库的其他部分可能存在更多未检测到的错误。难道我们不应该在它们造成伤害或被利用之前找到它们吗?
  3. Java开发人员社区对我们的报告的反应有些令人失望:他们没有使用我们的mergeCollapse()的固定版本(并且经过验证!),而是选择“充分”增加分配的runLen。如我们所示,这是不必要的。因此,无论是谁使用java.utils。Collection.sort()被强制过度分配空间。考虑到使用这样一个中心例程的程序运行数量之大,这将导致相当大的能量浪费。至于我们的解决方案没有被采用的原因,我们只能猜测:也许JDK维护人员没有仔细阅读我们的报告,因此不信任和理解我们的修复。毕竟,开放Java是一项社区工作,主要由时间有限的志愿者推动。

我们能从中学到什么?如果我们的工作能够成为正式方法和开放语言框架开发人员之间更紧密合作的起点,我们将非常高兴。形式化方法已经被亚马逊成功采用[链接]和Facebook[链接]. 现代形式规范语言和形式验证工具神秘而超人的学习方式。可用性和自动化不断提高。但我们需要更多的人来尝试、测试和使用我们的正式工具。是的,开始正式指定和验证东西只需要花费一点点努力,但不比学习如何使用编译器框架或构建工具多。我们谈论的是天/周,而不是月/年。威尔接受挑战?


顺颂商祺,
Stijn de Gouw公司侏罗纪腐朽弗兰克·S·德·波尔理查德·布贝尔雷内·哈内尔

致谢:

部分资金来自欧盟项目FP7-610582 ENVISAGE:工程虚拟化服务(http://www.defect-project.eu).

如果没有热情的支持和温和的推动,这个博客是永远写不出来的阿蒙德·特维特!我们还要感谢Behrooz Nobakht提供了显示该错误的视频。

Envisage标志

关于“证明Android、Java和Python的排序算法已损坏(并说明如何修复)

  1. Pingback:Quora公司

留下回复