20 并行
Racket提供两种形式的并行(parallelism):前程(futures)和现场(places)。在提供多个处理器的平台上,并行可以提高程序的运行时性能。
关于Racket里顺序性能的信息另参见《性能》。Racket还为并发(concurrency)提供了线程,但线程没有提供并行性;有关的详细信息,请参见《并发与同步》。
racket/future库通过与前程(futures)以及future和touch函数的并行性,为性能改进提供支持。然而,这些构造中可用的并行性水平受到几个因素的限制,当前的实现最适合于数值任务。在《DrRacket中的性能》中的警告也适用于前程;值得注意的是,调试工具目前无法实现前程。
其它函数,如thread,支持创建可靠的并发任务。然而,线程永远不会真正并行运行,即使硬件和操作系统支持并行。
作为一个开始的例子,下面的any-double?函数获取一个数字列表,并确定列表中的任何数字是否具有列表中的double:
(define (any-double? l) (for/or ([i (in-list l)]) (for/or ([i2 (in-list l)]) (= i2 (* 2 i)))))
该函数以二次时间运行,因此在l1和l2这样的大列表上可能需要很长时间(大约一秒):
(define l1 (for/list ([i (in-range 5000)]) (+ (* 2 i) 1))) (define l2 (for/list ([i (in-range 5000)]) (- (* 2 i) 1))) (or (any-double? l1) (any-double? l2))
加快any-double?速度的最佳方法 any-double?是使用不同的算法。然而,在一台至少提供两个处理单元的机器上,上面的例子可以使用future和touch运行大约一半的时间:
(let ([f (future (lambda () (any-double? l2)))]) (or (any-double? l1) (touch f)))
前程的f与(any-double? l1)并行运行(any-double? l2),而(any-double? l2)的结果与(touch f)要求的结果大致相同。
只要前程能够安全运行,它们就可以并行运行,但“前程安全”的概念与实现有着内在的联系。“前程安全”和“前程不安全”操作之间的区别在Racket程序级别上可能还不明显。本节的其余部分将通过一个示例来说明这一区别,并展示如何使用前程的可视化工具来帮助阐明这一点。
考虑曼德尔布罗特集合(Mandelbrot-set)计算的以下核心:
(define (mandelbrot iterations x y n) (let ([ci (- (/ (* 2.0 y) n) 1.0)] [cr (- (/ (* 2.0 x) n) 1.5)]) (let loop ([i 0] [zr 0.0] [zi 0.0]) (if (> i iterations) i (let ([zrq (* zr zr)] [ziq (* zi zi)]) (cond [(> (+ zrq ziq) 4) i] [else (loop (add1 i) (+ (- zrq ziq) cr) (+ (* 2 zr zi) ci))]))))))
表达式(mandelbrot 10000000 62 500 1000)和(mandelbrot 10000000 62 501 1000)每个都需要一段时间才能得出答案。当然,计算两者所需的时间是前者的两倍:
(list (mandelbrot 10000000 62 500 1000) (mandelbrot 10000000 62 501 1000))
不幸的是,尝试与future并行运行这两个计算并不能提高性能:
(let ([f (future (lambda () (mandelbrot 10000000 62 501 1000)))]) (list (mandelbrot 10000000 62 500 1000) (touch f)))
要了解原因,请使用future-visualizer,如下所示:
(require future-visualizer) (visualize-futures (let ([f (future (lambda () (mandelbrot 10000000 62 501 1000)))]) (list (mandelbrot 10000000 62 500 1000) (touch f))))
这将打开一个窗口,显示计算跟踪的图形视图。窗口的左上部分包含执行时间线:
每个水平行代表一个操作系统级线程,彩色点代表程序执行过程中的重要事件(它们用颜色编码以区分事件类型)。时间线上左上角的蓝点代表前程的创建。前程在线程1上执行一小段时间(由第二行中的绿色条表示),然后暂停以允许运行时线程执行前程的不安全操作。
在Racket实现中,前程的不安全操作分为两类。阻塞(block)操作会中止对前程的求值,并且在被接触(touch)之前不会允许它继续。在touch内完成操作后,运行时线程将依次对前程工作的剩余部分求值。同步(synchronize)操作也会中止前程,但运行时线程可以随时执行该操作,一旦完成,前程可以继续并行运行。内存分配和JIT编译是同步操作的两个常见示例。
在时间线中,我们在线程1的绿色条右侧看到一个橙色点——这个点表示同步操作(内存分配)。线程0上的第一个橙色点表示运行时线程在前程暂停后不久执行了分配。不久后,前程将停止一次阻塞操作(第一个红点),并且必须等到touch后才能进行求值(略高于1049ms)。
当你把鼠标移动到某个事件上时,可视化工具将显示该事件的详细信息,并绘制连接相应前程所有事件的箭头。这张图片展示了我们前程的联系。
橙色虚线将前程的第一个事件连接到创建它的前程,紫色虚线连接前程的相邻事件。
我们之所以看不到并行性,是因为mandelbrot中循环下部的<和*操作涉及浮点值和固定(整数)值的混合。这种混合通常会触发执行中的慢路径,并一般的慢路径通常会阻塞。
将常数更改为mandelbrot中的浮点数解决了第一个问题:
(define (mandelbrot iterations x y n) (let ([ci (- (/ (* 2.0 y) n) 1.0)] [cr (- (/ (* 2.0 x) n) 1.5)]) (let loop ([i 0] [zr 0.0] [zi 0.0]) (if (> i iterations) i (let ([zrq (* zr zr)] [ziq (* zi zi)]) (cond [(> (+ zrq ziq) 4.0) i] [else (loop (add1 i) (+ (- zrq ziq) cr) (+ (* 2.0 zr zi) ci))]))))))
有了这样的改变,mandelbrot计算可以并行运行。尽管如此,我们仍然看到一种特殊类型的慢路径操作限制了我们的并行性(橙色点):
问题是,本例中的大多数算术运算都会产生一个不精确数,必须分配其存储空间。虽然某些分配可以在没有运行时线程的情况下安全地以独占方式执行,但特别频繁的分配需要同步操作,这会影响性能的提高。
通过使用flonum特定的操作(请参见Fixnum和Flonum优化),我们可以重写mandelbrot以使用更少的分配:
(define (mandelbrot iterations x y n) (let ([ci (fl- (fl/ (* 2.0 (->fl y)) (->fl n)) 1.0)] [cr (fl- (fl/ (* 2.0 (->fl x)) (->fl n)) 1.5)]) (let loop ([i 0] [zr 0.0] [zi 0.0]) (if (> i iterations) i (let ([zrq (fl* zr zr)] [ziq (fl* zi zi)]) (cond [(fl> (fl+ zrq ziq) 4.0) i] [else (loop (add1 i) (fl+ (fl- zrq ziq) cr) (fl+ (fl* 2.0 (fl* zr zi)) ci))]))))))
即使在顺序模式下,这种转换也可以将mandelbrot的速度提高8倍,但避免分配也可以使mandelbrot在并行模式下运行得更快。执行此程序会在可视化工具中产生以下结果:
请注意,这里只显示了一个绿色条,因为其中一个曼德尔布罗特计算没有被前程(在运行时线程上)求值。
作为一般准则,JIT编译器内联的任何操作都安全运行地并行运行,而其它未内联的操作(包括禁用JIT编译器的所有操作)则被认为是不安全的。raco反编译工具对编译器可以内联的操作进行注释(请参见《(part ("(lib scribblings/raco/raco.scrbl)" "decompile"))》),因此反编译器可以用来帮助预测并行性能。
racket/place库通过与place表的并行性来支持性能改进。place表创建了一个现场(place),这实际上是一个新的Racket实例,可以与其它地方(包括初始现场)并行运行。Racket语言的全部功能在每一个现场都可以使用,但现场只能通过消息传递进行通信——在有限的一组值上使用place-channel-put和place-channel-get函数——这有助于确保并行计算的安全性和独立性。
作为一个开始的例子,下面的racket程序使用现场来确定列表中的任何数是否具有在列表中的双位数:
#lang racket (provide main) (define (any-double? l) (for/or ([i (in-list l)]) (for/or ([i2 (in-list l)]) (= i2 (* 2 i))))) (define (main) (define p (place ch (define l (place-channel-get ch)) (define l-double? (any-double? l)) (place-channel-put ch l-double?))) (place-channel-put p (list 1 2 4 8)) (place-channel-get p))
place后面的标识ch绑定到 现场通道(place channel)。place表中剩余的主体表达式将在一个新的现场求值,主体表达式使用ch与生成新位置的现场进行通信。
在上面的place表的主体中,新位置通过ch接收一个数字列表,并将列表绑定到l。它接着调用表上的any-double?并将结果绑定到l-double?。最终的主体表达式发送l-double?结果返回ch上的原始现场。
在DrRacket中,保存并运行上述程序后,在交互窗口对(main)求值以创建新的现场。在DrRacket中使用现场时,必须将包含现场代码的模块保存到一个文件中,然后才能执行。或者,将程序保存为"double.rkt",并从一个命令行运行以下内容
racket -tm double.rkt
其中,-t标志告诉racket加载double.rkt模块,-m标志调用导出的main函数,同时-tm将这两个标志组合起来。
place表有两个微妙的特点。首先,它将place主体提升到一个匿名的模块级函数。这种提升意味着,place主体引用的任何绑定必须在模块的顶层可用。其次,place表dynamic-require在新创建的现场中包含封闭模块。作为dynamic-require的一部分,当前模块主体将在新现场被求值。第二个特性的结果是,place不应立即出现在模块中或在模块顶层调用的函数中;否则,调用模块将在新现场调用同一模块,以此类推,从而触发一系列位置创建,很快就会耗尽内存。
#lang racket (provide main) ; Don't do this! (define p (place ch (place-channel-get ch))) (define (indirect-place-invocation) (define p2 (place ch (place-channel-get ch)))) ; Don't do this, either! (indirect-place-invocation)
racket/place/distributed库为分布式编程提供支持。
下面的示例演示了如何启动远程racket节点实例,在新的远程节点实例上启动远程现场,以及启动监视远程节点实例的事件循环。
示例代码也可以在"racket/distributed/examples/named/master.rkt"中找到。
#lang racket/base (require racket/place/distributed racket/class racket/place racket/runtime-path "bank.rkt" "tuple.rkt") (define-runtime-path bank-path "bank.rkt") (define-runtime-path tuple-path "tuple.rkt") (provide main) (define (main) (define remote-node (spawn-remote-racket-node "localhost" #:listen-port 6344)) (define tuple-place (supervise-place-at remote-node #:named 'tuple-server tuple-path 'make-tuple-server)) (define bank-place (supervise-place-at remote-node bank-path 'make-bank)) (message-router remote-node (after-seconds 4 (displayln (bank-new-account bank-place 'user0)) (displayln (bank-add bank-place 'user0 10)) (displayln (bank-removeM bank-place 'user0 5))) (after-seconds 2 (define c (connect-to-named-place remote-node 'tuple-server)) (define d (connect-to-named-place remote-node 'tuple-server)) (tuple-server-hello c) (tuple-server-hello d) (displayln (tuple-server-set c "user0" 100)) (displayln (tuple-server-set d "user2" 200)) (displayln (tuple-server-get c "user0")) (displayln (tuple-server-get d "user2")) (displayln (tuple-server-get d "user0")) (displayln (tuple-server-get c "user2")) ) (after-seconds 8 (node-send-exit remote-node)) (after-seconds 10 (exit 0)))) Figure 1: examples/named/master.rkt
spawn-remote-racket-node原语连接到"localhost",并在那里启动一个racloud节点,该节点在端口6344上侦听进一步的指令。新的racloud节点的句柄被分配给remote-node变量。使用localhost,以便仅使用一台计算机即可运行该示例。然而,localhost可以被任何具有ssh公钥访问和racket的主机更换。supervise-named-dynamic-place-at在remote-node上创建一个新现场。这个新现场将在前程中以其名称符号'tuple-server标记。通过调用带有tuple-path模块路径和'make-tuple-server符号的dynamic-place,可以返回现场描述符。
元组服务器现场的代码存在于文件"tuple.rkt"中。"tuple.rkt"文件包含使用define-named-remote-server表,该表定义了一个适合supervise-named-dynamic-place-at调用的RPC服务器。
#lang racket/base (require racket/match racket/place/define-remote-server) (define-named-remote-server tuple-server (define-state h (make-hash)) (define-rpc (set k v) (hash-set! h k v) v) (define-rpc (get k) (hash-ref h k #f)) (define-cast (hello) (printf "Hello from define-cast\n") (flush-output))) Figure 2: examples/named/tuple.rkt
define-named-remote-server表以标识和自定义表达式列表作为参数。通过make-前缀前面加上前缀,可以从标识创建一个place-thunk函数。在本例中,make-tuple-server。make-tuple-server标识是上面的supervise-named-dynamic-place-at表所使用的place-function-name。define-state自定义表转换为一个简单的define表,该表由define-rpc表关闭。
define-rpc表扩展为两部分。第一部分是调用rpc函数的客户机存根。客户机函数名是通过将define-named-remote-server标识tuple-server与RPC函数名set连接起来形成tuple-server-set的。RPC客户机函数获取一个目标参数,它是一个remote-connection%描述符,进而是RPC函数参数。这个RPC客户机函数通过调用内部函数named-place-channel-put将RPC函数名、set和RPC参数发送到目标。然后,RPC客户机调用named-place-channel-get等待RPC响应。
define-rpc的第二个扩展部分是RPC调用的服务器实现。该服务器由make-tuple-server函数中的匹配表达式实现。tuple-server-set的匹配子句匹配以'set符号开头的消息。服务器使用传递的参数执行RPC调用,并将结果发送回RPC客户机。
define-cast表类似于define-rpc表,只是没有从服务器到客户机的应答消息外。
(module tuple racket/base (require racket/place racket/match) (define/provide (tuple-server-set dest k v) (named-place-channel-put dest (list 'set k v)) (named-place-channel-get dest)) (define/provide (tuple-server-get dest k) (named-place-channel-put dest (list 'get k)) (named-place-channel-get dest)) (define/provide (tuple-server-hello dest) (named-place-channel-put dest (list 'hello))) (define/provide (make-tuple-server ch) (let () (define h (make-hash)) (let loop () (define msg (place-channel-get ch)) (define (log-to-parent-real msg #:severity (severity 'info)) (place-channel-put ch (log-message severity msg))) (syntax-parameterize ((log-to-parent (make-rename-transformer #'log-to-parent-real))) (match msg ((list (list 'set k v) src) (define result (let () (hash-set! h k v) v)) (place-channel-put src result) (loop)) ((list (list 'get k) src) (define result (let () (hash-ref h k #f))) (place-channel-put src result) (loop)) ((list (list 'hello) src) (define result (let () (printf "Hello from define-cast\n") (flush-output))) (loop)))) loop)))) Figure 3: Expansion of define-named-remote-server