Golang的并发编程(2)

作者: adm 分类: go 发布时间: 2024-08-08

channel
channel是Go语言在语言级别提供的goroutine间的通信方式。
我们可以使用channel在两个或多个goroutine之间传递消息。channel是进程内的通信方式,因此通过channel传递对象的过程和调用函数时的参数传递行为比较一致,比如也可以传递指针等。如果需要跨进程通信,我们建议用分布式系统的方法来解决,比如使用Socket或者HTTP等通信协议。Go语言对于网络方面也有非常完善的支持。

channel是类型相关的。也就是说,一个channel只能传递一种类型的值,这个类型需要在声明channel时指定。如果对Unix管道有所了解的话,就不难理解channel,可以将其认为是一种类型安全的管道。

在了解channel的语法前,我们先看下用channel的方式重写上面的例子是什么样子的,以此对channel先有一个直感的认识,如代码清单所示。

package main 
import "fmt" 
func Count(ch chan int) { 
 ch <- 1 
 fmt.Println("Counting") 
} 
func main() { 
 chs := make([]chan int, 10) 
 for i := 0; i < 10; i++ { 
 chs[i] = make(chan int) 
 go Count(chs[i]) 
 } 
 for _, ch := range(chs) { 
 <-ch 
 } 
}

在这个例子中,我们定义了一个包含10个channel的数组(名为chs),并把数组中的每个channel分配给10个不同的goroutine。在每个goroutine的Add()函数完成后,我们通过ch <- 1语句向对应的channel中写入一个数据。在这个channel被读取前,这个操作是阻塞的。在所有的goroutine启动完成后,我们通过<-ch语句从10个channel中依次读取数据。在对应的channel写入数据前,这个操作也是阻塞的。这样,我们就用channel实现了类似锁的功能,进而保证了所有goroutine完成后主函数才返回。是不是比共享内存的方式更简单、优雅呢? 我们在使用Go语言开发时,经常会遇到需要实现条件等待的场景,这也是channel可以发挥作用的地方。对channel的熟练使用,才能真正理解和掌握Go语言并发编程。下面我们学习下channel的基本语法。 基本语法 一般channel的声明形式为:

var chanName chan ElementType

与一般的变量声明不同的地方仅仅是在类型之前加了chan关键字。ElementType指定这个channel所能传递的元素类型。举个例子,我们声明一个传递类型为int的channel:

var ch chan int

或者,我们声明一个map,元素是bool型的channel:

var m map[string] chan bool

上面的语句都是合法的。
定义一个channel也很简单,直接使用内置的函数make()即可:

ch := make(chan int)

这就声明并初始化了一个int型的名为ch的channel。 在channel的用法中,最常见的包括写入和读出。将一个数据写入(发送)至channel的语法
很直观,如下:

ch <- value

向channel写入数据通常会导致程序阻塞,直到有其他goroutine从这个channel中读取数据。从channel中读取数据的语法是

value := <-ch

如果channel之前没有写入数据,那么从channel中读取数据也会导致程序阻塞,直到channel中被写入数据为止。我们之后还会提到如何控制channel只接受写或者只允许读取,即单向channel。

select
早在Unix时代,select机制就已经被引入。通过调用select()函数来监控一系列的文件句柄,一旦其中一个文件句柄发生了IO动作,该select()调用就会被返回。后来该机制也被用于实现高并发的Socket服务器程序。Go语言直接在语言级别支持select关键字,用于处理异步IO问题。

select的用法与switch语言非常类似,由select开始一个新的选择块,每个选择条件由case语句来描述。与switch语句可以选择任何可使用相等比较的条件相比,select有比较多的限制,其中最大的一条限制就是每个case语句里必须是一个IO操作,大致的结构如下:

select { 
 case <-chan1: 
 // 如果chan1成功读到数据,则进行该case处理语句
 case chan2 <- 1: 
 // 如果成功向chan2写入数据,则进行该case处理语句
default: 
 // 如果上面都没有成功,则进入default处理流程
}

