Golang知识点四、并发编程

并发编程

  Golang并发通过goroutine来实现。goroutine是由Go语言的运行时(runtime)调度完成的,而线程是由操作系统调度完成。goroutinechannel是Go秉承的CSP(Communicating Sequential Process)并发模式的重要实现基础。

1. 协程(goroutine)

  Go中使用goroutine非常简单,只需要在调用函数时在前面加上go关键字。一个goroutine必定对应一个函数,可以创建多个goroutine去执行相同的函数。

  OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这么大。所以在Go语言中一次创建十万左右的goroutine也是可以的。
  参考自https://www.liwenzhou.com/posts/Go/14_concurrence/

1.1. 创建一个goroutine

  下面的代码开启一个单独的goroutine来执行hello函数。结果只打印了main,是因为执行hello的goroutine还没来得及调度,main函数就已经终止了。

	package main
	
	import (
		"fmt"
	)
	
	func hello() {
		fmt.Println("hello")
	}
	func main() {
		go hello()
		//time.Sleep(time.Second)
		fmt.Println("main")
	}

在这里插入图片描述
  如何将上述hello()函数执行呢?一种直接的方法,使用time.Sleep(),让main函数所在的goroutine休眠一段时间。但是在实际的业务逻辑中, 这样的方式实现goroutine同步,绝对是一种憨憨做法。于是,便有了一种优雅的方式来等待goroutine的结束,即sync.WaitGroup

1.2. sync.WaitGroup

  使用sync.WaitGroup的难点在于确定goroutine是什么时候结束的,即在哪里调用wg.Done()让计数器减一。

  1. 下面我开启了100个goroutine,来执行hello()中的任务,为了使得0-99个goroutine调度顺序依次执行,我开启每个goroutine以后,休眠1ms再开启下一个goroutine
	package main

	import (
		"fmt"
		"time"
	)
	
	func hello(i int) {
		fmt.Println("hello goroutine", i)
	}
	
	func main() {
		for i := 0; i < 100; i++ {
			go hello(i)
			time.Sleep(time.Millisecond)
		}
	}

   如果一个goroutine花费0.1ms就可以调度完成,这里我给每个goroutine分配1ms就很不合理,而且每个goroutine有可能执行时间不一样,那么我是否可以让一个goroutine里的任务一致性完,就调度另一个goroutine而不是这种sleep的憨憨做法。

  1. 使用sync.WaitGroup同步方式

  通常情况下,会使用sync.WaitGroup的方式实现多个goroutine之间的同步。

	package main
	
	import (
		"fmt"
		"sync"
	)
	
	func hello(i int) {
		defer wg.Done() // goroutine将hello中任务执行完,计数器减一
		fmt.Println("hello goroutine", i)
	}
	
	var wg sync.WaitGroup
	func main() {
		for i := 0; i < 100; i++ {
			wg.Add(1) // 每开启一个goroutine,计数器加一
			go hello(i)
			//time.Sleep(time.Millisecond)
			wg.Wait() // 等待计数器降为0,再继续执行
		}
		// 更多的业务场景是,多个goroutine和main之间的同步,上述代码只是为了模拟sleep同样的效果。
	}

