Go语言之路————并发

发布于:2025-05-19 ⋅ 阅读:(17) ⋅ 点赞:(0)

Go语言之路————并发

前言

  • 我是一名多年Java开发人员,因为工作需要现在要学习go语言,Go语言之路是一个系列,记录着我从0开始接触Go,到后面能正常完成工作上的业务开发的过程,如果你也是个小白或者转Go语言的,希望我这篇文章对你有所帮助。
  • 有关go其他基础的内容的文章大家可以查看我的主页,接下来主要就是把这个系列更完,更完之后我会在每篇文章中挂上连接,方便大家跳转和复习。

协程

在学go之前,大家肯定听说过go底层天然支持并发,相信这也是很多人选择学习这款语言的原因之一,那么它到底怎么个天然法,怎么个支持,下面我就一一道来。

Goroutine(轻量级线程),正如标题一样,它也叫做协程,它是go的并发执行单元,是一种比线程更加轻量级的单位,创建一个协程非常简单,只需要用到一个关键词:go,go后面一定要更一个函数:

func main() {
	go func() {
		fmt.Print(1)
	}()
}

我这里用一个go启动一个匿名函数,如果你copy这个代码去执行,你会发现控制台没有任何打印,因为协程就跟Java的线程一样,它是并发去执行的,当我们的main方法跑完的时候,如果协程未执行,那么 整个程序都会关掉,就没有任何输出了。

那怎样让它正常输出呢?聪明的同学肯定会想到,让main线程沉睡一下不就行了,我们来看看代码:

func main() {
	go func() {
		fmt.Print(1)
	}()
	time.Sleep(1 * time.Second)
}

控制台打印:1

由此可见,让主线程沉睡确实可以做到这点,那么我就要提出下一个问题了,如果有多个协程呢?看看下面代码:

func main() {
	for i := 0; i < 10; i++ {
		go fmt.Println(i)
	}
	time.Sleep(1 * time.Second)
}

当把这段代码执行后,你会发现每次执行的结果都是不一样的,这也引出了协程的一个特性,那就是执行的时候是无序的,那有啥方法解决吗,我们先用上面的sleep看能否解决:
每次执行协程前,我们都让它沉睡一秒,然后主线程沉睡十秒

func main() {
	for i := 0; i < 10; i++ {
		time.Sleep(1 * time.Second)
		go fmt.Println(i)
	}
	time.Sleep(10 * time.Second)
}

执行后的结果:

0
1
2
3
4
5
6
7
8
9

目前来看,是做到了,但是这个方法太笨了,有啥办法可以优雅的解决吗,当然,go提供了管道、信号量、上下文、锁等各种工具来辅助开发者进行并发编程。

管道

管道:channel,官方对它的解释:Do not communicate by sharing memory; instead, share memory by communicating.
我用白话文在翻译一次:它的作用就是解决协程之间的通信的,数据传输或者共享的。
一个通道,用chan来定义,定义的时候必须要指定它存的数据类型:

var ch chan int

此时的管道还没初始化,是不能使用的,在go中,初始化一个管道,有且只有一个办法,那就是make关键词,make关键词提供一个额外参数:缓冲区

var ch = make(chan int, 1)

这里就是用make创建了一个缓冲区为1的管道,先看看使用:

func main() {
	var ch = make(chan int, 1)
	ch <- 1
	println(<-ch)
}
输出:1

结合例子,说一下管道的输出和输出:<-,没错就是用箭头表示,箭头的指向表示数据流向,a <- 1,表示把1发到a,<- a,表示从a读取数据

如何理解缓冲区:可以理解为Java中线程池中的阻塞队列,往管道中发送的数据会先存到缓冲区,然后才会被读取,如果一个管道没有缓冲区,那么发送信息后需要立马有读取的操作,否则程序就会阻塞,我们通过下面例子来看:

func main() {
	var ch = make(chan int)
	ch <- 1
	<-ch
}

我们创建一个没有缓冲区的管道,像管道里面输入1,马上再读取。看似人畜无害的代码,执行起来确是这个结果:deadlock

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
	D:/goland/workspace/test/main.go:5 +0x2d

Process finished with the exit code 2

那读者又会想了,既然这样,那岂不是所有的管道创建都需要缓冲区。其实不然,如果我们通过协程去输入就能正常输出:

func main() {
	var ch = make(chan int)
	go func() {
		ch <- 1
	}()
	println(<-ch)
}
输出:1

思考:为啥有协程的参与就能正常读写?我们回到缓冲区的本质,它是存数据的缓冲的,如果我们没有缓冲区,那么证明这个管道是没办法存数据的,就意味着,我这边写了,必须马上有人读,但是通过同步操作是实现不了的,有协程异步来操作才可行。

