2

发现一个月没刷技术文章了, 有点慌, 整理一篇短的 CSP 用法出来,
只包含最基本的用法, 在 Go 里边最清晰, 不过我是在 Clojure 写的 CSP.
js 版本的 CSP 实现包含异步, 用法会更繁琐一些, 但是也值得看看.
我相信 async/await 普及之前, js-csp 还是一个很有意思的选择.

我的代码写的是 CoffeeScript, 可以自动脑补圆括号花括号上去...
注意包含 yield 的函数自动被转成 function*() {}, 所以注意脑补.
脑补不出来的只好贴在这边编译下了 http://coffeescript.org/

使用 timeout

首先是最基本的 CSP 的例子, 也就是用同步的代码写异步的逻辑,
CSP 当中最核心的概念是 Channel, 最简单的 csp.timeout(1000) 创建 channel.

csp = require 'js-csp'

# 用 csp.go 启动一个 yield 函数
csp.go ->
  # 有 csp.take 从这个管道取出数据, yield 来模拟阻塞的效果
  yield csp.take csp.timeout(1000)
  console.log 'Gone 1s'

运行一下:

=>> coffee async.coffee
Gone 1s

我注意到对于 timeout 来说, 省掉 csp.take 也是能够正常运行的:

csp = require 'js-csp'

csp.go -> # 脑补 yield 函数

  yield csp.timeout 1000
  console.log 'Gone 1s'
  yield csp.timeout 2000
  console.log 'Gone 2s'
  yield csp.timeout 3000
  console.log 'Gone 3s. End'

运行一下:

=>> coffee async.coffee
Gone 1s
Gone 2s
Gone 3s. End

使用 put 和 take

csp.timeout 比较特殊, 默认就会产生数据, 只要进行 csp.take 就好了.
一般的 Channel 的话, 需要手动创建出来, 然后手动推数据,
比如下面的代码创建了一个数据, 用 csp.go 启动另一个"进程"往 Channel 推数据,
这里的"进程"的说法并不是真正的进程, 只是模拟进程的行为:

csp = require 'js-csp'

talk = (ch) ->
  yield csp.timeout 3000
  console.log 'Done 3s timeout'
  # 等待 3s 然后往 Channel 当中写入数据, yield 会产生等待
  yield csp.put ch, 'some result'

csp.go ->
  ch = csp.chan()

  # 启动另一个"进程"
  csp.go talk, [ch] # 数组里是传给 talk 函数的参数

  # 使用 yield.take 从 Channel 取出数据, 使用 yield 模拟等待
  result = yield csp.take ch
  console.log 'Result:', JSON.stringify(result)

运行一下:

=>> coffee async.coffee
Done 3s timeout
Result: "some result"

假装有两个进程

同样是上边的代码, 只是调整一下写法, 看上去像是分别启动了两个"进程",
虽然它们的运行时独立的, 但是可以通过管道进行通信,
而且在对应的 csp.takecsp.put 操作过程中, 会通过 yield 进行等待:

csp = require 'js-csp'

talk = (ch) ->
  yield csp.timeout 3000
  console.log 'Done 3s timeout'
  yield csp.put ch, 'some result'

listen = (ch) ->
  result = yield csp.take ch
  console.log 'Result:', JSON.stringify(result)

# 创建 Channel, 启动两个"进程"
theCh = csp.chan()
# csp.go 后面第一个是 yield 函数, 第二个是参数的数组, 虽然比较难看
csp.go talk, [theCh]
csp.go listen, [theCh]

运行一下:

=>> coffee async.coffee
Done 3s timeout
Result: "some result"

封装异步事件

实际使用当中, 会需要把 js 环境的异步代码封装成管道的形式,
不封装成管道, 就不能借助 csp.go 来封装同步代码了,
由于 js 不像 Go 那样整个语言层面做了处理, 实际上会有奇怪的写法,
所以 js-csp 提供了 csp.putAsynccsp.takeAsync:

csp = require 'js-csp'

talk = (ch) ->
  setTimeout ->
    csp.putAsync ch, 'some result'
    console.log 'Finished 3s of async'
  , 3000

listen = (ch) ->
  result = yield csp.take ch
  console.log 'Result:', JSON.stringify(result)

theCh = csp.chan()
talk theCh
csp.go listen, [theCh]

运行一下:

=>> coffee async.coffee
Finished 3s of async
Result: "some result"

处理超时

一个操作是否超时的问题, 可以同时启动一个定时的"进程",
然后观察两个"进程"哪一个先执行完成, 从而判断是否超时,
这就用到了 csp.alts 函数, 这个奇怪的命名是用 Clojure 带过来的:

csp = require 'js-csp'

talk = (ch) ->
  time = Math.random() * 4 * 1000
  setTimeout ->
    console.log "Get result after #{time}ms"
    csp.putAsync ch, 'some result'
  , time

listen = (ch) ->
  hurry = csp.timeout 2000
  # 通过 csp.alts 同时等待多个 Channel 返回数据
  result = yield csp.alts [ch, hurry]
  # result.channel 可以用于判断数据的来源, result.value 才是真正的数据
  if result.channel is hurry
    console.log 'Too slow, got no result'
    # close 只是设置 Channel 的状态, 其实还需要手工处理一些逻辑
    hurry.close()
  else
    console.log 'Fast enough, got', JSON.stringify(result.value)

