GO并发编程入门-协程和锁

一、使用协程

go 的协程使用上十分的简单,只需要在方法前添加 go 关键字,就可以开启一个新的用户态线程去异步执行。

package main

import (
	"fmt"
	"time"
)

func main() {
	go doSomething("mclink")
	doSomething("study")
}

func doSomething(str string) {
	for i:=0 ;i < 5 ; i++ {
		time.Sleep(time.Second)
		fmt.Println(str)
	}
}


打印的结果如下:

study
mclink
mclink
study
study
mclink
mclink
study
mclink
study

我们都知道,一个进程中至少会有一个线程,我们称之为主线程,在代码中,我们通过 go 关键词起了另一个线程去执行 doSomething 方法,此时,通过 go 的调度器,两个线程会乱序的打印出相应的字符串。

在 go 语言中,同一个程序中的所有 goroutine 共享同一个地址空间, 与Java的多线程不同,goroutne 是基于用户态的,实际上一个 goroutine 只是一个 go 语言定义的结构体,因此你可以在一个进程内大量的创建,并且只会占用极少的资源,而 Java 的多线程是基于内核态的,创建的成本相对较高。

协程和线程类似,共享堆,但不共享栈,一般来说,协程的转换一般由程序员在代码中显式的控制,它避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂。

二、 通道的使用

由于同一个进程的线程共享该进程的资源,因此线程间的通信成本远比进程间要小,在 go 语言中, channel 则是沟通多个 goroutine 之间的桥梁。

我们可以把 channel 类似成 Unix系统的通道。

2.1 如何创建通道

在 go 语言中,我们通过make关键字来创建通道,通道的类型一般是确定的,也就是说 string 类型的通道只能放进string 类型的变量。例如:

package main

import "fmt"

func main() {
	ch := make(chan int) // 非缓冲通道
	ch2 := make(chan string, 2) // 缓冲通道
	fmt.Println(ch, ch2)
}

2.2通道的类型

通道的类型只有两种,缓冲通道和非缓冲通道

//	Channel: The channel's buffer is initialized with the specified
//	buffer capacity. If zero, or the size is omitted, the channel is
//	unbuffered.
func make(t Type, size ...IntegerType) Type

我们通过 make 函数的注释说明就可以知道,当 size 参数为 0 或者是没有传,则为非缓冲队列,否则是缓冲队列

  • 缓冲通道

    • 即使通道元素没有被接收,也可以继续往里面发送元素,直到超过缓冲值,通过设置这个缓冲值可以提高通道的操作效率。
  • 非缓冲通道

    • 表示往通道中发送一个元素后,只有该元素被接收后才能存入下一个元素

通道实际上就是一个队列,队列是先进先出的数据结构,如果你对队列还不熟悉,可以看看我之前写过的队列文章传送门

在这里我们需要注意的是,一旦通道满了,再写入数据时,该协程将会被阻塞,直到有其他协程把数据取出。
我们可以举个例子看看:

package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan int, 1)
	go func() {
		fmt.Println("这里是子协程")
		ch <- 1 // 塞进去
		fmt.Println("塞进了一个1")
		ch <- 2 //当前协程可能阻塞
		fmt.Println("塞进了一个2")
		time.Sleep(time.Second * 3)
		ch <-3
		fmt.Println("塞进了一个3")
	}()
	fmt.Println("主协程")
	time.Sleep(time.Second * 3) // 这里注意,阻塞 3s 后,主协程取出通道数据后,上面 2 才会塞进去
	fmt.Println("取出通道数据:" , <-ch)
	time.Sleep(time.Second *3)
	fmt.Println("取出通道数据:", <-ch)
	fmt.Println("取出通道数据:", <-ch) // 空通道取数据会有阻塞作用
	fmt.Println("over")
}


输出如下:

主协程
这里是子协程
塞进了一个1
取出通道数据: 1
塞进了一个2
取出通道数据: 2
塞进了一个3
取出通道数据: 3
over

我们可以发现,在主协程取出通道数据前, 2 是塞不进通道里的。利用这种队列阻塞的原理,我们可以很轻松的实现多端异步消费的功能

2.3 关闭通道

一般涉及到资源类的,我们一般都需要手动去回收资源,尤其是常驻进程类的服务,如果没有回收资源的意识,很容易造成内存泄漏。

