深入CSP:从设计哲学看Go并发的本质

在 go 语言的世界里,我们经常听到一句话:"不要通过共享内存来通信,而要通过通信来共享内存",这句话是 CSP 并发编程的核心,是 go 团队设计并发模型的指导思想,也是 go 程序员编写并发代码需要遵守的规范,但是这句话本身并没有指出 CSP 并发编程到底有什么优势,简而言之,这是思想,而非优势。我看到的很多讲 go 语言 CSP 并发编程模型的文章也主要是讲怎么用管道(channel),却很少深入探讨这背后的真正优势。所以我打算在这篇文章详细地说明一下 CSP 的设计哲学以及它到底为什么能帮助我们解决传统并发编程中的痛点。

并发编程核心需要解决的问题:

首先我们需要想明白并发编程的痛点在哪,或者说并发编程本质上需要解决什么问题,只有想明白问题是什么,才能去考虑为什么 CSP 优雅地解决了这些问题。实际上并发编程主要是要解决以下两个问题:

1. 线程间的通信:也就是说如何在线程之间交换数据,当你启动一个线程去做一件事情的时候,总得通过一种方式知道这件事情执行的结果吧,这就需要线程间能够交换数据。

2. 线程间的同步:也就是指我们必须在某个线程到达某种状态时才能让另一个线程继续执行,比如主线程控制一系列工作线程,通过前一个线程的结果才能知道后一个线程要做什么,这就需要线程之间做同步,或者说做等待。

管道则非常优雅、简单地同时解决了这两个问题,在管道中发送、接收数据即可完成不同线程间的通信,在一方没有发送数据或接收数据时,另一方自动进入等待,从而完成线程间的同步,可以说真的是非常完美的做法。我们可以写两段并发代码对比一下基于管道的方式和传统的基于锁的方式:

基于管道的方式:

go 复制代码
func main() {
    ch := make(chan int)

    go func() {
       // 模拟做一些操作
       time.Sleep(time.Second)
       // 操作结束后写入结果
       ch <- 42
    }()

    // 等待工作协程完成操作并读取结果
    res := <-ch
    fmt.Println(res)
}

可以看到整个代码真的非常流畅,启动一个协程完成一些操作,然后通过管道告知执行的结果,主协程在等待工作协程完成操作时自动进入阻塞,在工作协程完成工作后,自动恢复阻塞并读取到结果,非常简单、优雅地完成了整个逻辑。并且仔细想一下,其实绝大多数并发场景都是处理这些事,一方完成工作并写回结果,另一方等待完成并读取结果, 管道就提供了一种很简单的方式处理这些场景。

传统的基于锁+条件变量的方式:

go 复制代码
func main() {
    var (
       mu    sync.Mutex
       cond  = sync.NewCond(&mu)
       queue []int
    )

    go func() {
       mu.Lock()
       // 模拟做一些操作
       time.Sleep(1 * time.Second)
       // 写入结果
       queue = append(queue, 42)
       // 通知等待的消费者
       cond.Signal()
       mu.Unlock()
    }()

    mu.Lock()
    // 等待工作协程完成操作
    for len(queue) == 0 {
       cond.Wait()
    }
    // 读取结果
    res := queue[0]
    queue = queue[1:]
    mu.Unlock()
    fmt.Println(res)
}

这段代码和上面那段代码其实完成的都是同一件事,但直观看起来就非常啰嗦、凌乱,而且有一种关注了太多底层细节的感觉。因为这段代码缺乏像管道那样的上层抽象,我们只能从锁这种比较底层的并发原语实现业务逻辑,用锁去保护一段共享的内存,然后在这个内存中读取其他协程产出的结果,也就是所谓的"通过共享内存来通信"。同时只用锁做并发保护还无法解决协程间同步的问题,故还得引入条件变量机制,在工作协程获取到结果之后通知主协程可以获取结果,在没有处理完成之前,主协程基于条件变量进行等待。

通过上面两段代码的对比,可以看到基于管道进行协程间的通信和同步是非常优雅且易于理解的,让我们更加深刻地理解"不要通过共享内存来通信,而要通过通信来共享内存",归根到底是因为"通过通信来共享内存"提供了一种十分易于理解、易于使用的上层抽象,这个抽象在 go 中也就是管道(channel)。

