通道:
1.创建通道
声明并初始化一个通道的时候,需要用到Go语言的内建函数make。我们传给这个函数的第一个参数应该是代表了通道的具体类型的类型字面量。
在声明一个通道类型变量的时候,我们首先要确定该通道类型的元素类型。
比如 chan int chan代表通道类型关键字,int代表了通道类型的元素类型
初始化通道时,make还可以接收一个int类型的参数
容量为0时称为非缓冲通道,不为0时称为缓冲通道
通道相对于一个先进先出的队列,元素的发送和接收使用操作符<-
package main
import "fmt"
func main(){
ch1 :=make(chan int,3)
ch1<- 2
ch1<- 1
ch1<- 3
elem1 :=<- ch1
}
因为容量为3所以可以在通道不包含任何元素的时候,连续发送三个值,这三个值会被缓存在通道之中
2.通道的发送和接受操作有哪些基本特性
- 对于同一个通道,发送操作之间是互斥的,接受操作之间也是互斥的。
- 发送操作和接受操作对元素值的处理都是不可分割的。
在同一时刻,Go语言的运行时系统只会执行对同一个通道的任意个发送操作中的某一个。
直到这个元素被完全复制进该通道之后,其他针对该通道的发送操作才可能被执行,接收操作同理。同时接收操作与发送操作也是互斥的。
不可分割代表处理元素值时是不会被打断的。既是为了保证通道中元素值的完整性,也是为了保证通道操作的唯一性。对于通道中的同一个元素值来说,它只可能是某一个发送操作放入的,同时也只可能被某一个接收操作取出
发送操作包括了复制元素值和防止副本到通道内部这两个步骤,在这两个步骤完全完成之前,发起这个发送操作的那句代码会一直阻塞在那里。也就是说,在它之后的代码不会有执行的机会,直到阻塞解除。
只有在通道完成发送操作之后,运行时系统才会通知这句代码所在goroutine,让它争取继续运行代码的机会。
接受操作通常包含 复制通道内的元素值 放置副本到接收方 删掉原值 三个步骤 机制与上述同理
问题一:发送操作和接收操作在什么时候可能被长时间的阻塞
对于缓冲通道来说 如果通道已满 那么所有发送操作都会被阻塞,直到有元素被取走,这时候系统会通知最早那个groutine groutine会再次执行发送操作
通知的顺序的总是公平的
如果通道已空 相应的 所有接收操作都会被阻塞
对于非缓冲通道来说,无论发送还是接收,一开始就会被阻塞,直到配对的操作也开始执行,由此可见,非缓冲通道是在用同步的方式传递数据,也就是说只有收发双方对接上了,数据才会被传递。并且数据是直接从发送方复制到接收放的。
大多数情况下,缓冲通道会作为收发双方的中间件,但假如发送操作在执行的时候发现正好有等待的接受操作,则会直接把元素值复制给对方
对于值为nil的通道,不论他的具体类型是什么,对它的发送操作和接收操作都会永久的处于阻塞操作
3.单向通道
所谓单向通道,只能进行单向操作
声明单向通道
var uselessChan=make(chan<- int,1)
注意那个<-,这表示了这个通道是单向的,并且只能发而不能收
如果在chan的左边表示只能收不能发。所以前者可以被简称为发送通道,后者可以被简称为接收通道
4.单向通道的应用价值
单向通道最主要的用途就是约束其他代码的行为
func SendInt(ch chan<- int){
}
这里声明了一个叫做Sendint的函数,这个函数中的代码只能向ch发送元素值,而不能从它那里接收元素值
func getIntChan() <-chan int{
num :=5
ch :=make(chan int,num)
for i :=0;i<num;i++{
ch<-i
}
close(ch)
return ch
}
函数getIntChan会返回一个<-chan int 类型的通道,这就意味着得到该通道的程序,只能从通道中接收元素值。
select语句与通道怎样连用
select语句只能与通道联用,他一般由若干个分支组成。每次执行这种语句的时候,一般只有一个分支中的代码会被运行
select 语句的分支分为两种,一种叫做候选分支,另一种叫做默认分支。候选分支总是以关键字case开头,后跟一个case表达式和一个冒号,然后我们可以从下一行开始写入当分支被选中需要执行的语句
默认分支当且仅当没有候选分支被选中时他才会被执行,所以他以关键字default开头并直接后跟一个冒号。同样的,我们可以在default:的下一行写入要执行的语句
//准备好几个通道
intChannels:=[3]chan int{
make(chan int,1),
make(chan int,1),
make(chan int,1)
}
index:=rand.Intn(3)
fmt.Printf("The index:%d\n",index)
intChannels[index]<-index
//哪一个通道中有可取得的元素值,哪个对应的分支就会被执行
select{
case <-intChannels[0]:
fmt.Println("first")
case<-intChannels[1]:
fmt.Println("second")
case elem:=<-intChannels[2]:
fmt.Println("third")
default:
fmt.Println("nothing")
}
intChan :=make(chan int,1)
time.AfterFunc(time.Second,func(){
close(intChan)
})
select{
case _,ok:=<-intChan:
if !ok{
fmt.Println("The candidate case is closed")
break
}
fmt.Println("The candidate case is selected")
}
select语句的分支选择规则
- 对于每一个case表达式,都至少会包含一个代表发送操作的发送表达式或者一个代表接收操作的接收表达式,同时也可能会包含其他的表达式
- select语句包含的候选分支中的case表达式都会在该语句执行开始时先被求值,并且求值的顺序是依从代码编写的顺序从上到下的
- 对于每一个case表达式,如果其中的发送表达式或者接收表达式在被求值时,相应的操作正处于阻塞状态,那么对该case表达式的求值就是不成功的
- 仅当select语句中的所有case表达式都被求值完毕后,他才会开始选择候选分支
- 如果select语句发现同时有多个候选分支满足选择条件,那么它就会用一种伪随机的算法在这些分支中选择一个并执行
- 一条select语句中只能够有一个默认分支。
- select语句的每次执行,包括case表达式求值和分支选择都是独立的。
go语言执行规则
进程,描述的就是程序的执行过程,是运行着的程序的代表。换句话说,一个进程其实就 是某个程序运行时的一个产物。如果说静静地躺在那里的代码就是程序的话,那么奔跑着 的、正在发挥着既有功能的代码就可以被称为进程。
再来说说线程。首先,线程总是在进程之内的,它可以被视为进程中运行着的控制流(或 者说代码执行的流程)。
一个进程至少会包含一个线程。如果一个进程只包含了一个线程,那么它里面的所有代码 都只会被串行地执行。每个进程的第一个线程都会随着该进程的启动而被创建,它们可以 被称为其所属进程的主线程。
相对应的,如果一个进程中包含了多个线程,那么其中的代码就可以被并发地执行。除了 进程的第一个线程之外,其他的线程都是由进程中已存在的线程创建出来的
也就是说,主线程之外的其他线程都只能由代码显式地创建和销毁。这需要我们在编写程 序的时候进行手动控制,操作系统以及进程本身并不会帮我们下达这样的指令,它们只会 忠实地执行我们的指令。
在 Go 程序当中,Go 语言的运行时(runtime)系统会帮助我们自动地创建和销毁 系统级的线程。这里的系统级线程指的就是我们刚刚说过的操作系统提供的线程。
而对应的用户级线程指的是架设在系统级线程之上的,由用户(或者说我们编写的程序) 完全控制的代码执行流程。用户级线程的创建、销毁、调度、状态变更以及其中的代码和 数据都完全需要我们的程序自己去实现和处理。
Go语言有独特的并发编程模型,以及用户级线程goroutine,还拥有强大的用于调度goroutine、对接系统级线程的调度器
这个调度器是 Go 语言运行时系统的重要组成部分,它主要负责统筹调配 Go 并发编程模 型中的三个主要元素,即:G(goroutine 的缩写)、P(processor 的缩写)和 M (machine 的缩写)
其中的 M 指代的就是系统级线程。而 P 指的是一种可以承载若干个 G,且能够使这些 G 适时地与 M 进行对接,并得到真正运行的中介。
什么是主goroutine,它与我们启用的其他goroutine有什么不同
package main
import "fmt"
func main(){
for i:=0;i<10;i++{
go func(){
fmt.Println(i)
}()
}
}
一定要注意,go函数真正被执行的时间,总会与其所属的go语句被执行的时间不同。当程 序执行到一条go语句的时候,Go 语言的运行时系统,会先试图从某个存放空闲的 G 的队 列中获取一个 G(也就是 goroutine),它只有在找不到空闲 G 的情况下才会去创建一个 新的 G。
然而,创建 G 的成本也是非常低的。创建一个 G 并不会像新建一个进程或者一个系统级线 程那样,必须通过操作系统的系统调用来完成,在 Go 语言的运行时系统内部就可以完全 做到了,更何况一个 G 仅相当于为需要并发执行代码片段服务的上下文环境而已。
在拿到了一个空闲的 G 之后,Go 语言运行时系统会用这个 G 去包装当前的那个go函数 (或者说该函数中的那些代码),然后再把这个 G 追加到某个存放可运行的 G 的队列中。
这类队列中的 G 总是会按照先入先出的顺序,很快地由运行时系统内部的调度器安排运 行。虽然这会很快,但是由于上面所说的那些准备工作还是不可避免的,所以耗时还是存 在的。
因此,go函数的执行时间总是会明显滞后于它所属的go语句的执行时间。当然了,这里所 说的“明显滞后”是对于计算机的 CPU 时钟和 Go 程序来说的。我们在大多数时候都不会 有明显的感觉。
在说明了原理之后,我们再来看这种原理下的表象。请记住,只要go语句本身执行完毕, Go 程序完全不会等待go函数的执行,它会立刻去执行后边的语句。这就是所谓的异步并 发地执行。
在确定最终的答案之前,你还需要知道一个与主 goroutine 有关的重要特性,即:一旦主 goroutine 中的代码(也就是main函数中的那些代码)执行完毕,当前的 Go 程序就会结 束运行。
如此一来,如果在 Go 程序结束的那一刻,还有 goroutine 未得到运行机会,那么它们就 真的没有运行机会了,它们中的代码也就不会被执行了。
严谨地讲,Go 语言并不会去保证这些 goroutine 会以怎样的顺序运行。由于主 goroutine 会与我们手动启用的其他 goroutine 一起接受调度,又因为调度器很可能会在 goroutine 中的代码只执行了一部分的时候暂停,以期所有的 goroutine 有更公平的运行 机会。
所以哪个 goroutine 先执行完、哪个 goroutine 后执行完往往是不可预知的,除非我们使 用了某种 Go 语言提供的方式进行了人为干预。然而,在这段代码中,我们并没有进行任 何人为干预。
怎样能让主goroutine等待其他goroutine
最简单的方法就是让他小睡一会
for i:=0;i<10;i++{
go func(){
fmt.Println(i)
}()
}
time.Sleep(time.Millisecond*500)
然而不好把握睡眠的时间
你是否想到了通道呢?我们先创建一个通道,它的长度应该与我们手动启用的 goroutine 的数量一致。在每个手动启用的 goroutine 即将运行完毕的时候,我们都要向该通道发送 一个值。
注意,这些发送表达式应该被放在它们的go函数体的最后面。对应的,我们还需要在main 函数的最后从通道接收元素值,接收的次数也应该与手动启用的 goroutine 的数量保持一 致。
怎样让我们启用的多个goroutine按照既定的顺序运行
首先,我们需要稍微改造一下for语句中的那个go函数,要让它接受一个int类型的参数, 并在调用它的时候把变量i的值传进去。为了不改动这个go函数中的其他代码,我们可以把 它的这个参数也命名为i。
for i:=0;i<10;i++{
go func(i int){
fmt.Println(i)
}(i)
}
只有这样,Go 语言才能保证每个 goroutine 都可以拿到一个唯一的整数。其原因与go函 数的执行时机有关。
在go语句被执行时,我们传给go函数的参数i会先被求值,如此就 得到了当次迭代的序号。之后,无论go函数会在什么时候执行,这个参数值都不会变。也 就是说,go函数中调用的fmt.Println函数打印的一定会是那个当次迭代的序号。
for i:=unit32(0);i<10;i++{
go func(i uint32){
fn :=func(){
fmt.Println(i)
}
trigger(i,fn)
}(i)
}
我在go函数中先声明了一个匿名的函数,并把它赋给了变量fn。这个匿名函数做的事情很 简单,只是调用fmt.Println函数以打印go函数的参数i的值。
在这之后,我调用了一个名叫trigger的函数,并把go函数的参数i和刚刚声明的变量fn 作为参数传给了它。注意,for语句声明的局部变量i和go函数的参数i的类型都变了,都 由int变为了uint32。至于为什么,我一会儿再说。
再来说trigger函数。该函数接受两个参数,一个是uint32类型的参数i, 另一个是 func()类型的参数fn。你应该记得,func()代表的是既无参数声明也无结果声明的函数 类型。
trigger:=func(i uint32,fn func()){
for{
if n:=atomic.LoadUint32(&count);n==i{
fn()
atomic.AddUint32(&count,1)
break
}
time.Sleep(time.Nanosecond)
}
}
trigger函数会不断地获取一个名叫count的变量的值,并判断该值是否与参数i的值相 同。如果相同,那么就立即调用fn代表的函数,然后把count变量的值加1,最后显式地退 出当前的循环。否则,我们就先让当前的 goroutine“睡眠”一个纳秒再进入下一个迭 代。
注意,我操作变量count的时候使用的都是原子操作。这是由于trigger函数会被多个 goroutine 并发地调用,所以它用到的非本地变量count,就被多个用户级线程共用了。 因此,对它的操作就产生了竞态条件(race condition),破坏了程序的并发安全性。
所以,我们总是应该对这样的操作加以保护,在sync/atomic包中声明了很多用于原子操 作的函数。
另外,由于我选用的原子操作函数对被操作的数值的类型有约束,所以我才对count以及 相关的变量和参数的类型进行了统一的变更(由int变为了uint32)。
纵观count变量、trigger函数以及改造后的for语句和go函数,我要做的是,让count 变量成为一个信号,它的值总是下一个可以调用打印函数的go函数的序号。
这个序号其实就是启用 goroutine 时,那个当次迭代的序号。也正因为如此,go函数实际 的执行顺序才会与go语句的执行顺序完全一致。此外,这里的trigger函数实现了一种自 旋(spinning)。除非发现条件已满足,否则它会不断地进行检查。
最后要说的是,因为我依然想让主 goroutine 最后一个运行完毕,所以还需要加一行代 码。不过既然有了trigger函数,我就没有再使用通道。
调用trigger函数完全可以达到相同的效果。由于当所有我手动启用的 goroutine 都运行 完毕之后,count的值一定会是10,所以我就把10作为了第一个参数值。又由于我并不想 打印这个10,所以我把一个什么都不做的函数作为了第二个参数值。
总之,通过上述的改造,我使得异步发起的go函数得到了同步地(或者说按照既定顺序 地)执行,你也可以动手自己试一试,感受一下。
本文详细介绍了Go语言中的通道(Channel)和协程(Goroutine)的使用,包括通道的创建、发送与接收操作、单向通道及其应用价值。重点讲解了通道的阻塞情况以及非缓冲与缓冲通道的区别。此外,还讨论了单向通道如何约束代码行为,select语句在通道中的运用以及其分支选择规则。同时,文章深入探讨了Go语言的执行规则,包括主goroutine与普通goroutine的区别,如何让主goroutine等待其他goroutine,以及如何确保多个goroutine按顺序执行。通过实例解析,展示了如何利用通道和原子操作实现goroutine的顺序执行控制。

2962

被折叠的 条评论
为什么被折叠?



