Go语言并发编程1 - 基础

0 并发编程基础

并发编程是一种现代计算机编程技术。它的含义比较宽泛,可以是多进程编程、也可以是多线程编程、还可以是编写分布式程序。并发编程思想来源于多任务操作系统,它允许同时运行多个程序。在早起的单用户操作系统中,任务是一个接一个地运行,各个任务的执行完全是串行的。只有在一个任务运行完成之后,另一个任务才会被读取并执行。而多任务操作系统则允许终端用户同时运行多个程序。当一个程序暂时不需要使用CPU时,系统会把该程序挂起或中断,以便其他程序可以使用CPU。而Go语言最明显的优势在于拥有基于多线程的并发编程方式。

0.1 并发和并行

并发和并行是两个不同的概念:

  • 并发(concurrency):逻辑上具备同时处理多个任务的能力,即在单位时间内是同时运行的。
  • 并行(parallelism):物理上在同一时刻执行多个并发任务,即在任何时刻都是同时运行的。

并行就是在任意粒度的时间内都具备同时执行的能力。最简单的并行就是多机,多台机器并行处理;SMP(Symmetrical Multi-Processing,对称多处理)表面看是并行的,但由于是共享内存,以及线程间的同步,不可能完全做到并行。

并发是在规定时间内多个任务都得到执行和处理,强调的是给外界的感觉,实际上并不一定真在同一时刻运行。在单核处理器上,它们是以间隔方式切换执行的。并发重在避免阻塞,使程序不会因为一个阻塞而停止运行。并发典型的应用场景:分时操作系统就是一种并发设计。

并行是硬件和操作系统开发者重点考虑的问题,作为应用程序的开发者,唯一可以选择的就是充分借助操作系统提供的API 和程序语言的特性,结合实际需求设计出具有良好并发结构的应用程序,提升程序的并发处理能力。并行是并发设计的理想执行模式。在当前的计算机体系下:并行具有瞬时性,并发具有过程性;并发在于结构,并行在于执行。应用程序具备好的并发结构,操作系统才能更好地利用硬件并行执行,同时避免阻塞等待,合理地进行调度,提升CPU的利用率。应用程序开发者提升程序并发处理能力的一个重要手段就是为程序设计良好的并发结构。

0.2 并发模型

当前主流的并发编程模型有以下几种:

  • 多进程。多进程是在操作系统层面进行并发的基本模式。同时就是系统开销最大的模式。在Linux平台上,很多工具链正是采用这种模式在工作。比如某个Web服务器,它会有专门的进程负责网络端口的监听和链接管理,还会有专门的进程负责事务和处理。这种方法的好处在于简单、进程间互不影响,坏处在于进程的切换和调度系统开销大,因为所有的进程都是由操作系统内核管理和调度的。每一个进程在运行时,都有自己的调用栈和堆,有一个完整的上下文。支持多任务操作系统中,多个进程之间是分时轮询执行的,在一个时间片内是执行A进程,下一个时间片内执行B进程,以此类推。当然,切换CPU正在运行的进程是需要付出代价的。例如,内核此刻要换下正在CPU上运行的进程A,并让CPU开始运行进程B。在换下进程A之前,内核必须要及时保存进程A的运行时状态。另一方面,假设进程B不是第一次运行,那么在让进程B重新运行之前,内核必须要保证已经依据之前保存的进程B的相关信息把进程B恢复到之前被换下时的运行时状态。这种在进程换入换出期间必须要做的工作称为进程的上下文切换,这个工作主要是由内核来完成的。除了进程切换工作,为了使各个生存着的进程都有运行的机会,内核还要解决下次切换时运行哪个进程、何时进行切换、被换下的进程何时再换上,等等。解决类似问题的工作称为进程调度。进程切换和进程调度是多个进程并发执行的基础,没有前者,后者就无从谈起。
  • 多线程。多线程在大部分操作系统上都属于系统层面的并发模式,也是我们使用最多的最有效的一种模式。它比多进程的系统开销小很多,但是其开销依旧比较大,在高并发模式下,效率会有影响。
  • 基于回调的非阻塞/异步IO。这种架构的诞生实际上源于多线程模式的危机。在很多高并发服务器开发实践中,使用多线程模式会很快耗尽服务器的内存和CPU资源。而这种模式通过事件驱动的方式使用异步IO,是服务器持续运转,且尽可能地少用线程,降低开销,它目前在Node.js中得到了很好的实践。但是使用这种模式,编程比多线程要复杂,因为它把流程做了分割,对于问题本身的反映不够自然。
  • 协程。协程(Coroutine) 本质上是一种用户态线程,也叫轻量级线程。不需要操作系统来进行抢占式调度,且在真正的实现中寄存于线程中,因此,系统开销极小,可以有效提高线程的任务并发性,而避免多线程的缺点。使用协程的优点是编程简单,结构清晰;缺点是需要语言层面的支持,如果不支持,则需要用户在程序中自行实现调度器。目前,原生支持协程的编程语言还很少。