可以看出,select不像switch,后面并不带判断条件,而是直接去查看case语句。每个case语句都必须是一个面向channel的操作。比如上面的例子中,第一个case试图从chan1读取一个数据并直接忽略读到的数据,而第二个case则是试图向chan2中写入一个整型数1,如果这两者都没有成功,则到达default语句。
基于此功能,我们可以实现一个有趣的程序:

ch := make(chan int, 1) 
for { 
 select { 
 case ch <- 0: 
 case ch <- 1: 
 } 
 i := <-ch 
 fmt.Println("Value received:", i) 
}

能看明白这段代码的含义吗?其实很简单,这个程序实现了一个随机向ch中写入一个0或者1的过程。当然,这是个死循环。

缓冲机制
之前我们示范创建的都是不带缓冲的channel,这种做法对于传递单个数据的场景可以接受,但对于需要持续传输大量数据的场景就有些不合适了。接下来我们介绍如何给channel带上缓冲,从而达到消息队列的效果。
要创建一个带缓冲的channel,其实也非常容易:

c := make(chan int, 1024)

在调用make()时将缓冲区大小作为第二个参数传入即可,比如上面这个例子就创建了一个大小为1024的int类型channel,即使没有读取方,写入方也可以一直往channel里写入,在缓冲区被填完之前都不会阻塞。

从带缓冲的channel中读取数据可以使用与常规非缓冲channel完全一致的方法,但我们也可以使用range关键来实现更为简便的循环读取:

for i := range c { 
 fmt.Println("Received:", i) 
}

超时机制
在之前对channel的介绍中,我们完全没有提到错误处理的问题,而这个问题显然是不能被忽略的。在并发编程的通信过程中,最需要处理的就是超时问题,即向channel写数据时发现channel已满,或者从channel试图读取数据时发现channel为空。如果不正确处理这些情况,很可能会导致整个goroutine锁死。

虽然goroutine是Go语言引入的新概念,但通信锁死问题已经存在很长时间,在之前的C/C++开发中也存在。操作系统在提供此类系统级通信函数时也会考虑入超时场景,因此这些方法通常都会带一个独立的超时参数。超过设定的时间时,仍然没有处理完任务,则该方法会立即终止并返回对应的超时信息。超时机制本身虽然也会带来一些问题,比如在运行比较快的机器或者高速的网络上运行正常的程序,到了慢速的机器或者网络上运行就会出问题,从而出现结果不一致的现象,但从根本上来说,解决死锁问题的价值要远大于所带来的问题。
使用channel时需要小心,比如对于以下这个用法:

i := <-ch

不出问题的话一切都正常运行。但如果出现了一个错误情况,即永远都没有人往ch里写数据,那么上述这个读取动作也将永远无法从ch中读取到数据,导致的结果就是整个goroutine永远阻塞并没有挽回的机会。如果channel只是被同一个开发者使用,那样出问题的可能性还低一些。但如果一旦对外公开,就必须考虑到最差的情况并对程序进行保护。

Go语言没有提供直接的超时处理机制,但我们可以利用select机制。虽然select机制不是专为超时而设计的,却能很方便地解决超时问题。因为select的特点是只要其中一个case已经完成,程序就会继续往下执行,而不会考虑其他case的情况。
基于此特性,我们来为channel实现超时机制:

// 首先,我们实现并执行一个匿名的超时等待函数
timeout := make(chan bool, 1) 
go func() { 
 time.Sleep(1e9) // 等待1秒钟
 timeout <- true
}() 
// 然后我们把timeout这个channel利用起来
select { 
 case <-ch: 
 // 从ch中读取到数据
 case <-timeout: 
 // 一直没有从ch中读取到数据,但从timeout中读取到了数据
}

