11 go并发编程-上

其他编程语言并发编程的效果

并发编程可以让开发者实现并行的算法以及编写充分利用多核处理器和多核性能的程序。在当前大部分主流的编程语言里,如C,C++,java等,编写维护和调试并发程序相比单线程程序而言要困难的多。而且也不可能总是为了使用多线程而将一个过程切分成更小的粒度来处理。whatever,由于线程本身的性能损耗,多线程编程不一定要能够达到我们想要的性能,而且容易犯错。
还有一种解决方法就是使用多进程,但是这个劣势就是如何处理所有进程间通信的问题,通常这个比共享内存的并发模型有更多的开销。

go并发编程的优点

  1. 首先go并发编程提供了上层支持,因此正确处理并发是很容易做到的。
  2. 用来处理并发的goroutine比线程更加轻量。
  3. 并发程序的内存管理有时候非常复杂,而go语言提供了自动垃圾回收机制。

go为并发编程而内置的上层API基于CSP模型(communicating Sequential Processes)。这意味着显式锁(意思是在恰当的时候上锁和解锁所需要关心的东西)都是可以避免的。因为Go语言通过线程安全的通道发送和接收数据实现同步,大大简化了并发程序的编写。这样普通的台式机能够轻松跑成千上万的goroutine进行资源竞争。

goroutine

在并发编程里面,我们通常把一个过程切分成好几块,然后每个goroutine各自负责一块工作,除此之外还有main()函数也是一个单独的goroutine来执行(为了方便起见,我们将main()函数所在的goroutine称为主goroutine,其他附加创建出来负责处理相应工作的goroutine简称为工作goroutine。) 每个工作goroutine执行完毕后可以立即将结果输出,或者所有工作goroutine都完成后统一处理。
这里有两个可能会发生的错误说下:

  1. 当程序完成时我们没有得到任何结果。是因为主goroutine退出后,其他的工作goroutine也会自动退出,所以我们必须非常小心地保证所有工作goroutine都完成后才能让主goroutine退出。
  2. 当所有工作完成后,主goroutine和工作goroutine还存活,这种情况通常由于工作完成了但是主goroutine无法获得goroutine的完成状态。
  3. 当两个不同的goroutine都锁定了受保护的资源而且同时去获得对方资源的时候。

channel

通道为并发运行的goroutine之间提供了一个无锁通信方式。当一个通道发生通信时,发送通道和接收通道(包括他们对应的goroutine)都处于同步状态。
默认情况下,通道都是双向的,也就是说既可以往里面发送数据也可以从里面接收数据。但是我们经常将一个通道作为参数进行传递而只希望对方单向使用的,那么只让它发送数据,要么接收数据,这个时候我们就需要指明通道方向了,例如 chan<- Type类型就是一个只发送数据的通道。
本质上来说,在通道里传输布尔类型、整型或者float64类型的值都是安全。因为都是传送的副本,所以并发时如果不小心大家都访问了一个相同的值,这也没有什么风险。同样发送字符串也是安全的,因为Go里面不允许修改字符串。
但是如果通道里面传送指针或者引用类型(如切片或者映射)的安全性,因为指针指向的内容或者所引用的值可能在对方接收到的时候被发送方更改了。所以当涉及到指针和引用的时候,我们必须保证这些值在任何时候只能被一个goroutine访问得到。也就是对这些值访问时串行的。
除了使用互斥实现串行访问,还有一个方法就是设定一个规则,一旦指针或者应用发送了之后发送方就不会再访问它了,然后让接受者来访问和释放指针或者引用指向的值。如果双方都发送指针或者引用的话,那么就发送方和接受方都要应用这种机制。
通道里面还可以传送接口类型的值,也就是说只要实现了这个接口定义的所有方法,就可以使用这个接口的方式在通道里面传输。只读型接口的值可以在任何多个goroutine里使用,但是某些值,它虽然实现了这个接口的方法,但是某些方法也修改了这个值本身的状态,就必须和指针一样处理。让它访问串行化。

拿代码来说话

我们看下 下面的代码

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

在上面的代码中,我们在一个Go循环中调用了10次Add()函数,它们是并发执行的。可是当你执行上面的代码的时候,就会发现一个奇怪 的现象。
"what the hell ? why didn't work ?" ,明明调用 了10次输出才对啊?
好了,我们说说Go语言的执行机制吧。
Go程序从初始化main package并执行main()函数开始,当main()函数返回时,程序退出,且程序并不等待其他goroutine(非主goroutine)结束。
对于上面的例子,主函数启动了10个goroutine,然后返回,这个时候程序就退出了,而被启动的Add(i,i)的goroutine没有来得及执行,所以程序没有任何输出了。
so what should i do for this problem? 请看下回讲解。

我们稍作修改下就能够打印1到10 了

// add
package main

import (
	"fmt"
	"runtime"
	"sync"
)

var counter int = 0

