Go语言基础(四):并发|青训营笔记

342 阅读5分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第5篇笔记;主要学习了Go的并发编程。

goroutine 协程(轻量级线程)

以32位机器为例:

  • 一个进程大约占据虚拟内存4G
  • 线程大约4M (linux下应该不分线程进程吧)

当前并发任务的两个难点:

  1. 高消耗(频繁的上下文切换)
  2. 高内存占用

携程的设计

3.png 将传统的线程分割成用户态和内核态两部分,并一一绑定,cpu调度只会关注内核空间中的线程,隐藏了用户态的"线程"的动作,即为携程,目的是尽量减少上下文切换

(经典加一层。。。)

用户态的携程可以通过携程调度器唯一绑定内核空间的线程

类比线程和进程设计的关系,有如下三种设计方式

设计一:

4.png 这种设计最大的缺陷是一旦其中一个携程阻塞,会影响到之后携程的调度

设计二:

5.png 1:1 的关系,解决了携程阻塞的问题,但是此时并没有减少上下文切换的概率

设计三:

6.png M:N的关系,是上面的综合,该设计的避免了上述两种设计各自的弊端,性能瓶颈只存在于协程调度器的设计,设计的越好,效率就越高

Golang 对协程的处理

  1. 首先,改了个名字。。。

co-routine --> Goroutine

  1. 规定了协程大小

内存:几kb, 可以大量开辟

3.灵活调度(调度器)

可以频繁切换(反正就是在用户态的层面来回切,没有上下文切换)

Golang 早期调度器的处理

7.png

8.png 弊端:

  1. 创建、销毁、调度携程都需要获取M个互斥锁,形成了激烈的锁竞争
  2. 线程转移携程会造成延迟和额外的系统负载(例:携程一号被调度到线程中,执行时创建了携程二号,携程二号进入队列,被另一个线程调度,可是携程一号和二号具有空间连续性,如果被同一线程调度那效率会更快)
  3. 上下文切换(系统调用)导致频繁的线程阻塞和唤醒,增加了系统开销

Golang 优化后的调度策略:GMP

9.png processor包含了其本地队列上每个协程的资源 执行中的协程创建了新协程,会优先放进当前执行线程的本地队列中,如果本地队列满了才会放入全局队列

10.png

通过一个宏值来定义 processor的个数

调度器的设计策略

  • 复用线程
  • 利用并行
  • 抢占策略
  • 全局协程队列
复用线程

work stealing 机制

11.png

12.png 如果一个本地队列是空,而周围有队列还有等待调度的协程,就从周围的队列中“偷”一个过来执行。

hand off 机制 如果一个线程执行的协程被阻塞,且与该线程绑定的processor对应的本地队列还有其他协程,则创建or唤醒一个新的thread,将该processor和本地队列重新绑定到新的thread上,老的thread等待被阻塞协程,该协程在接下来被唤醒后执行完毕,这个线程进入睡眠或者销毁。

13.png

14.png

利用并行

通过 GOMAXPROCS 这个宏限定processor的个数

大约等于 CPU核心数/2

抢占策略

co-routine 没有设计时间片轮转机制,所以只有co-routine 主动释放和thread 的链接,否则其他co-routine 会一直等待

goroutine 增加了时间片轮转机制,使得并发度进一步提高

15.png

全局协程队列

全局队列的存取是加锁的,效率上要比本地队列慢 如果一个processor 的本地队列为空,并且其他processor 本地队列中也为空,那么该processor才会从全局队列中取出协程放入本地队列,即优先执行work stealing 机制,执行失败才会从全局队列中取

创建goroutine

// 第一个并发程序
package main

import (
  "fmt" // 格式化IO
  "time"
)

func goFunc(i int) {
  fmt.Println("goroutine", i)
}

func main() {
  for i := 0; i < 1000; i++ {
    go goFunc(i)
  }
  
  time.Sleep(time.Second) // 休眠1s
}
runtime.Goexit() // 推出当前协程

channel 协程间通信

// 创建方式
make(chan Type)
make(chan Type, capacity)

channel <- value // 向管道写
<- channel // 接收并丢弃
x := <- channel // 接收并传值
x,ok := <- channel // 功能同上,并检查channel是否为空或者关闭
// 无缓冲型的channel
package main

import(
	"fmt"
)

func main() {
	// 定义一个channel ,无缓冲型
	c := make(chan int)
	
	// 匿名的go程
	go func() {
		defer fmt.Println("goroutine is end")
		fmt.Println("goroutine is working")
		// 将数据写入管道
		c <- 666	
	}()

	num := <-c // 从c中读数据,并且给num做初始化
	fmt.Println("main goroutine num = ", num) 
}
// 有缓冲的channel
package main

import(
	"fmt"
	"time"
)

