Go 语言系列教程(十一) : 并发(1)--浅析Goroutines和Channels |Go主题月

484 阅读5分钟

前言

在Go语言中,每一个并发的执行单元叫作一个goroutine(协程)。如果你使用过操作系统或者其它语言提供的线程,那么你可以简单地把goroutine类比作一个线程,但实际上两者有本质区别。

一. 概念

1.1 协程和线程的区别

  1. 调度上的区别
  • 进程线程都是由操作系统进行调度, 有CPU时间片的概念,进行抢占式调度
  • 协程是用户态的轻量级线程,对内核透明,所以协程的调度与切换完全由用户控制
  • 正因为协程不由操作系统调度,才有 ---- “线程是操作系统调度的最小单位”
  1. 切换开销的区别
  • 线程太重,资源占用太高,频繁创建销毁会带来严重的性能问题; 协程切换远比线程小
  • 协程的好处:一个协程几乎就是一个普通的对象,因此可以放心阻塞,一旦阻塞那么让当前线程执行其他的协程(goroutine)

1.2 goroutine和协程的区别

  1. goroutine是协程的go语言实现,相当于把别的语言的类库的功能内置到语言里。
  2. 不同的是: Golang在runtime,系统调用等多方面对goroutine调度进行了封装和处理,即goroutine不完全是用户控制,一定程度上由go运行时(runtime)管理,好处:当某goroutine阻塞时,会让出CPU给其他goroutine。

Tip:与传统的系统级线程和进程相比,协程的最大优势在于其轻量级,可以轻松创建上百万个而不会导致系统资源衰竭,而线程和进程通常最多也不能超过1万个。这也是协程也叫轻量级线程的原因。

1.3 Channel

如何处理go并发机制中不同goroutine之间的同步与通信,golang 中提供了sync包和channel机制,本节先讨论channel机制

  • 协程都是独立运行的,他们之间没有通信。
  • 协程可以使用共享变量来通信,但是不建议这么做。在Go中有一种特殊的类型channle通道,可以通过它来进行goroutine之间的通信,可以避免共享内存的坑。channel的通信保证了同步性
  • 数据通过通道,同一时间只有一个协程可以访问数据,所以不会出现数据竞争

二. Go的协程和通道

2.1 goroutine的创建和使用

语法:go语句是一个普通的函数或方法调用前加上关键 字go。go语句会使其语句中的函数在一个新创建的goroutine中运行。

下面我们创建一个加法的函数,让他并发执行

package main

import "fmt"

func add(x, y int) int {
	fmt.Println("计算结果是:", x+y)
	return x + y
}

func main() {

	fmt.Println("开始执行main")
	for i := 0; i < 10; i++ {
		go add(i, i)
	}

	fmt.Println("main执行结束的操作")

}

结果

开始执行main
main执行结束的操作

结果分析:Go 程序从初始化 main package 并执行 main() 函数开始,当 main() 函数返回时,程序退出,且程序并不等待其他 goroutine(非主 goroutine)结束。所以这里非主 goroutine还没有来得及执行,主协程就退出了。

那么如何输出呢,很简单,我们可以简单借助sleep来实现,在主协程 后面加入time.Sleep(time.Second) 让main等待一秒,给非主协程执行留出时间

	fmt.Println("开始执行main")
	for i := 0; i < 10; i++ {
		go add(i, i)
	}
	time.Sleep(time.Second)  // 新增

结果

开始执行main
计算结果是: 0
计算结果是: 2
计算结果是: 4
计算结果是: 6
计算结果是: 8
计算结果是: 10
计算结果是: 12
计算结果是: 14
计算结果是: 16
计算结果是: 18
main执行结束的操作

结果分析:我们利用for开了10个协程,由于是并发执行,打印的结果并不是顺序打印,谁先执行完谁打印。

2.2 channel的创建和使用

Go实现了两种并发形式。第一种是大家普遍认知的:多线程共享内存。其实就是Java或者C++等语言中的多线程开发。另外一种是Go语言特有的,也是Go语言推荐的:CSP(communicating sequential processes)并发模型 --- 不同于传统的多线程通过共享内存来通信,CSP讲究的是“以通信的方式来共享内存”。

