您现在的位置是:首页 >技术杂谈 >go语言channel(管道)和 select的结合使用网站首页技术杂谈

go语言channel(管道)和 select的结合使用

cool-wangtongzhou 2024-06-17 10:19:17
简介go语言channel(管道)和 select的结合使用

给个小建议:如果是初学者,建议把基础知识朗读一遍,有个大概印象,后面思考多了,就会“由量变达到质变”,从而有所顿悟。

一、基础知识

  1. 在Go语言中,管道是协程间通信的方式
  2. 管道是nil,则读写都会永久阻塞
  3. 管道关闭(通过close()函数关闭),则读 ;写 ,会触发panic
  4. 数据读写

    在这里插入图片描述
  5. 创建管道的两种方式:
    // 声明管道,值为nil
    var ch chan int 
    
    // make创建无缓冲管道和带缓冲管道
    ch1 := make(chan string)
    ch2 := make(chan string, 10)
    
  6. 管道是双向可用的,在函数间传递时,可以通过操作符(<-)来控制管道的可读和可写。
    func ChanParamRW(ch chan int) {
    	// 管道可读写
    }
    
    func ChanParamR(ch <- chan int) {
    	// 只能从管道读取数据
    }
    
    func ChanParaW(ch chan<- int) {
    	// 只能向管道写入数据
    }
    

二、例子1

1、管道ch的缓冲区为10,select中有case读取管道的数据

代码示例

func main() {
	ch := make(chan int, 10)
	go func() {
		for i := 0; i < 20; i++ {
			select {
			case ch <- 1:
				fmt.Println("输入1")
			case ch <- 2:
				fmt.Println("输入2")
			case <-ch:
				fmt.Println("输出")
			default:
				fmt.Println("default")
			}
		}

	}()

	time.Sleep(time.Millisecond)
}

输出结果

输入2
输入2
输入2
输入1
输入2
输入2
输出
输入2
输入2
输入2
输入1
输入1
输出
输入1
输出
输入2
输出
输出
输出
输入1

2、管道ch的缓冲区为10,select中有case读取管道的数据

代码示例

func main() {
	ch := make(chan int, 10)
	go func() {
		for i := 0; i < 20; i++ {
			select {
			case ch <- 1:
				fmt.Println("输入1")
			case ch <- 2:
				fmt.Println("输入2")			
			default:
				fmt.Println("default")
			}
		}

	}()

	time.Sleep(time.Millisecond)
}

输出结果

输入2
输入1
输入1
输入1
输入2
输入2
输入2
输入2
输入1
输入1
default
default
default
default
default
default
default
default
default
default


3、管道ch的缓冲区为10select中有case读取管道的数据

代码示例

func main() {
	ch := make(chan int)
	go func() {
		for i := 0; i < 20; i++ {
			select {
			case ch <- 1:
				fmt.Println("输入1")
			case ch <- 2:
				fmt.Println("输入2")
			case <-ch:
				fmt.Println("输出")
			default:
				fmt.Println("default")
			}
		}
	}()

	time.Sleep(time.Millisecond)
}

输出结果

default
default
default
default
default
default
default
default
default
default
default
default
default
default
default
default
default
default
default
default

4、管道ch的缓冲区为10,select中有case读取管道的数据

代码示例

