Bootstrap

go语言多线程与并发编程

go语言并发编程

在了解go语言的并发编程之前,我们必须先了解并发和并行的概念。我们知道,当启动一个应用的时候实际上是启动了一个进程,通过该进程实现资源的调度和分配,并且多个进程之间是相互隔离的,所以我们运行其中一个应用不会对其他应用造成影响。

默认情况下一个进程只有一个线程,也就是单线程应用,这种模式下所有的操作都是同步的,处理完A事件以后才能接着处理B事件再接着处理C事件。

那么,单线程模式下能否进行并发编成,当然可以。我们设想一下,我有A、B、C三件事情要做,但是我又不想一个一个同步的去做,这时候我们可以使用并发模式,我先做A的一部分,接着做B的一部分,然后是C的一部分,通过这种不停切换的方式可以保证三件事情同时推进,这就算并发。

在多核处理器之前,也就是单核时代,我们只能在一个线程之间进行多任务的调度,这种调度方式也就算并发,随之技术的进步,现在已经进入了多核时代,为计算机的并行开发提供了条件。

我们在设想一下,以前就只有自己一个人,现在我又多个几个双胞胎兄弟(多核),那我是不是可以再请两个兄弟帮着做B、C事情,我自己做A事情,这样同时推进A、B、C事情,是的,这就是并行。

有时候并发和并行是同时的,比如多个线程同时处理数百件任务,多个线程并行的执行任务,一个线程又并发的执行多个任务。

言归正传,接下来我们开始分析go语言的并发编程。

1、goroutine

package main

import (

"fmt"

"time"

)

func printString(value string) {

for i := 0; i < 10; i++ {

fmt.Println(value)

time.Sleep(time.Second) //0.1 second

}

}

func main() {

go printString("A")

go printString("B")


time.Sleep(time.Second * 10)//暂时挂起主线程,让goroutine的两个线程跑完

}

输出结果:ABABBAABABBABABAABAB

两个线程交叉输出,说明是以并发形式执行,这里为什么说是并发而不是并行,因为go语言默认是单核执行线程。如果想要多核并行执行,需要在启动goroutine之前首先调用以下语句配置cpu核数:

runtime.GOMAXPROCS()

自从Go 1.5开始, Go的GOMAXPROCS默认值已经设置为 CPU的核数,我们不用手动设置这个参数。

2、go语言的线程通信

无论是哪种编程语言,实现多线程之间的通信方式无非是共享数据和消息两种方式。

1)我们先看下go语言是如何通过共享数据实现线程通信。

package main

import (

"fmt"

"sync"

"time"

)

var counter int = 0

func Count(lock *sync.Mutex) {

lock.Lock()

counter++

fmt.Println(counter)

lock.Unlock()

}

func main() {

lock := &sync.Mutex{}

for i := 0; i < 5; i++ {

go Count(lock)

}

time.Sleep(time.Second * 5)

}

输出结果:1 2 3 4 5

此时,多个线程共享数据counter,实际上当业务逻辑比较复杂并且共享数据比较多的情况下,使用这种方式是一件十分头疼的事情。我们这里暂且不提这些。

2)消息机制实现多线程通信

go语言使用channel在两个或多个线程之间传递消息。channel翻译成中文就算通道的意思,我们直接看代码:

package main

import (

"fmt"

"sync"

"time"

)

func WriteIn(ch chan int) {

for i := 0; i < 10; i++ {

ch <- i

}

}

func ReadOut(ch chan int) {

for i := 0; i < 10; i++ {

value := <-ch

fmt.Println(value)

}

}

func main() {

ch := make(chan int)

go WriteIn(ch)

go ReadOut(ch)

time.Sleep(time.Second * 5)

}

输出:1 2 3 4 5 6 7 8 9,通过chanel在不需要锁的情况下实现了数据的共享,chanel的用法中,常见的操作包括写入和读出,写入和读出都会导致线程的阻塞,也就是说数据写入后线程会等待另一个线程的读取,而读取之前如果没有数据写入也会进入阻塞状态等待数据写入。

3、go语言的线程同步

1)使用互斥锁实现线程同步

互斥锁是最简单的一种锁类型,同时也比较暴力,当一个goroutine获得了锁之后,其他goroutine就只能乖乖等到这个goroutine释放该锁。go语言使用sync.Mutex实现互斥锁。

代码如下:

package main

import (

"fmt"

"sync"

"time"

)

type MutexInfo struct {

mutex sync.Mutex

infos []int

}

func (m *MutexInfo) addInfo(value int) {

m.mutex.Lock()

m.infos = append(m.infos, value)

m.mutex.Unlock()

}

