这是我参与「第五届青训营 」伴学笔记创作活动的第 6 天
(内容根据字节跳动青训营课程内容以及自己的理解编写)
近期将日更这几个主题的文章,欢迎关注!
- channel通信
- Kitex
- Gorm
- Hertx
- go的测试环节
- go的内存管理
- go的性能优化及工具
Lock
信号量(持有,才能执行操作)锁
var lock sync.Mutex
func main() {
sum := 0
for i := 0; i < 10; i++ {
add(&sum, &i)
}
}
func add(sum, val *int) {
lock.Lock()
*sum += *val
lock.Unlock()
}
WaitGroup
可以用WaitGroup阻塞住任务(go协程)直到任务完成
例子:
func main() {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func(i int) {
defer wg.Done()
fmt.Println(i)
}(i)
}
wg.Wait()
}
goroutine协程基本概念
Go 语言支持并发,我们只需要通过 go 关键字来开启 goroutine 即可。
goroutine 是轻量级线程,goroutine 的调度是由 Golang 运行时进行管理的。
计算机相关的专业有门必修课叫《操作系统》
里面大家会学到OS进程和线程的相关知识,现在可以初步理解为线程是将执行进程变成执行轻量级线程,减少切换损耗的。协程同理:(goroutine和OS线程是多对多的关系,即m:n)
两个优点:
- 协程是用户态的,线程是内核态的(可以理解为需要更高的权限(要切换成内核态)才能调用线程,而协程不需要,所以go语言可以直接调用协程)
- 协程是栈KB级别的,线程是栈MB级别的,所以可以轻松开上万个协程
地鼠文档的一张图:(很多经典的话)
并且从中摘录一段话:
可增长的栈
OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这个大。所以在Go语言中一次创建十万左右的goroutine也是可以的。
goroutine调度
GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程。
- 1.G很好理解,就是个goroutine的,里面除了存放本goroutine信息外 还有与所在P的绑定等信息。
- 2.P管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务。
- 3.M(machine)是Go运行时(runtime)对操作系统内核线程的虚拟, M与内核线程一般是一一映射的关系, 一个groutine最终是要放到M上执行的;
P与M一般也是一一对应的。他们关系是: P管理着一组G挂载在M上运行。当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时 回收旧的M。
P的个数是通过runtime.GOMAXPROCS设定(最大256),Go1.5版本之后默认为物理线程数。 在并发量大的时候会增加一些P和M,但不会太多,切换太频繁的话得不偿失。
单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。 其一大特点是goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。
协程的基本定义(有无缓冲区)和使用
其实有无缓冲区相当于同步和异步的区别,就像你去拿快递,快递是可以放在快递点的,所以你随时去拿就行,这就是异步的。通道里面缓冲区的消息下一个通道随时来取就行了,有点类似于消息队列MQ的实现
func main() {
// 定义有缓冲区的通道
ch1 := make(chan int)
ch2 := make(chan int)
// 定义有缓冲区的通道
ch3 := make(chan int)
ch4 := make(chan int)
// 开启goroutine 把0-100写入到ch1通道中
go func() {
for i := 0; i < 100; i++ {
ch1 <- i
}
close(ch1)
}() // 这里要带(),意思就是调用
// 开启goroutine 从ch1中取值,值的平方赋值给 ch2
go func() {
for {
i, ok := <-ch1 //通道取值后 再取值 ok = false
if ok {
ch2 <- i * i
} else {
break
}
}
close(ch2)
}()
// 主goroutine 从ch2中取值 打印输出
// for x := chan 有值取值,通道关闭时跳出goroutine
for i := range ch2 { // 这一段是主协程,ch2关闭时关闭
fmt.Println(i)
}
}
Goroutine池
这个直接看官网
CSP(Communication Sequential Processes)
如果说goroutine是Go程序并发的执行体,channel就是它们之间的连接。channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制。
什么场景下用channel合适呢?
- 通过全局变量加锁同步来实现通讯,并不利于多个协程对全局变量的读写操作。
- 加锁虽然可以解决goroutine对全局变量的抢占资源问题,但是影响性能,违背了原则。
- 总结:为了解决上述的问题,我们可以引入channel,使用channel进行协程goroutine间的通信。
记住这句话:
提倡通过通信共享内存而不是通过共享内存而实现通信
channel队列,先入先出,能保证消息接收的顺序
channel操作
这段代码会编译成功,但是执行报错
func main() {
ch := make(chan int)
ch <- 10
fmt.Println("发送成功")
}
因为我们使用ch := make(chan int)创建的是无缓冲的通道,无缓冲的通道只有在有人接收值的时候才能发送值。就像你住的小区没有快递柜和代收点,快递员给你打电话必须要把这个物品送到你的手中,简单来说就是无缓冲的通道必须有接收才能发送。
上面的代码会阻塞在ch <- 10这一行代码形成死锁,除非等有一个通道来接受了,同理,你如果没拿到快递也会一直等,也会阻塞
只读通道和只写通道
// chan<-就是只读通道,<-chan就是只写通道 chan就是又可读又可写
func counter(in chan<- int) {
defer close(in)
for i := 0; i < 100; i++ {
in <- i
}
}
func square(in chan<- int, out <-chan int) {
defer close(in)
for i := range out {
in <- i * i
}
}
// 输出是只写通道
func output(out <-chan int) {
for i := range out {
fmt.Println(i)
}
}
// 改写成单向通道
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go counter(ch1)
go square(ch2, ch1)
output(ch2)
}
runtime包
1.刷新,再次分配任务:
runtime.Gosched()
func main() {
go func(s string) {
for i := 0; i < 2; i++ {
fmt.Println(s)
}
}("world")
// 主协程
for i := 0; i < 2; i++ {
// 切一下,再次分配任务
runtime.Gosched()
fmt.Println("hello")
}
}
2.退出当前的协程:
runtime.Goexit()
这个没啥好演示的
3.分配几个核来执行代码:
runtime.GOMAXPROCS
比如你单核执行两个任务和两个核执行任务,会有区别的