Golang核心编程(7)-使用协程和通道进行并发通信

版权声明: https://blog.csdn.net/pbrlovejava/article/details/84033168


更多关于Golang核心编程知识的文章请看:Golang核心编程(0)-目录页


优雅的并发编程范式,完善的并发支持,出色的并发性能是Go语言区别于其他语言的一大特色。

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

一、协程

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

1.1、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 关键字,这次调用就会在一个新的goroutine中并发执行。当被调用的函数返回时,这个goroutine也自动结束了

二、并发通信

现有如下的例子,add.go:

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)
}
}

执行是结果是什么呢?屏幕上什么都没有,程序没有正常工作。

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

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

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

在循环外加上一条代码,令main()函数先等待,就可以看到打印的结果:
time.Sleep(1000)

2.1、传统并发通信方式

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

2.2、Go的并发通信方式

Go语言提供的是另一种通信模型,即以消息机制而非共享内存作为通信方式

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

Go语言提供的消息通信机制被称为channel

所以,在Go语言中有一句口号:

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

三、channel

channel是Go语言在语言级别提供的goroutine间的通信方式。我们可以使用channel在两个或多个goroutine之间传递消息。channel是进程内的通信方式,因此通过channel传递对象的过程和调用函数时的参数传递行为比较一致,比如也可以传递指针等。

channel是类型相关的。也就是说,一个channel只能传递一种类型的值,这个类型需要在声明channel时指定。

一个例子说明channel:

package main
import "fmt"
func Count(ch chan int) {
	ch <- 1
	fmt.Println("Counting")
}
func main() {
	chs := make([]chan int10)
	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完成后主函数才返回。

3.1、基本语法

一般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)

在channel的用法中,最常见的包括写入和读出。将一个数据写入(发送)至channel的语法很直观,如下:

ch <- value

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

value := <-ch

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

3.2、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 语句。

3.3、缓冲机制

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

c := make(chan int, 1024)

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

3.4、超时机制

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

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

// 首先,我们实现并执行一个匿名的超时等待函数
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秒超时的效果。

3.5、关闭channel

关闭channel非常简单,直接使用Go语言内置的 close()函数即可:
close(ch)在介绍了如何关闭channel之后,我们就多了一个问题:如何判断一个channel是否已经被关闭?我们可以在读取的时候使用多重返回值的方式:

x, ok := <-ch

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

四、同步锁和读写锁

Go语言包中的 sync 包提供了两种锁类型: sync.Mutexsync.RWMutexMutex 是最简单的一种锁类型,同时也比较暴力,当一个goroutine获得了 Mutex 后,其他goroutine就只能乖乖等到这个goroutine释放该 Mutex 。基于同步锁机制。

RWMutex 相对友好些,是经典的单写多读模型。在读锁占用的情况下,会阻止写,但不阻止读,也就是多个goroutine可同时获取读锁(调用 RLock() 方法;而写锁(调用 Lock() 方法)会阻止任何其他goroutine(无论读和写)进来,整个锁相当于由goroutine独占。基于读写锁机制。

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

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

猜你喜欢

转载自blog.csdn.net/pbrlovejava/article/details/84033168