首页 > 编程笔记 > Go语言笔记 阅读:7

Go语言channel通道详解(新手必看)

Go 语言中除了使用计数器 sync.WaitGroup 控制协程,还可以使用通道(channel)控制协程。

通道是 Go 语言内置的核心数据类型,使用通道可以很方便地在协程间进行数据通信。这源于 CSP(Communicating Sequential Processes,通信顺序进程)模型,Go 实现了其中部分理论。CSP 模型由并发执行的实体(如进程或线程)组成,实体之间通过发送消息进行通信,其中通道承担了在实体之间传递消息的责任。

在 Go 语言中,协程就是实体,而通道则是用来完成通信的载体。
“Don’t communicate by sharing memory; share memory by communicating.”(不要通过共享内存来通信,而应该通过通信来共享内存。)
这是 Go 语言的作者之一 Rob Pike(罗布·派克)说过的很有名的一句话,这充分说明了他对通道的推崇。通道类似 UNIX 上的管道(可以在进程间传递数据),其本质就是协程之间的内存共享。

所谓的“传递数据”是指一个协程将数据交给另一个协程,相当于把数据的拥有权(引用)交出去。在协程之间使用通道传递数据,是通道的常见应用场景。

Go语言通道的行为

同步是指在多个协程(即并发执行的线程)访问共享数据时,使用同步机制来协调它们的行为,以确保数据的正确性和一致性。

在并发编程中,多个协程可以同时读取或修改共享数据,如果没有进行同步,则可能会导致数据不一致或产生错误。例如,当多个协程同时尝试修改同一个变量时,可能会出现竞争条件(race condition),进而导致最终的结果不可预测或不正确。

编组是指让这些协程彼此之间有效地协调。sync.WaitGroup 并不算是同步机制,它更多是用来对协程进行编组。sync.WaitGroup 可让多个协程完成各自的任务后进行汇报,它的本质是一个“专用”的计数器。

在 Go 语言中,通道是一种重要的并发原语,用于协程之间的通信,它提供了一种通过发送和接收数据来同步协程的机制。通道类似于管道或者队列,它连接了一个发送者协程和一个接收者协程:
通道会阻塞发送者或接收者,直到有数据可以发送或接收为止。因此,它也是一种对协程进行编组的方式。通道的主要作用是协调不同的协程之间进行数据传输,而不是同步它们的执行。

有些读者认为通道是一种数据结构,是一种同步队列,这其实并不完全正确。通道实际上是一种特殊的类型,它在语言级别提供了一种在不同的协程之间传输数据的机制。虽然通道内部使用了一些同步机制来确保发送者和接收者之间的正确协调,但它并不是一个同步的队列,因为它并不能保证元素按照任何特定的顺序进入或离开通道。

不要从数据结构的角度来看通道,而应该把重点放在行为上。这里的行为是指发送和接收信号(数据)的行为。我们应该从收、发信号这种行为入手,即考虑如何使用通道实现这种行为。当我们谈论通道的时候,其实只关心一件事,那就是如何实现对信号的收和发,至于信号具体是什么,我们并不关心。

Go语言创建通道

通道是一种引用类型,通过 make() 函数可以创建一个新的通道,该函数会返回一个通道值。使用 make() 函数时需要指定通道的类型和缓冲区的大小(可选)。

示例代码如下:
var ch_name chan TYPE = make(chan TYPE, BUFFER) //定义及初始化
 
i := make(chan int)    // 创建一个int类型的无缓冲通道
j := make(chan string, 0)      // 创建一个string类型的无缓冲通道
k := make(chan *os.File, 100)  // 创建一个文件指针类型、缓冲区大小为100的通道
其中,TYPE 表示通道的类型,BUFFER 表示通道的缓冲区大小,如果不写 BUFFER 值则默认为 0。