// The close built-in function closes a channel, which must be either
// bidirectional or send-only. It should be executed only by the sender,
// never the receiver, and has the effect of shutting down the channel after
// the last sent value is received. After the last value has been received
// from a closed channel c, any receive from c will succeed without
// blocking, returning the zero value for the channel element. The form
//	x, ok := <-c
// will also set ok to false for a closed channel.
func close(c chan<- Type)

我们只需要关注最后一句话:一个封闭的通道c,任何接收c的操作将被阻塞,返回channel元素的零值。

x,ok:= <-c

关闭了通道,ok的结果也将为false。

关闭通道的操作只能执行一次,试图关闭已关闭的通道会引发 panic。此外,关闭通道的操作只能在发送数据的一方关闭,如果在接收一方关闭,会导致 panic,因为接收方不知道发送方什么时候执行完毕,向一个已经关闭的通道发送数据会导致 panic。

简单总结如下:

  • 关闭一个未初始化(nil) 的 channel 会产生 panic
    重复关闭同一个 channel 会产生 panic
  • 向一个已关闭的 channel 中发送消息会产生 panic
  • 从已关闭的 channel 读取消息不会产生 panic,且能读出 channel 中还未被读取的消息,若消息均已读出,则会读到类型的零值。
  • 从一个已关闭的 channel 中读取消息永远不会阻塞,并且会返回一个为 false 的 ok-idiom,可以用它来判断 channel 是否关闭
  • 关闭 channel 会产生一个广播机制,所有向 channel 读取消息的 goroutine 都会收到消息

最后一个关闭通道的广播我们可以举个例子:

package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan int)
	for i := 0; i < 5; i++ { // 创建五个协程
		go func(i int, ch chan int) {
			for {
				if isCancel(ch) { // 死循环监听,能消费到就会退出
					break
				}
				time.Sleep(time.Microsecond * 5)
			}
			fmt.Println(i, "cancel")
		}(i, ch)
	}
	cancel(ch)
	time.Sleep(time.Second)
}

func cancel(ch chan int) {
	// 关闭有通道广播的作用,所有消费者都能收到通知,即 <-ch, 拿到对应的类型的零值
	close(ch)
}

func isCancel(ch chan int) bool {
	select {
	case <-ch:
		return true
	default:
		return false
	}
}

输出如下:

4 cancel
3 cancel
0 cancel
1 cancel
2 cancel
2.4 获取通道的数据

在上面我们可以通过 <- 操作符来后来获取通道的一个元素,我们还可以通过
for range 语法来获取。

package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan int, 1)
	go func() {
		for i:= 0 ; i < 10; i++ {
			ch <- i
			time.Sleep(time.Second * 1)
		}
		close(ch) // 这个不能省,否则主协程不知道子协程什么时候执行完毕,从一个空的通道接收数据会报如下运行时错误(死锁):fatal error: all goroutines are asleep - deadlock!

	}()
	// 接收数据
	for i := range ch {
		fmt.Println("receive data: ", i)
	}
	fmt.Println("over")
}

输出如下:

receive data:  0
receive data:  1
receive data:  2
receive data:  3
receive data:  4
receive data:  5
receive data:  6
receive data:  7
receive data:  8
receive data:  9
over
三、锁

go 的协程并不是线程安全的,对于一些场景,我们希望可以进行资源的控制,否则很容易造成数据的错误。

比如说 加N操作, A 和 B 都看到当前的数字为 10, A想加100,B想加 200 ,于是他们同时发出了修改请求,对于A 、B来说,正确的结果应该分别为 110和210, 但是最终的结果却是 310 。 为了保证正确的结果,在对其进行操作时,应该限制同时只能由单个人去处理。

资源的共享,必然会引起竞争,为了避免竞争所带来的的数据错误影响,我们应该保证共享的资源同一时刻只有一个人可以操作,这就是并发编程的弊端,通过锁的方式保证的数据一致性,却牺牲了部分性能。

在 go 语言中,锁主要分为两种:互斥锁和读写锁

3.1 互斥锁 (mutex)

我们先举一个没有锁机制的并发代码

package main

import (
	"fmt"
	"time"
)

func main() {
	count := 0
	for r := 0; r < 1000; r++ {
		go func() {
			count += 1
		}()
	}

	time.Sleep(time.Second * 3)
	fmt.Println("the count is : ", count)
}

此时count 的值一般是低于 1000 的,因为部分协程拿到的值是同一个,并且 count+=1 操作并不是原子操作。

//互斥锁是一个互斥锁。
//互斥锁的零值是未锁定的互斥锁。
//互斥锁不能在第一次使用后被复制。
type Mutex struct {
	state int32
	sema  uint32
}

