并发与通道

本文详细介绍了Go语言中的并发和并行概念,强调了并发执行和并行执行的区别。文章通过实例展示了Go语言中goroutine的使用,以及如何通过无缓冲、有缓冲和单向通道实现goroutine间的通信。通过通道,可以实现goroutine的同步和数据交换,确保程序执行的顺序和效率。总结了通道在并发编程中的重要作用,并提到了通道操作的注意事项,如对nil通道的处理和通道关闭后的使用规则。
摘要由CSDN通过智能技术生成

目录

  1. 并发与并行
  2. goroutine
  3. 通道
  • 3.1 无缓冲通道
  • 3.2 缓冲通道
  • 3.3 单向通道
  1. 总结
1. 并发与并行

某个周末,你玩着游戏,突然到饭点了。于是,你点了个外卖。这时,你可能会吃两口饭再玩一会儿游戏,说明你支持并发。但是,团战很激烈,于是不得不一边吃饭一边玩游戏,说明你支持并行。

并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果。

并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。

并发的关键是你有处理多个任务的能力,不一定要同时。并行的关键是你有同时处理多个任务的能力。

为了提高机器的性能和执行效率,支持并发是编程中一个重要的话题。Go 语言中支持两种并发的风格,一种叫 通信顺序进程(Communicating Sequential Process,CSP),一种是共享内存。CSP 是一个在不同的执行体(goroutine)之间传递值的并发模式,Go 语言中用通道(chan)来传递值。

2. goroutine

Go 语言中,每一个并发执行的活动称为 goroutine,一个程序里面至少有一个主 goroutine,那就是 main 函数执行的活动体。可以通过内置的 go 关键字,创建新的 goroutine:

func main() {
    add(1, 2) // 调用 add 函数,等待它返回
    go func(x, y int){ // 新建一个 goroutine 去调用函数,不用等待
        sum := x + y
        fmt.Println("sum is ", sum)
    }(3, 4) 
    time.Sleep(time.Second) // 休眠一段时间,让 goroutine 的函数执行完
}

如上所示,当创建新的 goroutine 执行别的任务时,主函数需要休眠一段时间。因为当 main 函数执行完毕时,所有的 goroutine 都将直接终结,然后程序退出,go add(3, 4) 就很可能还未执行结束就停止了。

那么,我们怎么知道 go add(3, 4) 需要执行多久呢?注意,上面休眠的一秒钟是一个可变值,如果一秒钟内 goroutine 还是没有执行结束,这个程序就是不稳定的。如果时间分配过多了,那么执行的效率就没法保证。

所以,要是 goroutine 之间可以通信就好了。一个 goroutine 执行结束,就可以告知另一个,你可以继续执行了。

3. 通道

如果说 goroutine 是 Go 程序并发的执行体,那通道就是它们之间的连接。通道可以让一个 goroutine 发送特定值给另一个 goroutine。相当于在两个并发的 goroutine 中,通道充当了它们交流的媒介。

创建通道:

ch := make(chan int, 0) // 创建一个 int 类型的通道

通道由 元素类型容量 组成,它们在创建的时候就需要初始化完成。每一个通道是一个具体类型传输的导管,叫做通道的元素类型,一个有 int 类型元素的通道写为 chan int。容量是通道中可包含的元素个数,当容量为 0 时,后面的数字 0 可以省略。

通道可以发送(send)或接收(receive)数据,两者执行的过程统称为 通信

x := <- ch // 接收
ch <- x // 发送

当发送完毕时,我们需要通过内置函数 close 将通道关闭。

close(ch)

此时,向通道发送数据会报错并引发 panic 宕机;但是从该通道接收数据不会报错,只会获取通道元素对应的零值。那么,我们如何保证从通道中获取的值是有效的而非对应的零值呢?

第一种方式,ok 语法:

func main() {
    ch := make(chan int, 100)
    go func() {
        for i:=0; i<100; i++ {
        	ch <- i
    	}
        close(ch) // 当通道关闭后,不能再往通道中发送数据
    }()
    for {
        x, ok := <- ch
        if !ok {
            break // 通道关闭且数据已读完
        }
        fmt.Println(x)
    }
}

第二种方式,Go 语言提供了 range 循环语法:

func main() {
    ch := make(chan int, 100)
    go func() {
        for i:=0; i<100; i++ {
        	ch <- i
    	}
        close(ch) // 当通道关闭后,不能再往通道中发送数据
    }()
    for v := range ch { // 通道关闭则退出循环
        fmt.Println(v)
    }
}
3.1 无缓冲通道

当通道的容量为 0 时,表示该通道是一个无缓冲通道。

ch := make(chan int) // 创建一个无缓冲通道

