思考并回答以下问题:
Listing 5-1 manual.php
1 |
|
Listing 5-2 manual-output.txt
1 | 1st function : 0.0007178783416748 secs, 40 bytes |
如您所见,第二个函数比第一个函数需要更长的时间。现在,您知道使脚本变慢的问题是第二个循环,您可以通过删除usleep语句并完全删除该循环并使用str_repeat(‘abc’,1000000)填充字符串来解决此问题。
记忆化
如果您已经编程了一段时间,尤其是在Web领域,那么您将遇到缓存的概念。缓存是一个过程,通过该过程,您可以获取“昂贵的”计算结果,存储结果,然后在下次调用该计算时使用存储的结果,而不必再次运行计算本身。昂贵意味着运行时间长,占用大量内存,进行大量外部API调用或出于成本或性能原因而希望将其最小化的任何其他操作。缓存失效是您选择从缓存中删除项目的过程。例如,如果要花费很多精力来生成新闻网站的首页,则需要缓存该页面,这样就不必在每次访问者访问网站时都生成它。但是,一旦发生下一个重大故事,您将要更新首页,并且您不希望访问者点击缓存的版本并获取旧消息,因此您将使缓存“无效”并重新生成页面。
如果您曾经参与编写或使用缓存系统,那么毫无疑问,您会根据Phil Karlton的说法熟悉以下说法(或至少了解它的来历):
在计算机科学中,只有两件困难的事情:命名事物,缓存无效化和一次性错误。
您已经研究了递归如何减少一次性错误,并且没有人希望解决命名问题,那么如何解决缓存失效呢?举起手来,如果你认为
函数编程有其窍门。好,金星为您服务!确实如此,诀窍是永远不要使缓存无效。问题解决了!
我实际上是认真的。函数式编程提供了一种称为备忘录的技术,该技术植根于纯函数固有的属性中。在较早的理论章节中,您研究了纯函数如何具有参照透明性。给定一组特定的输入参数,纯函数将始终产生相同的返回值,并且(对于该组输入)该函数可以简单地由返回值替换。这听起来应该有点像缓存:对于给定的一组输入(例如,您的新闻报道),您想用其返回值(缓存的输出)替换(运行昂贵)功能。获取纯函数的输出并将其缓存的过程是备忘录,这只是缓存的一种特殊情况。
假设您正在记忆一个昂贵的功能,并将结果缓存到磁盘。每次使用不同的参数运行该函数时,您可能会得到不同的结果。您要消除的是在同一参数上多次运行该函数的成本,因为每次(纯)函数都可以保证获得相同的结果。因此,您可以缓存结果,例如,通过创建代表所使用输入参数的哈希并将其用作文件名来存储该运行的返回值。
下次运行该函数时,将再次对输入参数进行哈希处理,查看是否存在该名称的缓存文件,如果存在则返回其内容(而不是重新运行昂贵的函数)。
到目前为止,这是典型的缓存。但是,如何避免不得不使缓存无效?答案是你不知道。记忆有效地为您做到了。您的功能是纯净的,这意味着没有副作用。因此,如果在您的虚构新闻网站上有新故事中断,则该故事的详细信息将仅通过(纯)功能通过该功能的输入参数创建您的首页。例如,您可能将一系列标题作为一个参数。突然,您的参数的哈希值已更改,因此备注函数将无法在具有该哈希值的磁盘上找到文件,因此将运行完整功能并将新结果缓存到以新哈希值命名的文件中的磁盘上值。回顾一下,作为您唯一的输入
是参数,如果所有参数均未更改,则必须可以使用缓存。但是,如果参数已更改,则将没有相应的缓存文件,因此无需使其无效。
当然,旧的缓存文件仍然存在,因此当您不小心发布错误地声称这本书是垃圾的故事时,您可以立即将其撤回,该功能将返回使用旧的缓存文件,因为哈希将再次出现匹配参数。
到现在为止还挺好。但是,函数式编程并没有止于其优点,哦,不。如果您正在考虑如何编写函数来进行记忆,请停止操作。通常,您不需要。您可以简单地将函数包装在另一个为您自动记住的函数中。这样的包装器函数易于编写,因为您所关心的只是纯函数的输入和输出,而不是其内部的功能。
因此,让我们来看一个备忘录的示例。在清单5-3中,您会将纯函数的结果缓存到磁盘。为简便起见,将不纯磁盘功能分成单独的功能,而不是煮一些IO monad,但是您当然可以根据需要这样做。
Listing 5-3 memoize.php
1 |
|
首先,您的备忘录功能通过将输入参数编码为JSON来使输入参数具有唯一的字符串表示形式。 例如,如果您想知道为什么不简单使用implode(“ |”,$ params),请考虑以下两个函数调用:
1 | func("Hello","|There"); |
这将导致两者实际上都被编码为Hello || There,因此实际上被视为相同的一组参数。如果可以保证,可以使用带有粘合字符的爆破
该字符永远不会出现在您的参数中,但是通常以防万一,最好使用防御性代码并使用适当的序列化功能。您可以使用PHP的serialize()函数代替json_encode
因为在某些工作负载下它可能会更快。两者都具有一些极端的情况,您可能需要在选择一种情况之前就熟悉一下这些情况,例如,serialize()无法使用某些类型的对象。
有关这两者的更多信息,请参见PHP手册。
一旦有了输入的字符串表示形式,就需要将其转换为另一个适合用作文件名的字符串。您的JSON字符串可能包含对文件无效的字符
名称,因此您将为其创建一个SHA1哈希。 MD5散列的创建速度会稍快一些,但发生散列冲突的可能性更大(对于两个不同的输入会生成相同的散列)。即使SHA1也会发生冲突,尽管风险通常很小。如果您绝对无法解决冲突,那么您将需要编写一些代码来解析序列化的字符串并替换无效字符,依此类推,以一致的方式确保对缓存介质(文件例如,写入磁盘的名称长度)。
现在,您有了哈希(或其他描述输入参数的独特方式)。然后,您尝试从缓存中加载以哈希为名称的文件内容。如果您无法读取它(通常是因为它不存在,因为这是您第一次使用这些参数调用),则可以使用call_user_func_array()运行纯函数,获取其返回值并创建缓存文件,最后返回所获取的值你
返回值。如果您可以读取文件,则只需返回内容作为返回值,然后跳过执行该函数。您会注意到这里没有使用任何形式的严格输入。如果您从pure函数返回的值是一个int(例如),那么当您第一次运行pure函数时,您会将其写入磁盘并将int返回给调用者。但是,在随后的运行中,您将缓存文件的内容作为字符串获取并返回,因此返回值为字符串。如果在应用程序中键入很重要,则始终可以将值序列化到磁盘,并在读回时再次将其反序列化。
现在让我们看一个如何实际使用此备忘功能的示例。您将使用另一个经典的示例任务,即一种算法来生成斐波那契数列。我正在使用此功能,因为它是一个简短易懂的功能,而且恰好是递归的。记忆可用于任何功能,无论是否具有递归功能,但它通常特别有用,因为如前所述,递归功能通常会占用大量资源。如果您不熟悉斐波那契数列,它是一系列数字,其中前两个(如果从零开始,则为三个)之后的每个数字都是前两个数字的和,因此:
1 | 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946 and so on… |
该算法采用整数n并计算序列中的第n个数字。因此,$fibonacci(7)将返回13(13是上一个序列中的第7个数字,从0开始)。
您将创建两个函数:该函数的标准版本和包装在早期$ memoize函数中的一个版本。 通常,您将只创建一个函数并将其包装在$ memoize中。 但是,正如我想
演示一个递归调用该记忆版本的递归版本(并将其与未记忆形式进行对比),您将在此处创建两个。 而且对于现代人来说,斐波那契并不是一项特别繁重的任务
PC,您将以usleep语句的形式添加一些人为的“费用”,以使每次计算花费的时间更长。 这将说明备忘录对真正长时间运行的功能的影响。 参见清单5-4和清单5-5。
Listing 5-4 memo_example.php
1 |
|
Listing 5-5. memo_example-output.txt
1 | Array |
如果查看清单5-5中的第一次运行的输出,您会看到标准函数需要2.5秒才能计算出第六个斐波那契数,而备忘录版本仅需0.7秒。当然,它们应该在第一轮操作相同,因为尚未缓存任何内容。好吧,由于您的函数是递归的,因此实际上您每次计算都会多次调用该函数,并且由于已记忆的版本会使用相同的参数多次调用自身,因此将使用缓存。
第三次运行表明,下次使用参数6再次调用标准函数仍需要2.5秒,这很明显,因为它不进行缓存。但是,在6上调用记忆版本需要0秒(向下舍入!),因为计算中的每个递归调用都将命中缓存。
接下来计算第十个数字,您只需花费0.4秒。这比计算第6个数字要快,因为它们共享一些步骤(每个人都需要计算第1、2、3等个数字),这些步骤已经被缓存了,而第10个数字只需要实际计算第7、8、9和最后10个数字。下一轮进一步展示了这一点。现在,计算第11个数字仅需0.1秒(因为它只有一个未缓存的函数调用),而最后一次计算第8个数字的运行时间为0秒,因为它是从生成第10个数字起已经在缓存中。
如果您第二次调用该脚本,则会发现所有使用备注功能的运行都在0秒内完成,因为您的缓存已经存在所有需要的值,因为您已至少一次生成了所有这些值。除非有人改变了数学的基本原理,否则您可以永久保留高速缓存,因为高速缓存的结果对于给定的输入始终是正确的。如果您想知道高速缓存的外观,则运行/ tmp / memo-cache- *可以得到清单5-6的输出。
如您所见,有12个文件,这很有意义,因为您计算了第11个斐波那契数(从0开始计数),因此使用12个不同的参数调用了记忆功能。
Listing 5-6. cache_files.txt
1 | :::::::::::::: |
在这些示例中,您缓存到了磁盘,这使您可以创建持久的缓存,该缓存可以在重新启动后幸存下来并由多个进程使用。 但是,有时磁盘速度太慢,并且如果您的函数参数经常更改,您可能只想在单个脚本运行期间进行缓存。 另一种方法是在内存中缓存,实际上PHP提供了一种创建变量的方法,这些变量的作用类似于全局变量,但仅限于给定的函数,非常适合在脚本的一次运行中进行缓存。 这些称为静态变量,如果您不熟悉它们,清单5-7(和清单5-8)是静态变量($ sta)与全局参数($ glo),参数($ 参数)和普通函数作用域($ nor)变量。
Listing 5-7. static.php
1 |
|
Listing 5-8. static-output.txt
1 | string(10) "static : 1" |
如您所见,即使每次都使用相同的参数(1)调用my_func,每次$sta的值也不同。 因此,尽管您无法从函数外部的任何范围访问它,但通常仍将其视为“副作用”,因为对于函数的任何特定调用,您都无法确定其处于何种状态(在这种情况下, 不知道该函数已被调用多少次)。 那么,如何在功能程序中使用静态变量? 答案是,小心。 让我们看一个示例(参见清单5-9)。 您将创建备忘录功能的一个版本,该版本使用静态数组来保存缓存而不是写入磁盘。
Listing 5-9. memoize-mem.php
1 |
|
因此,您放入$ cache数组中的所有内容,然后再从中读取,完全取决于您使用(通过散列)调用函数的参数,而放入其中的则是该函数的值。
您对static变量的使用实际上是参照透明的,因此在这种情况下,您不会产生任何潜在的副作用。 如果调用与以前相同的memoize-example.php脚本,但改用此基于内存的备忘录功能,则会得到清单5-10的输出。
Listing 5-10. memo_mem_example-output.txt
1 | Array |
如您所见,它的输出与基于文件的示例完全相同。实际上,它的运行速度要快一点,因为您没有在进行磁盘I / O,但是在这里四舍五入到最接近的0.1秒。与基于磁盘的示例相比,唯一的不同之处在于,如果您第二次运行该脚本,您将再次获得此输出(而不是用于记忆调用的全零),因为用于缓存的静态变量为脚本结束时销毁。
除了基于磁盘和基于会话的内存缓存之外,还有一种替代方法是普通的RAM磁盘。在Linux类型的系统上,有一个名为tmpfs的文件系统,该文件系统允许您创建和使用存储在其中的文件。
内存而不是磁盘。这些虚拟文件的行为和操作类似于磁盘上的普通文件,因此可以像使用普通“磁盘上”文件一样,允许不同的PHP进程读取和写入文件中的缓存数据。 tmpfs带来的好处是双重的。首先,它很快,其次,一切都是暂时的。因为文件保存在内存中,所以没有机械硬盘可以等待,因此I / O非常快。而且由于它们保留在内存中,因此它们只是临时的,如果您尚未删除它们,则在重新启动后会消失。
另一个优点是,它们是普通文件,不是特定于PHP的技术,因此可以根据需要从其他软件进行访问。您可以使用与普通文件和流相同的方式来访问tmpfs文件系统上的文件。它们在内存中的事实对您的PHP脚本是透明的。较早的基于文件的示例将与RAM磁盘完美配合。
要在Linux上创建tmpfs文件系统,请首先在磁盘上创建一个目录,用于将存储设备“附加”到文件系统。然后将存储设备安装在该位置并开始使用它。清单5-11中的shell脚本(清单5-12中的输出)给出了安装和卸下tmpfs RAM磁盘的示例。
Listing 5-11. ramdisk.sh
1 |
|
Listing 5-12. ramdisk-output.txt
1 | Hello |
在清单5-11中,您在/ tmp / myMemoryDrive中创建一个目录来附加存储设备,然后将其安装在该目录中。您可以执行一行PHP来演示如何像创建其他任何文件一样创建内存文件,然后将其编入目录,该文件应输出Hello。最后,您卸载了设备并尝试再次保存文件,但是正如您所期望的,文件已经消失了。它永远不会保存到物理磁盘。您可以使用mount命令挂载tmpfs设备,如您每次引导系统或每次使用它们时所显示的那样,也可以将其添加到fstab文件中,以使其在每次系统引导时自动创建。无论以哪种方式安装它,在关闭或重新启动时,请始终记住它及其中的所有文件都会被破坏。
由于tmpfs的运行方式与普通文件系统相同,因此您需要确保设置了相关的文件许可权,以允许所有应用程序对其进行访问(或阻止那些不应该使用
能够干预)。还请记住,如果系统内存不足,则可能会发生内存交换到磁盘的情况,因此在这种情况下,数据可能会暂时碰到硬盘,在某些情况下,此后可以从磁盘恢复。请始终考虑您选择的任何缓存系统的安全性。
如果出于性能原因考虑使用tmpfs代替物理硬盘,则还应记住,现代操作系统(包括现代Linux)可以使用主动内存缓存来访问磁盘。这意味着操作系统透明地将经常读取的基于磁盘的文件缓存到动态分配的未使用的内存中(通常您甚至不知道)以提高明显的物理磁盘性能。在这些情况下,当您从tmpfs内存磁盘读取某些文件并遍历目录树时,可能不会看到预期的性能改进。写磁盘和访问较少的文件通常不会被缓存,因此在这种情况下,tmpfs仍然可以为您带来预期的收益。
在Windows中,没有内置的方法来创建基于内存的文件系统。存在用于创建RAM磁盘的各种第三方软件,但尚未标准化,并且大多数应用程序需要GUI才能在每个系统上手动设置磁盘。接下来列出的Wikipedia页面为您提供了更多指示,以供您探索是否仍然感兴趣。
记忆化的缺点
如您所见,通过记忆进行缓存通常是一件好事,但正如我母亲一直说的:“您可以拥有太多的好东西。”在默认情况下,开始记忆所有功能的诱惑可能会蔓延开来,但与所有其他功能一样,首先要考虑一些折衷。记住的函数会带来一些开销,用于每次运行时检查缓存版本是否可用以及获取或存储生成的任何缓存版本。如果您要记住要加快脚本的执行速度,并且您的缓存与前面的主要示例一样位于磁盘上,那么磁盘I / O会花费额外的时间(与内存存储或实际上许多仅用于计算的功能相比,这通常很慢)可能比运行中低复杂度功能所需的时间更长。当然,如果您要缓存以优化低内存系统,减少对外部API的调用次数或将其他与时间无关的资源使用量降到最低,那么这可能是可以接受的折衷方案。
使用备注进行缓存时,要考虑的另一个考虑因素是某些数据的短暂性是否会限制您从中获得的成本价值。例如,如果您的功能的参数之一是客户ID,但您的客户很少对您的网上商店进行多次访问/购买,则对该功能的任何缓存都可能仅在那一次访问期间受益。与更一般的缓存情况相比,使用纯函数记忆化的好处之一是,您不必担心缓存失效,因为您的缓存永远不会无效。但是,这导致了诱惑,便简单地忘记了缓存而将其保留,从编程的角度来看这是完全可以的。您的代码将继续以正确的输出正常运行。但是,您的系统管理员可能很快就会出现并开始询问您是否真的需要昂贵的SAN上的所有磁盘空间。磁盘空间的成本可能超过脚本的有限加速。在这些情况下,您有三个选择。
•删除备忘录:接受一些运行时间更长的脚本。
•缓存到磁盘上的内存或每个会话文件,而不是长期磁盘上:
这样可以在一次访问中加快多个呼叫的速度,但会暂时占用一些内存。
•执行某种形式的缓存逐出:删除已存在一个月以上的缓存文件。
懒惰评估
懒惰评估是一种艺术,它仅进行最少的工作即可获得所需的结果。
PHP程序员应该自然而然地做到这一点! 考虑以下伪代码:
1 | if ( do_something_easy() OR do_something_hard() ) { return } |
该代码说明“如果do_something_easy()或do_something_hard()为true,则返回。”因此,要确定是否应返回,可以调用两个函数,如果其中一个返回true,则知道要返回。但是,请考虑一下,如果do_something_easy()返回true,则do_something_hard()返回什么都无所谓,因为无论如何您都将返回。因此,在运行do_something_easy()之后,实际上没有必要运行第二个函数调用,并且您可以节省这样做的开销。相反,如果返回的是false,则需要运行第二个,但是,与第一个同时自动调用两者的情况相比,您的情况不会更糟。这称为惰性评估;您只评估需要的内容,而没有声明更多。
在评估布尔表达式时,PHP使用一种称为短路评估的惰性评估类型,这取决于逻辑运算符的先例。因此,如果要从这样的表达式中调用函数以确保您不会使短路短路,那么除了注意手册中的以下几页之外,您无需执行其他任何操作!
迭代器
但是,您可以采用这种惰性求值的概念,并将其应用到您的函数中以加快执行速度。在上一章中介绍的功能组合示例中,通常会获取一个数据数组,对其进行处理,将该数组传递给下一个函数,然后执行其他操作,等等。
即使实际上不需要数组中的所有数据,也通常将其传递给整个数组,然后将函数和转换应用于整个数组。您看过array_filter,它确实使用某些过滤器函数将数组的大小缩减为某些元素,但是即使那样,过滤器函数也将应用于数组的每个单个元素。如果只需要前10个匹配元素并且有100个匹配元素,那么您就浪费了时间,在找到前10个匹配元素之后应用filter函数,还需要执行其他步骤,例如使用array_slice将结果100缩减为10 。
PHP有一个称为生成器的有用语言工具,它在PHP 5.5中引入。生成器允许您创建函数,该函数返回有点像数组的东西,但是其数据是在访问元素时“实时”生成的。您可以使用生成器来创建仅执行最少必需工作的惰性函数。
将生成器功能链接在一起时,执行将向后执行。考虑如下三个标准函数的伪链:
- array_filter some_function();
- array_filter another_function();
- array_slice 0, 10;
首先将对整个数组进行过滤,然后对整个结果再次进行过滤,然后将第二个结果减少到十个项目。 在基于生成器的系统中,您可以编写如下所示的链:
- lazy_filter some_function();
- lazy_filter another_function();
- lazy_slice 0, 10;
它看起来是一样的,但是执行时,操作实际上是从lazy_slice开始,它会拉动整个链中的值。 slice函数从第二个过滤器请求值,直到有十个为止。每次第二个过滤器收到一个值请求时,它都会向第一个过滤器请求值,并向它们应用another_function()直到匹配为止。并且,每当第一个过滤器获取一个值请求时,它都会从数组中获取值,并对它们应用some_function()直到获得匹配。因此,当lazy_slice获得其十个值时,这两个lazy_filter函数仅足够多次调用了它们(可能昂贵的)过滤函数,以生成那十个,而不是(不必要)生成原始数据的所有项。
稍后,您将看到一个发电机的基本示例。但是在您这样做之前,让我们创建一个函数以重复调用一个函数。当您查看时间时,同一台PC上无关的任务可能会暂时降低脚本的运行速度。多次运行脚本或函数可以限制这种暂时性的延迟对基准计时编号的影响。参见清单5-13。
Listing 5-13 repeat.php
1 |
|
现在,让我们看一个生成器的简单示例(请参见清单5-14,输出如清单5-15所示)。
生成器是具有yield语句而不是return语句的函数。 与普通函数在返回时会丢失其状态不同,yield的函数会保持其状态直到下一次调用。
PHP具有一个名为range()的本地函数,该函数返回从$ start到$ end的数字数组,并带有可选的$ step值。 您将创建一个生成器版本gen_range(),该版本会产生相同的输出,但会产生延迟。 您将使用相同的参数调用这两者,以生成介于1到1000万之间的第四个数字,然后在得到一个可被123整除的数字时退出运行函数。
Listing 5-14 generators.php
1 |
|
Listing 5-15. generators-output.txt
1 | *** range() *** |
因此,如您所见,惰性版本使用的内存量比正常的range()函数少得多。这是因为range()必须在开始迭代之前生成值的整个数组
通过foreach遍历它们,而gen_range()仅保存序列中的当前值。 gen_range()所花费的时间也少得多,因为一旦您达到369,就完成了,而range()必须甚至在开始之前就生成序列中的每个单个值。
注意,使用的内存是$ run函数返回时返回的值memory_get_usage,这对于您的函数来说可能只是每个函数中使用的最大内存量。
因此,这就是生成器的外观。现在,让我们看一下如何在功能组合中使用它们,以最大程度地减少功能链要做的工作量。您将创建一个脚本,该脚本需要莎士比亚的名副其实的完整著作(作为纯文本文件),获得提及英雄一词的行,并获得任何长度超过60个字符的行,然后返回前三个匹配项。
清单5-16展示了如何以一种非延迟的方式进行操作,清单5-17中显示了输出。
Listing 5-16. filter.php
1 |
|
Listing 5-17. filter-output.txt
1 | Array |
这为您提供了三行所需的信息,在我的薄弱笔记本电脑上运行100次大约需要6秒钟。 清单5-18懒惰地重写了此脚本,输出如清单5-19所示。
Listing 5-18. lazy_filter.php
1 |
|
Listing 5-19. lazy_filter-output.txt
1 | Array |
您得到相同的结果,但仅需2秒钟,大约快了三倍。 那么,这是如何工作的呢? 好吧,您的lazy_filter不会返回任何数据,而是“产生”一个生成器对象。 该对象实现了PHP的迭代器接口,因此诸如foreach之类的功能会自动知道如何使用它,就好像它是任何其他可迭代的数据类型一样。 当您使用gen_slice()函数时,这一点变得尤为明显。该函数不是假装您正在使用数组,而是仅调用生成器对象的current()和next()方法来请求下三个数据。 如果您不熟悉迭代器,则PHP手册的以下部分将对您进行分类。
顺便说一句,当我编写以前的脚本时,我首先使用compose语句命名了它链接在一起的三个功能,然后向后进行工作以找出实现它们所需的功能。 在进行功能编程时,您经常会发现这种模式。 声明式的性质使其适用于自上而下的程序设计方法。
懒惰评估的缺点
生成器很棒,并且惰性评估通常是一个非常有用的工具。 但是,正如您可能期望的那样,值得一提的是可能会有不利之处。 如果再次运行您的generators.php示例,但是这次不是寻找一个可被123整除的数字,而是使用值9999989,清单5-20和清单5-21显示了发生的情况。
Listing 5-20. generators2-output.txt
1 | *** range() *** |
标准range()函数需要26秒,但是您的gen_range()惰性函数几乎将其翻了一番,达到41秒。为什么?好的,发电机中存在固有的开销。寻找一个可以被9999989整除的数字(在这种情况下,它本身就是数字)意味着您必须一直进行直到找到数字序列的末尾为止。但是您必须对序列中的每个数字都调用一个函数(通过foreach),而不是对range()进行一次函数调用,而且每次函数调用都会产生少量开销。
此外,您要调用的函数是由您用PHP编写的,而不是由整个PHP核心开发人员团队使用C编写的,因此,高度优化的代码要少得多。因此,通常会出现一个问题,即与最初进行全面评估相比,生成器的时间效率较低。通常这是最小的,并且在评估过程即将结束时,并且如果您的运行输入值“分散”了,即使其中一些花费的时间比完整的评估方法花费的时间更长,通常也可以总体上领先。不过,始终值得考虑您的用例,并确保根据实际数据对代码进行性能分析。
不过,这也不是个坏消息。 如果查看一下内存使用情况的数字,您会发现它们与第一个示例中的数字完全相同,在第一个示例中,您寻找的数字可以被123整除。在这种情况下,您可能会考虑由于 如果您正在使用内存受限的设备,则每次更改值(而不是预先生成所有值)都值得偶尔的额外执行时间。
并行编程
在写书的漫长过程中,我经常希望自己的双手可以同时写不同的章节。这样一来,我完成本书的速度就会快两倍。不幸的是,当我意识到我微不足道的大脑一次只能跟踪一组单词时,我的狡猾计划受到了挫败。
幸运的是,现代计算机并没有我这么有限,可以一次执行并跟踪许多任务。
计算机以各种方式(并行计算,多任务,多线程,多处理等)执行此操作,但是它们全都归结为一件事:您同时执行的次数越多,完成任务的速度就越快。
不过,即使您同时执行不同的操作,即使拥有现代PC的智能功能,也可以确保一切顺利。资源争用,死锁,争用条件:当多个线程或进程试图同时访问相同的资源(变量,数据,文件,硬件等)时,这些都是发生的事情。像这样的编程中最难的部分可能是在考虑脚本执行在不同路径上可能发生的所有可能性。
函数式编程可以使此操作更容易。当您的程序需要执行并行任务时,它们将剥离一些线程,子进程或类似任务以完成任务,并且它们通常会合并结果或在线程或进程返回时采取某些措施。如果您使用本书中介绍的功能原理编写这些任务工作程序,则每个工作程序都可以成为一连串的纯函数,其中:
•任务仅取决于其给定的输入(例如函数的参数),而不取决于
外部状态。
•由于不受其他任务的影响,因此可以很容易地对任务进行单独推理。
这意味着您不必(过多)担心其他任务在做什么,它们可能正在使用的所需资源等,等等。您的任务具有调用时所需要的一切作为输入的一部分,并且它将返回其输出以供父脚本使用,以担心处理/存储等问题。即使它不是严格的函数,也可以像编写脚本一样编写工作脚本,接受来自父文件的输入,就好像它是参数一样,并返回单个最后返回给父级的值,就像返回值一样。
PHP并非自然而然地用于并行编程,但是有多种实现并行计算的方法,可以在需要时将其付诸实践。也许最简单的方法是使用PHP的内置过程控制功能并行启动多个PHP脚本来完成工作。让我们看一个以这种方式使用流程控制的示例。
您将创建一个程序来对莎士比亚的完整作品进行一些分析。您将创建一个以正常线性方式进行分析的函数,以及一个生成的函数
多个“客户端” PHP工作程序脚本并行进行分析。首先,您将看到主要的parallel.php控制脚本,然后是并行版本中使用的client.php脚本,最后您将看到functions.php脚本,其中包含各种分析和并行化功能。您的脚本将从文本中挑选出符合特定条件的单词,将这些单词在整个文本中出现的次数相加,然后报告该集合中出现次数最多的十个单词。您将重复每个功能100次以对其进行基准测试。
Listing 5-21. parallel.php
1 |
|
在$ analyse_parallel组合中,$ launch_clients函数将并行启动清单5-22中的脚本多次运行。
Listing 5-22. client.php
1 |
|
最后,清单5-23显示了functions.php脚本,该脚本实现了您在先前脚本中组成的所有功能。 我将它们分开,以使脚本更易于阅读,也因为两个脚本都可以访问许多脚本。
Listing 5-23. functions.php
1 |
|
让我们运行parallel.php看看会发生什么(参见清单5-24)。
Listing 5-24. parallel-output.txt
1 | Array |
如您所见,从单个流程版本和并行流程版本的分析中都可以得到相同的结果,但是并行版本大约需要执行一半的时间。 如您所做的那样,将文本分块可以并行地给您提供四个客户端流程来分析所有文本。 考虑到两个版本的函数使用的是完全相同的昂贵函数($ analyze_words),您可能想知道为什么有四个客户端在四分之一的时间内都没有完成。 原因是要并行运行需要大量的设置,包括以下内容:
•将文本分成大块
•启动新的PHP流程
•写入和读取过程管道
•最后将结果组合在一起
因此,如果您想进一步加快速度,难道您不能简单地同时增加更多的客户吗? 让我们尝试一下,将文本分成100,000个字符的块,这需要38个客户端来并行计算(请参见清单5-25)。
Listing 5-25. parallel-output2.txt
1 | Array |
在这种情况下,您的速度从原来的两倍提高到了将近三倍!这再次是因为协调所有客户端并将结果汇总在一起的开销。因此,使用这种技术,并行处理的数量通常会达到最佳效果。这在很大程度上取决于手头的任务,对于具有以下特征的功能,您可能会获得更好的结果:
•不需要大量后处理的功能(例如,来自不同客户的结果的顺序或内容无关紧要)
•设置便宜的功能(例如,最少的处理以拆分输入数据,最少的数据传输到客户端)
•运行时间更长的功能(与函数执行时间相比,时间开销最小)
如您所见,没有很多额外的代码来管理并行化,就不会提高速度。在进入并行化代码阶段之前,您可以做很多事情
加快执行速度,包括以下步骤:
•使用懒惰求值,首先对单词进行计数和排序(便宜的操作)
然后将分析作为生成器功能的一部分
•重新排列array_filter中的操作以利用PHP的延迟评估,在调用更昂贵的preg_match之前,先使用strlen等廉价函数对数据进行缩减
•预先计算metaphone(’bard’)并存储在变量中,而不是每次都计算
•用便宜的strpbrk PHP函数替换preg_match如果这不足以使您达到性能目标,并且需要并行运行,则可以做一些其他事情来加快并行版本的速度(我还没有这样做)为了使代码简单和
节省书中的空间)。
•仅在每个脚本中包括所需的功能,也许使用构建步骤来内联它们。
•直接在共享内存中传递数据,而不是通过管道传递数据,这样可以更快。
•不要等待每个客户端发送数据之后再继续从下一个客户端读取数据,以无阻塞的方式反复遍历它们,直到每个客户端都准备好数据为止。
使用并行脚本很难进行惰性求值,因为每个脚本都以适合其本地输入的顺序返回数据,而不一定代表整个数据。例如,使用此脚本,每个客户都可以计算自己的最佳结果,但是您不能只接受您收到的前十个结果,因为它们可能不是莎士比亚作品中前十个,而仅仅是那些经过分析并先返回。如您所见,并行化工作需要一些思考,即使函数式编程通过消除考虑副作用的额外负担来帮助您。
还请考虑一下,如果您的一个客户未能完成或挂起,我什至没有涉及该怎么办,您将了解为什么只有在真正必要时才考虑使用此类技术。
多线程编程
多线程编程的工作方式与您在上一节中介绍的多进程示例类似。关键区别在于并行执行发生在同一流程而不是单独的流程中。 PHP不是多线程的;但是,使用Pthreads扩展可以实现多线程。 Pthreads是基于OOP的可靠实现,其性能可以明显优于多进程脚本。但是,由于线程在同一进程中共存,因此实现起来比多进程代码更复杂。另外,请注意,Pthreads扩展只能与PHP的“线程安全”版本一起使用,该版本与许多PHP扩展都不兼容。 Linux上的大多数程序包管理器都不包含线程安全版本,因此将要求您手动编译PHP(如果需要自己编译PHP的信息,请参阅附录A),或者对于Windows,则需要下载线程安全的可执行文件。从PHP网站。
尽管如此,采用前面所示的函数式编程原理仍可以帮助您绕过多线程编程常见的一些问题领域。可以在Pthreads网站上找到有关扩展的更多信息和使用示例。
标准PHP库(SPL)
在本章的开头,我讨论了一个事实,即PHP存在一些明显的性能问题,这是因为为用户提供易于使用和通用的数据结构和功能所需的开销。如果您发现这种开销开始限制脚本,则可以调用的标准端口库是标准PHP库(SPL),它是包含通用和深奥的数据结构和功能的核心PHP扩展。
它们旨在解决常见的编程问题,尽管与PHP较常见的结构(如普通的PHP数组类型)相比,需要使用更多的思想。没有什么是独家的
SPL中的函数式编程,但是您可以在本书中介绍的函数式技术中使用一些有用的函数和结构。
因此,例如,如果您发现传递大量数据导致脚本达到内存限制,则可能需要查看SplFixedArray类。它有一些限制(您只能使用整数作为索引,并且必须预先指定数组的长度),但是提供了比普通数组使用更少内存的更快实现。如果您不熟悉SPL中的某些数据结构(例如堆,链接列表等),那么计算机科学的大多数基本介绍(或使用更传统的语言进行编程)都可以为您提供帮助。 SPL还包含用于常见的基于迭代器的任务的函数和类,您可以将它们与之前查看的生成器一起使用。
清单5-26中的示例脚本向您介绍了iterator_to_array函数,SplFixedArray结构和FilterIterator类。
Listing 5-26. spl.php
1 |
|
总结
在本章中,您研究了性能改进领域中函数编程的一些常见应用程序。 即使您不会全力以赴地用功能代码编写应用程序,也可以挑选出导致瓶颈的关键功能,并牢记功能原理进行重写,这可以使您将这些提高性能的技术应用于代码的这些部分。 当然,如果您确实以一种功能样式从头开始编写应用程序,那么在发现问题功能时应用备忘等技术便可以快速简便地完成。