笔者在《Golang 入门 : 竞争条件》一文中介绍了 Golang 并发编程中需要面对的竞争条件。本文我们就介绍如何使用 Golang 提供的 channel(通道) 消除竞争条件。

Channel 是 Golang 在语言级别提供的 goroutine 之间的通信方式,可以使用 channel 在两个或多个 goroutine 之间传递消息。Channel 是进程内的通信方式,因此通过 channel 传递对象的过程和调用函数时的参数传递行为比较一致,比如也可以传递指针等。使用通道发送和接收所需的共享资源,可以在 goroutine 之间消除竞争条件。

当一个资源需要在 goroutine 之间共享时,channel 在 goroutine 之间架起了一个管道,并提供了确保同步交换数据的机制。Channel 是类型相关的,也就是说,一个 channel 只能传递一种类型的值,这个类型需要在声明 channel 时指定。可以通过 channel 共享内置类型、命名类型、结构类型和引用类型的值或者指针。

基本语法

声明 channel 的语法格式为:
var ChannelName chan ElementType

与一般变量声明的不同之处仅仅是在类型前面添加了一个 chan 关键字。ElementType 则指明这个 channel 能够传递的数据的类型。比如声明一个传递 int 类型的 channel:

var ch chan int

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

var m map[string] chan bool

在 Golang 中需要使用内置的 make 函数类创建 channel 的实例:

ch := make(chan int)

这样就声明并初始化了一个名为 ch 的 int 型 channel。使用 channel 发送和接收数据的语法也很直观,比如下面的代码把数据发送到 channel 中:

ch <- value

向 channel 中写入数据通常会导致程序阻塞,直到有其它 goroutine 从这个 channel 中读取数据。下面的代码把数据从 channel 读取到变量中:

value := <-ch

注意,如果 channel 中没有数据,那么从 channel 中读取数据也会导致程序阻塞,直到 channel 中被写入数据为止。

根据 channel 是否有缓冲区可以简单地把 channel 分为无缓冲区的 channel 和带缓冲区的 channel,在本文接下来的篇幅中会详细的介绍这两类 channel 的用法。

select

Linux 系统中的 select 函数用来监控一系列的文件句柄,一旦其中一个文件句柄发生了 I/O 动作,select 函数就会返回。该函数主要被用来实现高并发的 socket 服务器程序。Golang 中的 select 关键字和 linux 中的 select 函数功能有点相似,它主要用于处理异步 I/O 问题。

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

select {
case <-chan1: // 如果 chan1 成功读取到数据,则执行该 case 语句
case chan2 <- : // 如果成功向 chan2 写入数据,则执行该 case 语句
default: // 如果上面的条件都没有成功,则执行 default 流程
}

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

无缓冲的 channel

无缓冲的 channel(unbuffered channel) 是指在接收前没有能力保存任何值的 channel。这种类型的 channel 要求发送 goroutine 和接收 goroutine 同时准备好,才能完成发送和接收操作。如果两个 goroutine 没有同时准备好,channel 会导致先执行发送或接收操作的 goroutine 阻塞等待。这种对通道进行发送和接收的交互行为本身就是同步的。其中任意一个操作都无法离开另一个操作单独存在。我们可以通过下面的图示形象地理解两个 goroutine 如何利用无缓冲的 channel 来共享一个值(下图来自互联网):

下面详细地解释一下上图:

  • 在第 1 步,两个 goroutine 都到达通道,但两个都没有开始执行数据的发送或接收。
  • 在第 2 步,左侧的 goroutine 将它的手伸进了通道,这模拟了向通道发送数据的行为。这时,这个 goroutine 会在通道中被锁住,直到交换完成。
  • 在第 3 步,右侧的 goroutine 将它的手放入通道,这模拟了从通道里接收数据。这个 goroutine一样也会在通道中被锁住,直到交换完成。
  • 在第 4 步和第 5 步,进行数据交换。
  • 在第 6 步,两个 goroutine 都将它们的手从通道里拿出来,这模拟了被锁住的 goroutine 得到释放。两个 goroutine 现在都可以去做别的事情了。

下面的例子模拟一场网球比赛。在网球比赛中,两位选手会把球在两个人之间来回传递。选手总是处在以下两种状态之一:要么在等待接球,要么将球打向对方。可以使用两个goroutine来模拟网球比赛,并使用无缓冲的通道来模拟球的来回:

// 这个示例程序展示如何用无缓冲的通道来模拟
//2个goroutine间的网球比赛
package main import(
"math/rand"
"sync"
"time"
"fmt"
) // wg用来等待程序结束
var wg sync.WaitGroup func init() {
rand.Seed(time.Now().UnixNano())
} // main是所有Go程序的入口
func main() {
// 创建一个无缓冲的通道
court := make(chan int) // 计数加2,表示要等待两个goroutine
wg.Add() // 启动两个选手
go player("Nick", court)
go player("Jack", court) // 发球
court <- // 等待游戏结束
wg.Wait()
} // player 模拟一个选手在打网球
func player(name string, court chan int) {
// 在函数退出时调用Done来通知main函数工作已经完成
defer wg.Done() for{
// 等待球被击打过来
ball, ok := <-court
if !ok {
// 如果通道被关闭,我们就赢了
fmt.Printf("Player %s Won\n", name)
return
} // 选随机数,然后用这个数来判断我们是否丢球
n := rand.Intn()
if n% == {
fmt.Printf("Player %s Missed\n", name) // 关闭通道,表示我们输了
close(court)
return
} // 显示击球数,并将击球数加1
fmt.Printf("Player %s Hit %d\n", name, ball)
ball++ // 将球打向对手
court <- ball
}
}

运行上面的代码,会输出类似下面的信息:

Player Jack Hit
Player Nick Hit
Player Jack Hit
Player Nick Hit
Player Jack Missed
Player Nick Won

简单解释一下上面的代码:
在 main 函数中创建了一个 int 类型的无缓冲的通道,使用该通道让两个 goroutine 在击球时能够互相同步。然后创建了参与比赛的两个 goroutine。在这个时候,两个 goroutine 都阻塞住等待击球。court <- 1 模拟发球,将球发到通道里,程序开始执行这个比赛,直到某个 goroutine 输掉比赛。
在 player 函数里,主要是运行一个无限循环的 for 语句。在这个循环里,是玩游戏的过程。goroutine 从通道接收数据,用来表示等待接球。这个接收动作会锁住 goroutine,直到有数据发送到通道里。通道的接收动作返回时,会检测 ok 标志是否为 false。如果这个值是 false,表示通道已经被关闭,游戏结束。在这个模拟程序中,使用随机数来决定 goroutine 是否击中了球。如果击中了球,就把 ball 的值递增 1,并将 ball 作为球重新放入通道,发送给另一位选手。在这个时刻,两个 goroutine 都会被锁住,直到交换完成。最终,引某个 goroutine 没有打中球会把通道关闭。之后两个 goroutine 都会返回,通过 defer 声明的 Done 会被执行,程序终止。

带缓冲的 channel

带缓冲的 channel(buffered channel) 是一种在被接收前能存储一个或者多个值的通道。这种类型的通道并不强制要求 goroutine 之间必须同时完成发送和接收。通道会阻塞发送和接收动作的条件也会不同。只有在通道中没有要接收的值时,接收动作才会阻塞。只有在通道没有可用缓冲区容纳被发送的值时,发送动作才会阻塞。这导致有缓冲的通道和无缓冲的通道之间的一个很大的不同:无缓冲的通道保证进行发送和接收的 goroutine 会在同一时间进行数据交换;有缓冲的通道没有这种保证。可以通过下面的图示形象地理解两个 goroutine 分别向带缓冲的通道里增加一个值和从带缓冲的通道里移除一个值(下图来自互联网):

下面详细地解释一下上图:

  • 在第 1 步,右侧的 goroutine 正在从通道接收一个值。
  • 在第 2 步,右侧的这个 goroutine 独立完成了接收值的动作,而左侧的 goroutine 正在发送一个新值到通道里。
  • 在第 3 步,左侧的 goroutine 还在向通道发送新值,而右侧的 goroutine 正在从通道接收另外一个值。这个步骤里的两个操作既不是同步的,也不会互相阻塞。
  • 最后,在第 4 步,所有的发送和接收都完成,而通道里还有几个值,也有一些空间可以存更多的值。

创建带缓冲区的 channel 非常简单,只需要再添加一个缓冲区的大小就可以了,比如创建一个传递 int 类型数据,缓冲区为 10 的 channel:

ch := make(chan int, )

下面的 demo 使用一组 goroutine 来接收并完成任务,带缓冲区的通道提供了一种清晰而直观的方式来实现这个功能:

// 这个示例程序展示如何使用
// 有缓冲的通道和固定数目的
// goroutine来处理一堆工作
package main import(
"math/rand"
"sync"
"time"
"fmt"
) const(
numberGoroutines = // 要使用的goroutine的数量
taskLoad = // 要处理的工作的数量
) // wg用来等待程序结束
var wg sync.WaitGroup func init() {
rand.Seed(time.Now().UnixNano())
} // main是所有Go程序的入口
func main() {
// 创建一个有缓冲的通道来管理工作
tasks := make(chan string, taskLoad) // 启动goroutine来处理工作
wg.Add(numberGoroutines)
for gr := ; gr <= numberGoroutines; gr++ {
go worker(tasks, gr)
} // 增加一组要完成的工作
for post := ; post <= taskLoad; post++ {
tasks <- fmt.Sprintf("Task: %d", post)
} // 当所有工作都处理完时关闭通道
// 以便所有goroutine退出
close(tasks) // 等待所有工作完成
wg.Wait()
} // worker作为goroutine启动来处理
// 从有缓冲的通道传入的工作
func worker(tasks chan string, worker int) {
// 通知函数已经返回
defer wg.Done() for{
// 等待分配工作
task, ok := <-tasks
if !ok{
// 这意味着通道已经空了,并且已被关闭
fmt.Printf("Worker: %d: Shutting Down\n", worker)
return
} // 显示我们开始工作了
fmt.Printf("Worker: %d: Started %s\n", worker, task) // 随机等一段时间来模拟工作
sleep := rand.Int63n()
time.Sleep(time.Duration(sleep)* time.Millisecond) // 显示我们完成了工作
fmt.Printf("Worker: %d: Completed %s\n", worker, task)
}
}

运行上面的程序,输出结果大致如下:

Worker: : Started Task:
Worker: : Started Task:
Worker: : Completed Task:
Worker: : Started Task:
Worker: : Completed Task:
Worker: : Started Task:
Worker: : Completed Task:
Worker: : Started Task:
Worker: : Completed Task:
Worker: : Shutting Down
Worker: : Completed Task:
Worker: : Shutting Down

代码里有很详细的注释,因此不再赘言,只解释一下通道的关闭:
关闭通道的代码非常重要。当通道关闭后,goroutine 依旧可以从通道接收数据,但是不能再向通道里发送数据。能够从已经关闭的通道接收数据这一点非常重要,因为这允许通道关闭后依旧能取出其中缓冲的全部值,而不会有数据丢失。从一个已经关闭且没有数据的通道里获取数据,总会立刻返回,并返回一个通道类型的零值。如果在获取通道时还加入了可选的标志,就能得到通道的状态信息。

处理超时

使用 channel 时需要小心,比如对于下面的简单用法:

i := <-ch

碰到永远没有往 ch 中写入数据的情况,那么这个读取动作将永远也无法从 ch 中读取到数据,导致的结果就是整个 goroutine 永远阻塞并且没有挽回的机会。如果 channel 只是被同一个开发者使用,那样出问题的可能性还低一些。但如果一旦对外公开,就必须考虑到最差情况并对程序进行维护。

Golang 没有提供直接的超时处理机制,但可以利用 select 机制变通地解决。因为 select 的特点是只要其中一个 case 已经完成,程序就会继续往下执行,而不会考虑其它的 case。基于此特性我们来实现一个 channel 的超时机制:

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

执行上面的代码,输出的结果为:

Timeout occurred.

关闭 channel

关闭 channel 非常简单,直接调用 Golang 内置的 close() 函数就可以了:

close(ch)

在关闭了 channel 之后我们要面对的问题是:如何判断一个 channel 是否已关闭?
其实在从 channel 中读取数据的同时,还可以获得一个布尔类型的值,该值表示 channel 是否已关闭:

x, ok := <-ch

如果 ok 的值为 false,则表示 ch 已经被关闭。

参考:
《Go语言实战》
《Go语言编程入门与实战技巧》