注意:每个管道用完后需要我们手段关闭,直接调用系统提供的close方法,一个管道只能close一次,多次close会报错

func close(c chan<- Type)

但是通常,我们建议把通道的关闭结合defer来用:

func main() {
	var ch = make(chan int)
	go func() {
		ch <- 1
		defer close(ch)
	}()
	println(<-ch)
}

注意点,除了同步读写无缓冲管道会造成堵塞之外,下面几种情况也会造成deadlock:

  1. 缓冲区满了继续噻数据:
    func main() {
    	var ch = make(chan int, 1)
    	defer close(ch)
    	ch <- 1
    	ch <- 1
    	println(<-ch)
    }
    
    缓冲区大小为1,写入一个后满了没读,继续写
  2. 有缓冲区,但是数据为空
    func main() {
    	// 创建的有缓冲管道
    	intCh := make(chan int, 1)
    	defer close(intCh)
    	// 缓冲区为空,阻塞等待其他协程写入数据
    	<-intCh
    }
    
  3. 管道未初始化
    func main() {
      var intCh chan int
      intCh <- 1
    }
    

管道数据除了一个个读之外,我们还可以用for range来遍历一个管道:

func main() {
	intCh := make(chan int, 10)
	go func() {
		for i := 0; i < 10; i++ {
			intCh <- i
		}
	}()
	for ch := range intCh {
		println(ch)
	}
}

看看输出:

0
1
2
3
4
5
6
7
8
9
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
	D:/goland/workspace/test/main.go:10 +0xa8

在输出之后出现了阻塞,这是因为for range会一直去读写管道中的数据,当管道中数据为空时就会死锁,直到有其他协程向管道写入数据才会解除,所以我们代码改一下,在写入数据完毕后就关闭管道:

func main() {
	intCh := make(chan int, 10)
	go func() {
		for i := 0; i < 10; i++ {
			intCh <- i
		}
		close(intCh)
	}()
	for ch := range intCh {
		println(ch)
	}
}

最后再补充一个知识点,管道的读取其实是有返回值的:

v, ok := <-intCh

第一个是值,第二个是个bool代表是否读取成功:

func main() {
	intCh := make(chan int, 10)
	go func() {
		intCh <- 1
	}()
	a, ok := <-intCh
	println(a, ok)
}

输出:1 true

Select

在 Go 中,select 是一种管道多路复用的控制结构,某一时刻,同时监测多个元素是否可用,在这里我们可以用来检测多个管道:

func main() {
	ch1 := make(chan int, 10)
	ch2 := make(chan int, 10)
	ch3 := make(chan int, 10)
	defer func() {
		close(ch1)
		close(ch2)
		close(ch3)
	}()
	select {
	case i := <-ch1:
		fmt.Println("ch1 is ", i)
	case j := <-ch2:
		fmt.Println("ch2 is ", j)
	case k := <-ch3:
		fmt.Println("ch3 is ", k)
	default:
		fmt.Print("检测失败")
	}
}

创建三个管道,然后用select分别去监测三个管道的数据,然后doSomething,让我们没有往管道输入任何数据的时候,默认输出检测失败,我们在select前往ch1输入一个数据看看:

func main() {
	ch1 := make(chan int, 10)
	ch2 := make(chan int, 10)
	ch3 := make(chan int, 10)
	defer func() {
		close(ch1)
		close(ch2)
		close(ch3)
	}()
	ch1 <- 1
	select {
	case i := <-ch1:
		fmt.Println("ch1 is ", i)
	case j := <-ch2:
		fmt.Println("ch2 is ", j)
	case k := <-ch3:
		fmt.Println("ch3 is ", k)
	default:
		fmt.Print("检测失败")
	}
}

输出:ch1 is  1

sync

讲到了并发,怎么能离开锁,go的sync包下面提供了很多锁相关的工具类,就类似于Java的juc包,我们下面简单说点常用的。

WaitGroup

WaitGroup 即等待执行,它的方法只有三个,使用起来也非常简单:

  • Add:添加一个计数器,表示总数
  • Done:每调用一次计数器减1
  • Wait:如果计数器不为0,则等待

还记得我们文章开头提到的例子吗,就是在main线程中使用了协程,协程还未执行但是main已经结束了,当时我们用的是sleep方法,现在我们看看怎么用WaitGroup去解决这个问题:
先看看原例子:

func main() {
	println("start")
	go func() {
		println("doSomething")
	}()
	println("end")
}

再看看解决后的:

var waitGroup sync.WaitGroup

func main() {
	println("start")
	waitGroup.Add(1)
	go func() {
		println("doSomething")
		waitGroup.Done()
	}()
	waitGroup.Wait()
	println("end")
}

看看输出:
start
doSomething
end

