两个 core.async 写 debounce 的例子

这篇文章分析两个例子, 用来解释 Clojure 当中 debounce 是如何实现的. 如果不知道 core.async , 其实我说的是 CSP(Communicating sequential processes), 细节可以自己 Google. CSP 出名是从 Go Channel 使用 CSP 模型开始, 用于 goroutine 之间的消息通讯. 而 core.async 是 Clojure 语言当中的 CSP 的实现. 这篇文章当中两个例子都是 Clojure 代码.

大致上 Channel 的概念就是一个管道, 可以从一头发送数据, 从另一头获取数据, 可以粗略理解成两个进程庄户通信时一头写数据, 一头读数据, 但是不会发生两头都读或者写的情况. 另外, 与事件通信不同, Channel 的读写是会暂停代码的执行过程的, 像 yield 那种暂停, 所以例子当中的同步代码其实描述的是异步过程, 这个要理解.

第一个例子是 Google 上找的, 其中 in 和 out 是两个 Channel, 代码实现的功能是从 in 读取的数据, 按照事件做 debounce 丢弃掉一部分数据, 保证过于频繁发送的数据只会去最后一个.

解释一想用到的函数:

  • chan, 这个函数用来创建一个 Channel, 这里通过 let 赋值到 out, 给后面的代码使用.
  • go-loop, 这实际上是一个 Macro, 跟后面 recur 配合使用, 实现的是一个 go block 的匿名尾递归. 这个尾递归是带参数的, 第一个参数 last-value 默认值为 nil, 后面的 `(recur new-val)` 就是用新的参数调用函数.
  • timeout, 接收一个毫秒时间创建一个 Channel, 在这个时间过去之后, 往 Channel 当中写入一个数据.
  • <! 是从管道中读取数据的意思, 这里用了 "!" 意味着副作用, 这个函数是会暂停代码执行, 直到获得数据.
  • alts! , 这个函数接收一个向量, 向量当中是几个 Channel, 函数执行时会暂停代码执行, 当任何一个 Channel 获取到数据时, 返回一个值 `[new-val ch]`, 其中 new-val 是数据, ch 是对数据来源的 Channel, 它们会在后面逻辑代码当中用到.
  • let 是 Clojure 当中绑定变量的一个 Macro, 方括号当中成对出现的是"变量名/值", 方括号后面是依赖这些变量执行的代码.
  • condp, Clojure 当中的 Macro, 类似 switch/case 的分支语句.
  • >! 是跟 <! 对应的管道操作, 意思是往管道当中写入数据, 同样会暂停代码执行, 直到数据被取走.

高亮的代码可以看 GitHub 版本: gist.github.com/scttnls

(defn debounce [in ms]
  (let [out (chan)]
    (go-loop [last-val nil]
      (let [val (if (nil? last-val) (<! in) last-val)
            timer (timeout ms)
            [new-val ch] (alts! [in timer])]
        (condp = ch
          timer (do (>! out val) (recur nil))
          in (if new-val (recur new-val)))))
    out))

这个代码的执行顺序是从 go-loop 开始一直往下执行, 执行到 recur 就回到 go-loop, 所以这段代码就是一直循环下去的, 其中的 `(>! in)` `(alts! [in timer])` `(>! out val)` 三个表达式分别会暂停代码执行, 所以这个 go-loop 并不完全是一个死循环, 而是会异步任务时等待.

铺垫了这么多, 然后来看怎么 debounce... 抓住几个关键点: 怎么控制时间? 怎么取出想要的数据? 怎么丢弃数据?

  • 怎么控制时间? 按照变量名就能看到 timeout, 所以 timer 这个 Channel 返回数据的时间就是计时器.
  • 怎么取出想要的数据? alts! 等待两个 Channel 任意一个返回的数据, 如果是 timer 触发了, 就说明时间到了, 就可以把缓存下来的 last-val 发送给 out, 这样就拿到了数据. 从代码还能看到个特殊情况, 就是第一个数据是会等待 in 当中拿到的.
  • 怎么丢弃不需要的数据? 整体看这个 go-loop, 它的参数是 last-val, 从 in Channel 拿到的数据是 new-val, 除去上面讲的 timer 那个分支, 另一个分支写的是 `(recur new-val)`, 所以旧数据被丢弃了. 也就是说, 如果没有到达计时就获得数据, 那么会被丢弃掉.

初次看有点难理解, 但整个流程并不复杂, 步骤也很少.

清楚一点之后可以仔细想想 recur 调用的参数在整个代码执行过程当中的扮演了怎样的角色. 在函数式编程当中函数参数的常常用来传递状态, 这里的 val 和 new-val 就是一个局部状态, 并且作为函数尾递归当中的重要部分. 而这个状态实际上就是多个时间间隔当中进行延续和用于逻辑判断的状态.

如果还是难懂, 来看一个 Go 语言写的简化的版本:

nathanleclaire.com/blog

func debounce(interval time.Duration, input chan int, f func(arg int)) {
    var (
        item int
    )
    for {
        select {
        case item = <-input:
            fmt.Println("received a send on a spammy channel - might be doing a costly operation if not for debounce")
        case <-time.After(interval):
            f(item)
        }
    }
}

其中的 for 对应上文 go-loop 的功能, 而 select/case 代替了上文 condp 的功能, "<-"语法表示从 Channel 获取数据. 由于 Go 整个语言都对 CSP 做了专门的, 分支逻辑要更加清晰. 可以看到, 如果从 input 获得的数据, 就修改掉 item 这个变量, 如果是从 time.After 获得的数据, 就通过 f 输出数据, 同样的, 每个 for loop 都会生成新的 timeout 来控制时间.

