Go并发编程 | 青训营笔记

105 阅读6分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记

协程通信

  • Go语言提倡通过通信共享内存,而不是通过共享内存实现通信。
  • 像java里面,线程之间通信的方式就是用一块共享内存(临界区)的方式,但是这不是go提倡的。
  • 但是go也保留着通过共享内存实现通信的方式(加锁)

sync

sync.WaitGroup

package main

import (
	"fmt"
	"sync"
)

var wg = sync.WaitGroup{}

func printNum(i int) {

	fmt.Println("aFunc:", i)
	 wg.Done() // // goroutine结束就登记-1
}
func main() {
	for i := 0; i < 4; i++ {
		wg.Add(1) //  启动一个goroutine就登记+1
		go printNum(i)
	}
	wg.Wait() // 等待所有登记的goroutine都结束
}
  • wg.Add(1)表示计数器+1
  • wg.Done() 表示计数器 -1
  • wg.Wait() // 等待所有登记的goroutine都结束

这个方法解决了什么问题呢?

答:解决了主函数不知道goroutine协程啥会儿结束而不知道等他多久的问题。正规解释是用它解决多个goroutine之间的同步问题。以前都用睡觉的方式等待,但是不知道睡多久,就预估了一个睡眠时间,用了这个sync.WaitGroup这个库呢就完美解决了这个问题。

sync.Mutex

互斥锁。

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

sync.RWMutex

读写锁

互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在Go语言中使用sync包中的RWMutex类型。

读写锁分为两种:读锁和写锁。当一个goroutine获取读锁之后,其他的goroutine如果是获取读锁会继续获得锁,如果是获取写锁就会等待;当一个goroutine获取写锁之后,其他的goroutine无论是获取读锁还是写锁都会等待。

var (
   x int
   // lock sync.Mutex
   wg     sync.WaitGroup
   rwLock sync.RWMutex
)

func read() {
   defer wg.Done()
   // lock.Lock()
   rwLock.RLock() // 上读锁
   time.Sleep(time.Millisecond)
   rwLock.RUnlock() // 解读锁
   // lock.Unlock()
}
func write() {
   defer wg.Done()
   // lock.Lock()
   rwLock.Lock() // 写锁
   x += 1
   time.Sleep(time.Millisecond * 5)
   rwLock.Unlock() // 解写锁
   // lock.Unlock()
}

func main() {
   start := time.Now()
   // 读写比例为100:1 或者 10:1
   for i := 0; i < 1000; i++ {
      wg.Add(1)
      go read()
   }
   for j := 0; j < 10; j++ {
      wg.Add(1)
      go write()
   }
   wg.Wait()
   fmt.Println(time.Now().Sub(start))
   fmt.Println(x)
   // 使用 读写锁的所用时间为:176.561ms
   // 使用 互斥锁的所用时间为:15.9167479s
}

sync.Once

也就是常说的单例模式

在编程的很多场景下我们需要确保某些操作在高并发的场景下只执行一次,例如只加载一次配置文件等。

Go语言中的sync包中提供了一个针对只执行一次场景的解决方案–sync.Once

sync.Once只有一个Do方法,其签名如下:

func (o *Once) Do(f func()) {}

注意:如果要执行的函数f需要传递参数就需要搭配闭包来使用。

例子:

该案例只是说明sync.Once的作用,不是特别好,因为,对于cube函数并发执行由于速度不一样可能导致先关闭了ch2,通道ch2无法继续接收发送值。

var (
   closeOnce sync.Once
   wg        sync.WaitGroup
)

func create(ch chan<- int) {
   defer wg.Done()
   for i := 1; i <= 10; i++ {
      ch <- i
   }
   close(ch)
}
func cube(ch1 <-chan int, ch2 chan<- int) {
   defer wg.Done()
   for num := range ch1 {
      ch2 <- num * num * num
   }
   // 采用sync.Once 关闭操作只执行一次
   closeOnce.Do(func() {
      close(ch2)
   })
   // close(ch2) 关闭已经关闭的通道会引发panic->panic: close of closed channel
}
func main() {
   ch1 := make(chan int, 10)
   ch2 := make(chan int, 10)
   wg.Add(1)
   go create(ch1)
   for i := 0; i < 3; i++ {
      wg.Add(1)
      go cube(ch1, ch2)
   }
   wg.Wait()
   for ret := range ch2 {
      fmt.Println(ret)
   }
}

并发安全的单例模式

下面是借助sync.Once实现的并发安全的单例模式:

package singleton