无缓冲通道上的发送操作将会阻塞,直到另一个 goroutine 在该通道上执行接收操作;同样地,如果接收操作先执行,接收方的 goroutine 将阻塞,直到另一个 goroutine 向该通道发送数据。

怎么理解这句话呢?比如,你喜欢隔壁班一个女孩子,已经准备表白。于是,你约她到老师办公室门口想给她一个惊喜。这时,你是信息的发送者,心仪的女孩子是数据接收者。如果你先到了,表白这个信息也只能阻塞,等到女孩子出现并等她准备好,才可以继续执行;同样地,女孩子先到,也会阻塞等待你发送信息。

使用无缓冲通道进行的通信,将导致发送和接收 goroutine 的 同步化。因此,无缓冲通道也叫 同步通道。通过同步通道,我们可以将上面的例子改写:

func main() {
    add(1, 2)
    done := make(chan int)
    go func(x, y int){
        sum := x + y
        fmt.Println("sum is ", sum)
        done <- 1 // 发送任意数据到通道
    }(3, 4)
    <- done // 从通道接收数据,并丢弃
}

通过阻塞可以严格地控制程序的执行顺序,并且不用分配额外的时间。当 goroutine 执行结束时,传入一个任意数值到通道中,解除主 goroutine 的阻塞。

注意,此程序中的通道数据并不是我们关心的地方,所以不需要用变量去接收,Go 语言允许我们直接丢弃通道里的数据 <- ch

3.2 缓冲通道

缓冲通道有一个元素队列,队列的最大长度是通道的容量,在创建时初始化:

ch := make(chan string, 3) // 创建队列大小为 3 的 int 通道

缓冲通道可以看作是一个固定容量的信箱。当信箱的容量是 3 时,最多能容纳 3 封信,多了就会阻塞。直到有人把它们取出来,每取出一封时,就可以再投递一封。

ch <- "A"
ch <- "B"
ch <- "C" // 通道已满

从中接收一个值(队列的特点:先进先出,FIFO):

fmt.Println(<- ch) // "A"

内置函数 cap 获取通道的容量,len 获取通道的长度:

fmt.Println(cap(ch)) // 3
fmt.Println(len(ch)) // 2

当通道的容量被占满时,再向通道发送数据会阻塞;同样地,从一个空的通道中接收数据也会阻塞(想象一下,信箱里没有信件时,就需要等待其他人寄信)。

3.3. 单向通道

未明确传输方向的通道本身是可以发送,也可以接收数据的。但有时候我们为了表现通道中数据的传输方向,会用到 单向通道 类型。

chan<- int 是一个只能发送的通道,它需要从外界接收数据,用作输入;<-chan int 是一个只能接收的 int 类型的通道,负责输出:

// 向通道中发送数据,完成后关闭通道
func counter(out chan<- int) {
    for x:=0; x<100; x++ {
        out <- x
    }
    close(out)
}
// 从通道中读取数据并打印
func printer(in <-chan int) {
    for v := range in {
        fmt.Println(v)
    }
}

func main() {
    ch := make(chan int)
    go counter(ch)
    go printer(ch)
}

如上所示,我们定义了未声明传输方向的通道 ch,分别创建两个新的 goroutine 去调用两个函数。当调用 counter 函数时,chan int 被隐式地转化为参数要求的 chan<- int 类型;同样地,printer 函数做了类型 <-chan int 的转变。

总之,在任何赋值操作中,将双向通道转换为单向通道都是被允许的,但反过来不行。

4. 总结

用 CSP 方式来实现并发是 Go 语言的一大特色,正如我们看到的那样,它可以很方便地在两个甚至多个 goroutine 中通信。而这样的并发风格是极为简便的,不需要复杂的语法即可实现。

作为一种类似于数据类型的通信体,通道要在不同的 goroutine 进行数据传输,所以它是一种引用类型,和 slicemap 一样,在创建时必须用 make 语句进行初始化。

使用通道时,我们需要注意以下几点:

  • 对 nil 值的通道,接收或发送消息都会阻塞;
  • 对已关闭的通道,不能再次关闭,否则会像对该通道发送数据那样引发 panic;
  • 不能关闭值为 nil 的通道,否则会引发 panic;
  • 可以向已关闭的通道接收数据,不会引发错误,只会收到当前通道中的元素零值。

所以,在读 chan 时,需要判断 chan 是否已经关闭(用 ok-idiom 语法或 range 循环)

v, ok := <- ch
if !ok {
    fmt.Println("chan has been closed! v is 0:%+v", v)
}

否则可能导致协程读取到非预期的零值消息,或者导致协程读消息的循环体死循环。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值