Go 的并发编程 | 青训营

136 阅读3分钟

并发 vs. 并行

  • 并发:多线程程序通过切换在多个任务间进行。
  • 并行:多线程程序在多个核心的CPU上分别同时运行。

Go 可以充分发挥多核优势,高效运行。

Go语言中的并发程序可以用两种手段来实现。其一是goroutine和channel,其支持“顺序通信进程”(communicating sequential processes)或被简称为CSP。CSP是一种现代的并发编程模型,在这种编程模型中值会在不同的运行实例(goroutine)中传递,尽管大多数情况下仍然是被限制在单一实例中。另一种是更为传统的并发模型:多线程共享内存。

Goroutines

在Go语言中,每一个并发的执行单元叫作一个goroutine。可以简单地把goroutine类比作一个线程。但与线程不同的是,线程指的是操作系统调度到CPU中执行的基本单位,例如在浏览器里新建一个窗口就需要一个线程来进行处理,其栈大小为MB级别。而goroutine是从属于某一个线程的,由Go语言运行时的调度器进行调度,相当于用户态的轻量级线程,其栈大小只有KB级别。

当一个程序启动时,其主函数即在一个单独的goroutine中运行,我们叫它main goroutine。新的goroutine会用go语句来创建。在语法上,go语句是一个普通的函数或方法调用前加上关键字go。go语句会使其语句中的函数在一个新创建的goroutine中运行。而go语句本身会迅速地完成。

f()    // 调用f(); 等待其返回
go f() // 创建一个名为f()的goroutine;不需要等待返回

例如,下面这个例子通过一个名为spinner()的goroutine来实现一个加载动画。

func main() {
    go spinner(100 * time.Millisecond)
    const n = 45
    fibN := fib(n) // slow
    fmt.Printf("\rFibonacci(%d) = %d\n", n, fibN)
}

func spinner(delay time.Duration) {
    for {
        for _, r := range `-\|/` {
            fmt.Printf("\r%c", r)
            time.Sleep(delay)
        }
    }
}

func fib(x int) int {
    if x < 2 {
        return x
    }
    return fib(x-1) + fib(x-2)
}

主函数返回时,所有的goroutine都会被直接打断,程序退出。除了从主函数退出或者直接终止程序之外,没有其它的编程方法能够让一个goroutine来打断另一个的执行,但是之后可以看到一种方式来实现这个目的,通过goroutine之间的通信来让一个goroutine请求其它的goroutine,并让被请求的goroutine自行结束执行。

CSP (Communicating Sequential Processes)

CSP用于描述两个独立的并发实体通过共享的通讯channel进行通信的并发模型。它提倡通过通信共享内存而不是通过共享内存。在Go中,它的process就是goroutine,而它使用channel来作为goroutines之间的通信机制。

Channels

一个channel是一个通信机制,它可以让一个goroutine通过它给另一个goroutine发送值信息。使用内置的make函数,我们可以创建一个channel。

语法:make(chan TYPE, BUFFER_SIZE)

make(chan int) // 创建了一个可以发送int类型数据的channel;无缓冲通道
make(chan int, 2) // 有缓冲通道

一个Channel有发送和接收(生产和消费)两个主要操作,都是通信行为。

ch <- x // a send statement
x = <-ch // a receive expression in an assignment statement
<-ch // a receive statement; resuilt is discarded

要关闭一个channel,可以使用内置的close函数。

close(ch) 

例如在下面这个例子中,close(naturals)会关闭naturals这个channel。而要检验一个channel是否被关闭,可以让接收操作多接收一个状态值,true表示成功从channel接收到了值,而false表示channel已经被关闭。

// Squarer
go func() {
    for {
        x, ok := <-naturals
        if !ok {
            break // channel was closed and drained
        }
        squares <- x * x
    }
    close(squares)
}() // DUMB