2. channel

  单纯的将函数并发执行是没有意义的,函数和函数之间需要交换数据才能体现并发的价值。我们可以使用共享内存的方式进行数据交换(通过共享内存实现通信),但是它会导致不同的goroutine中发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法会导致性能问题。
  Golang并发模型(CSP : Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存而实现通信。channel可以让一个goroutine发送特定值到另一个goroutine,可以看作是一种通信机制。

2.1. channel类型

	// 1. 声明
	var ch1 chan int
	var ch2 chan bool
	
	// 2. 创建channel
	make(chan int, 16)
	
	// 3. 将一个值发送到通道
	ch <- 10
	// 4. 从一个通道中接收值
	x := <- ch	// 赋值给x
	<- ch		// 忽略结果
	
	// 5. 关闭通道
	close(ch)
	

2.2. 优雅的操作channel

  1. 循环读取数据
	package main
	
	import "fmt"
	
	func main() {
		ch1 := make(chan int)
		ch2 := make(chan int)
		
		// 开启一个goroutine,把0-24之间的数发送到ch1
		go func() {
			for i := 0; i < 25; i++ {
				ch1 <- i
			}
			close(ch1)
		}()
		
		// 从ch1中取出数据,计算平方,放到ch2
		go func() {
			for {
				x, ok := <- ch1
				if ok {
					ch2 <- x * x
				}
			}
			close(ch2)
		}()
	
		for i := range ch2 {
			fmt.Println(i)
		}
	}

2.3. 单向通道

  有些通道可能只用于发送,或者只用于接收。单向通道多用于函数的参数中。

	func f1(ch1 chan<- int) {
	}
	
	func f2(ch1 <-chan int){}

2.4. 通道注意事项

关闭: 只有在通知接收方goroutine所有的数据都发送完毕的时候才需要关闭通道。通道是可以被GC回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要作的,但关闭通道不是必须的。

2.5. 生产者消费者模型案例

  开启两个协程,一个协程生产数据,另一个协程对数据进行处理,处理完再把数据发回去。

	package main
	
	import (
		"fmt"
		"sync"
	)
	
	var wg sync.WaitGroup
	
	func producer(in chan<- int) {
		defer wg.Done()
		for i := 0; i < 10; i++ {
			in <- i * i
		}
		close(in)
	}
	
	func consumer(out <-chan int) {
		defer wg.Done()
		for {
			x, ok := <-out
			if !ok {
				break
			}
			fmt.Println("num: ", x)
		}
	}
	func main() {
		ch := make(chan int, 16)
		wg.Add(2)
		go producer(ch)
		go consumer(ch)
		wg.Wait()
		fmt.Println("主协程结束...")
	}

3. 锁

3.1. 竞态

  竞态是指在多个goroutine按某些交错顺序执行时,程序无法给出正确的结果。它对于程序是致命的,因为它们潜伏在程序中,出现的频率也很低,有可能仅在高负载环境或者在特定的编译器、平台和架构时才会出现。发生竞态现象的必要条件是:两个goroutine并发读写同一个变量,并且至少其中一个goroutine是写入操作。

  在存款操作和取款操作并发执行的时候,就会发生静态现象。有三种方法可以避免数据静态。

  1. 不要修改变量。
  2. 避免从多个goroutine访问同一变量。
  3. 允许多个goroutine访问同一变量,但同一时间只有一个goroutine可以访问。这种方法称为互斥机制。

  Golang中代码中加锁,一般体现在是对临界区加锁。

   临界区:程序片段访问临界资源的代码,临界区同一时刻只能有一个线程运行,其他线程必须等待访问,所以需要用到锁;

3.2. 互斥锁:sync.Mutex

  互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个goroutine可以访问共享资源。Go语言中使用sync包的Mutex类型实现互斥锁。
  使用互斥锁能够保证同一时间只有一个goroutine进入临界区,其他的goroutine则在等待锁;当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒的策略是随机的。

3.3. 读写互斥锁:sync.RWMutex

  互斥锁是完全互斥的,但是有很多实际的场景下是读多写少(数据库读写分离),当我们并发的去读取一个资源不涉及资源修改的时候,没必要加锁,这种情况下使用读写锁是更好的一种选择。读写锁在Go语言中使用sync包中的RWMutex类型。
  读写锁分为两种:读锁和写锁。当一个goroutine获取读锁之后,其他的goroutine如果获取的是读锁无需等待,如果是获取写锁就会等待;当一个goroutine获取写锁之后,其他的goroutine无论是获取读锁还是写锁都会等待。

4. sync包其它应用

4.1. sync.Once

  在编程的中,我们需要确保某些操作在高并发的场景下只执行一次,例如只加载一次配置文件、只关闭一次通道等。Golang中sync包提供了一个解决方案sync.Once

	// 如果要执行函数f,就需要搭配闭包来使用
	func (o *Once) Do(f func()) {}

4.2. sync.Map

  Go语言内置的map不是并发安全的,多个goroutine并发访问map时会发生错误(一般超过21个goroutine读写map时编译器就会报错)。可以使用互斥锁的方式访问临界资源(在操作map前加锁,操作完释放锁)。由于Golang针对map的使用场景较多,系统提供了开箱即用(不需要使用make初始化)的并发安全的map即 sync.Map

5. atomic原子性操作

  1. 我开10万个goroutine,每个goroutine对全局变量x加一,每次执行结果都不一样。
	package main
	
	import (
		"fmt"
		"sync"
	)
	
	var x int
	var wg sync.WaitGroup
	
	func add() {
		x++
		wg.Done()
	}
	
	func main() {
		for i := 0; i < 100000; i++ {
			wg.Add(1)
			go add()
		}
		wg.Wait()
		fmt.Println(x)
	}

在这里插入图片描述
2. 加锁(sync.Mutex)

package main

import (
	"fmt"
	"sync"
)

var x int
var wg sync.WaitGroup
var lock sync.Mutex

func add() {
	defer wg.Done()
	// 对临界区加锁
	lock.Lock()
	x++
	lock.Unlock()
}

func main() {
	for i := 0; i < 100000; i++ {
		wg.Add(1)
		go add()
	}
	wg.Wait()
	fmt.Println(x)
}

在这里插入图片描述
3. 使用原子操作

	package main
	
	import (
		"fmt"
		"sync"
		"sync/atomic"
	)
	
	var x int64
	var wg sync.WaitGroup
	var lock sync.Mutex
	
	func add() {
		defer wg.Done()
		//lock.Lock()
		//x++
		//lock.Unlock()
		atomic.AddInt64(&x, 1)
	}
	
	func main() {
		for i := 0; i < 100000; i++ {
			wg.Add(1)
			go add()
		}
		wg.Wait()
		fmt.Println(x)
	}

在这里插入图片描述

  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值