Go并发编程

目录

一些基本概念

并发任务单元的状态

并发任务单元:进程,线程,协程

同步

异步

并发和并行

并发编程

创建并发任务

WaitGroup

等待goroutine结束

WaitGroup.Wait

WaitGroup.Add

获取CPU数量

获取Goroutine的编号和返回值

GOMAXPROCS

重新调度

终止任务

终止进程:os.Exit

终止当前任务:runtime.Goexit

通道 Channel

声明

发送、接收数据

等待进程结束

同步模式和异步模式


Go的并发绝对称得上是Go的一大特色。Go使用类似协程的方式来处理并发单元,却又在运行时层面做了更深度的优化处理。这使得语法上的并发编程变得极为容易,仅仅使用关键字go就可以创建一个goroutine(并发任务单元)。然而简便的并发带来的是控制上的难度,而Go的并发编程也足以写出一部篇幅不少的大作。本篇博客用较为浅显的例子总结下Go并发编程中常见的问题和解决方案。

一些基本概念

在开启Go并发编程之前,我们先来总结一些常见的概念。这些概念在并发编程中或多或少都会用到。

并发任务单元的状态

一个并发任务单元的生命周期,从新建开始,包括就绪、运行、阻塞终止。阻塞指的是数据未准备就绪,该并发任务单元一直等待。例如某个运行中的并发任务单元需要使用一个资源,但不巧的是该资源被其它并发任务单元占用,因此该并发任务单元就会进入阻塞状态,一直等待该资源被释放。

并发任务单元:进程,线程,协程

进程、线程和协程是我们经常听到的三个概念。它们都是并发任务单元。

  • 进程:进程是指一个程序在给定数据集合上的一次执行过程,是系统进行资源分配和运行调用的独立单位。可以简单的理解为操作系统中正在执行的程序。
  • 线程:线程是由进程创建的。进程启动时会最先创建一个线程,即主线程。主线程可以创建其它的子线程,因此一个进程可以包含一个或多个线程。线程必须在某个进程中执行,一个进程内的多个线程共享该进程所拥有的所有数据资源,例如打开的文件、同一个地址空间、甚至是进程所拥有的硬件设备(物理内存、磁盘、打印机等)。
  • 协程:协程也被称作微线程,它的资源开销比线程更小。而go关键字创建的并发任务单元可以简单的理解为是一个协程。

同步

在发起一个调用时,在没有得到结果之前,该过程会一直等待,直到该调用返回。例如调用一个函数,在该函数没有返回结果之前(哪怕该函数本身没有返回值),调用者会一直等待该函数返回(即执行结束)。

异步

调用者在发起一个调用后,不必等待该调用是否执行完毕,这就是异步。

并发和并行

我们经常提到并发和并行的概念,但经常容易混淆二者的意思。先来看概念:

并发:逻辑上具备同时处理多个任务的能力

并行:物理上在同一时刻执行多个并发任务

我们通常说的程序是并发设计的,指的是所设计的程序允许多个任务同时执行。但实际上往往并不是我们所期望的那样。例如在单核处理器上,多个任务只能以间隔的方式切换执行。并行依赖多核处理器等物理设备,也就是说,并行是并发设计的理想执行模式。

并发编程

在说完上面常见的名词之后,我们来看看Go的并发编程。

创建并发任务

只需在函数调用前添加关键字go即可实现并发任务单元goroutine的创建:

package main

import "fmt"

func main() {
	go fmt.Println("hello, world!")
	
	go func(message string) {
		fmt.Println(message)
	} ("hello world!")
}

需要注意的是关键字go并非执行并发操作,而是创建了一个并发任务单元。创建后任务会被放置在系统队列中,等待调度器安排合适的系统线程去获取执行权。并发任务单元在运行时不保证彼此之间的执行顺序。

当有多个逻辑处理器时,调度器会将 goroutine 平等分配到每个逻辑处理器上。 这会让 goroutine 在不同的线程上运行。 不过要想真的实现并行的效果,用户需要让自己的程序运行在有多个物理处理器的机器上。 否则,哪怕 Go语言运行时使用多个线程,goroutine 依然会在同一个物理处理器上并发运行,达不到并行的效果。

与defer一样,goroutine在创建时会立即计算并复制执行参数:

package main

import (
	"fmt"
	"time"
)

// 默认值是0。
var c int

func counter() int {
	c++
	return c
}

