goroutine

并发概念

  回到在 Windows 和 Linux 出现之前的古老年代,程序员在开发程序时并没有并发的概念,因为命令式程序设计语言是以串行为基础的,程序会顺序执行每一条指令,整个程序只有一个执行上下文,即一个调用栈,一个堆。并发则意味着程序在运行时有多个执行上下文,对应着多个调用栈。我们知道每一个进程在运行时,都有自己的调用栈和堆,有一个完整的上下文,而操作系统在调度进程的时候,会保存被调度进程的上下文环境,等该进程获得时间片后,再恢复该进程的上下文到系统中。

  从整个操作系统层面来说,多个进程是可以并发的,那么并发的价值何在?下面我们先看以下几种场景。

  • 一方面我们需要灵敏响应的图形用户界面,一方面程序还需要执行大量的运算或者 IO 密集操作,而我们需要让界面响应与运算同时执行。
  • 当我们的 Web 服务器面对大量用户请求时,需要有更多的Web 服务器工作单元来分别响应用户。
  • 我们的事务处于分布式环境上,相同的工作单元在不同的计算机上处理着被分片的数据。
  • 计算机的 CPU 从单内核(core)向多内核发展,而我们的程序都是串行的,计算机硬件的能力没有得到发挥。
  • 我们的程序因为 IO 操作被阻塞,整个程序处于停滞状态,其他 IO 无关的任务无法执行。

  从以上几个例子可以看到,串行程序在很多场景下无法满足我们的要求。下面归纳了并发程序的几条优点,可以发现并发的势在必行:

  • 并发能更客观地表现问题模型;
  • 并发可以充分利用 CPU 核心的优势,提高程序的执行效率;
  • 并发能充分利用 CPU 与其他硬件设备固有的异步性。

  现在我们已经意识到并发的好处了,那么到底有哪些方式可以实现并发执行呢?就目前而言,并发包含以下几种主流的实现模型。

  • 多进程。多进程是在操作系统层面进行并发的基本模式。同时也是开销最大的模式。在 Linux 平台上,很多工具链正是采用这种模式在工作。比如某个 Web 服务器,它会有专门的进程负责网络端口的监听和链接管理,还会有专门的进程负责事务和运算。这种方法的好处在于简单、进程间互不影响,坏处在于系统开销大,因为所有的进程都是由内核管理的。
  • 多线程。多线程在大部分操作系统上都属于系统层面的并发模式,也是我们使用最多的最有效的一种模式。目前,我们所见的几乎所有工具链都会使用这种模式。它比多进程的开销小很多,但是其开销依旧比较大,且在高并发模式下,效率会有影响。
  • 基于回调的非阻塞/异步 IO。这种架构的诞生实际上来源于多线程模式的危机。在很多高并发服务器开发实践中,使用多线程模式会很快耗尽服务器的内存和 CPU 资源。而这种模式通过事件驱动的方式使用异步 IO,使服务器持续运转,且尽可能地少用线程,降低开销,它目前在 Node.js 中得到了很好的实践。但是使用这种模式,编程比多线程要复杂,因为它把流程做了分割,对于问题本身的反应不够自然。
  • 协程。协程(Coroutine)本质上是一种用户态线程,不需要操作系统来进行抢占式调度,且在真正的实现中寄存于线程中,因此,系统开销极小,可以有效提高线程的任务并发性,而避免多线程的缺点。使用协程的优点是编程简单,结构清晰;缺点是需要语言的支持,如果不支持,则需要用户在程序中自行实现调度器。目前,原生支持协程的语言还很少。

  接下来先诠释一下传统并发模型的缺陷,之后再所说 goroutine 并发模型是如何逐一解决这些缺陷的。

  人的思维模式可以认为是串行的,而且串行的事务具有确定性。线程类并发模式在原先的确定性中引入了不确定性,这种不确定性给程序的行为带来了意外和危害,也让程序变得不可控。线程之间通信只能采用共享内存的方式。为了保证共享内存的有效性,我们采取了很多措施,比如加锁等,来避免死锁或资源竞争。实践证明,我们很难面面俱到,往往会在工程中遇到各种奇怪的故障和问题。

  我们可以将之前的线程加共享内存的方式归纳为共享内存系统,虽然共享内存系统是一种有效的并发模式,但它也暴露了众多使用上的问题。计算机科学家们在近40年的研究中又产生了一种新的系统模型,称为消息传递系统

  对线程间共享状态的各种操作都被封装在线程之间传递的消息中,这通常要求:发送消息时对状态进行复制,并且在消息传递的边界上交出这个状态的所有权。从逻辑上来看,这个操作与共享内存系统中执行的原子更新操作相同,但从物理上来看则非常不同。由于需要执行复制操作,所以大多数消息传递的实现在性能上并不优越,但线程中的状态管理工作通常会变得更为简单。

  最早被广泛应用的消息传递系统是由 C. A. R. Hoare 在他的 Communicating Sequential Processes 中提出的。在 CSP 系统中,所有的并发操作都是通过独立线程以异步运行的方式来实现的。这些线程必须通过在彼此之间发送消息,从而向另一个线程请求信息或者将信息提供给另一个线程。使用类似 CSP 的系统将提高编程的抽象级别。

  随着时间的推移,一些语言开始完善消息传递系统,并以此为核心支持并发,比如 Erlang。