import (
    "sync"
)

type singleton struct {}

var instance *singleton
var once sync.Once

func GetInstance() *singleton {
    // 这样设计就能保证初始化操作的时候是并发安全的并且初始化操作也不会被执行多次。
    once.Do(func() {
        instance = &singleton{}
    })
    return instance
}

sync.Once其实内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成。这样设计就能保证初始化操作的时候是并发安全的并且初始化操作也不会被执行多次。

sync.Map

map也和java一样,是并发不安全的。所以他有一个并发安全的集合

map并发安全问题举例:

var m = make(map[int]string)

func main() {

	// map并发安全的例子
	for i := 0; i < 100; i++ {
		go func(i int) {
			m[i] = strconv.Itoa(i)
			fmt.Println(m[i])
		}(i)
	}

}
/*	fatal error: 2
    concurrent map writes
*/

解决办法:

  • 加锁
  • sync.Map

Go语言的sync包中提供了一个开箱即用的并发安全版map–sync.Map。开箱即用表示不用像内置的map一样使用make函数初始化就能直接使用。

sync.Map内置方法

方法名功能
func (m *Map) Store(key, value interface{})设置值
func (m *Map) Load(key interface{}) (value interface{}, ok bool)获取key对应的value
func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool)先判断是否存在,存在取出,不存在写入
func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool)查询并删除key
func (m *Map) Delete(key interface{})删除key
func (m *Map) Range(f func(key, value interface{}) bool)对map中的每个key-value依次调用f

sync.Pool

池化思想

sync.Pool 除了最常见的池化提升性能的思路,最重要的是减少 GC 。常用于一些对象实例创建昂贵的场景。注意,Pool 是 Goroutine 并发安全的。

blog.csdn.net/kevin_tech/…

协程池

在工作中我们通常会使用可以指定启动的goroutine数量–worker pool模式,控制goroutine的数量,防止goroutine泄漏和暴涨。

var wg = sync.WaitGroup{}

// worker pool 工作池的简单案例
func worker(id int, jobs <-chan int, results chan<- int) {
   defer wg.Done()
   for ret := range jobs {
      fmt.Printf("worker:%d start job:%d\n", id, ret)
      results <- ret * ret
      time.Sleep(time.Microsecond * 500) // 500ms
      fmt.Printf("worker:%d end job:%d\n", id, ret)
   }
}

func main() {
   /*
      有5个工作,交给三个线程去做
   */
   jobs := make(chan int, 5)
   results := make(chan int, 5)
   for i := 1; i <= 5; i++ {
      // 产生五个工作
      jobs <- i
   }
   close(jobs)
   for j := 1; j <= 3; j++ {
      wg.Add(1)
      go worker(j, jobs, results)
   }
   // 打印工作的成果
   for i := 0; i < 5; i++ {
      // 这里不能采取for range的方法去取,因为并没有关闭results通道
      ret := <-results
      fmt.Println(ret)
   }
   wg.Wait()
}

atomic(原子操作)

我们平常用的++ -- 操作是非原子操作,在并发的情况下,可能会出现遗漏的情况,也就是误加/减

比如下面:

package main

import (
	"fmt"
	"sync"
)

// 原子操作
var x = 0
var wg sync.WaitGroup

func add() {
	x++
	wg.Done()
}
func main() {

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go add()
	}
	wg.Wait()
	fmt.Println(x)
}

每次结果都不一样,但是都会 小于等于真实值。

解决办法:

  • 使用锁
  • Go内置了原子操作(atomic)
// 原子操作
var x int32 = 0
var wg sync.WaitGroup

func add() {
	atomic.AddInt32(&x, 1)
	wg.Done()
}
func main() {

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go add()
	}
	wg.Wait()
	fmt.Println(x)
}
方法解释
func LoadInt32(addr *int32) (val int32) func LoadInt64(addr *int64) (val int64) func LoadUint32(addr *uint32) (val uint32) func LoadUint64(addr *uint64) (val uint64) func LoadUintptr(addr *uintptr) (val uintptr) func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)读取操作
func StoreInt32(addr *int32, val int32) func StoreInt64(addr *int64, val int64) func StoreUint32(addr *uint32, val uint32) func StoreUint64(addr *uint64, val uint64) func StoreUintptr(addr *uintptr, val uintptr) func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)写入操作
func AddInt32(addr *int32, delta int32) (new int32) func AddInt64(addr *int64, delta int64) (new int64) func AddUint32(addr *uint32, delta uint32) (new uint32) func AddUint64(addr *uint64, delta uint64) (new uint64) func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)修改操作
func SwapInt32(addr *int32, new int32) (old int32) func SwapInt64(addr *int64, new int64) (old int64) func SwapUint32(addr *uint32, new uint32) (old uint32) func SwapUint64(addr *uint64, new uint64) (old uint64) func SwapUintptr(addr *uintptr, new uintptr) (old uintptr) func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)交换操作
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool) func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool) func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool) func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool) func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool) func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)比较并交换操作java中的CAS