0.3 协程

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

多数语言在语法层面并不直接支持协程,而是通过库的方式支持,但用库的方式支持的功能也并不完整,比如仅仅提供轻量级线程的创建、销毁与切换等能力。如果在这样的轻量级线程中
调用一个同步 IO 操作,比如网络通信、本地文件读写,都会阻塞其他的并发执行轻量级线程,从而无法真正达到轻量级线程本身期望达到的目标。

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

1 Go语言并发机制

1.1 goroutine

操作系统可以进行进程和线程的调度,本身具备并发处理能力,但进程切换代价还是过于高昂,进程切换需要保护现场,会耗费较多的时间。如果应用程序能在用户层再构建一级调度,将并发的粒度进一步降低,是不是可以更大限度地提升程序运行效率呢?Go语言的并发就是基于这个思想实现的。Go语言在语言层面支持这种并发模式。

Go语言的并发特性通过goroutine实现。goroutine这个特有名词是Go语言独创的,它代表的是可以并发执行的Go代码片段。Go语言的开发者们之所以专门创建这样一个名词,是因为他们认为已经存在的线程、协程、进程等属于都传达了错误的含义。为了和它们有所区别,goroutine这个词得以诞生。

goroutine是Go语言中的轻量级线程的实现,goroutine的概念类似于线程,但goroutine由Go语言运行时(runtime)进行调度和管理的,而线程是由操作系统进行调度和管理的。

1.2 创建goroutine

在Go语言里,每一个并发执行的活动称为goroutine。Go程序中使用go关键字为一个函数创建一个goroutine。一个函数可以被创建多个goroutine,一个goroutine必定对应一个函数。Go语言运行时会智能地将goroutine中的任何合理分配给每个CPU,Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制。

在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能–>goroutine,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数就可以了,就是这么简单粗暴。

Go程序是从main包的main() 函数开始执行的,在程序启动时,Go程序就会为main() 函数创建一个默认的goroutine,也就是说每一个Go程序至少有一个main() 函数对应的goroutine。

<注意> go 关键字后面必须跟一个函数,不能是语句或是代码块,函数的返回值被忽略。

1.2.1 使用普通函数创建goroutine

为一个普通函数创建goroutine的格式如下:

go 函数名(形参列表)
  • 函数名:被调用的函数。
  • 形参列表:调用函数需要传入的参数。

使用 go 关键字创建goroutine时,被调用函数的返回值会被忽略。

<提示> 如果需要在 goroutine 中返回数据,请看下面介绍的通道(channel)特性,通过通道把数据从goroutine中作为返回值传出。

示例1:串行执行程序。

package main

import (
    "fmt"
)

func hello(){
    fmt.Println("hello goroutine!")
}

func main(){
    hello()
    fmt.Println("main goroutine done")
}