theCh = csp.chan()
talk theCh
csp.go listen, [theCh]

用了随机数, 运行多次试一下, 可以看到根据不同的时间, 结果是不一样的:

=>> coffee async.coffee
Too slow, got no result
Get result after 3503.6168682995008ms

=>> coffee async.coffee
Too slow, got no result
Get result after 3095.264637685924ms

=>> coffee async.coffee
Get result after 703.6501633183257ms
Fast enough, got "some result"

=>> coffee async.coffee
Too slow, got no result
Get result after 3729.5125755664317ms

=>> coffee async.coffee
Get result after 101.51519531067788ms
Fast enough, got "some result"

循环任务

yield 用法类似, 如果有循环的代码, 也可以用 CSP 写出来,
这个的话不用怎么想应该能明白了, loop 只是 while true 的语法糖:

csp = require 'js-csp'

chatter = (ch) ->
  counter = 0
  loop
    yield csp.timeout 1000
    counter += 1
    yield csp.put ch, counter

repeat = (ch) ->
  loop
    something = yield csp.take ch
    console.log 'Hear something:', something

theCh = csp.chan()
csp.go chatter, [theCh]
csp.go repeat, [theCh]

运行一下:

=>> coffee async.coffee
Hear something: 1
Hear something: 2
Hear something: 3
Hear something: 4
^C

多个数据的消费者

实际场景当中会遇到多个消费者从单个生产者读取数据的需求,
这是一个用 Channel 比较合适的场景, 启动两个"进程"读取一个 Channel 就好了,
下面我模拟的是不同的处理时间 300ms 和 800ms 读取 100ms 频率的数据,
因为 CSP 自动处理了等待, 整个代码看上去挺简单的:

csp = require 'js-csp'

chatter = (ch) ->
  counter = 0
  loop
    yield csp.timeout 100
    counter += 1
    yield csp.put ch, counter

repeat = (ch) ->
  loop
    yield csp.timeout 800
    something = yield csp.take ch
    console.log 'Hear at 1:', something

repeat2 = (ch) ->
  loop
    yield csp.timeout 300
    something = yield csp.take ch
    console.log 'Hear at 2:', something

theCh = csp.chan()
csp.go chatter, [theCh]
csp.go repeat, [theCh]
csp.go repeat2, [theCh]

运行一下:

=>> coffee async.coffee
Hear at 2: 1
Hear at 2: 2
Hear at 1: 3
Hear at 2: 4
Hear at 2: 5
Hear at 2: 6
Hear at 1: 7
Hear at 2: 8
Hear at 2: 9
Hear at 1: 10
Hear at 2: 11
Hear at 2: 12
Hear at 2: 13
Hear at 1: 14
Hear at 2: 15
Hear at 2: 16
Hear at 1: 17
Hear at 2: 18
Hear at 2: 19
Hear at 2: 20
Hear at 1: 21
Hear at 2: 22
Hear at 2: 23
Hear at 1: 24
^C

使用 buffer

默认情况下管道是阻塞的, csp.put csp.take 成对进行,
也就是说, 只有一个就绪的话, 它会等待另一个开始, 然后一起执行,
但是用 buffer 的话, 管道就会先在一定范围内进行缓存,
这样 csp.put 就可以先运行下去了, 这个是不难理解的...
管道实际上有 3 种策略, fixed, dropping, sliding:

  • fixed, 缓存放满以后就会开始形成阻塞了

  • dropping, 缓存满了以后, 新的数据就会丢弃

  • sliding, 缓存满以后, 会丢弃掉旧的数据让新数据能放进缓存

随便演示一个丢弃数据的例子:

csp = require 'js-csp'

chatter = (ch) ->
  counter = 0
  loop
    yield csp.timeout 200
    counter += 1
    console.log 'Write data:', counter
    yield csp.put ch, counter

repeat = (ch) ->
  loop
    yield csp.timeout 300
    something = yield csp.take ch
    console.log 'Hear:', something

theCh = csp.chan(csp.buffers.dropping(3))
csp.go chatter, [theCh]
csp.go repeat, [theCh]

运行一下, 可以看到 "Hear" 部分丢失了一些数据, 但前三个数据不会丢:

=>> coffee async.coffee
Write data: 1
Hear: 1
Write data: 2
Hear: 2
Write data: 3
Write data: 4
Hear: 3
Write data: 5
Hear: 4
Write data: 6
Write data: 7
Hear: 5
Write data: 8
Hear: 6
Write data: 9
Write data: 10
Hear: 7
Write data: 11
Hear: 8
Write data: 12
Write data: 13
Hear: 9
Write data: 14
Hear: 11
Write data: 15
Write data: 16
Hear: 12
Write data: 17
Hear: 14
^C

小结

由于 CSP 是在 Go 语言发明的, 完整的用法还是看 Go 的教程比较好,
到了 Clojure 和 js 当中难免会增加一些坑, 特别是 js 当中...
上面提到的 API 在 js-csp 的文档上有描述, 例子也有, 但是挺少的:

另外还有一些高级一点的用法, 比如数据的 transform 和 pipe 之类的,
其实就是 Stream 的用法在 Channel 上的改版, 某种程度上 Channel 也是 Stream,
对于我个人来说, Channel 的抽象比起 Stream 的抽象舒服多了.


题叶
17.3k 声望2.6k 粉丝

Calcit 语言作者