在线时间:8:00-16:00
迪恩网络APP
随时随地掌握行业动态
扫描二维码
关注迪恩网络微信公众号
并发是编程里面一个非常重要的概念,Go语言在语言层面天生支持并发,这也是Go语言流行的一个很重要的原因。 一、并发与并行并发:同一时间段内执行多个任务(你在用微信和两个女朋友聊天)。 并行:同一时刻执行多个任务(你和你朋友都在用微信和女朋友聊天)。 Go语言的并发通过 Go语言还提供 二、goroutine概述在java/c++中我们要实现并发编程的时候,我们通常需要自己维护一个线程池,并且需要自己去包装一个又一个的任务,同时需要自己去调度线程执行任务并维护上下文切换,这一切通常会耗费程序员大量的心智。那么能不能有一种机制,程序员只需要定义很多个任务,让系统去帮助我们把这些任务分配到CPU上实现并发执行呢? Go语言中的 在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能– 三、使用goroutine Go语言中使用 一个 3.1 启动单个goroutine: 启动goroutine的方式非常简单,只需要在调用的函数(普通函数和匿名函数)前面加上一个 举个例子如下: func hello() { fmt.Println("Hello Goroutine!") } func main() { hello() fmt.Println("main goroutine done!") } 这个示例中hello函数和下面的语句是串行的,执行的结果是打印完 接下来我们在调用hello函数前面加上关键字 func main() { go hello() // 启动另外一个goroutine去执行hello函数 fmt.Println("main goroutine done!") } 这一次的执行结果只打印了 在程序启动时,Go程序就会为 当main()函数返回的时候该 所以我们要想办法让main函数等一等hello函数,最简单粗暴的方式就是 func main() { go hello() // 启动另外一个goroutine去执行hello函数 fmt.Println("main goroutine done!") time.Sleep(time.Second) } 执行上面的代码你会发现,这一次先打印 首先为什么会先打印 3.2 启动多个goroutine: 在Go语言中实现并发就是这样简单,我们还可以启动多个 var wg sync.WaitGroup func hello(i int) { defer wg.Done() // goroutine结束就登记-1 fmt.Println("Hello Goroutine!", i) } func main() { for i := 0; i < 10; i++ { wg.Add(1) // 启动一个goroutine就登记+1 go hello(i) } wg.Wait() // 等待所有登记的goroutine都结束 } 多次执行上面的代码,会发现每次打印的数字的顺序都不一致。这是因为10个 四、goroutine与线程4.1 可增长的栈: OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),一个 4.2 goroutine调度:
P与M一般也是一一对应的。他们关系是: P管理着一组G挂载在M上运行。当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时 回收旧的M。 P的个数是通过 单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的, 五、GOMAXPROCS(CPU核心数设置) Go运行时的调度器使用 Go语言中可以通过 Go1.5版本之前,默认使用的是单核心执行。Go1.5版本之后,默认使用全部的CPU逻辑核心数。 我们可以通过将任务分配到不同的CPU逻辑核心上实现并行的效果,这里举个例子: func a() { for i := 1; i < 10; i++ { fmt.Println("A:", i) } } func b() { for i := 1; i < 10; i++ { fmt.Println("B:", i) } } func main() { runtime.GOMAXPROCS(1) go a() go b() time.Sleep(time.Second) } 两个任务只有一个逻辑核心,此时是做完一个任务再做另一个任务。 将逻辑核心数设为2,此时两个任务并行执行,代码如下。 func a() { for i := 1; i < 10; i++ { fmt.Println("A:", i) } } func b() { for i := 1; i < 10; i++ { fmt.Println("B:", i) } } func main() { runtime.GOMAXPROCS(2) go a() go b() time.Sleep(time.Second) } Go语言中的操作系统线程和goroutine的关系:
六、channel单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义。 虽然可以使用共享内存进行数据交换,但是共享内存在不同的 Go语言的并发模型是 如果说 Go 语言中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。 6.1 channel类型:
var 变量 chan 元素类型
举几个例子: var ch1 chan int // 声明一个传递整型的通道 var ch2 chan bool // 声明一个传递布尔型的通道 var ch3 chan []int // 声明一个传递int切片的通道 6.2 创建channel: 通道是引用类型,通道类型的空值是 var ch chan int fmt.Println(ch) // <nil> 声明的通道后需要使用 创建channel的格式如下: make(chan 元素类型, [缓冲大小]) channel的缓冲大小是可选的。 举几个例子: ch4 := make(chan int) ch5 := make(chan bool) ch6 := make(chan []int) 6.3 channel操作: 通道有发送(send)、接收(receive)和关闭(close)三种操作。 发送和接收都使用 现在我们先使用以下语句定义一个通道: ch := make(chan int)
发送:将一个值发送到通道中。 ch <- 10 // 把10发送到ch中 接收:从一个通道中接收值。 x := <- ch // 从ch中接收值并赋值给变量x <-ch // 从ch中接收值,忽略结果 关闭:我们通过调用内置的 close(ch) 关于关闭通道需要注意的事情是,只有在通知接收方goroutine所有的数据都发送完毕的时候才需要关闭通道。通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。 关闭后的通道有以下特点:
七、无缓冲的通道(同步通道)无缓冲的通道又称为阻塞的通道。我们来看一下下面的代码: func main() { ch := make(chan int) ch <- 10 fmt.Println("发送成功") } 上面这段代码能够通过编译,但是执行的时候会出现以下错误: fatal error: all goroutines are asleep - deadlock! goroutine 1 [chan send]: main.main() .../src/github.com/Q1mi/studygo/day06/channel02/main.go:8 +0x54 为什么会出现 因为我们使用 上面的代码会阻塞在 一种方法是启用一个 func recv(c chan int) { ret := <-c fmt.Println("接收成功", ret) } func main() { ch := make(chan int) go recv(ch) // 启用goroutine从通道接收值 ch <- 10 fmt.Println("发送成功") } 无缓冲通道上的发送操作会阻塞,直到另一个 使用无缓冲通道进行通信将导致发送和接收的 八、有缓冲的通道解决上面问题的方法还有一种就是使用有缓冲区的通道。我们可以在使用make函数初始化通道的时候为其指定通道的容量,例如: func main() { ch := make(chan int, 1) // 创建一个容量为1的有缓冲区通道 ch <- 10 fmt.Println("发送成功") } 只要通道的容量大于零,那么该通道就是有缓冲的通道,通道的容量表示通道中能存放元素的数量。就像你小区的快递柜只有那么个多格子,格子满了就装不下了,就阻塞了,等到别人取走一个快递员就能往里面放一个。 我们可以使用内置的 九、如何优雅的从通道循环取值 当通过通道发送有限的数据时,我们可以通过 我们来看下面这个例子: // channel 练习 func main() { ch1 := make(chan int) ch2 := 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 { break } ch2 <- i * i } close(ch2) }() // 在主goroutine中从ch2中接收值打印 for i := range ch2 { // 通道关闭后会退出for range循环 fmt.Println(i) } } 从上面的例子中我们看到有两种方式在接收值的时候判断通道是否被关闭,我们通常使用的是 十、单向通道有的时候我们会将通道作为参数在多个任务函数间传递,很多时候我们在不同的任务函数中使用通道都会对其进行限制,比如限制通道在函数中只能发送或只能接收。 Go语言中提供了单向通道来处理这种情况。例如,我们把上面的例子改造如下: func counter(out chan<- int) { for i := 0; i < 100; i++ { out <- i } close(out) } func squarer(out chan<- int, in <-chan int) { for i := range in { out <- i * i } close(out) } func printer(in <-chan int) { for i := range in { fmt.Println(i) } } func main() { ch1 := make(chan int) ch2 := make(chan int) go counter(ch1) go squarer(ch2, ch1) printer(ch2) } 其中,
在函数传参及任何赋值操作中将双向通道转换为单向通道是可以的,但反过来是不可以的。 通道总结:
关闭已经关闭的 十一、worker pool(goroutine池) 在工作中我们通常会使用可以指定启动的goroutine数量– 一个简易的 func worker(id int, jobs <-chan int, results chan<- int) { for j := range jobs { fmt.Printf("worker:%d start job:%d\n", id, j) time.Sleep(time.Second) fmt.Printf("worker:%d end job:%d\n", id, j) results <- j * 2 } } func main() { jobs := make(chan int, 100) results := make(chan int, 100) // 开启3个goroutine for w := 1; w <= 3; w++ { go worker(w, jobs, results) } // 5个任务 for j := 1; j <= 5; j++ { jobs <- j } close(jobs) // 输出结果 for a := 1; a <= 5; a++ { <-results } } 十二、select多路复用在某些场景下我们需要同时从多个通道接收数据。通道在接收数据时,如果没有数据可以接收将会发生阻塞。你也许会写出如下代码使用遍历的方式来实现: for{ // 尝试从ch1接收值 data, ok := <-ch1 // 尝试从ch2接收值 data, ok := <-ch2 … } 这种方式虽然可以实现从多个通道接收值的需求,但是运行性能会差很多。为了应对这种场景,Go内置了
select{ case <-ch1: ... case data := <-ch2: ... case ch3<-data: ... default: 默认操作 } 举个小例子来演示下 func main() { ch := make(chan int, 1) for i := 0; i < 10; i++ { select { case x := <-ch: fmt.Println(x) case ch <- i: } } } 使用
十三、并发安全和锁 有时候在Go代码中可能会存在多个 举个例子: var x int64 var wg sync.WaitGroup func add() { for i := 0; i < 5000; i++ { x = x + 1 } wg.Done() } func main() { wg.Add(2) go add() go add() wg.Wait() fmt.Println(x) } 上面的代码中我们开启了两个 十四、互斥锁 互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个 var x int64 var wg sync.WaitGroup var lock sync.Mutex func add() { for i := 0; i < 5000; i++ { lock.Lock() // 加锁 x = x + 1 lock.Unlock() // 解锁 } wg.Done() } func main() { wg.Add(2) go add() go add() wg.Wait() fmt.Println(x) } 使用互斥锁能够保证同一时间有且只有一个 十五、读写互斥锁 互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在Go语言中使用 读写锁分为两种:读锁和写锁。当一个goroutine获取读锁之后,其他的 读写锁示例: var ( x int64 wg sync.WaitGroup lock sync.Mutex rwlock sync.RWMutex ) func write() { // lock.Lock() // 加互斥锁 rwlock.Lock() // 加写锁 x = x + 1 time.Sleep(10 * time.Millisecond) // 假设读操作耗时10毫秒 rwlock.Unlock() // 解写锁 // lock.Unlock() // 解互斥锁 wg.Done() } func read() { // lock.Lock() // 加互斥锁 rwlock.RLock() // 加读锁 time.Sleep(time.Millisecond) // 假设读操作耗时1毫秒 rwlock.RUnlock() // 解读锁 // lock.Unlock() // 解互斥锁 wg.Done() } func main() { start := time.Now() for i := 0; i < 10; i++ { wg.Add(1) go write() } for i := 0; i < 1000; i++ { wg.Add(1) go read() } wg.Wait() end := time.Now() fmt.Println(end.Sub(start)) } 需要注意的是读写锁非常适合读多写少的场景,如果读和写的操作差别不大,读写锁的优势就发挥不出来。 十六、sync.WaitGroup 在代码中生硬的使用
我们利用 var wg sync.WaitGroup func hello() { defer wg.Done() fmt.Println("Hello Goroutine!") } func main() { wg.Add(1) go hello() // 启动另外一个goroutine去执行hello函数 fmt.Println("main goroutine done!") wg.Wait() } 需要注意 十七、sync.Once在编程的很多场景下我们需要确保某些操作在高并发的场景下只执行一次,例如只加载一次配置文件、只关闭一次通道等。 Go语言中的
func (o *Once) Do(f func()) {} 备注:如果要执行的函数 加载配置文件示例: 延迟一个开销很大的初始化操作到真正用到它的时候再执行是一个很好的实践。因为预先初始化一个变量(比如在init函数中完成初始化)会增加程序的启动耗时,而且有可能实际执行过程中这个变量没有用上,那么这个初始化操作就不是必须要做的。我们来看一个例子: var icons map[string]image.Image func loadIcons() { icons = map[string]image.Image{ "left": loadIcon("left.png"), "up": loadIcon("up.png"), "right": loadIcon("right.png"), "down": loadIcon("down.png"), } } // Icon 被多个goroutine调用时不是并发安全的 func Icon(name string) image.Image { if icons == nil { loadIcons() } return icons[name] } 多个 func loadIcons() { icons = make(map[string]image.Image) icons["left"] = loadIcon("left.png") icons["up"] = loadIcon("up.png") icons["right"] = loadIcon("right.png") icons["down"] = loadIcon("down.png") } 在这种情况下就会出现即使判断了 使用 var icons map[string]image.Image var loadIconsOnce sync.Once func loadIcons() { icons = map[string]image.Image{ "left": loadIcon("left.png"), "up": loadIcon("up.png"), "right": loadIcon("right.png"), "down": loadIcon("down.png"), } } // Icon 是并发安全的 func Icon(name string) image.Image { loadIconsOnce.Do(loadIcons) return icons[name] } 关闭channel示例: var wg sync.WaitGroup var once sync.Once func f1(ch1 chan<- int) { defer wg.Done() for i := 0; i < 100; i++ { ch1 <- i } close(ch1) } func f2(ch1 <-chan int, ch2 chan<- int) { defer wg.Done() for { x, ok := <-ch1 if !ok { break } ch2 <- x * x } once.Do(func() { close(ch2) }) // 确保某个操作只执行一次 } func main() { a := make(chan int, 100) b := make(chan int, 100) wg.Add(3) go f1(a) go f2(a, b) go f2(a, b) wg.Wait() for ret := range b { fmt.Println(ret) } }
十八、sync.MapGo语言中内置的map不是并发安全的。请看下面的示例: var m = make(map[string]int) func get(key string) int { return m[key] } func set(key string, value int) { m[key] = value } func main() { wg := sync.WaitGroup{} for i := 0; i < 20; i++ { wg.Add(1) go func(n int) { key := strconv.Itoa(n) set(key, n) fmt.Printf("k=:%v,v:=%v\n", key, get(key)) wg.Done() }(i) } wg.Wait() } 上面的代码开启少量几个 像这种场景下就需要为map加锁来保证并发的安全性了,Go语言的 var m = sync.Map{} func main() { wg := sync.WaitGroup{} for i := 0; i < 20; i++ { wg.Add(1) go func(n int) { key := strconv.Itoa(n) m.Store(key, n) value, _ := m.Load(key) fmt.Printf("k=:%v,v:=%v\n", key, value) wg.Done() }(i) } wg.Wait() } 十九、原子操作 代码中的加锁操作因为涉及内核态的上下文切换会比较耗时、代价比较高。针对基本数据类型我们还可以使用原子操作来保证并发安全,因为原子操作是Go语言提供的方法它在用户态就可以完成,因此性能比加锁操作更好。Go语言中原子操作由内置的标准库 atomic包:
示例: 我们填写一个示例来比较下互斥锁和原子操作的性能。 var x int64 var l sync.Mutex var wg sync.WaitGroup // 普通版加函数 func add() { // x = x + 1 x++ // 等价于上面的操作 wg.Done() } // 互斥锁版加函数 func mutexAdd() { l.Lock() x++ l.Unlock() wg.Do |
请发表评论