func main() {
	// 定义一个channel ,有缓冲型的
	c := make(chan int, 3)
	
	// 匿名的go程
	go func() {
		defer fmt.Println("goroutine is end")
		fmt.Println("goroutine is working")
		// 将数据写入管道
		for i := 0; i < 100; i++ {
			// 下面两个操作应该要原子化,否则打印有问题
			c <- i  // 新版本的go编译器支持有空格,旧版本可能不能这么写
			fmt.Println("send:",i,"len():",len(c),"cap()",cap(c))
		}
	}()
	
	time.Sleep(1*time.Second)
	for i := 0; i < 100; i++ {
		num := <-c // 从c中读数据,并且给num做初始化
		// code还是有问题,下面的输出和上面的接收并不原子,导致打印有问题
		fmt.Println("receive:", num, "len():",len(c),"cap():",cap(c)) 
	}
}

tips: channel 本身具有同步机制,通过channel的传送和接收可以直接保证发送方和接收方的同步顺序

对于无缓冲的channel,两个协程虽然是以异步的方式执行、推进,但是channel的同步机制使得 接收方/发送方 阻塞等待,完成协程间的同步 对于有缓冲的channel,channel可以实现信号量机制,通道满了发送方阻塞,通道空了接收方阻塞,其余时间各自并发。

channel 关闭

package main

import(
	"fmt"
)

func main() {
	// 定义一个channel ,有缓冲型的
	c := make(chan int)
	
	// 匿名的go程
	go func() {
		defer fmt.Println("goroutine is end")
		fmt.Println("goroutine is working")
		// 将数据写入管道
		for i := 0; i < 5; i++ {
			// 下面两个操作应该要原子化,否则打印有问题
			c <- i  // 新版本的go编译器支持有空格,旧版本可能不能这么写
			fmt.Println("send:",i,"len():",len(c),"cap()",cap(c))
		}
		close(c) // 关闭channel, 因为接收方的接收方式改成持续读,直到channel关闭,
		         // 所以发送方发完数据以后需要关闭channel,否则会报死锁error
	}()
	
	
	
	// 循环的另一种写法,先执行表达式,在判断,data可以作为循环内部的形参
	// 如果ok为true表示channel没有关闭,否则表示已经关闭
	for {
		if data, ok := <- c; ok {
			fmt.Println("receive:", data, "len():",len(c),"cap():",cap(c))
		} else {
			break
		}
	}

}

tips:

  • 只有当确定没有任何数据要发送了,才需要关闭channel
  • 关闭channel,无法再向其发送数据(引发panic错误后导致接收自己返回零值)
  • 关闭channel后,如果此时还有数据,可以继续读
  • 对于nil channel,收发都会阻塞

channel 与 range

package main

import(
	"fmt"
)

func main() {
	// 定义一个channel 
	c := make(chan int)
	
	// 匿名的go程
	go func() {
		defer fmt.Println("goroutine is end")
		fmt.Println("goroutine is working")
		// 将数据写入管道
		for i := 0; i < 5; i++ {
			// 下面两个操作应该要原子化,否则打印有问题
			c <- i  // 新版本的go编译器支持有空格,旧版本可能不能这么写
			fmt.Println("send:",i,"len():",len(c),"cap()",cap(c))
		}
		close(c) // 关闭channel, 因为接收方的接收方式改成持续读,直到channel关闭,
		         // 所以发送方发完数据以后需要关闭channel,否则会报死锁error
	}()
	
	// range 可以被阻塞,配合channel 可以对下面代码进行简写
	for data := range c {
		fmt.Println("receive:", data, "len():",len(c),"cap():",cap(c))
	}
	
	// for {
	// 	if data, ok := <- c; ok {
	// 		fmt.Println("receive:", data, "len():",len(c),"cap():",cap(c))
	// 	} else {
	// 		break
	// 	}
	// }
	
}

channel 与 select

单流程下go只能监控一个channel的状态 可以通过select完成IO复用。

// 样例
select {
case <- c1:
	// 如果c1成功读到数据,则进行该处理语句
case c2 <- 1:
	// 如果成功向c2写入数据,则进行该处理语句
default:	
	// 执行默认语句
}

tips: 管道传参传的是引用(指针)


// 利用select 实现一个fibnacii
package main

import(
	"fmt"
)

func fibonacii(c, quit chan int) {
	x, y := 1,1
	for {
		select {
		case c <-x:
			// 如果c1成功读到数据,则进行该处理语句
			// var tmp int = x
			// x = y
			// y = tmp + y
			x,y = y,x+y
		case <- quit:
			// 如果成功向c2写入数据,则进行该处理语句
			fmt.Println("quit")
			return
		}
	}
}

func main() {
	c := make(chan int)
	quit := make(chan int)
	
	go func() {
		defer fmt.Println("goroutine is end")
		for i := 0; i < 10; i++ {
			fmt.Println(<-c)
		}
		quit <- 0
	}()
	
	fibonacii(c, quit)
}

参考

8小时转职Golang工程师(如果你想低成本学习Go语言)_哔哩哔哩_bilibili