这样使用select机制可以避免永久等待的问题,因为程序会在timeout中获取到一个数据后继续执行,无论对ch的读取是否还处于等待状态,从而达成1秒超时的效果。
这种写法看起来是一个小技巧,但却是在Go语言开发中避免channel通信超时的最有效方法。
在实际的开发过程中,这种写法也需要被合理利用起来,从而有效地提高代码质量。

channel的传递
需要注意的是,在Go语言中channel本身也是一个原生类型,与map之类的类型地位一样,因此channel本身在定义后也可以通过channel来传递。
我们可以使用这个特性来实现*nix上非常常见的管道(pipe)特性。管道也是使用非常广泛的一种设计模式,比如在处理数据时,我们可以采用管道设计,这样可以比较容易以插件的方式增加数据的处理流程。
下面我们利用channel可被传递的特性来实现我们的管道。为了简化表达,我们假设在管道中传递的数据只是一个整型数,在实际的应用场景中这通常会是一个数据块。
首先限定基本的数据结构:

type PipeData struct { 
 value int
 handler func(int) int
 next chan int
}

然后我们写一个常规的处理函数。我们只要定义一系列PipeData的数据结构并一起传递给这个函数,就可以达到流式处理数据的目的:

func handle(queue chan *PipeData) { 
 for data := range queue { 
 data.next <- data.handler(data.value) 
 } 
}

这里我们只给出了大概的样子,限于篇幅不再展开。同理,利用channel的这个可传递特性,我们可以实现非常强大、灵活的系统架构。相比之下,在C++、Java、C#中,要达成这样的效果,通常就意味着要设计一系列接口。
与Go语言接口的非侵入式类似,channel的这些特性也可以大大降低开发者的心智成本,用一些比较简单却实用的方式来达成在其他语言中需要使用众多技巧才能达成的效果。

单向channel
顾名思义,单向channel只能用于发送或者接收数据。channel本身必然是同时支持读写的,否则根本没法用。假如一个channel真的只能读,那么肯定只会是空的,因为你没机会往里面写数据。同理,如果一个channel只允许写,即使写进去了,也没有丝毫意义,因为没有机会读取里面的数据。所谓的单向channel概念,其实只是对channel的一种使用限制。

我们在将一个channel变量传递到一个函数时,可以通过将其指定为单向channel变量,从而限制该函数中可以对此channel的操作,比如只能往这个channel写,或者只能从这个channel读。
单向channel变量的声明非常简单,如下:

var ch1 chan int // ch1是一个正常的channel,不是单向的
var ch2 chan<- float64// ch2是单向channel,只用于写float64数据
var ch3 <-chan int // ch3是单向channel,只用于读取int数据

那么单向channel如何初始化呢?之前我们已经提到过,channel是一个原生类型,因此不仅支持被传递,还支持类型转换。只有在介绍了单向channel的概念后,读者才会明白类型转换对于channel的意义:就是在单向channel和双向channel之间进行转换。示例如下:

ch4 := make(chan int) 
ch5 := <-chan int(ch4) // ch5就是一个单向的读取channel
ch6 := chan<- int(ch4) // ch6 是一个单向的写入channel

基于ch4,我们通过类型转换初始化了两个单向channel:单向读的ch5和单向写的ch6。
为什么要做这样的限制呢?从设计的角度考虑,所有的代码应该都遵循“最小权限原则”,从而避免没必要地使用泛滥问题,进而导致程序失控。写过C++程序的读者肯定就会联想起const指针的用法。非const指针具备const指针的所有功能,将一个指针设定为const就是明确告诉函数实现者不要试图对该指针进行修改。单向channel也是起到这样的一种契约作用。
下面我们来看一下单向channel的用法:

func Parse(ch <-chan int) { 
 for value := range ch { 
 fmt.Println("Parsing value", value) 
 } 
}

除非这个函数的实现者无耻地使用了类型转换,否则这个函数就不会因为各种原因而对ch进行写,避免在ch中出现非期望的数据,从而很好地实践最小权限原则。