运行结果:

hello goroutine!
main goroutine done

 《代码说明》上面的示例程序中的代码是串行执行的,先执行hello()函数,然后打印main()函数中的输出语句。

示例2:并发执行程序。

package main

import (
    "fmt"
)

func hello(){
    fmt.Println("hello goroutine!")
}

func main(){
    go hello()  //启动一个goroutine
    fmt.Println("main goroutine done")
}

运行结果:

main goroutine done

《代码说明》这次的执行结果只打印了"main goroutine done",并没有执行hello()函数中的输出语句,这是为什么呢?我们上面说过,在Go程序启动时,Go程序运行时会为main()函数创建一个默认的goroutine。当main()函数对应的goroutine结束运行时,所有在main()函数中启动的goroutine会一同结束。所以,我们要想办法让main()函数等一等hello函数的goroutine,最简单粗暴方式就是 time.Sleep 了。

示例3:修改示例2中的代码。

package main

import (
    "fmt"
    "time"
)

func hello(){
    fmt.Println("hello goroutine!")
}

func main(){
    go hello() //启动一个goroutine
    fmt.Println("main goroutine done")
    time.Sleep(time.Second)
}

运行结果:

main goroutine done
hello goroutine!

《代码说明》从执行结果可以看到,首先打印的是“main goroutine done”,是因为我们在创建新的goroutine需要花费一些时间,而此时main()对应的goroutine会继续执行,当main()函数的goroutine在sleep期间,hello()函数对应的goroutine也已经创建完成并开始执行代码,打印“hello goroutine!”,最后main()函数的goroutine的sleep时间到达结束运行,hello()函数的goroutine也随之结束。

示例3代码的执行流程图如下所示:


1.2.2 使用匿名函数创建goroutine

go 关键字后面也可以为匿名函数或闭包创建goroutine。

使用匿名函数或闭包创建goroutine时,除了将函数定义部分写在go 关键字后面之外,还需要加上匿名函数的调用实参,格式如下

go func(形参列表) {

    //函数体
    ......

}(实参列表)

  示例4:使用匿名函数创建goroutine的例子。

package main

import (
    "fmt"
    "time"
)

func main(){
    fmt.Println("main goroutine start!")
    go func(){
        fmt.Println("goroutine start!")
        var times int
        for{
            times++
            fmt.Println("tick", times)
            time.Sleep(time.Second)     //睡眠1秒
        }
    }()
    
    var input string
    fmt.Scanln(&input)  //等待用户输入按回车键结束输入
    fmt.Println("main goroutine done!")
}

运行结果:

main goroutine start!
goroutine start!
tick 1
tick 2
tick 3
tick 4
tick 5          //按下回车键后,main()函数结束运行,匿名函数对应的goroutine也会随之结束。

main goroutine done!

<提示> 所有goroutine 在main()函数结束时会一同结束。终止 goroutine 的最好方法就是自然返回 goroutine对应的函数。可以使用 golang.org/x/net/context 包进行 goroutine 生命期深度控制,但这并不是官方推荐的特性。

1.3 goroutine 的特性

goroutine 有如下特性:

  • go 语句的执行是非阻塞的,不会等待。
  • go 关键字后面的函数的返回值会被忽略。
  • Go语言运行时(runtime)调度器不能保证多个 goroutine 的执行次序。
  • 没有父子 goroutine 的概念,所有的 goroutine 是平等地被调度和执行的。
  • Go程序在执行时会单独为main()函数创建一个 goroutine,遇到其他 go 语句时再去创建其他的 goroutine。
  • Go 没有暴露 goroutine id 给用户,所以不能在一个 goroutine 里面显式地操作另一个 goroutine,不过 runtime 包提供了一些函数访问和设置 goroutine 的相关信息。 

1、GOMAXPROCS() 函数

Go语言运行时调度器使用 GOMAXPROCS 参数来确定需要使用多少个OS线程来执行Go代码。