需要注意的是,通道必须在使用之前初始化。在声明通道时,通道的初始值为 nil,这时不能进行任何操作,否则会导致运行阶段的错误。通道只能通过 make() 函数初始化,所以通道不存在使用字面量构造这一说法。

在初始化过程中,make() 函数会为通道分配内存并初始化通道的相关数据结构,以便通道可以正确工作。通道的类型由元素类型决定,缓冲区的大小则决定了通道的工作模式:

Go语言通道的特性

通道通常是成对出现的,有发送通道,也有接收通道,通道的发送和接收操作都是阻塞式的。

可以使用 close() 函数关闭通道,也可以使用 range 关键字遍历通道,遍历时如果通道被关闭,则遍历会自动结束。

1) 通道的成对性

我们讨论收发信号通道的语义时,首先要谈的是如何保证信号送达,也就是说,我们需要一种有保证的信号传输机制,以确保其中一个协程发出的信号必定能够被另一个协程所接收。

那么这种机制如何工作呢?答案就是发送和接收必须作为整体来执行!即发送和接收必须是同步的,不能出现随机因素。可见,这种有保证的信号传输机制对于确保应用程序的数据一致性来说至关重要,它可以保证程序的行为是能够预测的。

做软件工程一定要意识到任何内容都会引发开销。上述这种有保证的信号传输机制也不例外,使用它时通常需要忍受时长不定的延迟。如果不想忍受这种延迟,可以使用缓冲通道。另外要说明的是,即使使用了有保证的信号传输机制,也仍然有可能陷入阻塞状态。

总之,若想减少延迟,就使用带缓冲的通道。如果要保证接收方立即接收到,就使用无缓冲通道。

还有一点必须说明,发送信号时可能包含数据,也可能不包含数据。如果是包含数据的信号,那么只能在两个协程之间进行一对一的交换。如果是不包含数据的信号就比较灵活,可以一对一也可以一对多地发送。一对多就是让很多个协程都接收这种不包含数据的信号。

2) 通道的阻塞性

初始化通道后,使用“ch<-”向通道发送数据(也就是让通道接收数据),使用“<-ch”从通道中接收数据(也就是让通道发送数据)。这里的符号“<-”很形象地表示了数据的流向。

我们在前面说过,多个协程之间使用通道收发消息,可见,通道是与协程绑定在一起的。除了前面提到的成对性,通道还具有阻塞特性,通道的读和写操作都是阻塞式的。

在默认情况下,通道是不带缓冲的,它不会存储数据,只负责数据的传递。对于无缓冲通道,数据的收发都是阻塞式的。当一个协程向一个通道发送数据时,它将被阻塞,直到有另外一个协程从这个通道获取数据为止,反之亦然。

通道本身是并发安全的,这意味着同一时间只允许一个协程操作通道。使用通道时至少要有两个协程分别操作(存和取)数据,如果只有一个协程使用通道,则会出现死锁。这种阻塞特性可用于两个协程的状态同步。

3) 通道与死锁

若有两个或两个以上的进程(或线程、协程)在运行过程中因争抢共享资源而处于一种互相等待的状态,那么如果没有外部干涉,它们都将无法继续执行下去。对于这种情况,称系统处于死锁状态或产生了死锁。

通道是在协程之间传递数据的载体。通道的收和发都是成对出现的,若向通道发送了数据但通道无法进行缓存,且没有其他协程接收这个通道中的数据,那么就会出现死锁。

死锁大都与资源竞争、没有成对的通道及所有的协程都在等待有关。来看一些示例:
① 对无缓冲通道进行操作时,若只有一个协程对通道进行了读或写(单发或者单收)操作,那一定会发生死锁。