func main() {
	a := 100

	// 使用真实的值传入
	go func(x, y int) {
		// 利用time.Sleep将goroutine阻塞1秒,使goroutine的逻辑在main之后运行。
		// 这里不是一个好的方式来阻塞goroutine。后面章节会介绍更好的方案来控制goroutine的执行顺序。
		time.Sleep(time.Second)
		fmt.Println("goroutine1: ", x, y)
	}(a, counter())

	// 换成指针
	go func(x, y *int) {
		// 让该goroutine最后执行。
		time.Sleep(time.Second * 2)
		fmt.Println("goroutine2: ", *x, *y)
	}(&a, &c)

	a += 100
	fmt.Println("main: ", a, counter())

	c = 23

	// 等待两个goroutine执行结束。
	// 这里也不是一个推荐方法,后续章节会详细介绍如何等待goroutine结束。
	time.Sleep(time.Second * 3)

	// 程序输出
	// main:  200 2
	// goroutine1:  100 1
	// goroutine2:  200 23
}

WaitGroup

WaitGroup的常见作用是等待goroutine的结束。因为进程退出时不会等待goroutine结束,因此当main函数退出时,goroutine可能还没有开始执行:

package main

import (
	"fmt"
	"time"
)

func main() {
	go func() {
		time.Sleep(time.Second)
		fmt.Println("Inside the goroutine.")
	}()

	fmt.Println("exit...")
	
	// 输出:
	// exit...
}

上一个小节“创建并发任务”中,main函数使用time.Sleep来等待所有goroutine结束。这虽然算是一个行之有效的方式,但并不推荐。因为在实际开发中,我们并不会严格知道goroutine的所用时间。

等待goroutine结束

如要等待多个任务结束,sync.WaitGroup是一个推荐的选择。通过设定计数器,让每个goroutine在退出前递减,直至归零时解除阻塞。

package main

import (
	"fmt"
	"sync"
	"time"
)

var (
	max       = 10
	waitGroup sync.WaitGroup
)

func main() {
	// 累加计数
	waitGroup.Add(max)
	for i := 0; i < max; i++ {
		go func(index int) {
			// 递减计数
			defer waitGroup.Done()
            // 这里的阻塞是为了保证main函数中 “Inside main function”的打印先于所有goroutine执行
			time.Sleep(time.Second)
			fmt.Println("goroutine: ", index)
		}(i)
	}

	fmt.Println("Inside main function.")

	// 此时main阻塞,直到计数归零
	waitGroup.Wait()

	fmt.Println("main exit.")

	// 程序输出:
	// Inside main function.
	// goroutine:  7
	// goroutine:  1
	// goroutine:  3
	// goroutine:  8
	// goroutine:  0
	// goroutine:  2
	// goroutine:  6
	// goroutine:  5
	// goroutine:  9
	// goroutine:  4
	// main exit.
}

WaitGroup.Wait

Wait可以在多处阻塞,它们都能接收到通知。

在上面的例子中,我们为了保证goroutine在main函数"Inside main function"打印之后执行,在goroutine内部添加了time.Sleep。其实我们可以利用Wait的机制避免使用time.Sleep这种粗暴的手段:

package main

import (
	"fmt"
	"sync"
)

var (
	max       = 10
	waitGroup sync.WaitGroup
	start     sync.WaitGroup
)

func main() {
	// 累加计数
	start.Add(1)
	waitGroup.Add(max)
	for i := 0; i < max; i++ {
		go func(index int) {
			// 递减计数
			defer waitGroup.Done()
			// 保证main函数中 “Inside main function”的打印先于所有goroutine执行
			start.Wait()
			fmt.Println("goroutine: ", index)
		}(i)
	}

	fmt.Println("Inside main function.")
	start.Done()

	// 此时main阻塞,直到计数归零
	waitGroup.Wait()

	fmt.Println("main exit.")
}

WaitGroup.Add

尽管WaitGroup.Add实现了原子操作,但建议在goroutine外使用。以免Add尚未执行,Wait已经退出。

package main

import (
	"fmt"
	"sync"
)

var (
	waitGroup sync.WaitGroup
)

func main() {
	go func() {
		// 来不及设置
		waitGroup.Add(1)
		fmt.Println("Inside the goroutine.")
		waitGroup.Done()
	}()

	waitGroup.Wait()
	fmt.Println("main exit.")
	// 程序输出:
	// main exit.
}

获取CPU数量

runtime.NumCPU函数返回一个int型数据,表示当前执行机器的CPU数量。

package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println(runtime.NumCPU())
}

获取Goroutine的编号和返回值

使用go关键字创建的goroutine无法像普通函数调用那样获取返回值。所创建的goroutine也不能获知并发任务的编号。这些问题我们可以使用本地存储的方式解决。

package main

import (
	"fmt"
	"sync"
)

var (
	size      = 10
	waitGroup sync.WaitGroup
)

// 存储goroutine ID和返回结果的本地存储
type LocalStorage struct {
	ID     int
	Result interface{}
}