当然短的主要原因是没有处理边界条件, 完整版本会长一些.

gist.github.com/leolara

func debounceChannel(interval time.Duration, output chan int) chan int {
  input := make(chan int)

  go func() {
    var buffer int
    var ok bool

    // We do not start waiting for interval until called at least once
    buffer, ok = <-input 
    // If channel closed exit, we could also close output
    if !ok {
      return
    }
    
    // We start waiting for an interval
    for {
      select {
      case buffer, ok = <-input:
        // If channel closed exit, we could also close output
        if !ok {
          return
        }

      case <-time.After(interval):
        // Interval has passed and we have data, so send it
        output <- buffer
        // Wait for data again before starting waiting for an interval
        buffer, ok = <-input
        if !ok {
          return
        }
        // If channel is not closed we have more data and start waiting for interval
      }
    }
  }()

  return input
}

Go 版本比较迎合 C 风格语法的广大用户的习惯. 不过我不是在解决 Go 的 Channel 写的...

第二个例子是在 UI 组件当中使用. 直接写一个停不下来的 go-loop 是不会被 GC 掉的, 而 UI 组件存在挂载卸载的情况, 而创建一个 Channel 本身就已经是副作用了... 再加上要和组件状态进行配合, 写起来会复杂一点, 只是作为例子来讲, 具体的效果可以看下面这个链接(脚本和字体加载比较慢, 注意网络):


repo.respo.site/debounc

简单说, 文本输入框设置了 500ms 的 debounce, 500ms 之后提交文本内容. 输入过程当中会提示正在输入.

相比写死的 Channel 和 go-loop, 这个例子当中 Channel 是动态创建, 然后绑定到组件 state 当中去的, 其中 mutate! 就是往 state 上存储状态. 输入框的每个输入事件会触发 on-input 内部的函数, 如果 Channel 不存在, 就通过 setup-chan! 创建一个, 等待一串操作结束, Channel 会被关闭.

这段代码有一些新的函数, 大致的意思:

  • merge, 合并两个 HashMap, 生成新的 HashMap, HashMap 的写法就是花括号键值对比如 `{:chan nil}`.
  • `(:chan state)` 这个语法等效 `(get state :chan)` 从 HashMap 当中提取一个值.
  • close! 用来关闭 Channel.
  • some? 用来判断一个数据不是 nil.
  • go 用来声明一个 go block, 在 Go 语言中 CSP 是被设计进语法的, 而在 Clojure 需要通过 Macro 来实现功能, 这个 go 就是一个 Macro.
(defn init-state [& args] {:chan nil, :text ""})
(defn update-state [state new-state] (merge state new-state))

(def time-gap 500)

(defn setup-chan! [text mutate!]
  (let [the-chan (chan)
        timeout-ch (timeout time-gap)]
    (mutate! {:chan the-chan})
    (go-loop [current-text text
              time-ch timeout-ch]
     (let [[v c] (alts! [the-chan time-ch])]
       (if (= c time-ch)
         (do
          (close! the-chan)
          (mutate! {:chan nil, :text current-text}))
         (if (nil? v)
             nil
             (if (= v current-text)
                 (recur v time-ch)
                 (recur v (timeout time-gap)))))))
    the-chan))

(defn on-input [state mutate!]
  (fn [e dispatch!]
    (let [text (:text state)
          input-chan (:chan state)
          the-chan (if (some? input-chan)
                       input-chan
                       (setup-chan! text mutate!))]
      
      (go (>! the-chan (:value e))))))
具体的逻辑是在 setup-chan! 这个函数当中完成的. 创建好需要的 Channel 之后, 就回到了上文的 debounce 逻辑, 等待两个 Channel 的数据, 然后根据数据的来源做不同的处理. 输出数据的代码有一些变化, 我通过 mutate! 把数据往外传递了. 另外考虑到特殊处理数据没有改变的情况, 在 recur 进行尾递归时, 我把控制时间的 Channel 也放在参数里传递了, 作为一个随着条件而变化的状态, 这也是我从别的一些例子学到的一个实用手法.

从例子当中可以看到通过 CSP 的处理, 时间的因素变成了程序当中比较普通的概念, 而对于时间相关任务的流程通知通过 alts! 搭配 condp 也达成了 if/switch 的效果, 所以异步的问题大致就通过平时我们处理同步代码的逻辑控制来解决了. 这对于我们精细地控制异步任务很有帮助. 相比引入复杂的抽象而言, 这应该是比较贴近问题内在复杂度的一个解决方案, 而且熟悉套路之后会发现大部分还是依赖我们从前的知识来解决新的问题.

由于不小心引入了 Go 的代码, Clojure 代码本反衬很难懂. 我想说 Clojure 里对于数据的不可变性和并发有着不同的看法, 将状态放进函数参数实际上就是因为 Clojure 去掉了 Go 的例子当中 var 所实现的可变的变量. 这当然是种约束, 虽然简单场景中是否真的需要不可变数据还值得讨论, 但是对应的场景当中依然有价值(可惜我说不清楚, 去看 InfoQ 吧 https://www.infoq.com/presentations/Value-Values).

当然, 希望多了这篇文章之后 CSP 的概念被更多人了解, 以及 core.async 这个类库. 毕竟把从前处理同步问题的大量知识能用在解决异步问题, 还是挺划算的. 如果你想在 JavaScript 当中使用 CSP, 某种程度上是可以的, 可以用下面这个库, 不过需要确定 JavaScript 引擎支持 yield, 毕竟需要暂停代码:

github.com/ubolonton/js

编辑于 2016-11-08

文章被以下专栏收录