下面的示例代码中,在 main() 函数这个主协程中创建了通道 ch1,并让它接收数据。但因为没有其他子协程向通道发送数据,所以在执行到 elem := <-ch1 这一步时被阻塞,出现了死锁。
//如果创建的通道直接在main函数中接收或者发送数据
//则main函数会被阻塞,出现死锁
//这是因为对同一个通道进行读和写操作时,要用到不同的协程
//在下面的代码中,elem := <-ch1 和 ch1 <- 1 在同一个协程中对通道ch1进行读和写操作
func TestChanBlockMain(t *testing.T) {
    //此时的ch1值为nil
    var ch1 chan int
 
    //需要用make函数创建出来
    ch1 := make(chan int)
 
    //用elem接收从这个通道发送过来的元素
    elem := <-ch1
    fmt.Printf("从通道中接收到的元素是: %v\n", elem)
    //发送一个1到通道中
    ch1 <- 1
}
执行程序会报错:

fatal error: allgoroutines are asleep - deadlock!

从上面的示例中我们可以得到一个启示,需要使用不同的协程对同一个通道进行读和写操作。

② 互相等待可能出现死锁。在下面的示例代码中,主协程等待通道 ch1 中的数据流出,通道 ch1 等待通道 ch2 中的数据流出,但同时通道 ch2 又在等待数据流入,也就是说,两个协程都在等待,因此会发生死锁。
var ch1 chan int = make(chan int)
var ch2 chan int = make(chan int)
 
func say(s string) {
    fmt.Println(s)
    ch1 <- <- ch2 //通道ch1等待从通道ch2中流出数据
}
 
//互相等待,也有可能出现死锁
func TestDeadLockSense2(t *testing.T) {
    go say("hello")
    <- ch1  //堵塞主线程
}

③ 无缓冲通道如果是有流入无流出,或者有流出无流入,也会发生死锁。示例代码如下:
func TestDeadLockSense3(t *testing.T) {
    ch1, ch2 := make(chan int), make(chan int)
 
    go func() {
        ch1 <- 1 // 通道ch1的数据没有被其他协程读取走,堵塞当前协程
        ch2 <- 0 // 通道ch2始终没有办法写入数据
    }()
 
    <-ch2 // 通道ch2 等待数据的写
}

Go语言让出当前协程的执行权

在向通道 ch 中写数据前,先开启一个子协程运行 for 循环,以便不断地接收从通道 ch 发送过来的数据。从表面上来看这好像没问题,但是输出结果既有可能是一条记录,也有可能是两条记录。

示例代码如下:
//测试通道是否输出、输入所有的数据
func TestChanOutputAllData(t *testing.T) {
    //创建一个chan
    ch:= make(chan int)
 
    //开一个协程, 使用for循环不停地接收从这个通道发送过来的元素
    go func() {
     for {
         fmt.Printf("从通道中接收到的元素是:%v\n",  <-ch)
     }
    }()
 
    //发送一个1到通道中
    ch <- 1
    //发送一个2到通道中
    ch <- 2
}
执行结果为:

从通道中接收到的元素是: 1

这里涉及协程的两个特性:
第一次往通道中发送数据(ch <- 1)后,子协程 go func() ...会接收从该通道发送过来的数据,这时可以正确输出。当第二次在主协程中向通道 ch 发送数据“2”时,主协程执行完以后,若没有阻塞语句,那么它就会直接退出。在主协程执行完 ch <- 2 退出程序之前,如果子协程还占着 CPU 的时间片,就有可能会输出 2,否则,在输出 2 之前程序就退出了。

要使程序不提前退出,可以在最后添加 time.Sleep 语句阻塞主协程,这样,子协程就可以继续执行了。但这个阻塞的时间并不精确,因为我们不知道子协程要执行多久。不过,可以使用函数 runtime.Gosched 主动切换协程,让当前子协程交出执行权。关键代码如下:
go func() {
    for {
        fmt.Printf("从通道中接收到的元素是:%v\n",  <-ch)
        runtime.Gosched()
    }
}()
在 Go 语言中,多个协程可以同时执行,但是在某些情况下,一个协程可能会长时间占用 CPU,导致其他协程无法运行,这时就需要使用 runtime.Gosched() 让其主动让出执行权限,并将当前的协程转移到可运行队列的末尾,以便其他协程运行。