协程

  执行体是个抽象的概念,在操作系统层面有多个概念与之对应,比如操作系统自己掌管的进程(process)、进程内的线程(thread)以及进程内的协程(coroutine,也叫轻量级线程)。与传统的系统级线程和进程相比,协程的最大优势在于其轻量级,可以轻松创建上百万个而不会导致系统资源衰竭,而线程和进程通常最多也不能超过1万个。这也是协程也叫轻量级线程的原因。

  多数语言在语法层面并不直接支持协程,而是通过库的方式支持,但用库的方式支持的功能也并不完整,比如仅仅提供轻量级线程的创建、销毁与切换等能力。如果在这样的轻量级线程中调用一个同步 IO 操作,比如网络通信、本地文件读写,都会阻塞其他的并发执行轻量级线程,从而无法真正达到轻量级线程本身期望达到的目标。

  Go 语言在语言级别支持轻量级线程,叫 goroutine。Go 语言标准库提供的所有系统调用操作(当然也包括所有同步 IO 操作),都会出让 CPU 给其他 goroutine。这让事情变得非常简单,让轻量级线程的切换管理不依赖于系统的线程和进程,也不依赖于 CPU 的核心数量。

goroutine

  goroutine是 Go 语言中的轻量级线程实现,由 Go 运行时(runtime)管理。你将会发现,它的使用出人意料得简单。

  假设我们需要实现一个函数 Add(),它把两个参数相加,并将结果打印到屏幕上,具体代码如下:

func Add(x, y int) {
    z := x + y
    fmt.Println(z)
}

  那么,如何让这个函数并发执行呢?具体代码如下:

go Add(1, 1)

  是不是很简单?

  你应该已经猜到,go 这个单词是关键。与普通的函数调用相比,这也是唯一的区别。的确,go 是 Go 语言中最重要的关键字,这一点从 Go 语言本身的命名即可看出。

  在一个函数调用前加上 go 关键字,这次调用就会在一个新的 goroutine 中并发执行。当被调用的函数返回时,这个 goroutine 也自动结束了。需要注意的是,如果这个函数有返回值,那么这个返回值会被丢弃

package main

import "fmt"

func Add(x, y int) {
    z := x + y
    fmt.Println(z)
}

func main() {
    for i := 0; i < 10; i++ {
        go Add(i, i)
    }
}

  在上面的代码里,在一个 for 循环中调用了10次 Add() 函数,它们是并发执行的。可是当你编译执行了上面的代码,就会发现一些奇怪的现象:

  “什么?!屏幕上什么都没有,程序没有正常工作!”

  是什么原因呢?明明调用了10次 Add(),应该有10次屏幕输出才对。要解释这个现象,就涉及 Go 语言的程序执行机制了。

  Go 程序从初始化 main package 并执行 main() 函数开始,当 main() 函数返回时,程序退出,且程序并不等待其他 goroutine(非主 goroutine)结束。

  对于上面的例子,主函数启动了10个 goroutine,然后返回,这时程序就退出了,而被启动的执行 Add(i, i) 的 goroutine 没有来得及执行,所以程序没有任何输出。

  OK,问题找到了,怎么解决呢?提到这一点,估计写过多线程程序的读者就已经恍然大悟,并且摩拳擦掌地准备使用类似 WaitForSingleObject、sleep 之类的调用,或者写个自己很拿手的忙等待或者稍微先进一些的 sleep 循环等待来等待所有线程执行完毕。

  在 Go 语言中有自己推荐的方式,它要比这些方法都优雅得多。

  要让主函数等待所有 goroutine 退出后再返回,如何知道 goroutine 都退出了呢?这就引出了多个 goroutine 之间通信的问题。

