Go语言并发编程,并发指在同一时间内可以执行多个任务。大家可以观察到,电脑、平板、手机,它们都可以一边播放音乐一边玩游戏,同时还能上网聊天,每个程序都要同时渲染画面和发出声音。随着科技的发展与人类需求的增长,并发变得越来越重要。一台Web服务器会一次处理成千上万的请求。出色的并发性是Go语言的特色之一,详细介绍Go语言的并发机制。
并发和并行
Ø 单道程序与多道程序
回到在Windows和Linux出现之前的古老年代,计算机编程使用单道程序设计模型,既串行,所有任务一个一个排序执行。一个任务运行完成之后,另一个任务才会被读取。即使CPU空闲,在人机交互时必须阻塞,不能同时播放音乐和浏览网页。显然,串行程序在很多场景下无法满足客户的要求
现代计算机编程采用多道程序设计模型,多个任务轮流使用CPU (当下常见CPU为纳秒级, 1秒可以执行近10亿条指令)。人们在使用计算机时可以边听音乐边上网,是因为人眼的反应速度是毫秒级,所以看似任务同时在运行。这种可以概括为,宏观并行,微观串行。
Ø 并发与并行的区别
在讨论如何在Go中进行并发处理之前,首先必须了解什么是并发,以及它与并行性有什么不同。
1. 并发
并发(Concurrency)是同时处理许多个任务。实际上是把任务在不同的时间点交给处理器进行处理。在微观层面,任务不会同时运行。
2. 并行
并行(Parallelism)是把一个任务分配给每一个处理器独立完成。多个任务一定是同时运行。并行就是同时做很多事情。乍听起来可能与并发类似,但实际上是不同的。
串行、并行、并发的区别如图所示。
生活中也有类似的场景,比如许多人去打水,多人排队使用一个水龙头,每个人一次性打满水后才轮到下一个人,这种情况就是串行。显然排在后面的人需要等很久才能打到水。
当多人使用一个水龙头时,每个人只能一次接水5秒,用完水后再去排队,这种情况就是并发。此时排在后面的人不需要等太久就能用到水了,宏观上看他们可以同时用到水。
当4个人同时使用4个水龙头打水时,这种情况就是并行,要求打水人数和水龙头数量相等才能做到。但是实际情况是需要打水的人往往要比水龙头的数量多,所以多数情况还是需要并发处理。
Ø 程序与进程
程序是编译好的二进制文件,在磁盘上,不占用系统资源(CPU、内存、设备)。进程是活跃的程序,占用系统资源。在内存中执行。程序运行起来,产生一个进程。程序就像是剧本,进程就像是演戏,同一个剧本可以在多个舞台同时上演。同样,同一个程序也可以加载为不同的进程(彼此之间互不影响),比如同时运行两个QQ。
Ø 进程与线程的区别
线程也叫轻量级进程,通常一个进程包含若干个线程;线程可以利用进程所拥有的资源,在引入线程的操作系统中,通常都是把进程作为分配资源的基本单位,而把线程作为独立运行和独立调度的基本单位,比如音乐进程,可以一边查看排行榜一边听音乐,互不影响。
Ø 进程与线程的联系
进程和线程是操作系统级别的两个基本概念。计算机的核心是CPU,它承担了所有的计算任务。它就像一座工厂,时刻在运行。进程就好比工厂的车间,它代表CPU所能处理的单个任务。进程是一个容器。线程就好比车间里的工人。一个进程可以包括多个线程,线程是容器中的工作单位。
Ø 协程的概念
协程(Coroutine),最初在1963年被提出。又称为微线程。是一种比线程更加轻量级的存在。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。如图所示。
协程是编译器级的,进程和线程是操作系统级的。协程不被操作系统内核管理,而完全是由程序控制。因此没有线程切换的开销。和多线程比,线程数量越多,协程的性能优势就越明显。协程的最大优势在于其轻量级,可以轻松创建上万个而不会导致系统资源衰竭。
Ø Go语言中的协程
Go语言中的协程叫做Goroutine,Goroutine由Go程序运行时(runtime)调度和管理,Go程序会智能地将Goroutine中的任务合理地分配给每个CPU。创建Goroutine的成本很小。每个Goroutine的堆栈只有几kb,且堆栈可以根据应用程序的需要增长和收缩。
Ø Coroutine和Goroutine
Goroutine可能并行执行;但是Coroutine只能顺序执行;Goroutine可在多线程环境产生,Coroutine只能发生在单线程,Coroutine程序需要主动交出控制权,系统才能获得控制权并将控制权交给其他Coroutine。
Coroutine的运行机制属于协作式任务处理,应用程序在不使用CPU时,需要主动交出CPU使用权。如果开发者无意间让应用程序长时间占用CPU,操作系统也无能为力,计算机很容易失去响应或者死机。
Goroutine属于抢占式任务处理,和现有的多线程和多进程任务处理非常类似。应用程序对CPU的控制最终需要有操作系统来管理,如果操作系统如何发现一个应用程序常时间占用CPU,那么用户有权终止这个任务。
Ø 普通函数创建Goroutine
在函数或方法调用前面加上关键字go,将会同时运行一个新的Goroutine。
使用go关键字创建Goroutine时,被调用的函数往往没有返回值,如果有返回值也会被忽略。如果需要在Goroutine中返回数据,必须使用channel,通过channel把数据从Goroutine中作为返回值传出。
Go程序的执行过程是:创建和启动主Goroutine,初始化操作,执行main()函数,当main()函数结束,主Goroutine随之结束,程序结束。使用方式参见。
被启动的Goroutine叫做子Goroutine。如果main()的Goroutine终止了,程序将被终止,而其他Goroutine将不再运行。换句话说,所有Goroutine在main()函数结束时会一同结束。如上例所示。如果main()的Goroutine比子Goroutine先终止,运行的结果就不会打印Hello world goroutine。修改后如例所示。
接下来再看一个案例,如例所示。
该案例中,Go程序在启动时,运行时runtime默认为main()函数创建一个Goroutine。在main()函数的Goroutine执行到go running()语句时,归属于running()函数的Goroutine被创建,running()函数开始在自己的goroutine中执行。此时,main()继续执行,两个Goroutine通过GO程序的调度机制同时运行。
Ø 匿名函数创建Goroutine
go关键字后也可以是匿名函数或闭包。将例12-3修改为匿名函数形式,参见。
Ø 启动多个Goroutine
下面通过一个案例展示多个Goroutines启动效果。参见。
Ø 调整并发的运行性能
在Go程序运行时,runtime实现了一个小型的任务调度器。此调度器的工作原理类似于操作系统调度线程,Go程序调度器可以高效地将CPU资源分配给每一个任务。在多个Goroutines的情况下,可以使用runtime.Gosched()交出控制权。
传统逻辑中,开发者需要维护线程池中的线程与CPU核心数量的对应关系。在Go语言中可以通过runtime.GOMAXPROCS()函数做到。
语法为格式如下所示。
逻辑CPU数量有几种数值,如表所示。
数值 | 含义 |
<1 | 不修改任何数值 |
=1 | 单核执行 |
>1 | 多核并发执行 |
Go1.5版本之前,默认使用单核执行。Go1.5版本开始,默认执行:runtime.GOMAXPROCS(逻辑CPU数量),让代码并发执行,最大效率地利用CPU。
Ø chanenl的概述
Channels即Go的通道,是协程之间的通信机制。一个channel是一条通信管道,它可以让一个协程通过它给另一个协程发送数据。每个channel都需要指定数据类型,即channel可发送数据的类型。如果使用channel发送int类型数据,可以写成chan int。数据发送的方式如同水在管道中的流动。
传统的线程之间可以通过共享内存进行数据交互,不同的线程之间对共享内存的同步问题需要使用锁来解决,这样会导致性能低下。Go语言中提倡使用channel的方式代替共享内存。换言之,Go语言主张通过数据传递来实现共享内存,而不是通过共享内存来实现消息传递。
Ø 创建channel类型
声明channel类型的语法格式如下所示。
chan类型的空置是nil,声明后需要配合make才能使用。
channel是引用类型,需要使用make进行创建,语法格式如下所示。
具体创建语法如下所示。
Ø 使用channel发送数据
channel发送使用特殊的操作符"<-",将数据通过channel发送的语法格式如下所示。
channel发送的值的类型必须与channel的元素类型一致。如果接收方一直没有接收,那么发送操作将持续阻塞。此时所有的goroutine,包括main的goroutine都处于等待状态。
运行会提示报错:fatal error: all goroutines are asleep - deadlock!
使用channel时要考虑发生死锁(deadlock)的可能。如果Goroutine在一个channel上发送数据,其他的Goroutine应该接收得到数据。如果这种情况没有发生,那么程序将在运行时出现死锁。如果Goroutine正在等待从channel接收数据,其他一些Goroutine将会在该channel上写入数据,否则程序将会死锁。
Ø 使用channel接收数据
channel收发操作在不同的两个Goroutine间进行。语法格式有四种。
1. 阻塞接收数据
channel接收同样使用特殊的操作符"<-"。语法格式如下所示。
执行该语句时将会阻塞,直到接收到数据并赋值给data变量。
2. 完整写法
阻塞接收数据的完整写法如下所示。
data 表示接收到的数据。未接收到数据时,data为channel类型的零值。
ok表示是否接收到数据。通过ok值可以判断当前channel是否被关闭。
3. 忽略接收数据
接收任意数据,忽略接收的数据,语法格式如下所示。
执行该语句时将会阻塞。其目的不在于接收channel中数据,而是为了阻塞goroutine。
4. 循环接收数据
循环接收数据,需要配合使用关闭channel,借助普通for循环和for ... range语句循环接收多个元素。遍历channel,遍历的结果就是接收到的数据,数据类型就是channel的数据类型。普通for循环接收channel数据,需要有break循环的条件;for range会自动判断出channel已关闭,而无需通过判断来终止循环。循环接收数据的三种语法格式参见教材例12-6。
Ø 阻塞
channel默认是阻塞的。当数据被发送到channel时会发生阻塞,直到有其他Goroutine从该channel中读取数据。当从channel读取数据时,读取也会被阻塞,直到其他Goroutine将数据写入该channel。这些channel的特性是帮助Goroutines有效地通信,而不需要使用其他语言中的显式锁或条件变量。
阻塞基本用法。
Ø 关闭channel
发送方如果数据写入完毕,需要关闭channel,用于通知接受方数据传递完毕。一般都是发送方关闭channel。通过多重返回值判断channel是否关闭,如果返回值是false,则表示channel已经被关闭。如果往关闭的channel中写入数据,会报错:panic: send on closed channel。但是可以从关闭后的channel中取数据,返回数据的默认值和false。
Ø 缓冲channel
默认创建的都是非缓冲channel,读写都是即时阻塞。缓冲channel自带一块缓冲区,可以暂时存储数据,如果缓冲区满了,就会发生阻塞。下面通过案例对比缓冲channel与非缓冲channel,参见。
非缓冲channel部分的打印结果是输入数据和接收数据交替的,这说明读写都是即时阻塞。缓冲channel部分的打印数据输入完毕以后才打印接收数据,这意味着当缓冲区没有满的情况下是非阻塞的。
Ø 单向channel
channel默认都是双向的。即可读可写。定向channel也叫单向channel,只读,或只写。
只读channel使用方式如下所示。
只写channel使用方式如下所示。
创建channel时,采用单向channel是没有意义的。通常都是创建双向channel。然后将channel作为参数传递的时候使用单向channel。
time包中与channel相关的函数、select分支语句、sync包
Ø Timer结构体
计时器类型表示单个事件。当计时器过期时,当前时间将被发送到C上(C是一个只读channel<-chan time.Time,该channel中放入的是Time结构体),除非计时器是AfterFunc创建的。计时器必须使用NewTimer()或After()创建。
Timer结构体的源码定义如下所示。
Ø NewTimer()函数
NewTimer创建一个新的计时器,它会在至少持续时间d之后将当前时间发送到其channel上。
NewTimer()函数的源码如下所示。
具体使用方式参见。
Ø After()函数
After()函数相当于NewTimer(d). C。
After()函数的源码如下所示。
具体使用方式参见。
Ø select分支语句—执行流程
select语句的机制有点像switch语句,不同的是,select会随机挑选的一个可通信的case来执行,如果所有case都没有数据到达,则执行default,如果没有default语句,select就会阻塞,直到有case接收到数据。
Ø select分支语句—示例代码
select分支语句的用法参见。
Ø sync包
sync包提供了互斥锁。除了Once和WaitGroup类型,其余多数适用于低水平的程序,多数情况下,高水平的同步使用channel通信性能会更优一些。sync包类型的值不应被拷贝。
前面的案例中,一般使用time.Sleep()函数,通过睡眠将主线程阻塞至所有线程结束。而更好的做法是使用WaitGroup来实现。
Ø 同步等待组
同步的sync是串行执行,异步的sync是同时执行。
WaitGroup同步等待组,定义如下所示。
WaitGroup,等待一组线程结束。父线程调用Add方法来设置应等待线程的数量。每个被等待的线程在结束时应该调用Done方法。与此同时,主线程里可调用Wait方法阻塞至所有线程结束。
WaitGroup中的方法如下所示。
Add()方法向内部计数加上delta,delta可以是负数;如果内部计数器变为0,Wait方法阻塞等待的所有线程都会释放,如果计数器小于0,则该方法panic。注意Add()加上正数的调用应在Wait之前,否则Wait可能只会等待很少的线程。通常来说本方法应该在创建新的线程或者其他应该等待的事件之前调用。
Done()方法减少WaitGroup计数器的值,应在线程的最后执行,定义如下所示。
Wait()方法阻塞直到WaitGroup计数器减为0。定义如下所示。
Ø 互斥锁
互斥锁的定义如下所示。
Mutex是一个互斥锁,可以创建为其他结构体的字段;零值为解锁状态。Mutex类型的锁和线程无关,可以由不同的线程加锁和解锁。
Mutex中的方法如下所示。
Lock()方法锁住m,如果m已经加锁,则阻塞直到m解锁。
Unlock()方法解锁m,如果m未加锁会导致运行时错误。锁和线程无关,可以由不同的线程加锁和解锁。
Ø 读写互斥锁
读写锁的定义如下所示。
读写互斥锁的定义方式如下所示。
RWMutex是读写互斥锁,简称读写锁。该锁可以被同时多个读取者持有或唯一个写入者持有。RWMutex可以创建为其他结构体的字段;零值为解锁状态。RWMutex类型的锁也和线程无关,可以由不同的线程加读取锁/写入和解读取锁/写入锁。
读写锁的使用中,写操作都是互斥的,读和写是互斥的,读和读不互斥。
该规则可以理解为,可以多个Goroutine同时读取数据,但是只允许一个Goroutine写数据。
Mutex中的方法如下所示。
Lock()方法将rw锁定为写入状态,禁止其他线程读取或者写入。
Unlock()方法解除rw的写入锁状态,如果m未加写入锁会导致运行时错误。
RLock()方法将rw锁定为读取状态,禁止其他线程写入,但不禁止读取。
Runlock方法解除rw的读取锁状态,如果m未加读取锁会导致运行时错误。
Rlocker()方法返回一个互斥锁,通过调用rw.Rlock和rw.Runlock实现了Locker接口。
Ø 条件变量
条件变量定义如下所示。
Cond实现了一个条件变量,一个线程集合地,供线程等待或者宣布某事件的发生。
每个Cond实例都有一个相关的锁(一般是*Mutex或*RWMutex类型的值),它须在改变条件时或者调用Wait方法时保持锁定。Cond可以创建为其他结构体的字段,Cond在开始使用后不能被拷贝。条件变量:sync.Cond,多个goroutine等待或接受通知的集合地
Cond中的方法定义如下所示。
使用锁l创建一个*Cond。Cond条件变量,总是要和锁结合使用。
Broadcast()唤醒所有等待c的线程。调用者在调用本方法时,建议(但并非必须)保持c.L的锁定。
Signal()唤醒等待c的一个线程(如果存在)。调用者在调用本方法时,建议(但并非必须)保持c.L的锁定。发送通知给一个人。
Wait自行解锁c.L并阻塞当前线程,在之后线程恢复执行时,Wait()方法会在返回前锁定c.L。和其他系统不同,Wait()除非被Broadcast()或者Signal()唤醒,不会主动返回。广播给所有人。
因为线程中Wait()方法是第一个恢复执行的,而此时c.L未加锁。调用者不应假设Wait()恢复时条件已满足,相反,调用者应在循环中等待。
具体使用方式如例所示。
Go语言并发编程小结
本篇首先介绍了Goroutine的特性以及使用方法,其次是Channnel的使用方法,然后是select的机制,最后是sync与time包的使用。本章的内容至关重要,尤其在Go服务器的编程中用处颇多,大家多加练习就可以编写出高可用的并发服务器了。