go 协程与通道

协程与通道

首先来回顾在操作系统中学过的一些概念。

进程 (processes) 是程序执行的基本单位,运行在一个独立的内存地址空间中;

一个进程由多个线程 (threads) 组成,线程的存在是为了能够同时执行多个任务,最大化利用时间,防止产生等待,线程间是共享内存地址空间的。

从 windows 资源管理器看这一点能看的很明白,如下,每个应用程序是一个进程,Typora程序下有两个线程在同时运行。

在这里插入图片描述
并发是建立在多线程之上的概念,将CPU的执行时间划分为许多很小的间隔,多个线程不断地切换执行,从上层看起来就像在同时执行一样,但本质上依然是线性的。

并行则是程序在某个特定的事件同时运行在多个CPU上,多核处理器为并行提供了可能。

因此,并发也可能是并行的。

协程 (goroutine)

Go原生支持并发,依靠的是协程(goroutine)和通道(channel)两个概念。

goroutines 的概念是为了和 processes、threads、coroutines 等概念区别。

其中 coroutines 也叫做协程,而且这才是常规意义下的协程,goroutines 只在 Go 中有效。

coroutines 是比线程更轻的一个概念,只使用很少的内存和资源。
它对栈进行分隔,从而动态地增加或缩减内存的使用,栈的管理也是自动的,在协程退出后自动释放空间。

协程可以运行在多个线程间,也可以运行在线程内,它的创建廉价到可以在同一地址空间存在100000个。