`func main() {
	ch := make(chan int)
	go func() {
		for i := 0; i < 20; i++ {
			select {
			case ch <- 1:
				fmt.Println("输入1")
			case ch <- 2:
				fmt.Println("输入2")
			default:
				fmt.Println("default")
			}
		}
	}()

	time.Sleep(time.Millisecond)
}

输出结果

default
default
default
default
default
default
default
default
default
default
default
default
default
default
default
default
default
default
default
default

5、补充知识:channel底层的等待队列

在runtime包中hchan定义了管道的数据结构,里面有recvq和sendq这两个等待队列。

type hchan struct {
	```
	recvq waitq 								// 等待读消息的协程队列
	sendq waitq								// 等待写消息的协程队列
	
	```
}

(1)协程阻塞,加入等待队列

  • 从管道「读取数据」时,如果「管道缓冲区为空」或「没有缓冲区」,则当前协程会被阻塞,并被加入recvq队列。
  • 向管道「写入数据」时,如果「管道缓冲区已满」或「没有缓冲区」,则当前协程会被阻塞,并被加入sendq队列。

举例:下面展示了一个没有缓冲区的管道,有几个协程阻塞等待读数据。

在这里插入图片描述

(2)协程被唤醒:

  • 因「读阻塞的协程」会被「向管道写入数据的协程」唤醒;
  • 因「写阻塞的协程」会被「从管道读取数据的协程」唤醒。

处于等待队列中的协程会在其他协程操作管道时被唤醒。
:为什么说是其他协程呢?
: 加入到等待队列中的协程应该是阻塞的才对,不管是读操作阻塞还是写操作阻塞,在同一个协程程序中后面的读写操作都是无法执行的,这个时候就只能由其他协程来写或读帮助唤醒这个协程,如果没有其他协程帮忙唤醒,就会出现死锁。

如果没有缓冲区的情况下,在同一个协程内进行写和读,因为没有其他协程去读这个管道,就会一直阻塞写这一步,从而出现死锁;

如果有缓冲区并且缓冲区未满的情况下,因为有缓冲区帮忙缓冲数据,所以在同一个协程内进行读和写是没有问题的。

(3)同一个协程里面,管道无缓冲区,会死锁

代码示例

func main() {
	ch := make(chan int)
	ch <- 1

	fmt.Println(<-ch)

	time.Sleep(time.Millisecond)
}

输出结果

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
        /Users/dns/GolandProjects/code/select/demo01.go:10 +0x37
exit status 2

底层示意图

没有缓冲区的情况

(4)同一个协程里面,管道有缓冲区,不会死锁

代码示例

func main() {
	ch := make(chan int, 10)
	ch <- 1
	
	fmt.Println(<-ch)

	time.Sleep(time.Millisecond)
}

输出结果

1

底层示意图

请添加图片描述

注意:

① 一般情况下,recvq 和 sendq 至少有一个为空(因为对于同一个管道而言,如果有读协程的话,那么等待队列中就不会有写协程,如果是有读有写,协程就不会阻塞,就不会被添加到等待队列中)。

特殊情况:同一个协程使用select语句向管道一边写入数据,一边读取数据,此时协程就会分别位于两个等待队列中。
具体来说就是:

  • 在一个协程中用select去监听「该协程向一个管道写入数据」,并且同时去监听「该协程向同一个管道读取数据」;
  • 该协程会位于等待队列中,是因为读写操作阻塞了;
  • 该协程会同时位于两个等待队列中,是因为select有多路复用的机制,能够同时监听多个case,同一个协程进行多个读写操作阻塞,自然就会同时放在两个等待队列中;
  • 假设读操作不阻塞的话,也就是其他协程中有对该管道写入数据,那么recvq等待队列里面就会把该协程移除,该协程就自然不会放在recvq等待队列中,select也就能够去随机执行一个case。

6、补充知识:select

  1. select功能:解决多个管道的选择问题,也可以叫多路复用,可以从多个管道中随机公平地选择一个来执行
  2. case后面必须进行的是io操作,不能是等值,随机去选择一个io操作
  3. default防止select被阻塞住,加入default

7、总结

  1. 同一个协程中,select监听同一个channel的和写,如果该channel有缓冲区,那么for循环下,select会随机执行读或写的case,一边取出一边放入,这样缓冲区就不会满了;
  2. 同一个协程中,select监听同一个channel的没有读,如果该channel有缓冲区,那么for循环下,select会随机执行读或写的case,只有放入,这样缓冲区就会容易写满而导致写操作阻塞,最后只能执行default;
  3. 同一个协程中,select监听同一个channel的和写,如果该channel无缓冲区,那么此时管道既不能读也不能写,所以会直接输出default(如果没有default,select就会陷入阻塞,直到任意一个管道解除阻塞;有了default,select就是非阻塞的);
  4. 同一个协程中,select监听同一个channel的没有读,如果该channel无缓冲区,此时管道也不能读,所以会直接输出default。

三、例子2

代码示例

var channels = [3]chan int{
	nil,
	make(chan int),
	nil,
}

var numbers = []int{1, 2, 3}

func main() {
	time.Sleep(time.Millisecond)
	select {
	case getChan(0) <- getNumber(0):
		fmt.Println("The first candidate case is selected.")
	case getChan(1) <- getNumber(1):
		fmt.Println("The second candidate case is selected.")
		fmt.Println("")
	case getChan(2) <- getNumber(2):
		fmt.Println("The second candidate case is selected.")
		fmt.Println("")
	default:
		fmt.Println("No candidate case is selected.")
	}
}

func getNumber(i int) int {
	fmt.Printf("numbers[%d]
", i)
	return numbers[i]
}

func getChan(i int) chan int {
	fmt.Printf("channels[%d]
", i)
	return channels[i]
}

输出结果

channels[0]
numbers[0]
channels[1]
numbers[1]
channels[2]
numbers[2]
No candidate case is selected.

select 语句的执行顺序

  1. 从上到下完成所有case后面的表达式
  2. case后面的表达式是从左往右依次执行
    因此控制台会依次输出所有channels[]、numbers[]
  3. 等所有表达式执行完成后,才会执行候选语句;
    执行default,是因为case后面的表达式因为阻塞,而导致条件不成立。具体来说就是:第一个和第三个case管道值为nil,所以不管是读还是写管道,都会阻塞;第二个case是向无缓冲区的管道中写入数据,所以需要有其他协程来帮忙从管道中读数据,因为没有其他协程的帮忙,所以会阻塞。因而最终执行成功的case就只有default,其他的case都只是执行了case后面附带的表达式而已。

四、参考资料

  1. 《Go专家编程》
  2. 马士兵教育 【协程与管道】select功能
风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。