golang在启动并发的方式上直接添加了语言级的关键字就可以实现,和其他编程语言相比更加轻量。
一、goroutine(协程)
-
一些概念
进程:进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。
线程:支持进程运行的一个执行实体,是cpu调度的最小单位。一个进程可以启动多个线程。
并发:单核cpu通过对线程的调度(分时间片运行)实现对外的同时运行。
并行:多核cpu场景下, 一个cpu运行一个线程。
开发者需要维护线程池中线程与 CPU 核数的对应关系,以充分利用多核CPU,go中使用的是下面方法
runtime.GOMAXPROCS(runtime.NumCPU())
协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。一个线程可以有多个协程,即协程是轻量级线程。
从main函数开始,go程序就会为main函数创建一个默认的goroutine
默认goroutine都是运行在同一个cpu上的,除非在main函数创建goroutine之前用上述方法设置好使用的cpu核数
2.创建一个goroutine
三种方式创建:
//使用go 调用已有函数
go 函数名( 参数列表 )
//使用匿名函数
go func( 参数列表 ){
函数体
}( 调用参数列表 )
//直接新建一个 goroutine 并在 goroutine 中执行代码块
go {
//do someting...
}
所有的goroutine会在main函数结束后终止。
goroutine在调度性能上没有线程细致,其细致程度取决于goroutine调度器的实现和运行环境
二、goroutine之间的并发通信
并发通信最常见的就是数据共享和利用消息机制。
数据在协程间的共享需要在每次使用数据前加锁,使用后释放锁,如果变量很多,锁很多,反而会让代码显得臃肿,可读性差(go没有synchronized、volatile这种关键字操作,也没有java的生态去操作),于是go用的是消息机制来实现协程间通信,这样每个协程只需要关系业务逻辑,不用关心共享变量的锁,这种机制称为channel。
线程与协程的区别:
线程是系统调度的基本单位。go协程由go语言运行时的调度器进行调度,操作系统内核感知不到协程的存在
在多核处理场景中,线程是并发与并行同时存在的,而go协程依托于线程,因此多核处理场景下,go协程也是并发与并行同时存在的。因为go协程从属于某一个线程,所以即便在单核处理器上某一时刻运行一个线程,在线程内go语言调度器也会切换多个协程执行,这时协程是并发的。在多核心处理器上,如果多个协程被分配给了不同的线程,而这些线程同时被不同的CPU核心所处理,这时协程就是并行处理的。
go协程与线程存在着很多不同之处:
1、调度方式
线程: 线程是根据CPU时间片进行抢占式调度的。操作系统通过中断信号(定时器中断、I/O设备中断等)执行线程的上下文切换。当发生线程上下文切换时,需要从操作系统用户态转移到内核态,并保存状态信息;当切换到下一个要执行的线程时,需要加载状态信息并从内核态转移到操作系统用户态。
协程: 协程存在于用户态,由go语言运行时调度器进行调度。协程从属于某一个线程,多个协程可以调度到一个线程中,一个协程也可能切换到多个线程中执行,因此协程与线程是多对多(M:N)的关系。
2、调度策略
线程: 抢占式调度。操作系统调度器为了均衡每个线程的执行周期,会定时发出中断信号强制执行线程上下文切换。
协程: 协作式调度。一个协程处理完自己的任务后,可以主动将执行权限让渡给其他协程,不会被轻易抢占。只有在协程运行了过长时间后,go语言调度器才会强制抢占其执行。
3、上下文切换速度
线程: 线程上下文的切换需要经过操作系统用户态与内核态的切换,切换速度大约为1~2微秒。
协程: 协程属于用户态轻量级的线程,协程的切换不需要经过用户态与内核态的切换,且切换时只需要保存极少的状态值,因此切换速度快数倍,大约为0.2微秒左右。(大约10倍于线程的切换速度)
4、栈的大小
线程: 线程的栈大小一般是在创建时指定的,linux及mac上默认的栈大小一般为8MB(可以通过ulimit -s查看)。2000个线程需要消耗16G虚拟内存。
协程: go协程栈大小默认为2KB, 16G虚拟内存可以创建800多万个协程。在实践中,经常可以看到存在成千上万的协程。
GMP调度模型:
Go里面GMP分别代表:G:goroutine,M:线程(真正在CPU上跑的),P:调度器。
调度器是M和G之间桥梁。
go进行调度过程:
1、某个线程尝试创建一个新的G,那么这个G就会被安排到这个线程的G本地队列LRQ中,如果LRQ满了,就会分配到全局队列GRQ中;
2、尝试获取当前线程的M,如果无法获取,就会从空闲的M列表中找一个,如果空闲列表也没有,那么就创建一个M,然后绑定G与P运行。
3、进入调度循环:
找到一个合适的G
执行G,完成以后退出
三、goroutine之间的竞争
有并发,就有竞争。
常见的例子就是一段程序做value++,启动两个协程运行这段程序,结束后查看value结果
那么在go中,可以使用go build -race生成一个可执行文件,运行它,便可输出整个协程的运行过程和竞争情况
但其实,go也提供了一些公共库来实现变量的加锁、原子操作这些,例如atomic 和 sync
atomic.AddInt64(&counter, 1) //安全的对counter加1
atomic.LoadInt64(&counter) //安全的读取counter
atomic.StoreInt64(&counter) //安全的写入counter
互斥锁:
var mutex sync.Mutex
//加锁
mutex.Lock()
//释放
mutex.Unlock()
四、通道channel
channels 是协程间通信的机制。一个 channels 是一个通信机制,它可以让一个 goroutine 通过它给另一个 goroutine 发送值信息
-
通道特性
在任何时候,同时只能有一个 goroutine 访问通道进行发送和获取数据,通道即一个队列,总是遵循先入先出的规则,保证收发数据的顺序。
2. 声明
var chanName chan int
3.初始化
chanName := make(chan int)
//定义一个结构体
type Equip struct{
a int
c string
}
ch2 := make(chan *Equip)
//可以是interface{}类型
chanName := make(chan interface{})
ch <- 0
ch <- "hello"
4.使用channel收发数据
(1)发
通道变量 <- 值
chanName <- 1
值可以是表达式、函数返回值等等,只要类型没错就行
(2)收
通道每次只能接收一个数据。
data := <-ch
data, ok := <-ch //没有值时,data为ch中数据类型的零值,ok表示是否接收到数据,一般用于接收超时检测,和select、计时器一起使用,不单独使用
<-ch //忽略值
//循环接收
for data := range ch {
//一定要写停止循环语句,否则其他goroutine发送完了之后,这里还一直在接收,会触发宕机报错
}
(3)收发对应(无缓冲通道)
某个goroutine发出数据后,当前goroutine中的程序会一直阻塞,直到消息被接收才会继续向下运行,但其他的goroutine这时候还可以向通道内发送数据
所以,收发不能单独存在,且要在不同的goroutine中进行
func main() {
// 创建一个整型通道
ch := make(chan int)
// 尝试将0通过通道发送
ch <- 0
}
这个代码会报错,因为ch没有被接收
5.单向通道
var 通道实例 chan<- 元素类型 // 只能写入数据的通道
var 通道实例 <-chan 元素类型 // 只能读取数据的通道
但是,如果一个通道只写不读,那写进去又有什么意义呢?同样的,一个通道只读不写,那消息来源在哪里呢?
所以,单向通道是成对存在的
ch := make(chan int)
// 声明一个只能写入数据的通道类型, 并赋值为ch
var chSendOnly chan<- int = ch
//声明一个只能读取数据的通道类型, 并赋值为ch
var chRecvOnly <-chan int = ch
//如果向chRecvOnly中写数据,会报错,同理chSendOnly
//如果直接创建一个只读通道,虽然没有写操作,但程序运行是没有问题的,只是没有任何意义
ch := make(<-chan int)
timer包中的计时器操作,就会有一个只读不写的单向通道
6.关闭通道
close(ch)
验证:
//当ok为false时,说明通道关闭,未读取到x时会阻塞,读取到x时ok=true
x, ok := <-ch
7.无缓冲的通道
指在接收前没有能力保存任何值的通道,要求收发的goroutine同时准备好,否则就会阻塞,即前面说的收发对应。常用于一收一发、某件事的传递
类似于手递手传东西,有一方没准备好,另一方都会等待
ch := make(chan int)
8.有缓冲的通道
指可以接收一个或多个值,这种情况下,只有当通道中无内容时,收操作才会阻塞;也只有当通道满了,发操作才会阻塞,并不像无缓冲通道一样,需要对应。不要求收发的goroutine都准备好
类似于通过快递传东西,发送的只需要把数据扔给快递就行了。
ch := make(chan int, 4) //要写容量
其实无缓冲通道也就是容量为1的有缓冲通道
9.为什么通道必须限制长度?
因为通道是用来数据交换的,如果不限制长度,意味着goroutine可以一直收发消息,当发的速度大于收时,通道内的数据将不断膨胀,占用的内存也会不断增加,直到应用崩溃
五、通道的使用
1.超时机制(select)
select {
case <-chan1:
// 如果chan1成功读到数据,则进行该case处理语句
case chan2 <- 1:
// 如果成功向chan2写入数据,则进行该case处理语句
default:
// 如果上面都没有成功,则进入default处理流程
// 如果没有写default,select就会阻塞,一直到case中有一个可以被执行
}
与switch相比,select有一些限制,最大的限制就是其case中必须是一个io操作。
超时的写法就是加一个time的case
func main() {
ch := make(chan int)
quit := make(chan bool)
//新开一个协程
go func() {
for {
select {
case num := <-ch:
fmt.Println("num = ", num)
case <-time.After(3 * time.Second):
fmt.Println("超时")
quit <- true
}
}
}() //别忘了()
for i := 0; i < 5; i++ {
ch <- i
time.Sleep(time.Second)
}
<-quit
fmt.Println("程序结束")
}
2.多路复用
使用对讲机时,需要按下通话按钮,才能说话(发消息),松开按钮才能听到别人的说话(收消息),这就是单边通信
而电话可以一边说,一边听,是双向通信的,电话是一种多路复用的设备
电脑联网后,可以同时上传和下载,也是多路复用
即可以同时收发就可实现多路复用
go中的多路复用,同样是使用select语句
select{
case 操作1:
响应操作1
case 操作2:
响应操作2
…
default:
没有操作情况
}
其中,case的条件既可以收 <- ch,也可以发ch <- 100;
ch := make(chan int, 1)
for {
select {
case ch <- 0:
case ch <- 1:
}
i := <-ch
fmt.Println("Value received:", i)
}
如果有多个case都满足,会随机执行一个
3.缓冲通道中,如果发送方的消息全部发完了,接收方还没处理完,发送方可以结束goroutine吗?
不可以的,发送方会等待通道内消息全部接收并处理完,才会接收本goroutine,缓冲通道还有数据前,本goroutine会阻塞的