关闭channel
关闭channel非常简单,直接使用Go语言内置的close()函数即可:

close(ch)

在介绍了如何关闭channel之后,我们就多了一个问题:如何判断一个channel是否已经被关闭?我们可以在读取的时候使用多重返回值的方式:

x, ok := <-ch

这个用法与map中的按键获取value的过程比较类似,只需要看第二个bool返回值即可,如果返回值是false则表示ch已经被关闭。

多核并行化
在执行一些昂贵的计算任务时,我们希望能够尽量利用现代服务器普遍具备的多核特性来尽量将任务并行化,从而达到降低总计算时间的目的。此时我们需要了解CPU核心的数量,并针对性地分解计算任务到多个goroutine中去并行运行。
下面我们来模拟一个完全可以并行的计算任务:计算N个整型数的总和。我们可以将所有整型数分成M份,M即CPU的个数。让每个CPU开始计算分给它的那份计算任务,最后将每个CPU的计算结果再做一次累加,这样就可以得到所有N个整型数的总和:

type Vector []float64
// 分配给每个CPU的计算任务
func (v Vector) DoSome(i, n int, u Vector, c chan int) { 
 for ; i < n; i++ { 
 v[i] += u.Op(v[i]) 
 } 
 c <- 1 // 发信号告诉任务管理者我已经计算完成了
} 
const NCPU = 16 // 假设总共有16核
func (v Vector) DoAll(u Vector) { 
c := make(chan int, NCPU) // 用于接收每个CPU的任务完成信号
for i := 0; i < NCPU; i++ { 
go v.DoSome(i*len(v)/NCPU, (i+1)*len(v)/NCPU, u, c) 
} 
 // 等待所有CPU的任务完成
 for i := 0; i < NCPU; i++ { 
 <-c // 获取到一个数据,表示一个CPU计算完成了
 } 
 // 到这里表示所有计算已经结束
}

这两个函数看起来设计非常合理。DoAll()会根据CPU核心的数目对任务进行分割,然后开辟多个goroutine来并行执行这些计算任务。
是否可以将总的计算时间降到接近原来的1/N呢?答案是不一定。如果掐秒表(正常点的话,应该用7.8节中介绍的Benchmark方法),会发现总的执行时间没有明显缩短。再去观察CPU运行状态,你会发现尽管我们有16个CPU核心,但在计算过程中其实只有一个CPU核心处于繁忙状态,这是会让很多Go语言初学者迷惑的问题。

官方的答案是,这是当前版本的Go编译器还不能很智能地去发现和利用多核的优势。虽然我们确实创建了多个goroutine,并且从运行状态看这些goroutine也都在并行运行,但实际上所有这些goroutine都运行在同一个CPU核心上,在一个goroutine得到时间片执行的时候,其他goroutine都会处于等待状态。从这一点可以看出,虽然goroutine简化了我们写并行代码的过程,但实际上整体运行效率并不真正高于单线程程序。

在Go语言升级到默认支持多CPU的某个版本之前,我们可以先通过设置环境变量GOMAXPROCS的值来控制使用多少个CPU核心。具体操作方法是通过直接设置环境变量GOMAXPROCS的值,或者在代码中启动goroutine之前先调用以下这个语句以设置使用16个CPU核心:

runtime.GOMAXPROCS(16)

到底应该设置多少个CPU核心呢,其实runtime包中还提供了另外一个函数NumCPU()来获取核心数。可以看到,Go语言其实已经感知到所有的环境信息,下一版本中完全可以利用这些信息将goroutine调度到所有CPU核心上,从而最大化地利用服务器的多核计算能力。抛弃GOMAXPROCS只是个时间问题。

出让时间片
我们可以在每个goroutine中控制何时主动出让时间片给其他goroutine,这可以使用runtime包中的Gosched()函数实现。
实际上,如果要比较精细地控制goroutine的行为,就必须比较深入地了解Go语言开发包中runtime包所提供的具体功能。