Tip:请大家记住一句话:不要以共享内存的方式来通信,相反,要通过通信来共享内存

语法:传数据用 channel <- data,取数据用 <-channel

在通信过程中,传数据channel <- data和取数据<-channel必然会成对出现,因为这边传,那边取,两个goroutine之间才会实现通信。

而且不管传还是取,必阻塞,直到另外的goroutine传或者取为止。 这种方式也使得channel的通信保证了同步性数据通过通道,同一时间只有一个协程可以访问数据,所以不会出现数据竞争

通道初始化

通道未初始化时其值为nil,可以从nil中尝试接收元素,但会被永远阻塞 初始化一个可以接收、发送int值类型的通道,无缓冲

	a := make(chan int)
	a := make(<-chan int)  //只读
	a := make(chan<- int)  //只写

初始化一个可以接收、发送int值类型的通道,可缓冲10个int值。第11个int值向通道中发送时会被阻塞。

        a := make(chan int, 10)  
	a := make(<-chan int, 10)  //只读
	a := make(chan<- int, 10)  //只写

被缓冲的元素值,会严格按发送的顺序接收。 从通道中接收元素

        c := make(chan int, 10) 
        n := <- c 

如果通道 c 被关闭,那么n的值为该元素类型的零值

channel类型:无缓冲和缓冲类型

  • 无缓冲型

一个协程向这个channel发送了消息后,会阻塞当前的这个协程,直到其他协程去接收这个channel的消息 ,要求双方必须同时完成发送和接收

       c := make(chan int)

案例

package main

import "fmt"

func main() {
	c := make(chan int)
        c <- 3
	n := <- c
	fmt.Println(n) // fatal error: all goroutines are asleep - deadlock!
}

分析:由于是无缓冲的,当main协程 发送了 3 到 通道中后,会阻塞当前的main协程,直到其他协程去去接受channel消息 解决:要解决很简单,第一种方案我们可以另外开启一个协程用于写信息,主线程再来读。第二种在主线程开始向通道里面写信息之前,开启一个协程用于读取信息,这样在读取完毕,主线程会自动解除阻塞。

// 第一种
package main

import "fmt"

func main() {
	c := make(chan int)
	go func() {
		for  {
			c <- 3
		}
	}()
	n := <- c
	fmt.Println(n)  // 3
}

// 第二种
package main

import (
	"fmt"
	"time"
)

func main() {
	c := make(chan int)
	go func() {
		for  {
			n := <-c
			fmt.Println(n)
		}
	}()
	c <- 3   // 这里main协程会被阻塞 直到通道被读取为止
	time.Sleep(time.Second)   // 防止主协程结束太快,其他协程来不及执行
}

结果

3
等待1秒后 程序运行结束 
  • 缓冲型

带缓冲的channel,是可以指定缓冲的消息数量 只有通道中没有要接收的值时,接收动作才会阻塞。 只有通道没有可用缓冲区容纳被发送的值时,发送动作才会阻塞。

这导致有缓冲的通道和无缓冲的通道之间的一个很大的不同:无缓冲的通道保证进行发送和接收的 goroutine 会在同一时间进行数据交换;有缓冲的通道没有这种保证。

       c := make(chan int,2)

下面我写的案例就模拟,缓冲型的chanel接收方和读取方出现阻塞情况

package main

import (
	"fmt"
	"time"
)