Go语言关闭通道

当不再使用通道时,必须使用内置函数 close() 将其关闭。

特别提醒,关闭通道的一定是发送方,接收方不能关闭通道!关闭通道的说明如下。

1) 仍然可以从关闭的通道中读取数据且不会导致panic

通道关闭后,仍然可以从中读取数据,并且不会导致 panic。在下面的示例代码中,输出 1、2 后,还会继续输出字符串类型的零值,直到 time.Sleep 执行完。
//通道的关闭演示,注意这里有Bug
func TestChannelClose(t *testing.T) {
    ch := make(chan string)
    go func() {
        for {
            fmt.Printf("接收数据: %s\n", <-ch)
        }
    }()
    ch <- "1"
    ch <- "2"
    close(ch)
    time.Sleep(time.Millisecond)
}
但是,向已经关闭的通道写数据,就会引起 panic。

2) 使用v, ok <-ch判断通道的关闭状态

通道支持 v, ok <-ch 这种语法,其中,v 表示通道的值,ok 用于标识通道是否打开。可以利用此特性判断通道的状态,示例代码如下:
//关闭通道前,先判断通道是否开启
//如果通道已经关闭,则不再接收数据
func TestChannelCloseWithFlag(t *testing.T) {
    ch := make(chan string)
    go func() {
        for {
            v, ok := <-ch
            if ok {
                fmt.Printf("接收数据: %s\n", v)
            } else {
                fmt.Println("通道已经关闭!")
                break
            }
        }
    }()
    ch <- "1"
    ch <- "2"
    close(ch)
    time.Sleep(time.Millisecond)
}
在上面的代码中,通过标识位判断通道是否打开,false 表示通道已经关闭,如果通道关闭,则不从通道中读取数据,因此没有多余的输出信息。

3) 与通道关闭有关的发生panic的情况

关闭一个未初始化(nil)的通道时;

4) 关闭通道时的注意事项

与关闭通道有关的注意事项如下:

Go语言遍历通道

可以使用关键字 range 对通道进行遍历,遍历时会从通道中依次取出元素,直到通道被关闭或者没有更多的元素可取为止。示例代码如下:
for {
    v, ok := <-ch
    if !ok {
        break
    }
    ...
}
遍历完成后,如果继续从该通道中读取数据不会被阻塞,也不会发生 panic,这时读取的数据为该通道类型的零值。

如果通道是带缓冲的,非空的通道关闭后,其中剩下的数据仍然可以被接收者读取,示例代码如下:
func TestChannelAfterClose(t *testing.T) {
    ch := make(chan string, 100)
 
    ch <- "1"
    ch <- "2"
    ch <- "3"
    close(ch)
    for v := range ch {
        fmt.Printf("接收数据: %s\n", v) //输出:1 2 3
    }
}
使用 for-range 遍历带缓冲的通道时,遍历会一直进行,直到通道被关闭为止。

如果不关闭通道,遍历完已有的缓冲数据后,range 操作会阻塞通道,出现死锁,示例代码如下:
//测试通道不关闭,range操作会阻塞通道
func TestRangeChannelAfterNoClose(t *testing.T) {
    ch := make(chan string, 100)
 
    ch <- "1"
    ch <- "2"
    ch <- "3"
    for v := range ch {
        fmt.Printf("接收数据: %s\n", v)
    }
}
上面的代码在输出 123 后会出现死锁。

在 for 语句前加上关闭通道的代码即可以解决死锁问题。

