并发概念
回到在 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 语言社区的那句著名的口号来结束这一小节:
不要通过共享内存来通信,而应该通过通信来共享内存。
文章评论