//Locker接口只有两个方法,一个加锁,一个解锁。
type Locker interface {
   Lock()
   Unlock()
}

//锁定当前的互斥量
//如果锁已被使用,则调用的 goroutine 将阻塞直到互斥锁可用。
func (m *Mutex) Lock() 

//对当前互斥量进行解锁
//如果在进入解锁时未锁定m,则为运行时错误。
//锁定的互斥锁与特定的 goroutine 无关。
//允许一个goroutine锁定Mutex然后安排另一个goroutine来解锁它。

func (m *Mutex) Unlock()

我们将上面的栗子进行简单的改写:

package main

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

func main() {
	var mutex sync.Mutex
	count := 0
	for r := 0; r < 1000; r++ {
		go func() {
			mutex.Lock()
			count += 1
			mutex.Unlock()
		}()
	}

	time.Sleep(time.Second * 3)
	fmt.Println("the count is : ", count)
}

就可以解决并发导致的问题。
我们可以发现这种方式,是对某个资源的上锁,一旦这个资源被上锁,该资源则无法被抢占。
但是大多数情况,我们是希望可并发读不可并发写的。

3.2 读写锁 (RWMutex)

读写锁是针对读写操作的互斥锁,可以分别针对读操作与写操作进行锁定和解锁操作 。

其访问控制规则如下:

  • 多个写操作之间是互斥的
  • 写操作与读操作之间也是互斥的
  • 多个读操作之间不是互斥的
    在这样的控制规则下,读写锁可以大大降低性能损耗。
// RWMutex是一个读/写互斥锁,可以由任意数量的读操作或单个写操作持有。
// RWMutex的零值是未锁定的互斥锁。
//首次使用后,不得复制RWMutex。
//如果goroutine持有RWMutex进行读取而另一个goroutine可能会调用Lock,那么在释放初始读锁之前,goroutine不应该期望能够获取读锁定。 
//特别是,这种禁止递归读锁定。 这是为了确保锁最终变得可用; 阻止的锁定会阻止新读操作获取锁定。
type RWMutex struct {
   w           Mutex  //如果有待处理的写操作就持有
   writerSem   uint32 // 写操作等待读操作完成的信号量
   readerSem   uint32 //读操作等待写操作完成的信号量
   readerCount int32  // 待处理的读操作数量
   readerWait  int32  // number of departing readers
}


//读操作锁定
func (rw *RWMutex) RLock()
//读操作解锁
func (rw *RWMutex) RUnlock()
//写操作锁定
func (rw *RWMutex) Lock()
//写操作解锁
func (rw *RWMutex) Unlock()

//返回一个实现了sync.Locker接口类型的值
func (rw *RWMutex) RLocker() Locker

因为读写锁控制的多个读操作并不是互斥的,所以对于同一个读写锁,添加了多少个读锁定,必须要有等量的读解锁,否则其他协程将没有机会进行资源的操作。

  • Unlock方法会试图唤醒所有因为进行读锁定而被阻塞的协程
  • RUnlock 只会在已无任何读锁定的情况下,试图唤醒一个因欲进行写锁定而被阻塞的协程。
  • 若对一个未被写锁定的读写锁进行写解锁,就会引发一个不可恢复的panic,同理对一个未被读锁定的读写锁进行读解锁也会如此。
package main

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

func main() {
	var rwm sync.RWMutex
	for i := 0; i < 3; i++ {
		go func(i int) {
			fmt.Println("尝试进行读锁定", i)
			rwm.RLock()
			fmt.Println("读锁定完成 ", i)
			time.Sleep(time.Second)
			fmt.Println("尝试进行读解锁 ", i)
			rwm.RUnlock()
			fmt.Println("读解锁完成", i)
		}(i)
	}
	time.Sleep(time.Microsecond * 300)
	fmt.Println("尝试执行写锁定")
	rwm.Lock() // 当所有的读解锁都完成,此句才能正常执行,否则将被阻塞
	fmt.Println("写锁定完成")
}

输出如下:

尝试进行读锁定 0
尝试进行读锁定 2
读锁定完成  2
读锁定完成  0
尝试进行读锁定 1
读锁定完成  1
尝试执行写锁定
尝试进行读解锁  2
读解锁完成 2
尝试进行读解锁  0
读解锁完成 0
尝试进行读解锁  1
读解锁完成 1
写锁定完成

我们可以发现当所有的读解锁都完成后,写锁定才能正常执行。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

MClink

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值