除了使用 for-range 遍历通道,还可以使用 for 循环和后面要讲的 select 机制对通道进行遍历。示例代码如下:
func main() {
  ch := make(chan int)
  go func() {
     ch <- 1
     close(ch)
  }()
  for {
     select {
     case v, ok := <-ch:
        if !ok {
           fmt.Println("通道已关闭")
           return
        }
        fmt.Println(v)
     }
  }
}
在这个例子中,创建了一个通道 ch,并启动了一个协程向通道中发送一个整数,然后关闭通道。接着,在主协程中使用 for 循环和 select 语句遍历通道。每次迭代时,select 语句都会尝试从通道中取出元素,如果通道已经关闭并且没有元素可取,则标志位 ok 会返回 false,否则返回 true,并且 v 会返回取出的元素的值。

Go语言通道的其他特性

1) 带缓冲的通道

除了通过添加协程从通道中接收数据来避免死锁,还可以通过设置缓冲区来解决死锁问题。“缓冲”代表通道既可以流通数据,也可以缓存数据。缓冲区的大小决定了通道的容量。我们可以将缓冲通道看作队列,当队列塞满时发送者会阻塞,队列清空时接收者会阻塞。

下面采用先进先出的原则读取缓冲区中的数据,数据从通道的一端写入,从另一端读取。
func main() {
   ch := make(chan int, 3)
   ch <- 1
   ch <- 2
   ch <- 3
}
在上述示例中,缓冲通道 ch 可以无阻塞地写入三个数据,但如果尝试向通道 ch 写第四个数据,因为通道的缓冲区已经装不下新的数据了,所以就会阻塞发送者(在这里是 main 协程),出现死锁。

无缓冲的通道意味着需要有对应的协程接收数据,这样在向通道发送数据时才不会出现死锁。带缓冲的通道则预先设置了容量,允许在没有接收者的情况下先把接收到的数据缓存起来,因此当前线程不会被阻塞。缓存的数量取决于初始化时容量的大小,在容量被填满之前,都可以向这个通道发送消息(数据)。

2) 缓冲区与延迟保障

在使用缓冲区时,还有一个概念叫作延迟保障(delayed guarantee),指的是使用容量为“1”的缓冲区,以便能够最大限度地减少延迟,让程序持续运行。此外,如果程序流程出现问题,也可以通过它找到并解决。

那么这种延迟保障是怎样的一种机制呢?我们用搬运沙袋的流水线举例。流水线上的所有人按照同一种方式搬运沙袋,这就是协调工作。当 A 把沙袋递给 B 时,B 应该去接过这个沙袋,然后再将沙袋交给 C。如果 B 已经把上一个沙袋交给 C 了,那么 A 就可以把新的沙袋交给 B。只要能保持这样的节奏,就能一直持续下去。这相当于 A 向 B 发送信号后,B 接收信号,这中间不会有延迟,因为每当 A 将沙袋交给 B 时,B 都已经准备好接收新的沙袋了。

但如果换一种情况,假设 B 处理任务的节奏要慢于 A 将任务交给 B 的节奏,那么当 A 把任务交给 B 时,A 就得等 B 先把已有的任务完成,然后才能把新任务交给他。下一个任务也是一样。

在这种情况下,如果想降低延迟,就可以使用容量大小为1的缓冲区。换句话说,A 可以把沙袋放到B面前,B面前最多只能堆放一个沙袋。如果B面前是空着的,那A就把沙袋放在那里,然后 A 的任务就完成了。等 A 把下一个沙袋拿过来时,可能会出现两种情况:
对于第二种情况,“A必须停下来等待”很关键,因为此时我们不能让问题变得更复杂。可以看出,如果 B 面前的沙袋还没被拿走,那就说明 B 在处理任务时存在瓶颈,必须找出问题的所在。作为开发者,我们在开发程序时,也必须下功夫对协程进行编组,以找到“流水线”中可能存在瓶颈或性能问题的地方。这可能是因为网络延迟、数据库崩溃,或者其他原因。

这时,我们最不应该做的就是把A的新沙袋放在还没处理的那个沙袋上面。