这一概念也存在于其它语言(C#, Java等)中,它与 goroutines 的区别在于:

  • Go 协程意味着并行(或者可以以并行的方式部署),协程一般不是理论上,Go 协程比协程更加强大。
  • Go 协程通过通道来通信,协程则通过让出与恢复操作来通信。

以一个简单模型来描述 goroutine:
它是一个和其它协程在同一地址空间并发执行的函数。
通过在函数或方法名前加上go关键字来创建和运行一个协程,运行结束后安静的退出(没有任何返回值)。

//并行的运行list.Sort,不等待
go list.Sort() 

Go程序中必须含有的 main() 函数可以看作一个协程,尽管它没有通过go关键字启动,在程序初始化的过程中 (init()函数运行),goroutine 也可以运行。

单纯的结束协程的概念是不够具体的,协程需要和通道来配合。

通道 (channel)

并发编程的困难之处在于实现对共享变量的正确访问,互斥量的方式是复杂的,Go鼓励采用一种不同的方法,

即在通道 (channel) 上传递共享值,如同Unix管道一般,通道用于发送类型化的数据,在任何给定的时间,只有一个协程可以对通道中的数据进行访问,从而完成协程间的通信,也避开了所有由共享内存导致的陷阱。

这种通过通道进行通信的方式保证同步性的同时,数据的所有权也因此被传递。

这一设计理念最终简化为一句话:
不要通过共享内存来通信,而通过通信来共享内存。

1 声明与初始化

声明通道的基本形式如下,未初始化的通道值为 nil

var identifier chan datatype

通道只能传输一种类型的数据,比如 chan intchan string,可以是任意类型,包括空接口 interface{} 和通道自己。

和 map 相同,通道也是引用类型,因此使用 make 进行初始化,可以指定第二个参数用来指定缓冲区的大小,即通道可容纳的数据个数,这一个值默认是 0,意思是无缓冲,无缓冲的通道将通信、值的交换、同步三者结合,保证两个协程的计算处于已知状态。

var ci chan string
// 无缓冲的整数通道
ci = make(chan string)
// 无缓冲的整数通道
cj := make(chan int, 0)
// 指向文件的指针的缓冲通道
cs := make(chan *os.File, 100)

2 通信操作符 <-

操作符直观的表示数据的传输:信息按照箭头方向流动。

流向通道(发送)用 ch <- int1 表示,意为利用通道 ch 发送变量 int1。

从通道流出(接收)用 int2 = <- ch 表示,意为变量 int2 从通道 ch 接收数据,如果 int2 没有声明过,可以使用 int2 := <- ch

<- ch 则用于表示丢弃当前值,获取通道的下一个值,可以用来验证,如:

if <- ch != 1000 {
    
    
    ...
}

为了可读性通道的命名通常以 ch 开头或者包含 chan。
通道的发送和接收都是原子操作,总是互不干扰的完成。
下面的示例展示了通信操作符的使用。

package main

import (
	"fmt"
	"time"
)

func main() {
    
    
	ch := make(chan string)

	go sendData(ch)
	go getData(ch)

	// 当前程序暂停 1 秒钟
	time.Sleep(1e9)
}

func sendData(ch chan string) {
    
    
	ch <- "Washington"
	ch <- "Tripoli"
	ch <- "London"
	ch <- "Beijing"
	ch <- "Tokyo"
}

func getData(ch chan string) {
    
    
	var input string
	// time.Sleep(2e9)
	for {
    
    
		input = <-ch
		fmt.Printf("%s ", input)
	}
}
//Output:
Washington Tripoli London Beijing tokyo

如果 2 个协程需要通信,必须给它们同一个通道作为参数。

上例中 main() 函数中启动了两个协程:
sendData() 通过通道 ch 发送了 5 个字符串,
getData() 按顺序接收它们并打印出来。

一些同步的细节如下:

1 main() 等待了 1 秒让两个协程完成,如果不这样(注释掉time.Sleep(1e9)),sendData() 就没有机会输出。

2 getData() 使用了无限循环:它随着 sendData() 的发送完成和 ch 变空也结束了。

3 如果我们移除一个或所有 go 关键字,程序无法运行,Go 运行时会抛出 panic。这是因为运行时(runtime)会检查所有的协程是否在等待什么东西(从通道读取或写入某个通道),这意味着陷入死锁,程序无法继续执行。

通道的发送和接收顺序是无法预知的,如果使用打印状态来输出,由于两者间的时间延迟,打印的顺序和真实发生的顺序是不同的。

3 通道阻塞

前面提到默认情况下通信是同步且无缓冲的,因此通道的发送 / 接收操作在对方准备好之前是阻塞的。

1 对于同一个通道,发送操作(协程或者函数中的),在接收者准备好之前是阻塞的:
如果 ch 中的数据无人接收,就无法再给通道传入其他数据。

2 对于同一个通道,接收操作在发送者可用之前是阻塞的(协程或函数中的)

下例中的协程在无限循环中不断地给通道发送数据,但由于没有接收者,只输出了数字 0。

package main

import "fmt"

func main() {
    
    
	ch1 := make(chan int)
	go pump(ch1)       // pump hangs
	fmt.Println(<-ch1) // prints only 0
}

func pump(ch chan int) {
    
    
	for i := 0; ; i++ {
    
    
		ch <- i
	}
}
//Output:0

定义一个新的协程用来接收通道值可以持续输出。

package main

import "fmt"

func main() {
    
    
	ch1 := make(chan int)
	go pump(ch1)
	go suck(ch1)
	time.Sleep(1e9)
}

func pump(ch chan int) {
    
    
	for i := 0; ; i++ {
    
    
		ch <- i
	}
}

func suck(ch chan int) {
    
    
	for {
    
    
		fmt.Println(<-ch)
	}
}

4 信号量(Semaphore)

Go 语言中,信号量(Semaphore)通常用于限制并发访问资源的数量。

Semaphore 是一种计数器对象,可以用于控制同时访问共享资源的线程数量。

Semaphore 通常具有两个基本操作:

  • Acquire 操作会尝试获取一个许可(或信号),如果当前可用的许可数量为 0,则会被阻塞等待,直到有许可可用。
  • Release 操作会释放一个许可,使得其他被阻塞的线程可以获取该许可。

Go 语言中可以使用 sync.Mutex 和 sync.Cond 实现 Semaphore。

Go 语言中,sync.Cond 是一个条件变量对象,用于在多个 goroutine 之间进行通信。
它通常与互斥锁(sync.Mutex)一起使用,以实现线程间同步。

当一个 goroutine 想要等待另一个 goroutine 的某个条件满足时,它可以调用 Wait() 方法将自己阻塞,等待条件变量的通知。

当条件满足时,通知可以通过 Signal() 或 Broadcast() 方法发出,以唤醒等待的 goroutine。

下面是一个使用 sync.Mutex 和 sync.Cond 实现 Semaphore 的示例代码:

package main

import (
	"fmt"
	"sync"
)

type Semaphore struct {
    
    
	// 当前可用的许可数量
	count int
	// 条件变量对象,用于等待和唤醒 goroutine
	cond *sync.Cond
}

// 创建许可数量
func NewSemaphore(count int) *Semaphore {
    
    
	return &Semaphore{
    
    
		count: count,
		cond:  sync.NewCond(&sync.Mutex{
    
    }),
	}
}

// 操作许可
func (s *Semaphore) Acquire() {
    
    
	// 获取条件变量对应的锁
	s.cond.L.Lock()
	defer s.cond.L.Unlock()

	// 如果当前没有可用的许可,就等待条件变量
	for s.count <= 0 {
    
    
		// 释放锁并等待条件变量满足
		s.cond.Wait()
	}
	s.count-- // 获取许可,许可数量减一
}

// 释放许可
func (s *Semaphore) Release() {
    
    
	s.cond.L.Lock()
	defer s.cond.L.Unlock()

	// 释放许可,许可数量加一
	s.count++
	// 唤醒等待条件变量的 goroutine 中的一个
	s.cond.Signal()
}

func main() {
    
    
	sem := NewSemaphore(3)
	// 协调多个 goroutine 的执行
	var wg sync.WaitGroup
	for i := 0; i < 5; i++ {
    
    
		wg.Add(1) // 增加计数器的值
		go func(id int) {
    
    
			sem.Acquire()
			defer sem.Release()
			fmt.Printf("Goroutine %d acquired 获取许可 semaphore\n", id)
			// do some work
			fmt.Printf("Goroutine %d released 释放许可 semaphore\n", id)
			wg.Done() //减少计数器的值
		}(i)
	}

	wg.Wait() //等待计数器归零
	fmt.Println("All goroutines have finished")
}
root@debiancc:~/www/test# go run test.go 
Goroutine 4 acquired 获取许可 semaphore
Goroutine 4 released 释放许可 semaphore
Goroutine 2 acquired 获取许可 semaphore
Goroutine 2 released 释放许可 semaphore
Goroutine 3 acquired 获取许可 semaphore
Goroutine 3 released 释放许可 semaphore
Goroutine 0 acquired 获取许可 semaphore
Goroutine 0 released 释放许可 semaphore
Goroutine 1 acquired 获取许可 semaphore
Goroutine 1 released 释放许可 semaphore
All goroutines have finished
root@debiancc:~/www/test#

5 通道工厂

编程时常用一种通道工厂的模式,即不将通道作为参数传递给协程,而是用函数来生成一个通道并返回。

package main

import (
	"fmt"
	"time"
)

func main() {
    
    
	stream := pump()
	go suck(stream)
	time.Sleep(1e9)
}

func pump() chan int {
    
    
	ch := make(chan int)
	go func() {
    
    
		for i := 0; ; i++ {
    
    
			ch <- i
		}
	}()
	return ch
}

func suck(ch chan int) {
    
    
	for {
    
    
		fmt.Println(<-ch)
	}
}

在一秒钟内不断的执行执行流入流出。

6 给通道使用 for 循环

for 循环的 range 语句可以用在通道 ch 上,便可以从通道中获取值,像这样:

for v := range ch {
    
    
	fmt.Printf("The value is %v\n", v)
}

这样的使用依然必须和通道的写入和关闭相配合, 不能单独存在。

package main

import (
	"fmt"
	"time"
)

func main() {
    
    
	suck(pump())
	time.Sleep(1e9)
}

func pump() chan int {
    
    
	ch := make(chan int)
	go func() {
    
    
		for i := 0; ; i++ {
    
    
			ch <- i
		}
	}()
	return ch
}

func suck(ch chan int) {
    
    
	go func() {
    
    
		for v := range ch {
    
    
			fmt.Println(v)
		}
	}()
}

7 关闭通道

通道可以被显式的关闭,不过只有发送者才需要关闭通道,接收者永远不需要。

ch := make(chan float64)
defer close(ch)

测试通道是否关闭则可以使用 ok 操作符。

v, ok := <-ch   // ok is true if v received value

一个完整的例子

package main

import "fmt"

func main() {
    
    
	ch := make(chan string)
	go sendData(ch)
	getData(ch)
}

func sendData(ch chan string) {
    
    
	ch <- "Washington"
	ch <- "Tripoli"
	ch <- "London"
	ch <- "Beijing"
	ch <- "Tokio"
	close(ch)
}

func getData(ch chan string) {
    
    
	for {
    
    
		input, open := <-ch
		if !open {
    
    
			break
		}
		fmt.Printf("%s ", input)
	}
}
root@debiancc:~/www/test# go run test.go 
Washington Tripoli London Beijing Tokio
root@debiancc:~/www/test# 

但是使用 for-range 读取通道是更好的办法,因为这会自动检测通道是否关闭。

for input := range ch {
    
    
  	process(input)
}

Select

从不同的并发执行的协程中获取值可以通过关键字 select 来完成,它和 switch 控制语句非常相似,其行为像是“你准备好了吗”的轮询机制;

select 监听进入通道的数据,也可以是用通道发送值的时候。

select {
    
    
case u:= <- ch1:
        ...
case v:= <- ch2:
        ...
        ...
default: // no value ready to be received
        ...
}

select 做的事情是:选择处理列出的多个通信情况中的一个。

  • 如果都阻塞了,会等待直到其中一个可以处理。
  • 如果多个可以处理,随机选择一个。
  • 如果没有通道操作可以处理并且写了 default 语句,它就会执行:default 永远是可运行的(这就是准备好了,可以执行)。

使用 default 可以保证发送不被阻塞,但没有 default 的监听模式也可能被使用,通过 break 语句退出循环。

一个完整的例子:

package main

import (
	"fmt"
	"time"
)

func main() {
    
    
	ch1 := make(chan int)
	ch2 := make(chan int)

	go pump1(ch1)
	go pump2(ch2)
	go suck(ch1, ch2)

	time.Sleep(1e9)
}

func pump1(ch chan int) {
    
    
	for i := 0; ; i++ {
    
    
		ch <- i * 2
	}
}

func pump2(ch chan int) {
    
    
	for i := 0; ; i++ {
    
    
		ch <- i + 5
	}
}

func suck(ch1, ch2 chan int) {
    
    
	for {
    
    
		select {
    
    
		case v := <-ch1:
			fmt.Printf("Received on channel 1: %d\n", v)
		case v := <-ch2:
			fmt.Printf("Received on channel 2: %d\n", v)
		}
	}
}

有 2 个通道 ch1 和 ch2,三个协程 pump1()、pump2() 和 suck()。

在无限循环中,ch1 和 ch2 通过 pump1() 和 pump2() 填充整数;

suck() 也在无限循环中轮询输入,

通过 select 语句获取 ch1 和 ch2 的整数并输出。

选择哪一个 case 取决于哪一个通道收到了信息。

程序在 main 执行 1 秒后结束。

示例

1 惰性生成器

生成器是指当被调用时返回一个序列中下一个值的函数,

例如:

generateInteger() => 0
generateInteger() => 1
generateInteger() => 2
....

生成器每次返回的是序列中下一个值而非整个序列;

这种特性也称之为惰性求值:
只在你需要时进行求值,同时保留相关变量资源(内存和cpu):

这是一项在需要时对表达式进行求值的技术。

例如,生成一个无限数量的偶数序列:
要产生这样一个序列并且在一个一个的使用可能会很困难,而且内存会溢出!但是一个含有通道和go协程的函数能轻易实现这个需求。

下例中实现了一个使用 int 型通道来实现的生成器。

通道被命名为 yield 和 resume,这些词经常在协程代码中使用。

package main

import (
	"fmt"
)

var resume chan int

func integers() chan int {
    
    
	yield := make(chan int)
	count := 0
	go func() {
    
    
		for {
    
    
			yield <- count
			count++
		}
	}()
	return yield
}

func generateInteger() int {
    
    
	return <-resume
}

func main() {
    
    
	resume = integers()
	fmt.Println(generateInteger()) //=> 0
	fmt.Println(generateInteger()) //=> 1
	fmt.Println(generateInteger()) //=> 2
}

make 与 new 的区别

在 Go 语言中,make 和 new 都可以用来分配内存,但是它们的使用场景和行为有所不同。

1 make 用于创建切片、映射和管道等引用类型(slices, maps, and channels),并且返回一个已经初始化后的引用类型(slice、map 或 channel)的值。这些类型都是对底层数据结构的引用,需要初始化后才能使用。

示例:

// 创建一个长度为 5,容量为 10 的整型切片
slice := make([]int, 5, 10) 

// 创建一个字符串类型的管道
channel := make(chan string) 

2 new 用于分配值类型(struct、int、float64 等)的内存空间,并返回一个指向该类型的指针。
分配的内存会被置为零值,即该类型的默认值。

示例:

var ptr *int

// 分配一个整型的内存空间,并将 ptr 指向该空间
ptr = new(int)

总的来说,make 和 new 的主要区别在于:
make 只能用于引用类型的初始化,并且返回已经初始化后的值;
而 new 则用于分配值类型的内存空间,并返回指向该类型的指针。

slice := new([]int, 5, 10) 这样可以吗?

不可以。

在 Go 语言中,new 函数用于分配值类型(struct、int、float64 等)的内存空间,并返回一个指向该类型的指针。

而切片(slice)是一个引用类型,因此不能使用 new 来创建一个切片。

值类型和引用类型什么区别?

值类型(Value Type)和引用类型(Reference Type)是两种不同的数据类型。

它们的主要区别在于:

  • 值类型的变量直接存储数据的值。
  • 引用类型的变量存储的是指向数据存储位置的引用。

更具体地说,值类型的变量存储的是数据本身,而不是对数据的引用。
这意味着,当一个值类型的变量被赋值给另一个变量时,会创建一个新的副本,两个变量互不影响。值类型的数据包括整数、浮点数、布尔值、字符等。

引用类型的变量存储的是一个指向数据存储位置的引用。
当一个引用类型的变量被赋值给另一个变量时,它们都将指向同一个数据存储位置,对其中任意一个变量的修改都会影响到另一个变量。引用类型的数据包括切片、映射、管道、接口、函数等。

猜你喜欢

转载自blog.csdn.net/weiguang102/article/details/130539435