Golang 入门 : channel(通道)的更多相关文章

  1. Golang 入门 : 竞争条件

    笔者在前文<Golang 入门 : 理解并发与并行>和<Golang 入门 : goroutine(协程)>中介绍了 Golang 对并发的原生支持以及 goroutine 的 ...

  2. Java程序员的Golang入门指南(下)

    Java程序员的Golang入门指南(下) 4.高级特性 上面介绍的只是Golang的基本语法和特性,尽管像控制语句的条件不用圆括号.函数多返回值.switch-case默认break.函数闭包.集合 ...

  3. Golang入门(4):并发

    摘要 并发程序指同时进行多个任务的程序,随着硬件的发展,并发程序变得越来越重要.Web服务器会一次处理成千上万的请求,这也是并发的必要性之一.Golang的并发控制比起Java来说,简单了不少.在Go ...

  4. golang的Channel

    golang的Channel Channel 是 golang 一个非常重要的概念,如果你是刚开始使用 golang 的开发者,你可能还没有真正接触这一概念,本篇我们将分析 golang 的Chann ...

  5. Java程序员的Golang入门指南(上)

    Java程序员的Golang入门指南 1.序言 Golang作为一门出身名门望族的编程语言新星,像豆瓣的Redis平台Codis.类Evernote的云笔记leanote等. 1.1 为什么要学习 如 ...

  6. Java NIO Channel通道

    原文链接:http://tutorials.jenkov.com/java-nio/channels.html Java NIO Channel通道和流非常相似,主要有以下几点区别: 通道可以读也可以 ...

  7. Golang 入门 : goroutine(协程)

    在操作系统中,执行体是个抽象的概念.与之对应的实体有进程.线程以及协程(coroutine).协程也叫轻量级的线程,与传统的进程和线程相比,协程的最大特点是 "轻"!可以轻松创建上 ...

  8. c#实现golang 的channel

    使用.NET的 BlockingCollection<T>来包装一个ConcurrentQueue<T>来实现golang的channel. 代码如下: public clas ...

  9. 推荐一个GOLANG入门很好的网址

    推荐一个GOLANG入门很好的网址,栗子很全 https://books.studygolang.com/gobyexample/

随机推荐

  1. Linux搭建图片服务器减轻传统服务器的压力(nginx+vsftpd)

    传统项目中的图片管理 传统项目中,可以在web项目中添加一个文件夹,来存放上传的图片.例如在工程的根目录WebRoot下创建一个images文件夹.把图片存放在此文件夹中就可以直接使用在工程中引用. ...

  2. 通过 Telegraf + InfluxDB + Grafana 快速搭建监控体系的详细步骤

    第一部分 Telegraf 部署和配置 Telegraf 是实现 数据采集 的工具.Telegraf 具有内存占用小的特点,通过插件系统开发人员可轻松添加支持其他服务的扩展. 在平台监控系统中,可以使 ...

  3. django之跨站请求伪造csrf

    目录 跨站请求伪造 csrf 钓鱼网站 模拟实现 针对form表单 ajax请求 csrf相关的两个装饰器 跨站请求伪造 csrf 钓鱼网站 就类似于你搭建了一个跟银行一模一样的web页面 , 用户在 ...

  4. 生产环境Shell脚本Ping监控主机是否存活(多种方法)

    在网上针对shell脚本ping监控主机是否存活的文档很多,但大多都是ping一次就决定了状态,误报率会很高,为了精确判断,ping三次不通再发告警,只要一次ping通则正常.于是,今天中午抽出点时间 ...

  5. mysql数据库多表查询where与内连接inner join的区别

    按理说where是对前面的笛卡尔积进行过滤,工作量大增,inner join则不会.但我实际测试了一下,两种查询耗时基本相等,甚至where还快一些,多次测试后基本如此. 如下图: where: in ...

  6. steamdb cookie

    steamdb cookie import requests, re, os, pymysql, time from lxml import etree from steamdb.YDM import ...

  7. 你必须知道的Docker数据卷(Volume)

    本篇已加入<.NET Core on K8S学习实践系列文章索引>,可以点击查看更多容器化技术相关系列文章. 一.将Docker数据挂载到容器 在Docker中,要想实现数据的持久化(所谓 ...

  8. 基于V7的emWin多屏显示方案模板,同时驱动LCD和OLED例程

    说明: 1.多屏驱动跟多图层驱动是类似的,可以使用函数GUI_SelectLayer做切换选择. 2.为了避免OLED闪烁问题,创建一个128*64bit的显存空间,然后使用emWin的GUI_TIM ...

  9. Element-ui 下拉列表 选项过多时通过自定义搜索来解决卡顿问题

    当使用Select选择器时,如果下拉列表的数据量太多,会有一个明显的卡顿体验,例如: <!DOCTYPE html> <html lang="en"> &l ...

  10. queue队列基础讲解

    前言 似乎这种对蒟蒻最重要的概念都搜不到,对巨佬来说也根本不必要提及. 导致我也不懂. Queue 意义 queue,队列,一种数据结构. 队列是一种操作受限制的线性表: 特点: 1.元素先进先出. ...