当两个协程想要交换一份数据时,直接通过管道这样一个更上层的对象进行数据收发,绝对要好过我们自己通过锁来共享一份变量,更何况管道在进行数据收发的时候,如果有协程没有准备好,管道的写入、读取都会直接进入阻塞,相当于把条件变量线程同步的那份工作也做了,只能说真的是非常完美的设计,管道以一种简单易用却又十分优雅地方式同时解决了协程间通信和同步的问题。

如何理解CSP:

CSP 的全称为 Communicating Sequential Processes,通信顺序进程。到底如何理解这个名词呢?其实可以逐个单词理解:

Process(进程):指的是独立运行程序的实体,在 go 中就是指协程。

Communication(通信):指的是解决并发编程的核心手段,也就是指通过管道通信来进行协程间的数据交换和同步等待。

Sequential(顺序):指最终编写的并发程序的风格就好像单线程顺序执行一样。

其实顺序是我们追求的最终目标,我们肯定都希望并发程序写起来和单线程顺序执行的程序一样简单,单线程的程序要好理解得多。

通过管道这种方式,几乎可以说是完成了这个目标,所有协程是并发执行的,但是将视角集中到单个协程内部,代码和单线程的程序几乎是一致的,当你需要将数据给到其他协程时只需要向管道发送数据就好,完全不用管其他协程此刻在做什么,如果没有协程能接收数据,当前协程自动进入阻塞,同样地如果当前协程还没发送,那么等待接收的协程自动进入阻塞,完全不用有同时考虑多个协程运行到哪里的心智负担,每个协程只要关注于自己要进行收发的管道就好了。至于锁、条件变量之类的底层细节也完全封装于管道内部,业务逻辑完全不用关心,这也会降低程序员的心智负担。

现在可以一句话总结一下:CSP 就是通过管道通信这种方式将复杂的并发问题拆解为多个易于理解的顺序过程。

是否有了管道就一定不能用锁:

我之前看到过有人分享自己的代码,里面用锁保护了一些会并发读写的数据,然后有一些评论说 go 里面不要通过共享内存来通信,而要通过通信来共享内存,不要用锁,要用管道,我觉得这就有点教条主义了,我们写代码用各种思想、各种技巧归根到底是为了易读,如果只是为了保护一段并发读写的数据显然用锁更易懂,同时性能也更好。

之前甚至看到过有一些面试题让你反过来用管道来实现锁,这就纯粹是为了面试而面试了,管道的底层结构就是一个锁、一个环形缓冲、一个等待接收数据的协程队列、一个等待发送数据的协程队列,使用管道收发数据时一开始也要抢锁以进行并发保护,现在再用管道反过来实现锁的逻辑,我觉得这其实没有任何意义。

整体而言,如果只是为了保护局部的数据,比如封装一个并发安全的map,那用锁就可以了,管道适合更加上层、更加复杂的场景,比如想设计一个并发程序,由很多个协程同时工作,彼此之间需要传递数据或者控制执行的状态,这个时候用管道就非常合适,写出来的代码也一定会因为妥善地使用管道而非常清晰。

使用管道的典型场景:

我们可以举一些典型的例子:

比如 go http 标准库就有一个很典型的用法,一个 http.Client 可以并发地发送多个不同 host 及 port 的 http 请求,比如同时发送 baidu.com 的请求和 juejin.cn 的请求,不同 host 使用不同的 tcp 连接发送请求,并且这个 tcp 连接需要复用,下次出现同一个 host 及 port 的 http 请求就不用再次创建连接了,直接使用空闲的 tcp 连接。

这样基本确定了该如何设计代码,首先每个 tcp 连接都需要在一个单独的协程中运行,因为不同连接的请求及响应是需要支持并发进行的,并且这个连接不能在一次请求响应结束后就关闭,而是要持续在协程中等待新的请求,那我们就需要一种手段控制多个协程中运行的 tcp 连接写入请求、读取响应,显然管道就是最好的选择。可以简单看下源码:

go 复制代码
func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) {
    pconn = &persistConn{
       t:             t,
       cacheKey:      cm.key(),
       reqch:         make(chan requestAndChan, 1), 
       // 写管道,用以接收需要写入 tcp 连接的请求
       writech:       make(chan writeRequest, 1),      
       closech:       make(chan struct{}),
       writeErrCh:    make(chan error, 1),
       writeLoopDone: make(chan struct{}),
    }

    ...

    conn, err := t.dial(ctx, "tcp", cm.addr())
    if err != nil {
       return nil, wrapErr(err)
    }
    pconn.conn = conn

    ...

    pconn.br = bufio.NewReaderSize(pconn, t.readBufferSize())
    pconn.bw = bufio.NewWriterSize(persistConnWriter{pconn}, t.writeBufferSize())

    // 启动协程,并发地进行不同连接的读写
    go pconn.readLoop()
    go pconn.writeLoop()
    return pconn, nil
}