func main() {

m := MutexInfo{}

for i := 0; i < 10; i++ {

go m.addInfo(i)

}

time.Sleep(time.Second * 5)

fmt.Println(m.infos)

}

输出:[0 1 2 3 4 5 6 7 8 9]

我们通过多次运行发现,输出的结果并不总是从0到9按顺序输出,说明创建的10个goroutine并不是有序的抢占线程的执行权,也就是说这种同步并不是有序的同步,我们可以让10个goroutine一个一个的同步执行,但是并不能安排执行次序。

运行到这里,假如我们注释掉同步锁的代码为发生什么?

我们将addInfo方法修改如下:

func (m *MutexInfo) addInfo(value int) {

//m.mutex.Lock()

m.infos = append(m.infos, value)

//m.mutex.Unlock()

}

运行代码,输出:[1 0 2]

结果是不是出乎意料?为什么写了10个输入,只有3个值输入成功?这时候我们不得不解释线程的另一个概念,那就是线程安全。

我们先看下go语言中slice的append过程,使用append添加一个元素时,可能会有两步来完成:先获取当前切片数组的容量,比如当前容量是2,然后在新的存储区开辟一块新的存储单元,容量为2+1,并将原来的值和新的值存入新的存储单元。在没有同步锁的情况下,如果两个线程同时执行添加元素的操作,这时候可能只有一个被写入成功。这种情况就是非线程安全,相比之下,如果同时对一个int类型数据进行操作,就不会出现这种非线程安全的情况。

2)读写锁实现线程同步

go语言提供了另一种更加友好的线程同步的方式:sync.RWMutex。相对于互斥锁的简单暴力,读写锁更加人性化,是经典的单写多读模式。在读锁占用的情况下,会阻止写,但不阻止读,也就是多个goroutine可同时获取读锁,而写锁会阻止其他线程的读写操作。

代码如下:

package main

import (

"fmt"

"sync"

"time"

)

type MutexInfo struct {

mutex sync.RWMutex

infos []int

}

func (m *MutexInfo) addInfo(value int) {

m.mutex.Lock()

defer m.mutex.Unlock()

fmt.Println("write start", value)

m.infos = append(m.infos, value)

fmt.Println("write start end", value)

}

func (m *MutexInfo) readInfo(value int) {

m.mutex.RLock()

defer m.mutex.RUnlock()

fmt.Println("read start", value)

fmt.Println("read end", value)

}

func main() {

m := MutexInfo{}

for i := 0; i < 10; i++ {

go m.addInfo(i)

go m.readInfo(i)

}

time.Sleep(time.Second * 3)

fmt.Println(m.infos)

}

输出结果:

read start 0

read end 0

write start 1

write start end 1

read start 4

read end 4

read start 9

read end 9

read start 3

read start 1

read start 7

read end 7

read start 2

read start 8

read end 8

read end 1

read start 6

read end 6

read start 5

read end 5

read end 3

read end 2

write start 0

write start end 0

write start 2

write start end 2

write start 3

write start end 3

write start 4

write start end 4

write start 5

write start end 5

write start 6

write start end 6

write start 7

write start end 7

write start 8

write start end 8

write start 9

write start end 9

[1 0 2 3 4 5 6 7 8 9]

从结果我们可以看出,开始的时候读线程占用读锁,并且多个线程可以同时开始读操作,但是写操作只能单个进行。

3)使用条件变量实现线程同步

go语言提供了条件变量sync.Cond,sync.Cond方法如下:

Wait,Signal,Broadcast。
Wait添加一个计数,也就是添加一个阻塞的goroutine。
Signal解除一个goroutine的阻塞,计数减一。
Broadcast接触所有wait goroutine的阻塞。

代码如下:

func printIntValue(value int, cond *sync.Cond) {
	cond.L.Lock()
	if value < 5 {
		//value小于5时,进入等待状态
		cond.Wait()
	}
	//大于5的正常输出
	fmt.Println(value)
	cond.L.Unlock()
}

func main() {

	//条件等待
	mutex := sync.Mutex{}
	//使用锁创建一个条件等待
	cond := sync.NewCond(&mutex)

	for i := 0; i < 10; i++ {
		go printIntValue(i, cond)
	}

	time.Sleep(time.Second * 1)
	cond.Signal()//解除一个阻塞
	time.Sleep(time.Second * 1)
	cond.Broadcast()//解除全部阻塞
	time.Sleep(time.Second * 1)
}

运行后先输出满足条件的值:5 6 7 8 9

解除一个阻塞,输出0,解除全部阻塞,输出1 2 3 4

go语言多线程支持全局唯一性操作,即一个只允许goruntine调用一次,重复调用无效。

go语言还支持sync.WaitGroup实现多个线程同步的操作。

;