同步
我们之前喊过一句口号,倡导用通信来共享数据,而不是通过共享数据来进行通信,但考虑到即使成功地用channel来作为通信手段,还是避免不了多个goroutine之间共享数据的问题,Go语言的设计者虽然对channel有极高的期望,但也提供了妥善的资源锁方案。

同步锁
Go语言包中的sync包提供了两种锁类型:sync.Mutex和sync.RWMutex。Mutex是最简单的一种锁类型,同时也比较暴力,当一个goroutine获得了Mutex后,其他goroutine就只能乖乖等到这个goroutine释放该Mutex。RWMutex相对友好些,是经典的单写多读模型。在读锁占用的情况下,会阻止写,但不阻止读,也就是多个goroutine可同时获取读锁(调用RLock()方法;而写锁(调用Lock()方法)会阻止任何其他goroutine(无论读和写)进来,整个锁相当于由该goroutine独占。从RWMutex的实现看,RWMutex类型其实组合了Mutex:

type RWMutex struct { 
 w Mutex 
 writerSem uint32
 readerSem uint32
 readerCount int32
 readerWait int32
}

对于这两种锁类型,任何一个Lock()或RLock()均需要保证对应有Unlock()或RUnlock()调用与之对应,否则可能导致等待该锁的所有goroutine处于饥饿状态,甚至可能导致死锁。锁的典型使用模式如下:

var l sync.Mutex 
func foo() { 
 l.Lock() 
 defer l.Unlock() 
 //... 
}

这里我们再一次见证了Go语言defer关键字带来的优雅。

全局唯一性操作
对于从全局的角度只需要运行一次的代码,比如全局初始化操作,Go语言提供了一个Once类型来保证全局的唯一性操作,具体代码如下:

var a string
var once sync.Once 
func setup() { 
 a = "hello, world" 
} 
func doprint() { 
 once.Do(setup) 
 print(a) 
} 
func twoprint() { 
 go doprint() 
 go doprint() 
}

如果这段代码没有引入Once,setup()将会被每一个goroutine先调用一次,这至少对于这个例子是多余的。在现实中,我们也经常会遇到这样的情况。Go语言标准库为我们引入了Once类型以解决这个问题。once的Do()方法可以保证在全局范围内只调用指定的函数一次(这里指setup()函数),而且所有其他goroutine在调用到此语句时,将会先被阻塞,直至全局唯一的once.Do()调用结束后才继续。这个机制比较轻巧地解决了使用其他语言时开发者不得不自行设计和实现这种Once效果的难题,也是Go语言为并发性编程做了尽量多考虑的一种体现。

如果没有once.Do(),我们很可能只能添加一个全局的bool变量,在函数setup()的最后一行将该bool变量设置为true。在对setup()的所有调用之前,需要先判断该bool变量是否已经被设置为true,如果该值仍然是false,则调用一次setup(),否则应跳过该语句。实现代码如下所示:

var done bool = false
func setup() { 
 a = "hello, world" 
 done = true
} 
func doprint() { 
 if !done { 
 setup() 
 } 
print(a) 
}

这段代码初看起来比较合理,但是细看还是会有问题,因为setup()并不是一个原子性操作,这种写法可能导致setup()函数被多次调用,从而无法达到全局只执行一次的目标。这个问题的复杂性也更加体现了Once类型的价值。
为了更好地控制并行中的原子性操作,sync包中还包含一个atomic子包,它提供了对于一些基础数据类型的原子操作函数,比如下面这个函数:
func CompareAndSwapUint64(val *uint64, old, new uint64) (swapped bool)
就提供了比较和交换两个uint64类型数据的操作。这让开发者无需再为这样的操作专门添加Lock操作。

如果觉得我的文章对您有用,请随意赞赏。您的支持将鼓励我继续创作!