Go 语言:基于协程的并发模型

Go 语言最大的特色就是拥有可以高效实现并发的 goroutine,既然谈到并发,前几天看到一篇关于 Java 的并发模型解析,今天干脆来梳理一下 Go 语言开发并发编程模型。

c10k

c10k,即 Client 10000, 单机服务器同时服务10000个客户端。当然这是早期的并发瓶颈,现在早已经达到了 c1000k,甚至更高。但是 c10k 已经成了并发瓶颈的代名词。早期的服务器是基于进程/线程模型,每新来一个连接,就分配一个进程(线程)去处理这个连接。而进程(线程)在操作系统中,占有一定的资源。由于硬件的限制,进程(线程)的创建是有瓶颈的。另外进程(线程)的上下文切换也有成本:每次调度器调度线程,操作系统都要把线程的各种必要的信息,如程序计数器、堆栈、寄存器、状态等保存起来。

CPU 运算远远快于 I/O 操作。但是,一般而言,常见的互联网应用都是 I/O 密集型而非计算密集型。I/O 密集型是指,计算机 CPU 大量的时间都花在等待数据的输入输出,而不是计算。所以 CPU 大部分时间都在等待 I/O,CPU的资源就被浪费了。

所以,简单粗暴地开一个进程/线程去 handle 一个连接是不够的。为了达到高并发,我们就必须好好设计一下 I/O 策略。

在这之前,我们先来了解一下一些基本的I/O相关概念:

  • 同步和异步:
    同步和异步的区别就是对调用结果的获取方式,被动获取就是异步,主动获取就是同步。
    同步,是调用对象主动查看调用的状态,随时等待,不可以同时做其他事情,主动轮询就是一个同步过程;
    异步,则是其他线程(可能是被调用对象,也可能是一个帮助关注调用结果的中间对象)来通知调用对象,调用对象可以同时做其他事情,当调用结果更新,调用对象就会收到通知,再去处理调用结果
  • 阻塞和非阻塞
    阻塞和非阻塞的区别是调用后被调用对象是否立即返回,调用对象是不是在等待。
    阻塞:A 调用完 B,就在调用处等待(阻塞),直到 B 方法返回才继续执行剩下的代码,这就是阻塞调用。
    非阻塞:非阻塞则是 A 方法调用 B 方法,B 方法立即返回,A 可以继续执行下面的代码,不会等在这里,不会被该调用阻塞。

如何实现并发

并发:逻辑上具有处理多个同时性任务的能力。
显然,单进程/线程是没有办法同时处理多个任务的能力的,所以,我们需要引进多进程或者多线程。

多进程并发

主进程监听和管理连接,当有客户请求的时候,fork 一个子进程来处理连接,父进程继续等待其他客户的请求。但是进程占用服务器资源是比较多的,服务器负载会很高。

这种架构的最大的好处是隔离性,子进程万一 crash 并不会影响到父进程。缺点就是对系统的负担过重。

多进程开发在客户端开发比较多,例如QQ,微信等软件,其实多会开启多个线程处理。服务端采用这种方式开发一般有如下两种模式:

  1. Prefork MPM : 使用多个子进程,但每个子进程不包含多线程。每个进程只处理一个连接。在许多系统上它的速度和worker MPM一样快,但是需要更多的内存。这种无线程的设计在某些性况下优于 worker MPM,因为它可在应用于不具备线程安全的第三方模块上(如 PHP3/4/5),且在不支持线程调试的平台上易于调试,另外还具有比worker MPM更高的稳定性。
  2. Worker MPM : 使用多个子进程,每个子进程中又有多个线程。每个线程处理一个请求,该MPM通常对高流量的服务器是一个不错的选择。因为它比prefork MPM需要更少的内存且更具有伸缩性。

Apache 服务器就是基于以上两种模式实现并发的。

多线程并发

和多进程的方式类似,只不过是替换成线程。主线程负责监听、accept()连接,子线程(工作线程)负责处理业务逻辑和流的读取。子线程阻塞,同一进程内的其他线程不会被阻塞。

多线程的缺点就是:

  1. 要频繁地创建、销毁线程,一般可以用线程池进行优化。
  2. 要处理同步的问题,当多个线程请求同一个资源时,需要用锁之类的手段来保证线程安全。同步处理不好会影响数据的安全性,也会拉低性能。
  3. 一个线程的崩溃会导致整个进程的崩溃。

基于协程的并发

现在进入重点,Go 语言拥有协程这种高效并发的工具,协程是什么呢?

协程也称为用户态线程,是基于非抢占式的调度来实现的。进程、线程是操作系统级别的概念,而协程是编译器级别的。它运行在用户空间,不受系统调度。它有自己的调度算法。在上下文切换的时候,协程在用户空间切换,而不是陷入内核做线程的切换,减少了开销。简单地理解,就是 Go 编译器提供一套自己的 runtime(而非内核)来做调度,做上下文的保存和恢复,重新实现了一套自己独有的并发机制。系统的并发是时间片的轮转,单处理器交互执行不同的执行流,营造不同线程同时执行的感觉;而协程的并发,是单线程内控制权的轮转。相比抢占式调度,协程是主动让权,实现协作。协程的优势在于,相比回调的方式,写的异步代码可读性更强。缺点在于,因为是用户级线程,利用不了多核机器的并发执行。

在并发实现中,引入线程是为了分离进程的两个功能:资源分配和系统调度。让更细粒度、更轻量的线程来承担调度,减轻调度带来的开销。但线程还是不够轻量,因为调度是在内核空间进行的,每次线程切换都需要陷入内核,这个开销还是不可忽视的。协程则是把调度逻辑在用户空间里实现,通过自己模拟控制权的交接,来达到更加细粒度的控制。

Go 语言协程的调度可以参看我的前一篇文章[Go的GMP模型]()

知道协程的调度之后,我们来具体看看基于协程的并发模型
Java 中的的多线程编程,是用共享内存的方式来进行同步的。但当并行度变高,不确定性就增加了,需要用锁等机制保证正确性,但锁用得不好,容易拉低系统性能。所以,Go 语言有一种新的并发编程模型,用消息通信的方式来实现并发。

「GO 语言哲学」有一句话“Don’t communicate by sharing memory, share memory by communicating”(不要通过共享内存来通信,而应该通过通信来共享内存),Go 的 CSP 就是基于这种思想的并发编程模型。

goroutine,对应发送消息的实体。 channel 对应 mailbox,是传递消息的载体。actor 和 mailbox 是耦合的。channel 是作为 first-class 独立存在的(这在 golang 中很明显),channel 是匿名的。mailbox 是异步的,channel 一般是同步的(在 golang 里,channel 有同步模式,也可以设置缓冲区大小实现异步)。