Go语言中可以通过 runtime.GOMAXPROCS()函数设置当前Go程序并发时占用的CPU逻辑核心数。函数格式:

runtime.GOMAXPROCS(逻辑CPU数量)

这里的逻辑CPU数量可以有如下几种数值:

  • < 1:不修改任何数值,表示查询当前的GOMAXPROCS值。
  • = 1:单核心执行。
  • > 1:多核并发执行。

一般情况下,可以使用 runtime.NumCPU() 函数查询 CPU数量,并使用 runtime.GOMAXPROCS()函数进行设置。例如:

runtime.GOMAXPROCS(runtime.NumCPU())

Go1.5版本之前,默认使用的是单核心执行。从Go 1.5 版本开始,默认执行上面的语句以便让代码并发执行,最大效率地利用CPU。

GOMAXPROCS 同时也是一个Go语言的环境变量,在应用程序启动前设置该环境变量也可以起到相同的作用。

示例1:

package main

import (
    "fmt"
    "runtime"
)

func main(){
    //获取当前的CPU核心数
    fmt.Printf("core number: %d\n", runtime.NumCPU())
    
    //获取当前的GOMAXPROCS 值
    fmt.Printf("GOMAXPROCS=%d\n", runtime.GOMAXPROCS(0))
    
    //设置 GOMAXPROCS 的值
    runtime.GOMAXPROCS(2)
    
    //重新获取当前的GOMAXPROCS 值
    fmt.Printf("GOMAXPROCS=%d\n", runtime.GOMAXPROCS(0))
}

运行结果:

core number: 1
GOMAXPROCS=1
GOMAXPROCS=2

2、Goexit() 函数

Goexit()函数是结束当前 goroutine 的运行,Goexit 在结束当前goroutine之前会调用当前 goroutine 已经注册的defer。Goexit 并不是产生 panic,所以该 goroutine defer 里面的 recover 调用都是返回nil。

3、Gosched() 函数

Gosched() 函数是放弃当前调度执行的机会,将当前 goroutine 放到队列中等待下次被调度。

1.4 goroutine调度

M P G模型是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程。

  • M(machine的缩写)一个M代表一个内核线程,或称“工作线程”。它是Go运行时(runtime)对操作系统内核线程的虚拟, M与内核线程一般是一一映射的关系, 一个groutine最终是要放到M上执行的。
  • P(Processor的缩写)一个P代表执行一个Go代码执行体所必须的资源(或称“上下文环境”)。P管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务。
  • G(goroutine的缩写)一个G就是一个goroutine。前者是对后者的一种封装。

简单地说,一个 G 的执行需要 P 和 M 的支持。一个M在与一个P关联后,就形成了有效的G运行环境(内核线程+上下文环境)。每个P都会包含一个一个可运行的G的队列(runq)。该队列中的G会被依次传递给与本地P关联的M,并获得运行机会。

M 与 P 之间是一对一的关系,而 P 与 G 之间则是一对多的关系,M 与 G 之间也会建立关联,因为一个 G 终归会由一个 M 来负责运行,它们之间的关联由 P 来牵线。M、P、G的关系如下图所示:

 

P的个数是通过 runtime.GOMAXPROCS 设定(最大256),Go1.5版本之后默认为物理线程数。 在并发量大的时候会增加一些P和M,但不会太多,切换太频繁的话得不偿失。

单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine 则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。 其一大特点是goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。

Go语言中的操作系统线程和goroutine的关系:

  1. 一个操作系统线程对应用户态多个goroutine。
  2. go程序可以同时使用多个操作系统线程。
  3. goroutine和OS线程是多对多的关系,即m:n。

2 并发通信

2.1 并发通信模型

单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义。

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

