GO并发编程快速上手

本文从实战出发,以线程间竞争、线程间协作两个角度,介绍GO语言并发实践,并在最后着重介绍了GO中的channel使用方法,此为GO语言并发的精华。让我们开始吧!

1 线程间竞争

当多线程执行同一条高级语言中对共享数据的竞争操作时,会导致数据不一致。使用“锁”可以避免同时使用共享数据,可以解决竞争问题。但“锁”的错误使用,比如,死锁,又是新的问题。GO语言中,支持“锁”的存在,主要有互斥锁和读写锁。GO还支持其他避免竞争的方法,主流的有原子性操作、MAP锁等等。

互斥锁

互斥锁保证同一时间内只有一个goroutine可以访问共享数据。Go语言中使用sync包来实现互斥锁,一个经典用例如下:

package main

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

type test struct {
	x int
	mutex sync.Mutex
}

func (t *test) add(){
	//对t.x++操作进行加锁,避免竞争。
	//如果不加锁,会出现错误的结果
	t.mutex.Lock()
	t.x++
	t.mutex.Unlock()
}

func (t *test) get()int{
	return t.x
}

func main() {
	t:=test{x: 0}
	for i := 0; i < 1000; i++ {
	//并发的运行添加操作
		go t.add()
	}
	//这里之所以加休止,是因为main函数也是一个特殊的go程。所以很有可能前面开启的go程还没运行完,main程已经选择输出了,也会出现错误的结果。
	//此处也可以使用WaitGroup方法,后面会介绍。
	time.Sleep(100)
	fmt.Println(t.get())
}

读写锁

读多写少的情况下,可以优先使用读写锁。读写锁和互斥锁用法非常相似,其读锁可以让共享资源被任意读但是不能写,其写锁就是一个互斥锁。GO语言中使用sync 库提供读写锁,一个经典用例如下:

package main

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

// 声明读写锁
var rwlock sync.RWMutex
var x int

// 写数据
func write() {
	rwlock.Lock()
	x += 1
	rwlock.Unlock()
}

func read(i int) {
	rwlock.RLock()
	fmt.Println(x)
	rwlock.RUnlock()
}

func main() {
	go write()
	for i := 0; i < 1000; i++ {
		go read(i)
	}
	time.Sleep(100)
}

map锁

GO中的map支持并发读,但是并不能支持并发写。但是可以使用sync中的支持并发的map来实现并发,示例代码如下:

package main

import (
	"fmt"
	"sync"
)

func main() {
	wg := sync.WaitGroup{}
	var m = sync.Map{}

	// 并发写
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			m.Store(i,i*i)
			}(i)
	}
	wg.Wait()
	fmt.Println(m.Load(1))
}

原子性操作

也可以使用sync/atomic库完成原子操作来保证线程安全。原子操作在用户态就可以完成,性能比加锁更高,主要有以下几个原子方法:

//加减方法
AddXxx()
//读取方法
LoadXxx()
//写入方法
StoreXxx()
//交换方法
SwapXxx()

2 线程间协作

线程之间不仅存在竞争关系,还存在协作关系。比如消费者/生产者模型,需要不同的线程之间进行配合协作。GO语言中实现协作的方法不仅可以使用传统的如条件变量等方法,还可以通过特有的channel机制来实现协作。接下来将会依次介绍WaitGroup、条件变量、channel。

WaitGroup

GO中可以使用WaitGroup实现多个GO程之间的同步。在 sync.WaitGroup类型中,每个 sync.WaitGroup 值在内部维护着一个计数,此计数的初始默认值为零。可以通过调用以下几个方法来改变此计数器。

方法名 功能
Add(x int) 等待组的计数器 +1
Done() 等待组的计数器 -1
Wait() 当等待组计数器不等于 0 时,阻塞直到变 0。

通常用作主函数GO程等待其他GO程完成的情况,简单使用如下:

package main

import (
	"fmt"
	"sync"
)
//声明一个计数器
var wg sync.WaitGroup

type test struct {
	x int
	mutex sync.Mutex
}