func main() {
	c := make(chan int, 3) //带缓冲的通道

	//内置函数 len 返回未被读取的缓冲元素数量, cap 返回缓冲区大小
	fmt.Printf("len(c)=%d, cap(c)=%d\n", len(c), cap(c))

	fmt.Println("-----------开始------------")
	go func() {
		//defer 将一个方法延迟到包裹该方法的方法返回时执行
		defer fmt.Println("发送协程结束")

		for i := 0; i < 4; i++ {
			c <- i
			fmt.Printf("子协程正在发送:%d, len(c)=%d, cap(c)=%d\n", i, len(c), cap(c))
		}
		fmt.Println("阻塞放开")
	}()

	time.Sleep(time.Second) //延时1s 让子协程把3条信息发完
	fmt.Println("-----------main线程开始接受数据-----------")
	for i := 0; i < 4; i++ {
		n := <-c
		fmt.Printf("main协程接收到的数据:%d ,len(c)=%d, cap(c)=%d\n", n, len(c), cap(c))
	}
	time.Sleep(time.Second) //延时1s 给子协程点时间让它执行完
	fmt.Println("main协程结束")
}

结果

len(c)=0, cap(c)=3
-----------开始------------
子协程正在发送:0, len(c)=1, cap(c)=3
子协程正在发送:1, len(c)=2, cap(c)=3
子协程正在发送:2, len(c)=3, cap(c)=3
-----------main线程开始接受数据-----------
main协程接收到的数据:0 ,len(c)=3, cap(c)=3
main协程接收到的数据:1 ,len(c)=2, cap(c)=3
main协程接收到的数据:2 ,len(c)=1, cap(c)=3
main协程接收到的数据:3 ,len(c)=0, cap(c)=3
子协程正在发送:3, len(c)=0, cap(c)=3
阻塞放开
发送协程结束
main协程结束

Process finished with exit code 0

分析:

  1. 我们首先设置了一个容量为3的通道,然后创建一个协程用于模拟发送方,同时在主线程设置了1秒的延迟保证发送方完全发送,但是大家可以看到我循环发送了4次,这意味着当发送当发送第4条数据时会出现阻塞
  2. 重点来了:主协程这边,开始接受消息,当接受了3条信息的时候,主协程(接受放)因为通道没有数据 也进入了阻塞, Tip : 这时候可能有人会问,不是并发执行吗,子协程在主协程消耗信息的时候,通道出现空余就应该开始发送了呀,但是同一时间只有一个协程可以访问数据,所以主协程只要没有被阻塞也就不会放开通道的访问,所以也就会一直执行下去。
  3. 此时主协程进入阻塞,放开通道访问,此时被阻塞的子协程开始继续发送数据,c <- i 执行完毕,主线程发现通道有数据开始继续执行,同时子协程也在并发执行,为了防止主协程执行过快,我又在最后加了一个等待,就是为了等子协程执行完。最后执行完毕结果打印。

2.3 channel关闭

channel关闭原则:

不要在消费端关闭channel,不要在有多个并行的生产者时对channel执行关闭操作。 也就是说应该只在[唯一的或者最后唯一剩下]的生产者协程中关闭channel,来通知消费者已经没有值可以继续读了。只要坚持这个原则,就可以确保向一个已经关闭的channel发送数据的情况不可能发生。

Tip: 不可重复关闭通道,关闭一个已经关闭的或未初始化的通道会引发异常 Tip: 通道关闭后,其中未接收的数据仍可被接收 Tip: 接收端应先判断通道是否关闭再从中取值,否则若通道关闭可能取出的值是通道类型的零值

先给大家看一下简单的通道关闭的例子

package main

import (
	"fmt"
)

func main() {
	c := make(chan int, 10)
	c <- 1
	c <- 2
	c <- 3
	close(c)
	for i := 0 ; i<6 ;i++{
		fmt.Println(<-c)
	}
}

结果

1
2
3
0
0
0

很简单的例子,但是大家想一想,我如何知道通道关闭了呢,或者什么时候我才能去关闭通道呢?要知道关闭通道可不是发送方一个人的事情,继续在关闭的通道上读,会读到通道传输数据类型的零值,继续在关闭的通道上写,将会panic,而且当有多个消费者和多个生产者的时候我们又怎么去协调在合适的时候关闭通道还不能违背通道关闭原则呢?显然简简单单的close()是不能满足我们的需求的。这里我们可以选择的方法有很多。select,sync包,for_range等等 大家可以自行浏览一下。

-----世界上很少有什么东西是能说清对错的,归根结底连‘对错’这个概念都是人主观定下来的东西。