传统的线程间通信是使用共享内存的方式,但是这种方式容易发生竞态问题,为了保证数据交换的正确性,必须使用互斥量对内存进行加锁操作,这种做法势必造成性能问题。并且,使用共享内存的方式写出来的逻辑代码会显得臃肿而且难以理解,特别是在一个大型系统中,具有无数的锁、无数的共享变量、无数的业务逻辑与错误处理分支,那将是一场噩梦。这噩梦就是众多C/C++开发者正在经历的,其实Java和C#开发者也好不到哪里去。

Go语言是以并发编程作为语言的最核心优势,为了杜绝上述问题,于是提供了一种更佳的解决方案:CSP(Communicating Sequential Processes,通信顺序进程)并发通信模型,即以消息机制而非共享内存作为通信方式。消息机制认为每个并发单元是自包含的、独立的个体,并且都有自己的变量,但在不同并发单元间这些变量并不共享。每个并发单元的输入和输出只有一种,那就是消息。这有点类似于进程的概念,每个进程不会被其他进程打扰,它只做好自己的工作就可以了。不同进程间靠消息来通信,它们不会共享内存。

需要说明的是,Go语言当然也是支持共享内存这种传统的并发通信模型,但是Go语言的口号是:“不要通过共享内存来通信,而应该通过通信来共享内存”。

2.2 通道(channel)

Go语言提供的消息通信机制被称为通道(channel)。如果说 goroutine 是Go程序并发的执行体,channel就是它们之间的连接桥梁。channel 是可以让一个 goroutine 发送特定消息到另一个 goroutine的通信机制。它是Go语言在语言层面提供的goroutine间的通信方式。

Go语言中的通道(channel)是一种类型,一种引用类型,类型名关键字为chan,是 channel 的简写。在任何时候,同时只能有一个goroutine访问通道进行发送和接收数据。goroutine间通过通道就可以通信。

通道就像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。channel是类型相关的,也就是说,一个channel只能传递一种类型的值,这个类型需要在声明channel 时指定。如果对Unix管道有所了解的话,就不难理解channel,可以将其认为是一种类型安全的管道。

2.2.1 声明通道

管道的声明格式如下:

var 管道变量 chan 管道类型
  • 通道类型:通道内传递的数据类型。
  • 通道变量:保存通道的变量。

chan 类型的空值是 nil,声明后需要配合使用 make 进行初始化后才能使用。

2.2.2 创建通道

通道是引用类型,需要使用 make 进行创建,格式如下:

通道实例 := make(chan 数据类型)
  • 数据类型:通道内传输的元素类型。
  • 通道实例:通过 make 创建的通道句柄。

例如,

ch1 := make(chan int)             //创建一个整型类型的通道
ch2 := make(chan interface{})     //创建一个空接口类型的通道,可以存放任意类型的数据

type Equip struct {}
ch2 := make(chan *Equip)          //创建Equip指针类型通道,可以存放*Equip

2.2.3 通道操作

通道有三种操作:发送(send)、接收(receive)和关闭(close)。

发送和接收都使用操作符:<-,通过调用内置的 close()函数来关闭通道。例如,

//创建一个整型通道
ch := make(chan int)

//将一个值发送到通道中
ch <- 10

//从通道中接收一个值
x := <- ch

//如果要忽略接收到的值,写法如下
<- ch

//调用内置的close函数来关闭通道
close(ch)

 1、使用通道发送数据

将数据通过通道发送的格式为:

通道变量 <- 值
  • 通道变量:通过make创建好的通道实例。
  • 值:可以是变量、常量、表达式或者函数返回值的。值的数据类型必须与通道中的元素类型一致。

把数据往通道中发送时,如果接收方一直都没有接收,那么发送操作将持续阻塞。Go语言运行时能智能地发现一些永远无法发送成功的语句并作出提示。示例代码如下:

func main(){
    //创建一个整型通道
    ch := make(chan int)
    
    //尝试将0通过通道发送
    ch <- 0
}

运行代码,报错:fatal error: all goroutines are asleep - deadlock!