func Count(lock *sync.Mutex) {
	lock.Lock()
	counter++
	fmt.Println(counter)
	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都执行完成了,这时候主函数返回,程序退出。
but,do you think these codes are look not simple ?
实现一个如此简单的功能,却写出如此臃肿且难以理解的代码。Go语言既然以并发编程作为语言的最核心优势,当然不至于将这一的问题用这么无奈的方法解决。 为此它提供了一个通信模型,即消息机制而非共享内存作为通信方式。

channle是Go语言在语言级别提供的goroutine间的通信方式。我们可以使用channel在两个或者多个goroutine之间传递消息。channel是进程内的通信方式,因此channel传递对象的过程和调用参数时的参数传递行为比较一致,比如也可以传递指针等。如果需要跨进程通信,我们建议用分布式系统的方式解决,比如使用socket或者HTTP等通信协议。Go语言对网络方面也有非常完善的支持。
channel是类型相关的。也就是说一个channel只能传递一种类型的值,这个类型需要在声明channel时指定,可以将channel认为一种类型安全的管道。

channel的简单使用

请看代码。

// channel1
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完成主体功能函数才返回。
对channel熟练使用,才能够真正理解和掌握Go语言并发编程。下面我们看看channel的基本语法:

channel的基本语法

一般channel的声明形式为:

var chanName chan ElementType

与一般的变量声明不同的地方仅仅实在类型之前添加了chan关键字。ElementType指定的是这个channel能给传递的元素类型。for example:

var ch chan int   //声明一个传递类型为int的channel
var m  map[string] chan bool   // 声明一个map,元素是bool型的channel

也可以使用make函数来声明一个chan,如下所示

ch := make(chan int)  // 初始化一个int型的名为ch的channel
channel的写入读取

写入数据:

ch <- value

读取数据:

value := <-ch

需要说明的是,向channel写入数据通常会导致程序阻塞,直到有其他的goroutine从这个channel中读取数据。如果channel之前没有写入数据,那么从channel中读取数据也会导致程序阻塞的,直到channel中被写入数据。如何控制channel只接受或者只允许读取呢?那么就要使用单向channel

select

Go直接在语言级别支持select关键字,用于处理异步IO问题。该语法与switch语法类似,由select开始一个新的选择块,每个选择条件由case语句描述。与switch语句可以选择任何可以使用相等比较条件相比,select有比较多的限制,其中最大的一条限制就是每个case语句里必须是一个IO操作。大致结构如下:

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

可以看出来,select 不像switch,后面并不带判断条件的,而是直接去查看case语句。每个case语句后面必须是一个面向channel的操作。

func main() {
	ch := make(chan int, 1)
	for {
		select {
		case ch <- 0:
		case ch <- 1:
		}
		i := <-ch
		time.Sleep(1 * time.Second)
		fmt.Println("value received:", i)
	}
}

channel缓冲机制

上面我们创建的channel是不带缓冲的channel,这种做法对于传递单个数据的场景可以接受,但对于需要持续传输大量数据的场景就有些不合适了。所以下面我们要聊聊怎么给channel带上缓冲,从而达到消息队列的效果。
创建带缓冲的channel语法:

ch := make(chan int, 1024)

调用make创建channel,第二个参数1024是创建了一个1024的int类型的channel,即使没有读取方,写入方也可以一直往channel里写入,在缓冲区填写完之前都不阻塞。
从带缓冲的channel读取数据可以使用和非常规缓冲的channel完全一致的方法,但是我们也可以使用range函数来实现更为简便的循环读取。

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

channel超时机制

在并发编程中,最需要处理 的就是超时问题。即想channel写数据时发现channel已满,或者从channel试图读取数据时发现channel为空。如果不正确处理这些问题,很有可能导致这个goroutine锁死。
使用channel时需要小心,比如对于下面这个方法:

i := <-ch

上面的写法如果出现了一个错误情况,即永远都没有人往ch写数据,那么上述读取动作也无法从ch读取到数据,导致整个goroutine永远阻塞并没有挽回的机会。
Go语言没有提供直接的超时处理机制,但我们可以利用select机制。虽然select机制不是专门为解决超时问题而设计的,却能够很方便处理超时问题。因为select的特点就是只要其中一个case已经完成,程序就会往下执行,而不考虑其他的case情况。
基于此特性,我们可以这么为channel设置超时机制

func main() {
	ch := make(chan int, 1)
	timeout := make(chan bool, 1)
	// 实现并执行一个匿名函数
	go func() {
		time.Sleep(2 * time.Second)  // 等待1秒钟
		timeout <- true
	}()
	fmt.Println("Begin to product data")
	select {
	case <-ch:   // 从ch里面读取数据
	case <-timeout:  // 一直没有从ch中读取到数据,但从timeout中读取到了数据
	}
	fmt.Println("end ....")
}

以上做法可以使用select机制避免永久等待的话题,这种写法看起来是一个小技巧,但却是在go语言开发中避免channel通信超时的最有效的方法。

channel传递

channel本身也是一个原生类型,与map之类的类型地位一样,因此channel本身在定义后也可以通过channel来传递。
管道是非常广泛的一种设计模式,比如在处理数据时,我们可以采用管道设计,这样可以比较容易以插件的方式增加数据的处理流程。 下面看看如果使用channel来实现我们的管道,为了简化表达,我们假设在管道中传递的数据只是一个整型数。

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

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

单向channel

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

单向channel变量的声明非常简单:

var ch2 chan <- float64  // ch2 是单向channel,只用于写float64 数据。
var ch2 <- chan int // ch3是单向channel,只用于读取int数据。

channel是一个原生类型,因此不仅支持被传递,还支持类型转换。类型转换对于channel的意义:就是在单向channel和双向channel之间进行转换。如下所示:

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

关闭channel

关闭channel非常简单:

close(ch)

如何判断一个channel是否已经关闭:

x,ok := <- ch

我们只需要查看ok这个值就可以了,如果ok是false则表示ch已经被关闭了。

posted @ 2017-05-31 16:17  温柔易淡  阅读(491)  评论(0编辑  收藏  举报