func main() {
	pool := make([]LocalStorage, size)

	for i := 0; i < size; i++ {
		waitGroup.Add(1)

		go func(id int) {
			defer waitGroup.Done()

			// 使用id*3拟定一个执行结果
			pool[id].ID = id
			pool[id].Result = id * 3
		}(i)
	}

	waitGroup.Wait()

	fmt.Printf("%+v\n", pool)
	// [{ID:0 Result:0} {ID:1 Result:3} {ID:2 Result:6} {ID:3 Result:9} {ID:4 Result:12} {ID:5 Result:15} {ID:6 Result:18} {ID:7 Result:21} {ID:8 Result:24} {ID:9 Result:27}]
}

提示:选用本地存储的数据结构时,一定要注意并发安全性。虽然Slice不是并发安全的,但由于本例中每个并发任务单元都是根据索引读写Slice中的不同元素,因此不会引发并发安全性的问题。

GOMAXPROCS

运行时可能会创建很多线程,但任何时候仅有限的几个线程参与并发任务执行。该数量默认与处理器核数相等(runtime.NumCPU函数获取)。runtime.GOMAXPROCS函数可以修改该数量的默认值。但如果设置值小于1,GOMAXPROCS仅返回当前设置值,不做任何调整。

重新调度

runtime.Gosched函数可以暂停并发任务单元,释放线程去执行其它任务。当前任务被放回队列中,等待下次调度时恢复执行。

package main

import (
	"runtime"
	"sync"
)

var (
	exit sync.WaitGroup
)

func main() {
	// 设置并发任务单元只允许进入一个处理器中执行
	runtime.GOMAXPROCS(1)
	exit.Add(1)

	// 创建并发任务A
	go func() {
		defer exit.Done()

		// 创建一个并发任务B。由于GOMAXPROCS设置为1,因此B会阻塞。
		go func() {
			println("task B")
		}()

		for i := 0; i < 4; i++ {
			println("a: ", i)

			if i == 1 {
				// 让出当前线程,执行任务B
				runtime.Gosched()
			}
		}
	}()

	exit.Wait()
}

// 程序输出
// a:  0
// a:  1
// task B
// a:  2
// a:  3

终止任务

终止进程:os.Exit

os.Exit函数会终止整个进程,且不会执行注册的延迟调用。

package main

import (
	"fmt"
	"os"
)

func PrintMessage() {
	defer func() {
		fmt.Println("This message print in defer function.")
	}()

	fmt.Println("hi, I want to print some message here!")
	os.Exit(0)
	fmt.Println("I am a function")
}

func main() {
	PrintMessage()
	// 最终输出
	// hi, I want to print some message here!
}

如果在goroutine中调用os.Exit,那么整个进程依然会被终止,已经创建但还未开始执行的goroutine和没有来得及创建的goroutine也会终止执行。

package main

import (
	"fmt"
	"os"
	"sync"
)

func main() {
	var waitGroup sync.WaitGroup

	for i := 0; i < 10; i++ {
		waitGroup.Add(1)
		go func(index int) {
			defer waitGroup.Done()
			fmt.Println("goroutine: ", index)
			if index == 5 {
				os.Exit(0)
			}
		}(i)
	}

	waitGroup.Wait()
	fmt.Println("main exit...")
	// 输出:
	// goroutine:  1
	// goroutine:  3
	// goroutine:  0
	// goroutine:  7
	// goroutine:  4
	// goroutine:  5
}

终止当前任务:runtime.Goexit

Goexit会立即终止当前任务,运行时确保所有已经注册的延迟调用被执行。该函数不会影响其它的并发任务单元,不会引发panic。

package main

import (
	"fmt"
	"runtime"
	"sync"
)

func main() {
	var waitGroup sync.WaitGroup

	for i := 0; i < 3; i++ {
		waitGroup.Add(1)
		go func(index int) {
			defer waitGroup.Done()
			defer fmt.Printf("goroutine: %d done.\n", index)
			if index == 1 {
				runtime.Goexit()
			}
			fmt.Printf("I am goroutine %d.\n", index)
		}(i)
	}

	waitGroup.Wait()
	fmt.Println("main exit...")
	// 输出:
	// I am goroutine 2.
	// goroutine: 2 done.
	// goroutine: 1 done.
	// I am goroutine 0.
	// goroutine: 0 done.
	// main exit...
}

如果在main函数中调用Goexit,它会等待其它任务结束,然后让进程直接崩溃。

package main

import (
	"fmt"
	"runtime"
)

func main() {
	for i := 0; i < 3; i++ {
		go func(index int) {
			fmt.Printf("goroutine: %d done.\n", index)
		}(i)
	}

	runtime.Goexit()
	fmt.Println("main exit...")
	// 输出:
	// goroutine: 2 done.
	// goroutine: 0 done.
	// goroutine: 1 done.
	// fatal error: no goroutines (main called runtime.Goexit) - deadlock!
}