报错的意思是:运行时发现所有的goroutine(包括main)都处于等待状态!也就是说所有goroutine中的channel并没有形成发送和接收对应的代码。

2、使用通道接收数据

通道接收有如下特性:

  • 通道的收发操作分别在两个不同的goroutine间进行。

由于通道的数据在没有接收方处理时,数据发送方会持续阻塞,因此通道的接收必定在另一个goroutine中进行。

  • 接收方将持续阻塞直到发送发发送数据。

如果接收方接收时,通道内没有发送方发送的数据,接收方也会阻塞,直到发送方发送数据为止。

  • 接收方每次接收一个数据。

通道一次只能接收一个数据元素。

通道的数据接收一共有以下4种写法:

(1)阻塞接收数据

阻塞模式接收数据时,将接收变量作为 <- 的左值,格式如下:

data := <- ch

执行该语句时将会阻塞,直到接收到数据并赋值给 data 变量。

(2)非阻塞接收数据

使用非阻塞方式接收数据时,语句不会阻塞,格式如下:

data, ok := <- ch
  • data:表示接受到的数据。未接收到数据时,data 为通道元素类型的零值。
  • ok:表示是否接收到数据。接收到,值为true;未接收到,值为false。

非阻塞的通道接收方式可能造成CPU占用过高,因此很少使用。如果需要实现接收超时检测,可以配合使用select 和 计时器 channel 进行。

(3)接收任意数据,忽略接收的数据

忽略从通道内返回的数据,格式如下:

<- ch

执行该语句时将会发生阻塞,直到接收到数据,但接收到到的数据会被忽略。这个方式实际上只是通过通道在goroutine间阻塞收发从而实现并发同步的目的。示例程序如下:

func main(){
    //创建一个通道
    ch := make(chan int)
    
    //启动一个并发匿名函数
    go func(){
        fmt.Println("start goroutine")
        //通过通道通知main函数的goroutine
        ch <- 0
        fmt.Println("end goroutine")
    }()
    
    fmt.Println("wait goroutine")
    
    //等待接收匿名goroutine发送的数据
    <- ch
    
    fmt.Println("main exit")
}

运行结果:

wait goroutine
start goroutine
end goroutine
main exit

(4)循环接收

通道内的数据接收可以借助 for...range 语句进行多个元素的接收操作。格式如下:

for data := range ch {

}

通道 ch 是可以进行遍历的,遍历的结果就是接收到的数据。data变量的数据类型就是通道内元素的数据类型。

示例:使用for...range 循环语句从通道中接收数据。

package main

import (
    "fmt"
    "time"
)

func main(){
    //创建一个通道
    ch := make(chan int)
    
    //启动一个并发匿名函数
    go func(){
        //从3循环到0
        for i := 3; i>=0; i-- {
            //发送3到0之间的数值
            ch <- i
            //每次发送完成后等待1秒
            time.Sleep(time.Second)
        }
    }()
    
    //遍历接收通道数据
    for data := range ch {
        //打印通道数据
        fmt.Println(data)
        
        //当遇到0时退出循环
        if data == 0 {
            break
        }
    }
}

运行结果:

3
2
1
0

 3、关闭通道

 我们通过调用内置的close函数来关闭通道。格式如下:

close(ch)

对于关闭需要注意的事情是,只有在发送方goroutine的所有数据都发送完毕的时候才需要关闭通道,关闭后的通道的发送操作将导致宕机,而在一个已经关闭的通道中接收消息,将获取到所有已经发送的数据,直到通道内为空。通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。

关闭后的通道有以下特点:

  1. 对一个已关闭的通道再发送数据就会导致panic。
  2. 对一个已关闭的通道进行接收操作,会一直获取数据直到通道为空。
  3. 对一个已关闭的并且没有数据的通道执行接收操作会得到对应类型的零值。
  4. 关闭一个已关闭的通道会导致panic。