并发通信

  从上面的例子中可以看到,关键字 go 的引入使得在 Go 语言中并发编程变得简单而优雅,但我们同时也应该意识到并发编程的原生复杂性,并时刻对并发中容易出现的问题保持警惕。别忘了,我们的例子还不能正常工作呢。

  事实上,不管是什么平台,什么编程语言,不管在哪,并发都是一个大话题。话题大小通常也直接对应于问题的大小。并发编程的难度在于协调,而协调就要通过交流。从这个角度看来,并发单元间的通信是最大问题。

  在工程上,有两种最常见的并发通信模型:共享数据消息

  共享数据是指多个并发单元分别保持对同一个数据的引用,实现对该数据的共享。被共享的数据可能有多种形式,比如内存数据块、磁盘文件、网络数据等。在实际工程应用中最常见的无疑是内存了,也就是常说的共享内存

  先看看在 C 语言中通常是怎么处理线程间数据共享的,如下代码清单所示。

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

void *count();

pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER;

int counter = 0;

main()
{
    int rc1, rc2;
    pthread_t thread1, thread2;

    /* 创建线程,每个线程独立执行函数functionC */
    if((rc1 = pthread_create(&thread1, NULL, &add, NULL)))
    {
        printf("Thread creation failed: %d\n", rc1);
    }
    if((rc2 = pthread_create(&thread2, NULL, &add, NULL)))
    {
        printf("Thread creation failed: %d\n", rc2);
    }

    /* 等待所有线程执行完毕 */
    pthread_join( thread1, NULL);
    pthread_join( thread2, NULL);
    exit(0);
}

void *count()
{
    pthread_mutex_lock( &mutex1 );
    counter++;
    printf("Counter value: %d\n",counter);
    pthread_mutex_unlock( &mutex1 );
}

  现在尝试将这段 C 语言代码直接翻译为 Go 语言代码,如下代码清单所示。

package main

import "fmt"
import "sync"
import "runtime"

var counter int = 0

func Count(lock *sync.Mutex) {
    lock.Lock()
    counter++
    fmt.Println(z)
    lock.Unlock()
}

func main() {
    lock := &sync.Mutex{}

    for i := 0; i < 10; i++ {
        go Count(lock)
    }

    for {
        lock.Lock()
        c := counter
        lock.Unlock()
        runtime.Gosched()
        if c >= 10 {
            break
        }
    }
}

  此时这个例子终于可以正常工作了。在上面的例子中,我们在10个 goroutine 中共享了变量 counter。每个 goroutine 执行完成后,将 counter 的值加1。因为10个 goroutine 是并发执行的,所以我们还引入了锁,也就是代码中的 lock 变量。每次对 n 的操作,都要先将锁锁住,操作完成后,再将锁打开。在主函数中,使用 for 循环来不断检查 counter 的值(同样需要加锁)。当其值达到10时,说明所有 goroutine 都执行完毕了,这时主函数返回,程序退出。

  事情好像开始变得糟糕了。实现一个如此简单的功能,却写出如此臃肿而且难以理解的代码。想象一下,在一个大的系统中具有无数的锁、无数的共享变量、无数的业务逻辑与错误处理分支,那将是一场噩梦。这噩梦就是众多 C/C++ 开发者正在经历的,其实 Java 和 C# 开发者也好不到哪里去。

  Go 语言既然以并发编程作为语言的最核心优势,当然不至于将这样的问题用这么无奈的方式来解决。Go 语言提供的是另一种通信模型,即以消息机制而非共享内存作为通信方式。

  消息机制认为每个并发单元是自包含的、独立的个体,并且都有自己的变量,但在不同并发单元间这些变量不共享。每个并发单元的输入和输出只有一种,那就是消息。这有点类似于进程的概念,每个进程不会被其他进程打扰,它只做好自己的工作就可以了。不同进程间靠消息来通信,它们不会共享内存。

  Go 语言提供的消息通信机制被称为 channel接下来我们将详细记录 channel。现在,用 Go 语言社区的那句著名的口号来结束这一小节:

  不要通过共享内存来通信,而应该通过通信来共享内存。

发表评论

电子邮件地址不会被公开。 必填项已用*标注