// gopl.io/ch8/pipeline2
func main() {
    naturals := make(chan int)
    squares := make(chan int)

    // Counter
    go func() {
        for x := 0; x < 100; x++ {
            naturals <- x
        }
        close(naturals)
    }()

    // Squarer
    go func() {
        for x := range naturals {
            squares <- x * x
        }
        close(squares)
    }()

    // Printer (in main goroutine)
    for x := range squares {
        fmt.Println(x)
    }
}

Go中还有一个defer关键字同样可以配合close函数用于关闭对应的channel。defer关键字用于声明一个延迟函数,一般称作defer函数,它会在defer语句所在函数返回前执行。它可以放在函数中中的任意位置,而有多个defer函数时,它们的执行遵循FILO顺序。

例如下面的例子中,defer close(ch)的作用和上面一个例子类似。

func CalSquare() {  
	src := make(chan int)  
	dest := make(chan int, 3)  
	go func() {  
		defer close(src)  
		for i := 0; i < 10; i++ {  
			src <- i  
		}  
	}()  
	go func() {  
		defer close(dest)  
		for i := range src {  
			dest <- i * i  
		}  
	}()  
	for i := range dest {  
		println(i)  
	}  
}

和map类似,channel也对应一个make创建的底层数据结构的引用。当我们复制一个channel或用于函数参数传递时,我们只是拷贝了一个channel引用,因此调用者和被调用者将引用同一个channel对象。和其它的引用类型一样,channel的零值也是nil。

两个相同类型的channel可以使用==运算符比较。如果两个channel引用的是相同的对象,那么比较的结果为真。一个channel也可以和nil进行比较。

并发安全与互斥锁 sync.Mutex

在一个有多个goroutine的程序中,当分别位于两个goroutine的两个事件的执行顺序无法判断时,我们就认为这两个事件是并发的。对于某个类型来说,如果其所有可访问的方法和操作都是并发安全的话,那么该类型便是并发安全的。并发可能会存在多个goroutine同时操作一个资源,而这种状态下会发生竞争,最终可能导致程序最后的结果与期待不符。

互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个goroutine可以访问共享资源。Go语言中使用sync包的Mutex类型来实现互斥锁。

var (
	x    int64
	lock sync.Mutex
)

func addWithLock() {
    for i := 0; i < 2000; i++ {
        lock.Lock() // 加锁,保证此时只有一个goroutine可以访问x
        x += 1
        lock.Unlock() // 解锁
    }
}

func addWithoutLock() {
    for i := 0; i < 2000; i++ {
        x += 1
    }
}

func Add() {
	x = 0
	for i := 0; i < 5; i++ {
		go addWithLock()
	}
	time.Sleep(time.Second)
	println("WithLock:", x)
	x = 0
	for i := 0; i < 5; i++ {
		go addWithoutLock()
	}
	time.Sleep(time.Second)
	println("WithoutLock:", x)
	
}

在上面这个例子中,我们可能会看到以下输出结果。

WithLock: 10000
WithoutLock: 8382

我们还可以使用读写锁(sync.RWMutex)使并发安全。

golang-china.github.io/gopl-zh/ch9…

sync.WaitGroup

如果有多个goroutines需要同时执行,然后执行下一步操作这样的场景,适合使用 sync.WaitGroup

WaitGroup下有几个函数。其中Add(delta int)用于将计数器+delta,Done函数可以让计数器-1,而Wait能使主协程阻塞直到计数器为0.

不适用WaitGroup进行快速打印时,我们可能这样写:

func HelloGoRoutine() {
    for i := 0; i < 5; i++ {
        go func(j int) {
            hello(j)
        }(i)
    }
    time.Sleep(time.Second)
}

而用WaitGroup实现协程的同步阻塞,我们可以这样写:

func ManyGo() {
    var wg sync.WaitGroup
    wg.Add(5) // 计数器+5
    for i := 0; i < 5; i++ {
        go func(j int) {
            defer wg.Done() //func()每次返回前对计数器-1
            hello(j)
        }(i)
    }
    wg.Wait() // 阻塞主协程直到计数器为0
}

部分引用自Go语言圣经