2.2.4 无缓冲通道

无缓冲通道又称为阻塞通道。上面示例代码中创建的都是无缓冲通道。创建无缓冲通道的格式:

//创建一个无缓冲通道,通道存放元素的类型为 dataType
make (chan dataType)

我们来看下面的一段代码:

func main() {
    ch := make(chan int)
    ch <- 10
    fmt.Println("发送成功")
}

上面这段代码可以编译通过,但是执行的时候会出现以下的错误:

fatal error: all goroutines are asleep - deadlock!

为什么会出现 deadlock 错误呢?

因为我们使用创建的是无缓冲的通道,无缓冲通道只有在有其他goroutine接收值的时候才能发送值。就像你住的小区没有快递柜和代收点,快递员给你打电话必须要把这个物品送到你的手中,简单来说就是无缓冲的通道必须有接收才能发送。

上面的代码会阻塞在 ch <- 10 这条语句中形成死锁。那如何解决这个问题呢?

一种方法是启动一个 goroutine 去接收值,代码如下:

func recv(c chan int) {
    ret := <-c
    fmt.Println("接收成功", ret)
}
func main() {
    ch := make(chan int)
    go recv(ch) // 启用goroutine从通道接收值
    ch <- 10
    fmt.Println("发送成功")
}

无缓冲通道上的发送操作会阻塞,直到另一个goroutine在该通道上执行接收操作,这是值才能发送成功。相反,如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine在该通道上发送一个值。

使用无缓冲的通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道。

2.2.5 带缓冲的通道

在无缓冲通道的基础上,为通道增加一个有限大小的存储空间形成带缓冲通道。带缓冲通道在发送时无需等待接收方接收即可完成发送操作,并且不会发生阻塞,只有当缓冲空间满时才会发生阻塞。同理,如果缓冲通道中有数据,接收时也将不会发生阻塞,直到通道中没有数据可读时,通道将会再度阻塞。

1、创建带缓冲的通道

创建带缓冲的通道格式如下:

通道实例 := make(chan 通道类型, 缓冲大小)

示例:理解带缓冲通道的用法。

package main

import (
    "fmt"
)

func main(){
    //创建一个3个元素缓冲大小的整型通道
    ch := make(chan int, 3)
    
    //查看通道的容量大小
    fmt.Printf("channel cap: %d\n", cap(ch))
    //查看当前通道的大小
    fmt.Printf("channel len: %d\n", len(ch))
    
    //发送3个整型元素到通道中
    ch <- 1
    ch <- 2
    ch <- 3
    
    //查看当前通道的大小
    fmt.Printf("channel len: %d\n", len(ch))
}

运行结果:

channel cap: 3
channel len: 0
channel len: 3

《代码说明》带缓冲的通道在创建完成时,内部的元素是空的,因此使用len()函数获取到的返回值是0。当发送3个整型元素到通道后,因为使用了缓冲通道,即便没有goroutine接收,发送操作也不会阻塞,此时通道内的长度变为3,说明此时通道内有3个元素存在了。

<说明> 我们可以使用内置的 len() 函数获取通道内元素的数量,使用 cap()函数获取通道的容量。

2、阻塞条件

带缓冲通道在很多特性上和无缓冲通道是类似的。无缓冲通道可以看做是长度永远为0的带缓冲通道。因此根据这个特性,带缓冲通道在下面的情况下依然后发生阻塞:

(1)带缓冲通道被填满时,尝试再次发送数据时会发生阻塞。

(2)带缓冲通道为空时,尝试接收数据时发生阻塞。

<提示> 可能有人会问了,为什么Go语言对通道要限制长度而不提供无限长度的通道呢? 因为我们知道通道是在两个 goroutine 之间进行通道的桥梁。使用 goroutine 的代码必然是有一方提供数据,另一方消费数据。当提供数据一方的数据供给速度大于消费方的数据处理速度时,如果通道不限制长度,那么内存将不断膨胀直至应用崩溃。因此,限制通道的长度有利于约束数据提供方的供给速度,供给数量必须在消费方处理量+通道容量的范围内,才能正常地处理数据。