(好像Goexit也可以做为等待所有并发任务单元执行结束的手段,但这样并不推荐)

无论身处哪一层,Goexit都会立即终止整个调用堆栈,这与return仅退出当前函数不同:

package main

import (
	"fmt"
	"runtime"
	"sync"
)

func PrintSomeMessage() {
	fmt.Println("I want to print some message here!")
	runtime.Goexit()
	fmt.Println("return")
}

func main() {
	var wait sync.WaitGroup
	wait.Add(1)

	go func() {
		defer func() {
			fmt.Println("Inside defer function...")
			wait.Done()
		}()

		fmt.Println("Before calling PrintSomeMessage function...")
		PrintSomeMessage()
		fmt.Println("After calling PrintSomeMessage function...")
	}()

	wait.Wait()
	fmt.Println("main exit...")
	// 输出:
	// Before calling PrintSomeMessage function...
	// I want to print some message here!
	// Inside defer function...
	// main exit...
}

通道 Channel

Go鼓励使用CSP通道,以通信来代替内存共享,实现并发安全。作为CSP的核心,通道Channel是显示的,要求操作双方必须知道数据类型和具体通道,并不关心另一端操作者身份和数量。可如果另一端未准备妥当,或消息未能及时处理,会阻塞当前端。从底层实现角度来说,通道只是一个队列。同步模式下,发送和接收双方配对,然后直接复制数据给对方。如果配对失败,则置入等待队列,直到另一方出现后才被唤醒。异步模式抢夺的则是数据缓冲槽。发送方要求有空槽可供写入,而接收方则要求有缓冲数据可读。需求不符时,同样加入等待队列,直到有另一方写入数据或腾出空槽后被唤醒。

声明

使用make函数声明一个通道。通道是有类型的,必须显示指定一个类型。声明后的通道只能接收该类型的数据。下面的例子是声明一个布尔类型的通道。

package main

func main() {
	channel := make(chan bool)
}

发送、接收数据

使用<-运算符给通道发送、接收数据:

package main

import "fmt"

func main() {
	channel := make(chan string)
	go func() {
		// 给通道发送数据
		channel <- "hi, this is a message send in a goroutine!"
	}()

	// 从通道中接收数据
	message := <-channel
	fmt.Println(message)
	// hi, this is a message send in a goroutine!
}

可以使用ok-idiom模式判断通道是否关闭。

package main

import "fmt"

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

	go func() {
		c <- "test"
	}()

	_, ok := <-c
	fmt.Println(ok)  // true 表示通道未关闭
}

等待进程结束

其实上一个例子中我们已经实现了等待goroutine的结束。通道c在没有数据可供读出时会进入阻塞,直到goroutine内有数据传入。需要注意的是,通道必须经过make函数初始化才能发挥作用。

package main

import "fmt"

func main() {
	channels := make([]chan bool, 3)

	for i := 0; i < 3; i++ {
		go func(index int) {
			fmt.Println("goroutine: ", index)
			channels[index] <- true
		}(i)
	}

	for i := 0; i < 3; i++ {
		<-channels[i]
	}

	fmt.Println("exit")  // fatal error: all goroutines are asleep - deadlock!
}

上面的程序最终会崩溃,因为仅仅使用make函数初始化了bool channel切片,但没有对每个channel成员继续使用make函数初始化。下面的修改会解决该问题的产生:

package main

import "fmt"

func main() {
	channels := make([]chan bool, 3)

	for i := 0; i < 3; i++ {
		channels[i] = make(chan bool)
		go func(index int) {
			fmt.Println("goroutine: ", index)
			channels[index] <- true
		}(i)
	}

	for i := 0; i < 3; i++ {
		<-channels[i]
	}

	fmt.Println("exit")
}

同步模式和异步模式

我们上面所看到的通道都是同步模式。带有缓冲区的通道是异步模式。下面的代码声明一个拥有3个缓冲区的通道:

channel := make(chan bool, 3)

同步模式必须有配对操作的goroutine出现,否则会一直阻塞:

package main

func main() {
	channel := make(chan any)
	channel <- true		// fatal error: all goroutines are asleep - deadlock!
}

而异步模式在缓冲区未满或数据未读完前,不会阻塞:

package main

import "fmt"

func main() {
	channel := make(chan any, 6)

	// 在缓冲区未满之前,不会阻塞
	channel <- true
	channel <- 2

	// 缓冲区尚有数据,不会阻塞
	fmt.Println(<-channel)
    fmt.Println(<-channel)
}

缓冲区大小仅属于内部属性,不属于类型组成部分。异步通道相比于同步通道,有助于提升性能,减少排队阻塞。

你可能感兴趣的:(golang)