go中常用的锁有两个:

  • 互斥锁:sync.Mutex
  • 读写锁:sync.RWMutex

互斥锁sync.Mutex ,实现了Locker 接口,它的用法非常简单,就三个:

func (m *Mutex) Lock() {
	m.mu.Lock()
}

func (m *Mutex) TryLock() bool {
	return m.mu.TryLock()
}

func (m *Mutex) Unlock() {
	m.mu.Unlock()
}

我们先来看看互斥锁Mutex,下面我来模拟一个经典的场景,就是不同线程对共享数据操作,让我们看看不用锁的情况下,会不会得到正确结果:

var wait sync.WaitGroup
var count = 0

func main() {
	wait.Add(10)
	for i := 0; i < 10; i++ {
		go func(data *int) {
			// 模拟访问耗时
			time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
			// 访问数据,这里必须要用temp当前数据存起来
			temp := *data
			// 模拟计算耗时
			time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
			// 修改数据
			*data = temp + 1
			fmt.Println(*data)
			wait.Done()
		}(&count)
	}
	wait.Wait()
	fmt.Println("最终结果", count)
}

运行起来发现,每次的输出都不一样,跟Java一样,多线程对共享数据的修改是不安全的,必须要加锁

1
1
2
1
1
1
1
1
1
3
最终结果 3

下面我们改进一下代码,将同步代码用互斥锁包起来,类似于Java的同步代码块:

var lock sync.Mutex
var wait sync.WaitGroup
var count = 0

func main() {
	wait.Add(10)
	for i := 0; i < 10; i++ {
		go func(data *int) {
			lock.Lock()
			// 模拟访问耗时
			time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
			// 访问数据
			temp := *data
			// 模拟计算耗时
			time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
			// 修改数据
			*data = temp + 1
			lock.Unlock()
			fmt.Println(*data)
			wait.Done()
		}(&count)
	}
	wait.Wait()
	fmt.Println("最终结果", count)
}

go的互斥锁很简单,用的时候就调用lock()方法,解锁就调用unlock()方法,看看输出:

1
2
3
4
5
6
7
8
9
10
最终结果 10

Process finished with the exit code 0

读写锁和互斥锁一样,只是说读写锁的精度更高一点,可以根据读多写少,或者读少写多的情况来判断,它同样实现了Locker接口,只是方法多一些,读写锁内部的读和写是互斥锁,并不是说有两个锁

// 加读锁
func (rw *RWMutex) RLock()

// 尝试加读锁
func (rw *RWMutex) TryRLock() bool

// 解读锁
func (rw *RWMutex) RUnlock()

// 加写锁
func (rw *RWMutex) Lock()

// 尝试加写锁
func (rw *RWMutex) TryLock() bool

// 解写锁
func (rw *RWMutex) Unlock()

下面看个读写锁的例子(本例来自官方中文文档):

var wait sync.WaitGroup
var count = 0
var rw sync.RWMutex

func main() {
	wait.Add(12)
	// 读多写少
	go func() {
		for i := 0; i < 3; i++ {
			go Write(&count)
		}
		wait.Done()
	}()
	go func() {
		for i := 0; i < 7; i++ {
			go Read(&count)
		}
		wait.Done()
	}()
	// 等待子协程结束
	wait.Wait()
	fmt.Println("最终结果", count)
}

func Read(i *int) {
	time.Sleep(time.Millisecond * time.Duration(rand.Intn(500)))
	rw.RLock()
	fmt.Println("拿到读锁")
	time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
	fmt.Println("释放读锁", *i)
	rw.RUnlock()
	wait.Done()
}

func Write(i *int) {
	time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
	rw.Lock()
	fmt.Println("拿到写锁")
	temp := *i
	time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
	*i = temp + 1
	fmt.Println("释放写锁", *i)
	rw.Unlock()
	wait.Done()
}

该例开启了 3 个写协程,7 个读协程,在读数据的时候都会先获得读锁,读协程可以正常获得读锁,但是会阻塞写协程,获得写锁的时候,则会同时阻塞读协程和写协程,直到释放写锁,如此一来实现了读协程与写协程互斥,保证了数据的正确性。例子输出如下:

拿到读锁
拿到读锁
释放读锁 0
释放读锁 0
拿到写锁
释放写锁 1
拿到读锁
拿到读锁
拿到读锁
拿到读锁
拿到读锁
释放读锁 1
释放读锁 1
释放读锁 1
释放读锁 1
释放读锁 1
拿到写锁
释放写锁 2
拿到写锁
释放写锁 3
最终结果 3

Process finished with the exit code 0

OK 上面就是go中并发的一些常用案例,不多,但是一定是最常用的,掌握了这些你就可以去深入扩展了。


网站公告

今日签到

点亮在社区的每一天
去签到