2.2.6 单向通道 — 通道中的单行道

Go语言的通道可以在声明时约束其操作方向,如只发送或是只接收。这种被约束方向的通道被称做单向通道。

1、单向通道的声明格式

var 通道实例 chan<- 元素类型      //只能发送数据通道(写入操作)
var 通道实例 <-chan 元素类型      //只能接收数据通道(读取操作)
  • 元素类型:通道包含的元素类型。
  • 通道实例:声明的通道变量。

2、单向通道的使用例子

func counter(out chan<- int) { //out是只发送数据单向通道
    for i := 0; i < 5; i++ {
        out <- i
    }
    close(out)
}

func squarer(out chan<- int, in <-chan int) { //out是只发送数据单向通道,in是只接收数据单向通道
    for i := range in {
        out <- i * i
    }
    close(out)
}

func printer(in <-chan int) { //in是只接收数据单向通道
    for i := range in {
        fmt.Println(i)
    }
}

func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    go counter(ch1)
    go squarer(ch2, ch1)
    printer(ch2)
}

运行结果:

0
1
4
9
16

<提示> 在函数传参及任何赋值操作中可以将双向通道转换为单向通道,但反过来是不可以的。

  • 通道总结

channel 常见的异常总结,如下图:

<注意> 关闭一个已经关闭了的channel 也会引发 panic。

2.2.7 通道的多路复用 — 同时处理接收和发送多个通道的数据

多路复用时通信和网络中的一个专业术语。多路复用通常表示在一个信道上传输多路信号或数据流的过程和技术。

在使用通道时,想同时接收多个通道的数据是一件困难的事情。通道在接收数据时,如果通道没有数据可以接收将会发生阻塞。虽然可以使用如下模式进行遍历,但运行性能会非常差。

for {
    //尝试接收ch1通道
    data, ok := <- ch1
    //尝试接收ch2通道
    data, ok := <- ch2
    //接收后续通道
    ...
}

Go语言中提供了 select 关键字,可以同时响应多个通道的操作。select 的每个case 都会对应一个通道的收发过程。当收发完成时,就会触发case 中响应的语句。多个操作在每次 select 中选择一个进行响应。格式如下:

select {
    case 操作1:
        响应操作1
    case 操作2:
        响应操作2
    ...
    default:
        没有操作情况
}
  • 操作1、操作2:包含通道收发语句。请参考下表:
select多路复用中可以接收的样式
操作语句示例
接收任意数据case <-ch :
接收变量case data := <-ch :
发送数据case ch <- data :
  • 响应操作1、响应操作2:当操作发生时,会执行对应case分支的响应语句。
  • default:当没有任何操作时,默认执行default中的语句。

示例:select 在通道中的使用。

func main(){
    ch := make(chan int, 1)
    
    for i:=0; i<6; i++{
        select {
            case data := <- ch:
                fmt.Printf("recv data: %d\n", data)
            case ch <- i:
                fmt.Printf("send data: %d\n", i)
            default:
                fmt.Printf("unknowed operation\n")
        }
    }
    close(ch)  //关闭通道
}

运行结果:

send data: 0
recv data: 0
send data: 2
recv data: 2
send data: 4
recv data: 4

select 使用注意事项:

  • select 可以处理一个或多个channel的发送和接收操作。
  • 如果有多个case同时满足,select会随机选择一个。
  • 对于没有case语句的select会一直等待,可用于阻塞main()函数。

参考

Go语言基础之并发

《Go语言从入门到进阶实战(视频教学版)》

《Go并发编程实战(第2版)》

《Go语言编程》

《Go语言核心编程》

《Go语言学习笔记》

 

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值