web编程

4.4 并发通信
从上面的例子中可以看到,关键字go的引入使得在Go语言中并发编程变得简单而优雅,但
我们同时也应该意识到并发编程的原生复杂性,并时刻对并发中容易出现的问题保持警惕。别忘
了,我们的例子还不能正常工作呢。
事实上,不管是什么平台,什么编程语言,不管在哪,并发都是一个大话题。话题大小通常
也直接对应于问题的大小。并发编程的难度在于协调,而协调就要通过交流。从这个角度看来,

并发单元间的通信是最大的问题。
在工程上,有两种最常见的并发通信模型:共享数据和消息。
共享数据是指多个并发单元分别保持对同一个数据的引用,实现对该数据的共享。被共享的
数据可能有多种形式,比如内存数据块、磁盘文件、网络数据等。在实际工程应用中最常见的无
疑是内存了,也就是常说的共享内存。
先看看我们在C语言中通常是怎么处理线程间数据共享的,如代码清单4-2所示。
在上面的例子中,我们在10个goroutine中共享了变量counter。每个goroutine执行完成后,
将counter的值加1。因为10个goroutine是并发执行的,所以我们还引入了锁,也就是代码中的
lock变量。每次对n的操作,都要先将锁锁住,操作完成后,再将锁打开。在主函数中,使用for
循环来不断检查counter的值(同样需要加锁)。当其值达到10时,说明所有goroutine都执行完
毕了,这时主函数返回,程序退出。
事情好像开始变得糟糕了。实现一个如此简单的功能,却写出如此臃肿而且难以理解的代码。
想象一下,在一个大的系统中具有无数的锁、无数的共享变量、无数的业务逻辑与错误处理分
支,那将是一场噩梦。这噩梦就是众多C/C++开发者正在经历的,其实Java和C#开发者也好不到
哪里去。
Go语言既然以并发编程作为语言的最核心优势,当然不至于将这样的问题用这么无奈的方
式来解决。Go语言提供的是另一种通信模型,即以消息机制而非共享内存作为通信方式。
消息机制认为每个并发单元是自包含的、独立的个体,并且都有自己的变量,但在不同并发
单元间这些变量不共享。每个并发单元的输入和输出只有一种,那就是消息。这有点类似于进程
的概念,每个进程不会被其他进程打扰,它只做好自己的工作就可以了。不同进程间靠消息来通
信,它们不会共享内存。
Go语言提供的消息通信机制被称为channel,接下来我们将详细介绍channel。现在,让我们
用Go语言社区的那句著名的口号来结束这一小节:
“不要通过共享内存来通信,而应该通过通信来共享内存。”
4.5 channel
channel是Go语言在语言级别提供的goroutine间的通信方式。我们可以使用channel在两个或
多个goroutine之间传递消息。channel是进程内的通信方式,因此通过channel传递对象的过程和调
用函数时的参数传递行为比较一致,比如也可以传递指针等。如果需要跨进程通信,我们建议用
分布式系统的方法来解决,比如使用Socket或者HTTP等通信协议。Go语言对于网络方面也有非
常完善的支持。
channel是类型相关的。也就是说,一个channel只能传递一种类型的值,这个类型需要在声
明channel时指定。如果对Unix管道有所了解的话,就不难理解channel,可以将其认为是一种类
型安全的管道。
在了解channel的语法前,我们先看下用channel的方式重写上面的例子是什么样子的,以此
对channel先有一个直感的认识,如代码清单4-4所示。
在这个例子中,我们定义了一个包含10个channel的数组(名为chs),并把数组中的每个
channel分配给10个不同的goroutine。在每个goroutine的Add()函数完成后,我们通过ch <- 1语
句向对应的channel中写入一个数据。在这个channel被读取前,这个操作是阻塞的。在所有的
goroutine启动完成后,我们通过<-ch语句从10个channel中依次读取数据。在对应的channel写入
数据前,这个操作也是阻塞的。这样,我们就用channel实现了类似锁的功能,进而保证了所有
goroutine完成后主函数才返回。是不是比共享内存的方式更简单、优雅呢?
我们在使用Go语言开发时,经常会遇到需要实现条件等待的场景,这也是channel可以发挥
作用的地方。对channel的熟练使用,才能真正理解和掌握Go语言并发编程。下面我们学习下
channel的基本语法
4.5.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)
这就声明并初始化了一个int型的名为ch的channel。
在channel的用法中,最常见的包括写入和读出。将一个数据写入(发送)至channel的语法
很直观,如下:
ch <- value
向channel写入数据通常会导致程序阻塞,直到有其他goroutine从这个channel中读取数据。从
channel中读取数据的语法是
value := <-ch
如果channel之前没有写入数据,那么从channel中读取数据也会导致程序阻塞,直到channel
中被写入数据为止。我们之后还会提到如何控制channel只接受写或者只允许读取,即单向
channel。
4.5.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语句。
基于此功能,我们可以实现一个有趣的程序:
ch := make(chan int, 1)
for {
select {
case ch <- 0:
case ch <- 1:
}
i := <-ch
fmt.Println("Value received:", i)
}
能看明白这段代码的含义吗?其实很简单,这个程序实现了一个随机向ch中写入一个0或者1
的过程。当然,这是个死循环。
4.5.3 缓冲机制
之前我们示范创建的都是不带缓冲的channel,这种做法对于传递单个数据的场景可以接受,
但对于需要持续传输大量数据的场景就有些不合适了。接下来我们介绍如何给channel带上缓冲,
从而达到消息队列的效果。
要创建一个带缓冲的channel,其实也非常容易:
c := make(chan int, 1024)
在调用make()时将缓冲区大小作为第二个参数传入即可,比如上面这个例子就创建了一个大小
为1024的int类型channel,即使没有读取方,写入方也可以一直往channel里写入,在缓冲区被
填完之前都不会阻塞。
4.5.4 超时机制
在之前对channel的介绍中,我们完全没有提到错误处理的问题,而这个问题显然是不能被忽
略的。在并发编程的通信过程中,最需要处理的就是超时问题,即向channel写数据时发现channel
已满,或者从channel试图读取数据时发现channel为空。如果不正确处理这些情况,很可能会导
致整个goroutine锁死。
虽然goroutine是Go语言引入的新概念,但通信锁死问题已经存在很长时间,在之前的C/C++
开发中也存在。操作系统在提供此类系统级通信函数时也会考虑入超时场景,因此这些方法通常
都会带一个独立的超时参数。超过设定的时间时,仍然没有处理完任务,则该方法会立即终止并
返回对应的超时信息。超时机制本身虽然也会带来一些问题,比如在运行比较快的机器或者高速
的网络上运行正常的程序,到了慢速的机器或者网络上运行就会出问题,从而出现结果不一致的
现象,但从根本上来说,解决死锁问题的价值要远大于所带来的问题。
使用channel时需要小心,比如对于以下这个用法:
i := <-ch
不出问题的话一切都正常运行。但如果出现了一个错误情况,即永远都没有人往ch里写数据,那
么上述这个读取动作也将永远无法从ch中读取到数据,导致的结果就是整个goroutine永远阻塞并
没有挽回的机会。如果channel只是被同一个开发者使用,那样出问题的可能性还低一些。但如果
一旦对外公开,就必须考虑到最差的情况并对程序进行保护。
Go语言没有提供直接的超时处理机制,但我们可以利用select机制。虽然select机制不是
专为超时而设计的,却能很方便地解决超时问题。因为select的特点是只要其中一个case已经
完成,程序就会继续往下执行,而不会考虑其他case的情况。
基于此特性,我们来为channel实现超时机制:
这样使用select机制可以避免永久等待的问题,因为程序会在timeout中获取到一个数据
后继续执行,无论对ch的读取是否还处于等待状态,从而达成1秒超时的效果。
这种写法看起来是一个小技巧,但却是在Go语言开发中避免channel通信超时的最有效方法。
在实际的开发过程中,这种写法也需要被合理利用起来,从而有效地提高代码质量。
4.5.5 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的这些特性也可以大大降低开发者的心智成本,用
一些比较简单却实用的方式来达成在其他语言中需要使用众多技巧才能达成的效果。













你可能感兴趣的:(web编程)