以写入请求为例:

go 复制代码
func (pc *persistConn) writeLoop() {
    defer close(pc.writeLoopDone)
    for {
       select {
       // 接收需要发送的请求
       case wr := <-pc.writech:
          startBytesWritten := pc.nwrite
          // 请求写入 tcp 连接
          err := wr.req.Request.write(pc.bw, pc.isProxy, wr.req.extra, pc.waitForContinue(wr.continueCh))
          if bre, ok := err.(requestBodyReadError); ok {
             err = bre.error
             // Errors reading from the user's
             // Request.Body are high priority.
             // Set it here before sending on the
             // channels below or calling
             // pc.close() which tears down
             // connections and causes other
             // errors.
             wr.req.setError(err)
          }
          // 刷新缓冲区
          if err == nil 
             err = pc.bw.Flush()
          }
          if err != nil {
             if pc.nwrite == startBytesWritten {
                err = nothingWrittenError{err}
             }
          }
          pc.writeErrCh <- err // to the body reader, which might recycle us
          wr.ch <- err         // to the roundTrip function
          if err != nil {
             pc.close(err)
             return
          }
       case <-pc.closech:
          return
       }
    }
}

可以看到写入请求的逻辑是很简单的,就是不断读取自己的 writech 管道,没有请求就阻塞,有了请求就将请求写入当前的 tcp 连接,同时也监听了 closech 管道,如果连接超过最大生存时间被关闭,closech 收到消息就会结束循环,避免协程泄露,其实 tcp 连接的申请也是用管道实现的,因为连接的创建也是个耗时操作,不能让程序阻塞在多次的 dail 上,故多个连接的创建也是并发进行的,连接创建完成便会写入管道中供申请者使用。

其实我们看这段标准库的代码会觉得并不复杂,感觉就和自己设想的一样,这就是 go 语言最大的魅力所在,所有人设想的代码几乎都是一样的。但假如没有管道呢,向多个协程中的tcp连接发送请求、读取响应绝不会这么流畅易懂。

golang 的 context 包也是管道的典型用法,context 包的核心是提供了一种在协程上下文之间传递截止时间、取消信号、元数据的机制。Context 对象的核心方法 Done() 返回一个 chan struct{},当 Context 对象被取消或超时时,这个管道会被 closeselect 语句通过监听这个 Done 管道的关闭来判断是否需要停止操作,故管道在 context 中完美地实现了一种信号广播机制。

经过上面的介绍,我们已经感受到基于 CSP 写并发代码是多么的优雅了,也明白了 CSP 到底是什么含义。

还需要注意的一点是 CSP 解决的是并发编程的可读性、易用性问题,并没有提升 go 并发的性能,我之前看过一些 "go 基于管道实现了超高的并发性"之类的话,这其实不太严谨,如果非要比较,管道大多数情况下应该都不会比锁加条件变量性能更好,毕竟锁更底层,直接用更加底层的东西肯定能写出性能更好的代码,管道的价值在于大大简化了多线程编程的复杂度,而 go 的高并发主要得益于协程足够轻量并且协程的调度、切换完全在用户态完成。

最后想说不要把管道仅仅作为一个内存级的消息队列,虽然这确实是个典型用法,但绝不是管道的全部,实际上应该说所有需要多个协程通信和同步的场景都可以用管道优雅地实现。

相关推荐
这里有鱼汤3 小时前
低价股的春天来了?花姐用Python带你扒一扒
后端·python
shark_chili3 小时前
程序员必读:CPU内存访问的底层原理与优化策略
后端
用户6120414922134 小时前
springmvc做的学生考勤管理系统
javascript·后端·spring
IT_陈寒4 小时前
SpringBoot性能翻倍的7个隐藏配置,90%开发者从不知道!
前端·人工智能·后端
月夕·花晨4 小时前
Gateway -网关
java·服务器·分布式·后端·spring cloud·微服务·gateway
绝无仅有4 小时前
面试之MySQL 高级实战& 优化篇经验总结与分享
后端·面试·github
绝无仅有4 小时前
某云大厂面试之Go 实际问题及答案
后端·面试·github
程序员爱钓鱼12 小时前
Go语言实战案例 — 工具开发篇:实现一个图片批量压缩工具
后端·google·go