func (t *test) add(){
	//运行完毕 计数器减一
	defer wg.Done()
	//对t.x++操作进行加锁,避免竞争。
	//如果不加锁,会出现错误的结果
	t.mutex.Lock()
	t.x++
	t.mutex.Unlock()
}

func (t *test) get()int{
	return t.x
}

func main() {
	t:=test{x: 0}
	for i := 0; i < 1000; i++ {
	   //运行一个新GO程,计数器加一
		wg.Add(1)
	   //并发的运行添加操作
		go t.add()
	}
	//直到计数器为0,不然会一直阻塞
	wg.Wait()
	fmt.Println(t.get())
}

条件变量

条件变量通常和互斥锁一起,用于实现等待/唤醒模型,是协作的重要一环。其可以将某个线程阻塞,直到得到一个外部唤醒信号再重新运行。GO中的条件变量是基于锁的,在sync库中实现,以下为GO中条件变量一个简单例子。

package main

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

var wg sync.WaitGroup
var cond *sync.Cond

func test(x int) {
	defer wg.Done()
	cond.L.Lock() // 获取锁
	cond.Wait()   // 等待通知 暂时阻塞
	fmt.Println(x)
	time.Sleep(time.Second * 1)
	cond.L.Unlock()
}

func main() {
	cond = sync.NewCond(&sync.Mutex{})
	fmt.Println("start all")
	for i := 0; i < 40; i++ {
		wg.Add(1)
		go test(i)
	}
	time.Sleep(time.Second * 3)
	fmt.Println("one")
	cond.Signal() // 下发一个通知给已经阻塞的goroutine
	time.Sleep(time.Second * 3)
	fmt.Println("one")
	cond.Signal() // 3秒之后 下发一个通知给已经阻塞的goroutine
	time.Sleep(time.Second * 3)
	fmt.Println("broadcast")
	cond.Broadcast() //3秒之后 下发广播给所有等待的goroutine
	wg.Wait()
}

3 channel

不要通过共享内存来通信,要通过通信来共享内存

扫描二维码关注公众号,回复: 16669988 查看本文章

以上介绍的锁、原子性操作是大部分编程语言都支持的,接下来,将会介绍GO语言的并发核心组件channel(管道)。

channel和Goroutine是相辅相成的。GO程作为GO特有的并发体,是用户态的协程,非常的轻便,可以轻而易举的在一个性能一般的电脑上跑起上千个GO程。而Channel主要作用就是作为通信管道为GO程之间提供通信桥梁,具有接受数据和发送数据的功能,类似一个全局的并发安全的队列。GO语言设计者强调,不要通过共享内存来通信,要通过通信来共享内存,这是GO语言的设计思想,接下来将会介绍channel的使用。

定义及创建一个channel

channel的定义和map很像。首先,channel内的数据结构是可以自己定义的,比如如下代码就定义了一个可以存储Int类型的channel。

var ch chan int

注意看以上代码,注意一下几点:

  • 定义ch为int类型的chan后,ch现在还是nil值,不能直接使用!就像map的使用一样,后续还需要用make来进行创建
  • 不仅可以定义int类型的chan,还可以定义其他类型的chan

channel的创建也和map很像,可使用如下代码进行创建:

ch=make(chan int,10)

注意:

  • 创建的时候指定的类型,必须跟定义的时候类型一致,只有创建后chan才能正常使用。
  • 创建时,可以指定通道的大小,上面的例子是10。当大小为0时,创建的通道为无缓冲通道,大于0时,为有缓冲通道

当然,也可以把定义和创建放在一起:

ch:=make(chan int,10)

channel的基本操作

channel具有发送数据和接受数据的功能,写法分别如下:

x := 0
//创建通道ch
ch:= make(chan int,1)
// 向ch发送数据
ch <- 1
// 从ch接收数据
x = <- ch

注意:

  • 每个通道都有缓冲区大小,当缓冲区满了后,发送会被阻塞,当缓冲区为空时,接受会被阻塞。
  • 无缓冲通道单个发送和单个接受都会被阻塞,只有发送和接受成对存在的时候才能继续往下运行。

而根据channel的操作分类,管道还可以分为普通通道以及单向通道,定义分别如下:

//只能向通道发送数据
var ch chan-< int
//只能从通道接受数据
var ch <-chan int  
  • 一般来说,把通道作为函数值传给一个函数时,会使用单向通道,这样可以限制函数对通道的操作。

接下来,编写一个双人打乒乓的例子,来展示channel的用法:

package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

var wg sync.WaitGroup

func A(ch chan int) {
	defer wg.Done()
	for {
		time.Sleep(time.Second)
		//得到击球机会
		num := <-ch
		//如果是有效的回球
		if num >= 1 {
			//产生随机数回球,如果数小于300,代表没打到球,输掉比赛。
			temp := rand.Int63() % 1000
			fmt.Println("A击出第", num, "球,得到", temp, "分!")
			if temp < 300 {
				fmt.Println("A输掉比赛!")
				ch <- 0
				break
			}
			ch <- num + 1
		} else {
			//如果是无效的球,退出循环
			break
		}
	}
	fmt.Println("A结束比赛~")
}
//B和A类似
func B(ch chan int) {
	defer wg.Done()
	for {
		time.Sleep(time.Second)
		//得到击球机会
		num := <-ch
		if num >= 1 {
			temp := rand.Int63() % 1000
			fmt.Println("B击出第", num, "球,得到", temp, "分!")
			if temp < 300 {
				fmt.Println("B输掉比赛!")
				ch <- 0
				break
			}
			ch <- num + 1
		} else {
			break
		}
	}
	fmt.Println("B结束比赛~")
}

func main() {
	ch := make(chan int)
	wg.Add(2)
	//启动双方
	go A(ch)
	go B(ch)
	//发球
	ch <- 1
	//等待比赛结束
	wg.Wait()
}

select

学会了chan的基本操作后,还需要学习一个很重要的东西——select。select跟switch的语法非常相似,但是使用方法却完全不同。可以理解为专用于通信的switch,其每一个case必须是一个通道通信操作(接受或者发送)。

注:Go 语言的 select 语句借鉴自 Unix 的 select() 函数,在 Unix 中,可以通过调用 select() 函数来监控一系列的文件句柄,一旦其中一个文件句柄发生了 IO 动作,该 select() 调用就会被返回(C 语言中就是这么做的),后来该机制也被用于实现高并发的 Socket 服务器程序。Go 语言直接在语言级别支持 select关键字,用于处理并发编程中通道之间异步 IO 通信问题。 ——《虎贲GO(21世纪之C语言)》

以下为基本语法:

select {
    case <-ch:
        // 如果从 ch 信道成功接收数据,则执行该分支代码
    case ch1 <- 1:
        // 如果成功向 ch1 信道成功发送数据,则执行该分支代码
    // 你可以定义任意数量的 case 
    //default为可选项
    default : 
     //如果没有case可运行,则运行此分支
}

注意一下几点:

  • 每一个case后面都应该接一个关于chan的通信操作

  • 所有case后面的表达式并不是都会被运行。如果存在多个可运行的case,select 会随机地执行一个可运行的 case执行,并执行其下的分支代码。

  • 如果没有 case 可运行,会运行default分支,如果没有default分支,它将阻塞,直到有 case 可运行。

select和定时器

select经常和定时器一起使用,可以实现定时执行某些操作,通常用于监控某个服务是否在指定时间内完成,以下有个简单代码,可以尝试修改一些:

package main

import (
	"fmt"
	"time"
)

func A(ch chan int) {
	time.Sleep(time.Second * 2)
	ch <- 1
}

func main() {
	ch := make(chan int)
	go A(ch)
	select {
	//接受来自ch的消息
	case <-ch:
		fmt.Println("接收到消息!")
		//定时器设置为1s,超过1s后case被激活
	case <-time.After(1 * time.Second):
		fmt.Println("没接收到消息!")
	}
}

总结

本文中,主要介绍了GO语言并发的基本语法并给出了一些小Demo。笔者GO语言专栏还有一些进阶版的实践的例子,有兴趣的朋友可以进入主页搜索“GO并发”查看。

猜你喜欢

转载自blog.csdn.net/doreen211/article/details/125066246