\

Context(很重要!!)

引出

如何优雅的控制子goroutine退出?

答:
1、全局变量控制
2、管道
3、context

管道方式:

package main

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

var (
	wg sync.WaitGroup
)

// 管道方式
func main() {
	c := make(chan int, 1)
	wg.Add(1)
	go f(c)
	time.Sleep(5 * time.Second)
	c <- 1
	wg.Wait()
	fmt.Println("main over.....")
}
func f(c chan int) {
	defer wg.Done()
FORLOOP:
	for {
		fmt.Println("打印f。。。。。。")
		time.Sleep(time.Millisecond * 500)
		select {
		case <-c:
			// 需要退出两层,所以用了标签
			break FORLOOP
		default:

		}
	}
}

context方式:

package main

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

var w sync.WaitGroup

func main() {

	// 造一个context,context.Background()表示根结点
	ctx, cancel := context.WithCancel(context.Background())
	w.Add(1)
	go ff(ctx)
	time.Sleep(5 * time.Second)
	cancel()  // 通知协程结束函数
	w.Wait()
	fmt.Println("main over.....")
}
func ff(c context.Context) {
	defer w.Done()
FORLOOP:
	for {
		fmt.Println("打印f。。。。。。")
		time.Sleep(time.Millisecond * 500)
		select {
		case <-c.Done():
			// 需要退出两层,所以用了标签
			break FORLOOP
		default:

		}
	}
}
  • <-c.Done() 表示的就是一个管道,是仅读的管道(因为设置的是struct{})。小知识:struct{}是空结构体,在内存中不占字节
  • context的方法和管道/全局变量的方法有啥不同呢?
    • 答:为了规范。!!

Context初识

Go1.7加入了一个新的标准库context,它定义了Context类型,专门用来简化 对于处理单个请求的多个 goroutine 之间与请求域的数据、取消信号、截止时间等相关操作,这些操作可能涉及多个 API 调用。

对服务器传入的请求应该创建上下文,而对服务器的传出调用应该接受上下文。它们之间的函数调用链必须传递上下文,或者可以使用WithCancel、WithDeadline、WithTimeout或WithValue创建的派生上下文。当一个上下文被取消时,它派生的所有上下文也被取消。

Context接口

context.Context是一个接口,该接口定义了四个需要实现的方法。具体签名如下:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

其中:

  • Deadline方法需要返回当前Context被取消的时间,也就是完成工作的截止时间(deadline);
  • Done方法需要返回一个Channel,这个Channel会在当前工作完成或者上下文被取消之后关闭,多次调用Done方法会返回同一个Channel;
  • Err方法会返回当前Context结束的原因,它只会在Done返回的Channel被关闭时才会返回非空的值;
    • 如果当前Context被取消就会返回Canceled错误;
    • 如果当前Context超时就会返回DeadlineExceeded错误;
  • Value方法会从Context中返回键对应的值,对于同一个上下文来说,多次调用Value 并传入相同的Key会返回相同的结果,该方法仅用于传递跨API和进程间跟请求域的数据;

Background()和TODO()

Go内置两个函数:Background()和TODO(),这两个函数分别返回一个实现了Context接口的background和todo。我们代码中最开始都是以这两个内置的上下文对象作为最顶层的partent context,衍生出更多的子上下文对象。

Background()主要用于main函数、初始化以及测试代码中,作为Context这个树结构的最顶层的Context,也就是根Context。

TODO(),它目前还不知道具体的使用场景,如果我们不知道该使用什么Context的时候,可以使用这个。

background和todo本质上都是emptyCtx结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的Context。

With系列函数

WithCancel

WithCancel的函数签名如下:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

WithCancel返回带有新Done通道的父节点的副本。当调用返回的cancel函数或当关闭父上下文的Done通道时,将关闭返回上下文的Done通道,无论先发生什么情况。

取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。

WithDeadline

WithDeadline的函数签名如下:

func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)