这个大小为 1 的缓冲区本身对性能并没有多大帮助,我们主要是想通过这种比较小的缓冲区查找程序内存在的问题,同时又能确保程序在没有问题的情况下顺利运行,避免出现过多的延迟。因此,使用只能保留一份数据的缓冲区既有助于减少程序协调操作时出现的延迟,同时又能帮助我们快速定位问题。

3) 通道的方向

通道还可以带有方向,可以通过使用关键字 chan 结合方向操作符来指定通道的发送和接收方向。

方向属性可用于限制仅发送或者仅接收。使用带方向的通道时,只需要看通道的箭头指向谁就能知道数据流的方向:
通道的方向属性提升了程序的类型安全性。如果尝试从只写通道中读取数据,则会报错:invalid operation: <-ch (receive from send-only type chan<- string)。同样,往只读通道中写入数据也会报错:invalid operation: ch <- xxx (send to receive-only type <-chan string)。

4) 通道的状态

通道是一种引用类型,如果通道的初始化值为零值,那么此时它就是一个 nil 通道。对 nil 通道执行任何与信号有关的操作,都会造成阻塞。

那 nil 通道有意义吗?答案是有的。在处理网络请求或者处理队列中的任务时,可能需要让程序短暂地停顿或者对其限速,例如,在一个循环中通道会不停地收到信号,但有时需要在接下来的几轮中什么都不做,这时就可以把通道设置为 nil 状态,等待这几轮结束后再把状态切回去。

通道是有状态的,通过 make() 函数创建的通道,都处于开启状态。下表展示了在不同状态下通道的发送和接收操作是否会受影响。

表:通道的动作与状态
通道动作 通道状态
nil open closed
发送数据 阻塞 正常 panic
接收数据 阻塞 正常 正常

Go语言通道的使用建议

1) 不要从数据结构的角度来看通道,而应该把重点放在行为上,要从收、发信号这种行为入手。

通道可以通过以下两种方式传递信号:
2) 在编写代码时,要根据实际需求和具体情况判断数据是否得当场收发,以及是否需要确认对方已收到发送的信号。如果程序的行为必须能够预测,那么就需要在发送和接收信号时进行确认,以确保数据的可靠性和正确性。这种做法可以避免数据的丢失和出现错误,但会增加程序的延迟和复杂度,并且无法预估要延迟多久。

如果没有上述要求,那就无须当场收发,可以采用异步的方式发送和接收信号,即不需要等待对方的确认,从而降低延迟,提高程序的性能。虽然这种做法可以降低延迟,但是会稍微增加一些风险,因为可能会导致数据的丢失和出现错误,故而需要在设计程序和实现时进行风险评估与控制。

3) 不能通过增加缓冲区来提高性能,因为将缓冲区设置得非常大并不是每次都可以解决性能问题。我们要合理地设置缓冲区的大小,同时要尽量减少延迟。

通道缓冲区的大小会影响发送和接收的效率。如果通道的缓冲区足够大,发送操作就不需要等待接收方接收数据。这的确可以提高程序的效率和吞吐量,但缺点是会浪费内存资源。而且,如果通道的缓冲区被占满了,不仅发送操作会被阻塞,接收操作也会被阻塞。因此,缓冲区的大小应该根据实际需求进行设置,既要考虑程序的性能,也要避免浪费内存资源。使用缓冲区的目的是让程序有地方暂存数据,以便继续运行下去。缓冲区设置得合理,才能把流程中的问题及时地探查出来。

4) 通道是协程间通信的载体,它可以在多个协程之间传递数据。多个协程可以同时从同一个通道里读取数据,这样可以更好地利用多核,从而提升性能。

5) 通道支持异步操作,可用于协程之间有数据传输的场景。设计异步通道是为了避免协程间的阻塞,从而提高程序的并发能力和执行效率。在异步通道中:
6) 通道是有状态的(nil、open、closed),可以利用这个特性做一些事情:

相关文章