优化 Perl 代码

坦率地说,我喜欢 Perl。也许是用太久了,有一定的感情,Perl编写了很多管理脚本,并编写了一些游戏。通常使用 Perl 是为了节省时间,并为我自动检查一些服务器信息,甚至使用 Perl 来自动编写邮件WebMail。由于使用 Perl 让一切都变得如此简单,因此很容易忘记对其进行优化。许多情况下,这并不是世界末日。因此多花几个毫秒来查询或处理日志文件又有什么关系呢?

然而,这些相同的懒惰习惯在小程序中可能只是多花费几毫秒的时间,但是在大规模开发项目中,多耗费的时间就变成数倍了。这就是 Perl 的 TMTOWTDI (There's More Than One Way To Do It) 颂歌开始变坏的地方。如果您需要很快的速度,不管有多少种慢速的方法,但是可能只有一两种方法可以达到最快的结果。最终,即使您可以得到预期的结果,但草率的编程还是会导致拙劣的性能。因此,在本文中,我们将介绍一些可以用来取消 Perl 应用程序额外执行周期的关键技术。

优化方法

首先,有必要随时记住 Perl 是一门编译语言程序。您所编写的源代码是转换为执行的字节码时进行编译的。字节码本身就有一个指令范围,所有的指令都是使用高度优化的 C 语言编写的。然而,即使在这些指令中,有些操作仍然可以进行优化,得到相同的结果,但是执行的效率更高。总体来讲,这意味着您要使用逻辑序列与字节码的组合,后者是从前者中生成的,最终会影响性能。某些相似操作之间性能的差距可能非常巨大。现在让我们考虑清单 1 和清单 2 中的代码。这两段代码都是将两个字符串连接为一个字符串,一个是通过普通的连接方法实现,而另外一个是通过生成一个数组并使用 join 方法进行连接。

清单 1. 连接字符串,版本 1
my $string = 'abcdefghijklmnopqrstuvwxyz';
my $concat = '';
foreach my $count (1..999999)
{
    $concat .= $string;
}
清单 2. 连接字符串,版本 2
my $string = 'abcdefghijklmnopqrstuvwxyz';
my @concat;
foreach my $count (1..999999)
{
    push @concat,$string;
}
my $concat = join('',@concat);

执行清单 1 需要 1.765 秒,而执行清单 2 则需要 5.244 秒。这两段代码都生成一个字符串,那么是什么操作耗费了这么多时间呢?传统上讲(包括 Perl 开发组),我们都认为连接字符串是一个非常耗时的过程,因为我们需要为变量扩展内存,然后将字符串及新添加的内容复制到新的变量中。另一方面,向一个数组中添加一个字符串应该非常简单。我们还添加了使用 join() 复制连接字符串的问题,这会额外增加 1 秒的时间。

这种情况下的问题在于,将字符串 push() 到一个字符串中非常耗时;首先,我们要执行一次函数调用(这会涉及压栈和出栈操作),还要添加额外的数组管理工作。相反,连接字符串操作非常简单,只是运行一个操作码,将一个字符串变量附加到一个现有的字符串变量中即可。即使设置数组的大小来减少其他工作的负载(使用 $#concat = 999999),也只能节省 1 秒钟的时间。

上面这个例子是非常极端的一个例子,在使用数组时,速度可以比使用字符串快数倍;如果需要重用一个特定的序列,但要使用不同的次序或不同的空格字符,那么这就是很好的一个例子。当然,如果想重新排列序列的内容,那么数组也非常有用。顺便说一下,在这个例子中,产生一个重复 999,999 次字符的字符串的更简便方法是:

$concat = 999999 x 'abcdefghijklmnopqrstuvwxyz';

这里介绍的很多技术单独使用都不会引起多大的差异,但是当您在应用程序中组合使用这些技术时,就可以在 Perl 应用程序中节省几百毫秒的时间,甚至是几秒的时间。

使用引用

如果使用大型数组或 hash 表,并使用它们作为函数的参数,那么应该使用它们的一个引用,而不应该直接使用它们。通过使用引用,可以告诉函数指向信息的指针。如果不使用引用,就需要将整个数组或 hash 表复制到该函数的调用栈中,然后在函数中再次对其进行复制。引用还可以节省内存(这可以减少足迹和管理的负载),并简化您的编程。

字符串处理

如果在程序中使用了大量的静态字符串,例如,在 Web 应用程序中,那么就要记得使用单引号,而不是使用双引号。双引号会强制 Perl 检查可能插入的信息,这会增加打印字符串的负载:

print 'A string','another string',"\n";

我使用逗号来分隔参数,而不是使用句号将这些字符串连接在一起。这样可以简化处理过程; print 只是简单地向输出文件发送一个参数。连接操作会首先将字符串连接在一起,然后将其作为一个参数打印。

循环

正如您已经看到的一样,带有参数的函数调用的开销很高,因为要想让函数调用正常工作,Perl 只能将这些参数压入调用堆栈之后,再调用函数,然后从堆栈中再次接收响应。所有这些操作都需要尽避免我们不需要的负载和处理操作。由于这个原因,在一个循环中使用太多函数调用不是个好主意。同样,这减少了比较的次数。循环 1,000 次并向函数传递信息会导致调用该函数 1,000 次。要解决这个问题,只需要调整一下代码的顺序即可。我们不使用 清单 3 的格式,而是使用清单 4 中的格式。

清单 3. 循环调用函数
foreach my $item (keys %{$values})
{
    $values->{$item}->{result} = calculate($values->{$item});
}
sub calculate
{
    my ($item) = @_;
    return ($item->{adda}+$item->{addb});
}
清单 4. 函数使用循环
calculate_list($values);
sub calculate_list
{
    my ($list) = @_;
    foreach my $item (keys %{$values})
    {
        $values->{$item}->{result} = ($item->{adda}+$item->{addb});
    }
}

更好的方式是在这种简单的计算中或者在简单的循环中使用 map

map { $values->{$_}->{result} = $values->{$_}->{adda}+$values->{$_}->{addb} } keys %{$values};

还要记住的是,在循环中,每次反复都是在浪费时间,因此不要多次使用相同的循环,而是要尽量在一个循环中执行所有的操作。

排序

另外一种有关循环的通用操作是排序,特别是对 hash 表中的键值进行排序。在这个例子中嵌入对列表元素进行排序的操作是非常诱人的,如清单 5 所示。

清单 5. 不好的排序
my @marksorted = sort {sprintf('%s%s%s',
      $marked_items->{$b}->{'upddate'},
      $marked_items->{$b}->{'updtime'},
      $marked_items->{$a}->{itemid}) <=>
      sprintf('%s%s%s',
            $marked_items->{$a}->{'upddate'},
            $marked_items->{$a}->{'updtime'},
            $marked_items->{$a}->{itemid}) } keys %{$marked_items};

这是一个典型的复杂数据排序操作,在该例中,要对日期、时间和 ID 号进行排序,这是通过将数字连接在一个数字上,然后对其进行数字排序实现的。问题是排序操作要遍历列表元素,并根据比较操作上下移动列表。这是一种类型的排序,但是与我们已经看到的排序的例子不同,它对每次比较操作都调用sprintf。每次循环至少执行两次,遍历列表需要执行的精确循环次数取决于列表最初排序的情况。例如,对于一个 10,000 个元素的列表来说,您可能会调用 sprintf 超过 240,000 次。

解决方案是创建一个包含排序信息的列表,并只生成一次排序域信息。参考清单 5 中的例子,我将这段代码改写为清单 6 的代码。

清单 6. 较好的排序
map { $marked_items->{$_}->{sort} = sprintf('%s%s%s',
      $marked_items->{$_}->{'upddate'},
      $marked_items->{$_}->{'updtime'},
      $marked_items->{$_}->{itemid}) } keys %{$marked_items};
my @marksorted = sort { $marked_items->{$b}->{sort} <=>
      $marked_items->{$a}->{sort} } keys %{$marked_items};

现在不需要每次都调用 sprintf,对 hash 表中的每一项,只需要调用一次该函数,就可以在 hash 表中生成一个排序字段,然后在排序时就可以使用这个排序字段了。排序操作只能访问排序字段的值。您可以将对包含 10,000 个元素的 hash 表的调用从 240,000 次减少到 10,000 次。这取决于最初对排序部分执行的操作,但是如果使用清单 6 中的方法,则可能节省一半的时间。

如果使用从数据库(例如 MySQL 或类似的数据库)查询的结果来构建 hash 表,并在查询中使用使用排序操作,然后按照这个顺序来构建 hash 表,那么就无需再次遍历这些信息来进行排序。

使用简短的逻辑

与排序相关的是如何遍历可选值列表。使用很多 if 语句耗费的时间可能会令人难以置信。例如,请参阅清单 7 中的代码。

清单 7. 进行选择
if ($userchoice > 0)
{
    $realchoice = $userchoice;
}
elsif ($systemchoice > 0)
{
    $realchoice = $systemchoice;
}
else
{
    $realchoice = $defaultchoice;
}

这段代码除了浪费篇幅之外,其结构也有两个问题。首先,从编程的观点来看,它从来不会检查变量值是否有效,如果激活警告信息,这就是要注意的一个因素。其次,只有在到达每个选项时,才会对每个选项进行检查,这是一种浪费,因为比较操作(尤其是对字符串进行比较操作)非常耗时。这两个问题都可以使用简短逻辑进行解决。

如果使用逻辑 || 操作符,Perl 就会使用后面的第一个真值,顺序从左到右取值。一旦发现一个有效值之后,它就不会继续处理其他值。另外,由于 Perl 正在寻找一个真值,因此它也可以忽略未定义的值,而不会抱怨这些值尚未定义。因此您可以将上面的代码重新改写成以下这样:

$realchoice = $userchoice || $systemchoice || $defaultchoice;

如果 $userchoice 值为真,那么 Perl 就不会查看其他变量。如果 $userchoice 为假(请参阅表 1),那么 Perl 就需要检查 $systemchoice 的值,依此类推,直到最后一个值,这个值通常都会被使用,不管它是真还是假。

表 1. $userchoice 值

使用 AutoLoader

在执行 Perl 脚本过程中,最耗时的部分是将源代码编译成可以真正执行的字节码。对于一个没有使用外部模块的小脚本来说,这个过程可能需要几毫秒的时间。而启动一个包括很多外部模块的 Perl 脚本,就会增加时间。原因是 Perl 对模块的操作远远不止导入文本并使用相同的编译步骤运行这么简单。它会将 200 行的脚本快速转换为 10,000 或 20,000 行。结果是您增加了编译过程初始阶段的工作,在这时,脚本还不能执行任何任务。

在正常执行脚本时,执行这些模块中定义的函数可能只需要使用 10% 甚至 5% 的时间。因此为什么不在启动脚本时加载这些模块呢?解决方案是使用 AutoLoader,其作用类似于 Perl 模块的动态加载程序。它使用了 AutoSplit 系统生成的文件,可以将一个模块划分成单个函数。当通过 use 加载模块时,要做的工作是加载该模块的 stub 代码。只有在调用 AutoLoader 加载的模块中包含的函数、然后为该函数加载并编译代码时,才会执行这项操作。结果是,把加载了模块的 20,000 个行的脚本又转换回 200 行的脚本,这将加速最初的加载和编译过程。

只是在程序中使用 AutoLoader 系统来代替预加载,就可以节省 2 秒钟的时间。这种方法很容易使用,只需将清单 8 所示的模块修改为清单 9 的格式,然后使用 AutoSplit 来创建需要的加载函数即可。注意,并不需要再使用 Exporter;AutoLoader 会自动处理加载单个函数的过程,而不用您显式地将其列出。

清单 8. 标准模块
package MyModule;
use OtherModule;
require 'Exporter';
@EXPORT = qw/MySub/;
sub MySub
{
   ...
}
1;
清单 9. 自动加载模块
package MyModule;
use OtherModule;
use AutoLoader 'AUTOLOAD';
1;
__END__
sub MySub
{
   ...
}

这里的主要区别是,您希望自动加载的函数不再是在模块的包中定义,而是在模块末尾的数据段(在__END__ 标志之后)定义。AutoSplit 会将在此处定义的所有函数放到特殊的 AutoLoader 文件中。要分割该模块,需要使用下面的命令行:

perl -e 'use AutoSplit; autosplit($ARGV[0], $ARGV[1], 0, 1, 1)' MyModule.pm auto

使用字节码和编译器后端

使用编译器有三种方法:字节码、完全编译或简单地作为一个调试/优化工具。前两种方法依赖于将原来的 Perl 源代码转换为编译好的字节码格式,并存储预编译的版本,以便以后执行。最好是通过 perlcc命令使用这种方法。这两种模式使用相同的基本模型,但是产生的最终结果不同。在字节码的模式中,最终编译好的字节码被写入另外一个 Perl 脚本中。该脚本由 ByteLoader 前同步码组成,编译好的代码以字节字符串的形式保存。要创建字节码的格式,请在 perlcc 命令中使用 -B 选项。如下所示:

$ perlcc -B script.pl

这会创建一个文件 a.out。然而,输出结果的 Web 界面并不友好。结果文件可以在任何平台上作为 Perl 可执行程序执行(Perl 字节码是与平台无关的):

$ perl a.out

这样做的优点是节省了 Perl 每次将脚本从源代码编译成字节代码的时间。相反,它只运行生成的字节码。这与 Java 编译的过程类似,实际上与任何语言的真正编译步骤相同。在短小的脚本中,特别是哪些使用了很多外部模块的脚本中,您可能不会注意到速度有很大的提升。在大型的没有使用外部模块的“独立”脚本中,您应该会看到速度有明显的提升。

完全编译模式差不多也是这样,除了它会产生一个 Perl 脚本,其中嵌入了一些编译好的字节码。 perlcc会产生一些嵌入 C 代码的版本,然后将其编译成完全独立的可执行代码。虽然这个代码不是跨平台兼容的,但是它允许您分发一个 Perl 的可执行脚本,而不会泄漏出源代码。然而,需要注意的是,它并没有将 Perl 代码转换为 C 代码,只是将 Perl 字节码嵌入一个基于 C 的应用程序中。这实际上是 perlcc 的默认格式,因此,一个简单的 $ perlcc script.pl 会创建并编译一个独立的程序 a.out

更少为人知的一种调试和优化代码的方法是,在 Perl 编译器中使用 "编译器后端"。

实际上是编译器后端驱动了 perlcc 命令,该命令可以使用一个后端模块直接创建一个 C 源代码文件,您可以查看该文件的内容。Perl 编译器通过使用所生成的字节码,以一种不同的方式输出结果。因为您正在查找在编译过程中生成的字节码,所以可以看到代码经过 Perl 自己内部优化后的结果。了解 Perl 的操作码,就可以开始判断哪里可能是瓶颈。从调试的观点来看,可以使用后端,例如 Terse (它自己就是 Concise 中的一个程序)和 Showlex。在清单 10 中,可以看到原来的清单 1 经过 Terse 后端处理后的样子。

清单 10. 使用 Terse 研究字节码
LISTOP (0x306230) leave [1]
    OP (0x305f60) enter
    COP (0x3062d0) nextstate
    BINOP (0x306210) sassign
        SVOP (0x301ab0) const [7] PV (0x1809f9c) "abcdefghijklmnopqrstuvwxyz"
        OP (0x305c30) padsv [1]
    COP (0x305c70) nextstate
    BINOP (0x305c50) sassign
        SVOP (0x306330) const [8] PV (0x180be60) ""
        OP (0x306310) padsv [2]
    COP (0x305f20) nextstate
    BINOP (0x305f00) leaveloop
        LOOP (0x305d10) enteriter [3]
            OP (0x305cf0) null [3]
            UNOP (0x305cd0) null [141]
                OP (0x305e80) pushmark
                SVOP (0x3065d0) const [9] IV (0x180be30) 1
                SVOP (0x3065f0) const [10] IV (0x1801240) 999999
        UNOP (0x305ee0) null
            LOGOP (0x305ec0) and
                OP (0x305d50) iter
                LISTOP (0x305e60) lineseq
                    COP (0x305e10) nextstate
                    BINOP (0x305df0) concat [6]
                        OP (0x305d70) padsv [2]
                        OP (0x305dd0) padsv [1]
                    OP (0x305ea0) unstack
concat1.pl syntax OK

其他工具

我们已经介绍的内容全部都是针对组成应用程序的代码的。虽然这是大部分问题之所在,但是还有一些工具和系统,可以用来帮助您判断和定位代码中最终有助于提高性能的一些问题。

warnings/strict

这是一个常见建议,但它们的确有所不同。使用 warnings 和 strict 标记可以确保不会出现可笑的变量使用、输入错误和其他问题。在脚本中使用这两个标记可以帮助您减少各种问题,其中很多都可能成为性能的瓶颈之源。这些标记引起的常见错误是:不正确的引用和取消引用,使用未定义的值,以及帮助判断输入错误,例如未使用的值或未定义的函数。

然而,所有这些帮助都可能会造成一些性能的损耗。我在编程和调试时保留了 warnings 和 strict,但在真正使用时,去掉了这些内容。这样不会节省太多时间,但能节省几毫秒的时间。

Profiling

Profiling 是一个非常有用的优化代码的工具,但是它的作用是判断问题的位置;它并不能真正指出潜在的问题是什么,以及如何解决这些问题。而且,由于 profiling 依赖于监视应用程序不同部分的多次执行情况,所以有时候,对问题出在哪儿以及问题的解决方法,它会给出一些错误的建议。

然而,profiling 仍然非常有用,通常它都是优化过程的一个关键步骤。不要仅仅依靠它告诉您自己应该知道的所有内容。

调试

对于我来说,一个优化不好的程序就意味着该程序有 bug。反之亦然:bug 通常都会引起性能的问题。典型的例子是错误的取消引用变量,或者读取或过滤错误的信息。不用关心所用的调试技术使用的是 print语句,还是 Perl 提供的完整调试器。越快消除这些 bug,就能越早开始优化程序。

使用所有的技术

现在您已经了解了一些技术,这里有一种方法可以使用这些这些技术生成优化的应用程序。在进行优化时,我通常遵循以下步骤:

  1. 使用上面介绍的技术编写尽可能优化的程序。一旦开始有规律地使用这些技术,它们就会变成您进行编程的惟一方法。
  2. 完成编程之后,或者在可以发布程序时,通读代码,再次手工检查程序,确定您使用的是 可用的最有效解决方法。仅仅通过阅读代码,您就可以发现一些问题,还可能发现一些潜在的 bug。
  3. 调试程序。bug 可能引起性能问题,因此,应该在进行优化之前首先消除 bug。
  4. 运行 profiler。通常我要对重要的应用程序都执行一次这样的操作,只是为了看一下自己是否遗漏了什么内容 —— 显然一般都会遗漏点什么。
  5. 回到步骤 1,重新开始。我忘记统计自己第一次忘记进行优化的次数了。我可能会反复这个过程 2 到 3 次,也可能离开做另外一个项目,然后在几天、几周或几个月后再回来进行优化。几周或几个月之后,您通常会发现实现相同功能而又可以节省时间的其他方法。

在每天日落时,并没有魔法杖可以帮助您优化软件。即使使用调试器和 profiler,您所获得的也只不过是一些简单的信息:什么可能导致了性能问题,以及有关应该如何修复这些问题的一些不太必要同时也不太有用的建议。还要注意的是,您可以优化的内容很有限。有些操作要花费很多时间才能完成。如果必须遍历一个 10,000 元素的 hash 表,那么就没有办法简化这个过程。但是,正如您已经看到的,有一些方法可以减少每种情况的开销。

相关主题

  • 您可以参阅本文在 developerWorks 全球站点上的 英文原文
  • 阅读有关 Perl 调试技术的内容: 功能丰富的 Perl:轻松调试 Perl (developerWorks, 2000 年 9 月)。
  • 在 developerWorks 中 Ted Zlatanov 撰写的 功能丰富的 Perl 中,可以找到有关 Perl 编程的更多内容。
  • 请参阅 CPAN,查看您可能想要的所有 Perl 模块。
  • 参阅 O'Reilly Network 的 Perl.com,其中有关于 Perl 的信息和相关资源。
  • 在 developerWorks Linux 专区中,可以找到更多有关 Linux 开发者的资源。
  • 从 developerWorks 的 Speed-start your Linux app 专区下载能够运行于 Linux 之上运行的 IBM 中间件产品的免费测试版本,其中包括 WebSphere® Studio Application Developer、WebSphere Application Server、DB2® Universal Database、Tivoli® Access Manager 和 Tivoli Directory Server,并可查找 how-to 文章和技术支持。
  • 在 Developer Bookstore 的 Linux 区中定购 Linux 的打折书籍。