返回上下文的副本,并将deadline调整为不迟于d。说人话就是,传入一个根节点和一个context的过期时间。当时间到了之后,返回上下文的Done通道将会被关闭,

取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。

func main() {
	d := time.Now().Add(50 * time.Millisecond)
	ctx, cancel := context.WithDeadline(context.Background(), d)

	// 尽管ctx会过期,但在任何情况下调用它的cancel函数都是很好的实践。
	// 如果不这样做,可能会使上下文及其父类存活的时间超过必要的时间。
	defer cancel()

	select {
	case <-time.After(1 * time.Second):
		fmt.Println("overslept")
	case <-ctx.Done():
		fmt.Println(ctx.Err())
	}
}

上面的代码中,定义了一个50毫秒之后过期的deadline,然后我们调用context.WithDeadline(context.Background(), d)得到一个上下文(ctx)和一个取消函数(cancel),然后使用一个select让主程序陷入等待:等待1秒后打印overslept退出或者等待ctx过期后退出。

在上面的示例代码中,因为ctx 50毫秒后就会过期,所以ctx.Done()会先接收到context到期通知,并且会打印ctx.Err()的内容。

WithTimeout

WithTimeout的函数签名如下:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithTimeout返回WithDeadline(parent, time.Now().Add(timeout))。

取消此上下文将释放与其相关的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。(说人话就是,设置一个超时时间,到时间了之后就关闭上下文的通弄到)通常用于数据库或者网络连接的超时控制。具体示例如下:

package main

import (
	"context"
	"fmt"
	"sync"

	"time"
)

// context.WithTimeout

var wg sync.WaitGroup

func worker(ctx context.Context) {
LOOP:
	for {
		fmt.Println("db connecting ...")
		time.Sleep(time.Millisecond * 10) // 假设正常连接数据库耗时10毫秒
		select {
		case <-ctx.Done(): // 50毫秒后自动调用
			break LOOP
		default:
		}
	}
	fmt.Println("worker done!")
	wg.Done()
}

func main() {
	// 设置一个50毫秒的超时
	ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50)
	wg.Add(1)
	go worker(ctx)
	time.Sleep(time.Second * 5)
	cancel() // 通知子goroutine结束
	wg.Wait()
	fmt.Println("over")
}

\

WithValue

WithValue函数能够将请求作用域的数据与 Context 对象建立关系。声明如下:

func WithValue(parent Context, key, val interface{}) Context

WithValue返回父节点的副本,其中与key关联的值为val。

仅对API和进程间传递请求域的数据使用上下文值,而不是使用它来传递可选参数给函数。

所提供的键必须是可比较的,并且不应该是string类型或任何其他内置类型,以避免使用上下文在包之间发生冲突。WithValue的用户应该为键定义自己的类型。为了避免在分配给interface{}时进行分配,上下文键通常具有具体类型struct{}。或者,导出的上下文关键变量的静态类型应该是指针或接口。

package main

import (
	"context"
	"fmt"
	"sync"

	"time"
)

// context.WithValue

type TraceCode string

var wg sync.WaitGroup

func worker(ctx context.Context) {
	key := TraceCode("TRACE_CODE")
	traceCode, ok := ctx.Value(key).(string) // 在子goroutine中获取trace code
	if !ok {
		fmt.Println("invalid trace code")
	}
LOOP:
	for {
		fmt.Printf("worker, trace code:%s\n", traceCode)
		time.Sleep(time.Millisecond * 10) // 假设正常连接数据库耗时10毫秒
		select {
		case <-ctx.Done(): // 50毫秒后自动调用
			break LOOP
		default:
		}
	}
	fmt.Println("worker done!")
	wg.Done()
}

func main() {
	// 设置一个50毫秒的超时
	ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50)
	// 在系统的入口中设置trace code传递给后续启动的goroutine实现日志数据聚合
	ctx = context.WithValue(ctx, TraceCode("TRACE_CODE"), "12512312234")
	wg.Add(1)
	go worker(ctx)
	time.Sleep(time.Second * 5)
	cancel() // 通知子goroutine结束
	wg.Wait()
	fmt.Println("over")
}

使用Context的注意事项

  • 推荐以参数的方式显示传递Context
  • 以Context作为参数的函数方法,应该把Context作为第一个参数。
  • 给一个函数方法传递Context的时候,不要传递nil,如果不知道传递什么,就使用context.TODO()
  • Context的Value相关方法应该传递请求域的必要数据,不应该用于传递可选参数
  • Context是线程安全的,可以放心的在多个goroutine中传递