Racket编程指南——20 并行

20 并行

Racket提供两种形式的并行(parallelism)前景(futures)现场(places)。在提供多个处理器的平台上,并行可以提高一个程序的运行时性能。

关于Racket里连续性能的信息又见性能。Racket还提供了对并发(concurrency)的线程,但线程没有提供并行;更多的信息见并发与同步。

20.1 前景并行

racket/future库通过与前景(futures)以及futuretouch函数的并行,为性能改进提供支持。然而,这些结构的并行性受到几个因素的限制,当前的实现最适合于数值任务。在DrRacket中的性能中的警告也适用于前景;值得注意的是,调试手段目前使前景失效了。

其它函数,如thread,支持创建可靠的并发任务。然而,即使硬件和操作系统支持并行性,线程也不会真正并行运行。

作为一个开始的例子,any-double?函数获取一个数字列表,并确定列表中的任何数字有一个也包含在列表中的double:

(define (any-double? l)
  (for/or ([i (in-list l)])
    (for/or ([i2 (in-list l)])
      (= i2 (* 2 i)))))

这个函数在二次时间中运行,所以像l1l2这样的大列表可能需要很长时间(按秒顺序):

(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?的最好的办法是使用不同的算法。然而,在提供至少两个处理单元的机器上,上述示例可以使用futuretouch的大约一半时间运行:

(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程序级别上可能还不太明显。本节剩余部分通过一个例子来说明这种区别,并显示如何使用前景的可视化工具有助于阐明这一点。

考虑一下曼德尔布罗特集合计算的以下核心:

(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的实现中,前景不安全操作分为两类。一个阻塞(blocking)操作中止前景求值,同时不允许它继续下去,直到它被接触(touched)。在touch中的操作完成之后,前景工作的剩余部分将由运行时线程依次进行求值。一个同步(synchronized)操作也中止前景,但运行时线程可以在任何时间执行操作,一旦完成,前景可能在并行中继续运行。内存分配和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反编译(raco decompile)工具对操作可以被反编译器内联编译(见(part ("(lib scribblings/raco/raco.scrbl)" "decompile"))),所以反编译器可以用来帮助预测并行性能。

20.2 现场(place)并行

racket/place库通过与place表的并行来提供性能改进的支持。place表创造了一个现场(place),这实际上是一个新的Racket实例,可以平行于其它现场,包括初始现场。在每一个现场都可以使用Racket语言的全部功能,但只能通过消息传递来传递现场——使用place-channel-putplace-channel-get函数在有限的值集上——这有助于确保并行计算的安全性和独立性。

作为一个开始的例子,下面的racket程序使用一个现场(place)来确定列表中的任何一个数是否有一个也在列表中的双数:

#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绑定到 现场通道(placechannel)。在place表中的剩余主体表达式在一个新的现场被求值,这个主体表达式使用ch与产生新位置的位置来表达。

在上面的place表的主体中,新的位置接收到一个超过ch的数字列表,并将列表绑定到l。它接着调用表上的any-double?并且绑定这个结果到l-double?。最终的主体表达式发送l-double?结果越过ch回到原来的现场。

在DrRacket里,保存并运行上面的程序后,在交互窗口对(main)求值以创建新的现场。当在DrRacket内使用现场(places),包含现场代码的模块在它被执行之前必须被保存到一个文件。另外,作为"double.rkt"保存该程序并且用以下内容从一个命令行运行

  racket -tm double.rkt

-t标志告诉racket加载double.rkt模块的地方,-m标志调用导出的main函数,同时-tm组合这两个标志。

place表有两个微妙的特点。首先,它将place主体提升为一个匿名的模块级的函数。这种提升意味着,place主体引用的任何绑定都必须在模块的顶层级可用。第二,placedynamic-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)

20.3 分布式现场

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)"并开始一个在端口6344侦听的racloud节点以做进一步说明。对新racloud节点的处理被分配给remote-node变量。本地主机被使用以便这个例子可以只使用一个单一的机器来运行。然而本地主机可以通过用ssh公钥访问任何的主机和racket更换。supervise-named-dynamic-place-atremote-node上创建一个新现场。新的现场将由它的名称符号'tuple-server在前景中标记。一个现场描述符被要求通过使用tuple-path模块路径和'make-tuple-serverdynamic-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表接受一个标识符和一个自定义表达式列表作为它的参数。从一个place-thunk函数标识符通过预先计划这个make-前缀来被创建。在这种情况下make-tuple-servermake-tuple-server标识符是place-function-name给到上边的supervise-named-dynamic-place-at表。define-state定制表转换成一个简单的define表,它通过define-rpc表关闭。

define-rpc表扩展为两部分。第一部分是调用rpc函数的客户机存根。客户机函数名字是通过连接define-named-remote-server标识符产生的,元组服务器(tuple-server),用RPC函数名称设置以产生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

你可能感兴趣的:(Lisp,Racket,Racket编程指南(中文译))