go并发编程(一)

57 阅读12分钟

channel

通道是go提供给开发者用于goroutine之间通信的一种高级数据结构。通道有带缓冲的和不带缓冲的,我们可以利用通道实现不同goroutine之间的同步和互斥操作。但本质上,goroutine就是一个带锁的缓冲队列;当通道是不带缓冲区或者缓冲队列满了的话,执行发送操作的goroutine会阻塞在这个通道上,从通道接收数据也是类似的。我们先看下通道的结构:

type hchan struct {
	qcount   uint // 队列中元素个数
	dataqsiz uint // 循环队列的大小
	buf      unsafe.Pointer // 指向循环队列
	elemsize uint16 // 通道里面的元素大小
	closed   uint32 // 通道关闭的标志
	elemtype *_type // 通道元素的类型
	sendx    uint   // 待发送的索引,即循环队列中的队尾指针rear
	recvx    uint   // 待读取的索引,即循环队列中的队头指针front
	recvq    waitq  // 接收等待队列
	sendq    waitq  // 发送等待队列
	lock mutex // 互斥锁
}
  • 循环队列通过加入了qcount指示元素个数,既能快速获得队列中的元素个数,也避免了使用空余法判断是满的,可以充分利用空间;
  • 用两个索引指示队列的队头和队尾,可以快速存取元素;
  • 使用了互斥锁保护共享内存
  • closed字段记录了该通道是否已经关闭
  • 有两个等待队列,分别记录了阻塞在该通道上希望读取/发送数据的goroutine

image.png

创建通道

func makechan(t *chantype, size int) *hchan {
	elem := t.Elem

	// compiler checks this but be safe.
	if elem.Size_ >= 1<<16 {
		throw("makechan: invalid channel element type")
	}
	if hchanSize%maxAlign != 0 || elem.Align_ > maxAlign {
		throw("makechan: bad alignment")
	}

	mem, overflow := math.MulUintptr(elem.Size_, uintptr(size))
	if overflow || mem > maxAlloc-hchanSize || size < 0 {
		panic(plainError("makechan: size out of range"))
	}

	// Hchan does not contain pointers interesting for GC when elements stored in buf do not contain pointers.
	// buf points into the same allocation, elemtype is persistent.
	// SudoG's are referenced from their owning thread so they can't be collected.
	// TODO(dvyukov,rlh): Rethink when collector can move allocated objects.
	var c *hchan
	switch {
	case mem == 0:
		// Queue or element size is zero.
		c = (*hchan)(mallocgc(hchanSize, nil, true))
		// Race detector uses this location for synchronization.
		c.buf = c.raceaddr()
	case !elem.Pointers():
		// Elements do not contain pointers.
		// Allocate hchan and buf in one call.
           // 当通道中的数据不包含指针的时候,hchan和buf是通过一次内存申请的
           // 也就是说,它们是连续的
		c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
		c.buf = add(unsafe.Pointer(c), hchanSize)
	default:
		// Elements contain pointers.
                // 包含指针,则分别申请内存
		c = new(hchan)
		c.buf = mallocgc(mem, elem, true)
	}

	c.elemsize = uint16(elem.Size_)
	c.elemtype = elem
	c.dataqsiz = uint(size)
	lockInit(&c.lock, lockRankHchan)

	if debugChan {
		print("makechan: chan=", c, "; elemsize=", elem.Size_, "; dataqsiz=", size, "\n")
	}
	return c
}

发送数据

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
   // 如果通道为空且在阻塞模式下,则阻塞该goroutine,并报错
	if c == nil {
		if !block {
			return false
		}
		gopark(nil, nil, waitReasonChanSendNilChan, traceBlockForever, 2)
		throw("unreachable")
	}
        
        
   // 省略了一些中间代码
	
   // 在上锁前,先快速判断一次。如果非阻塞且通道未关闭且通道已经满了则返回
	if !block && c.closed == 0 && full(c) {
		return false
	}

	var t0 int64
	if blockprofilerate > 0 {
		t0 = cputicks()
	}
   // 上锁
	lock(&c.lock)
   // 如果通道已经关闭,则解锁并触发panic
	if c.closed != 0 {
		unlock(&c.lock)
		panic(plainError("send on closed channel"))
	}
   // 从阻塞在该通道上的接收队列上取下一个goroutine,然后发送数据,发送完解锁
	if sg := c.recvq.dequeue(); sg != nil {
		// Found a waiting receiver. We pass the value we want to send
		// directly to the receiver, bypassing the channel buffer (if any).
		send(c, sg, ep, func() { unlock(&c.lock) }, 3)
		return true
	}
   // 如果缓冲队列未满,则把数据加入缓冲队列中
	if c.qcount < c.dataqsiz {
		// Space is available in the channel buffer. Enqueue the element to send.
		qp := chanbuf(c, c.sendx)
		if raceenabled {
			racenotify(c, c.sendx, nil)
		}
		typedmemmove(c.elemtype, qp, ep)
           // 发送索引递增
		c.sendx++
           // 到达数组尾,则从头开始,并未使用除于的方法
		if c.sendx == c.dataqsiz {
			c.sendx = 0
		}
		c.qcount++
		unlock(&c.lock)
		return true
	}

	if !block {
		unlock(&c.lock)
		return false
	}
   // 阻塞在该通道上,省略该部分代码
}

接收数据

// chanrecv receives on channel c and writes the received data to ep.
// ep may be nil, in which case received data is ignored.
// If block == false and no elements are available, returns (false, false).
// Otherwise, if c is closed, zeros *ep and returns (true, false).
// Otherwise, fills in *ep with an element and returns (true, true).
// A non-nil ep must point to the heap or the caller's stack.
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
	// raceenabled: don't need to check ep, as it is always on the stack
	// or is new memory allocated by reflect.

	if debugChan {
		print("chanrecv: chan=", c, "\n")
	}

	if c == nil {
		if !block {
			return
		}
		gopark(nil, nil, waitReasonChanReceiveNilChan, traceBlockForever, 2)
		throw("unreachable")
	}

	if c.timer != nil {
		c.timer.maybeRunChan()
	}

	// Fast path: check for failed non-blocking operation without acquiring the lock.
	if !block && empty(c) {
		// After observing that the channel is not ready for receiving, we observe whether the
		// channel is closed.
		//
		// Reordering of these checks could lead to incorrect behavior when racing with a close.
		// For example, if the channel was open and not empty, was closed, and then drained,
		// reordered reads could incorrectly indicate "open and empty". To prevent reordering,
		// we use atomic loads for both checks, and rely on emptying and closing to happen in
		// separate critical sections under the same lock.  This assumption fails when closing
		// an unbuffered channel with a blocked send, but that is an error condition anyway.
		if atomic.Load(&c.closed) == 0 {
			// Because a channel cannot be reopened, the later observation of the channel
			// being not closed implies that it was also not closed at the moment of the
			// first observation. We behave as if we observed the channel at that moment
			// and report that the receive cannot proceed.
			return
		}
		// The channel is irreversibly closed. Re-check whether the channel has any pending data
		// to receive, which could have arrived between the empty and closed checks above.
		// Sequential consistency is also required here, when racing with such a send.
		if empty(c) {
			// The channel is irreversibly closed and empty.
			if raceenabled {
				raceacquire(c.raceaddr())
			}
			if ep != nil {
				typedmemclr(c.elemtype, ep)
			}
			return true, false
		}
	}

	var t0 int64
	if blockprofilerate > 0 {
		t0 = cputicks()
	}

	lock(&c.lock)

	if c.closed != 0 {
		if c.qcount == 0 {
			if raceenabled {
				raceacquire(c.raceaddr())
			}
			unlock(&c.lock)
			if ep != nil {
				typedmemclr(c.elemtype, ep)
			}
			return true, false
		}
		// The channel has been closed, but the channel's buffer have data.
	} else {
		// Just found waiting sender with not closed.
		if sg := c.sendq.dequeue(); sg != nil {
			// Found a waiting sender. If buffer is size 0, receive value
			// directly from sender. Otherwise, receive from head of queue
			// and add sender's value to the tail of the queue (both map to
			// the same buffer slot because the queue is full).
			recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
			return true, true
		}
	}

	if c.qcount > 0 {
		// Receive directly from queue
		qp := chanbuf(c, c.recvx)
		if raceenabled {
			racenotify(c, c.recvx, nil)
		}
		if ep != nil {
			typedmemmove(c.elemtype, ep, qp)
		}
		typedmemclr(c.elemtype, qp)
		c.recvx++
		if c.recvx == c.dataqsiz {
			c.recvx = 0
		}
		c.qcount--
		unlock(&c.lock)
		return true, true
	}

	if !block {
		unlock(&c.lock)
		return false, false
	}

	// no sender available: block on this channel.
	gp := getg()
	mysg := acquireSudog()
	mysg.releasetime = 0
	if t0 != 0 {
		mysg.releasetime = -1
	}
	// No stack splits between assigning elem and enqueuing mysg
	// on gp.waiting where copystack can find it.
	mysg.elem = ep
	mysg.waitlink = nil
	gp.waiting = mysg

	mysg.g = gp
	mysg.isSelect = false
	mysg.c = c
	gp.param = nil
	c.recvq.enqueue(mysg)
	if c.timer != nil {
		blockTimerChan(c)
	}

	// Signal to anyone trying to shrink our stack that we're about
	// to park on a channel. The window between when this G's status
	// changes and when we set gp.activeStackChans is not safe for
	// stack shrinking.
	gp.parkingOnChan.Store(true)
	gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceBlockChanRecv, 2)

	// someone woke us up
	if mysg != gp.waiting {
		throw("G waiting list is corrupted")
	}
	if c.timer != nil {
		unblockTimerChan(c)
	}
	gp.waiting = nil
	gp.activeStackChans = false
	if mysg.releasetime > 0 {
		blockevent(mysg.releasetime-t0, 2)
	}
	success := mysg.success
	gp.param = nil
	mysg.c = nil
	releaseSudog(mysg)
	return true, success
}
  1. 元素不包含指针

    • 如果通道中的元素类型不包含指针(例如基本类型 intfloat 等),那么 hchan 和 buf 可以一次性分配内存。这是因为这些元素不会被垃圾回收器扫描,整个内存块可以被简单地分配和管理。
    • 优点:效率高,减少了内存分配的次数和内存碎片。
  2. 元素包含指针

    • 如果通道中的元素类型包含指针(例如指向结构体的指针,或者包含指针的结构体),那么 hchan 和 buf 需要分别分配内存。这是因为垃圾回收器需要能够单独识别和扫描包含指针的内存区域,以便正确管理和回收内存。
    • 优点:确保垃圾回收器能够正确工作,避免内存泄漏。

context

type Context interface {
    //返回绑定该context的超时时间
    Deadline() (deadline time.Time, ok bool)
    //返回一个只读通道
    Done() <-chan struct{}
    Err() error
    //返回存储在context中的k-v数据
    Value(key interface{}) interface{}
}

context的概念刚开始接触的时候我觉得很奇怪。为什么要在编程语言里加入一个上下文的概念。随着学习的深入,context的提出其实是为了解决两大问题。

  1. 不同请求之间的参数传递,尤其是对一些非控制性的信息,例如请求id、日志id等
  2. 对于子goroutine生命周期的控制

我来尽可能解释一下它的概念。在软件工程中,我们希望尽量避免全局变量,这会引发竞争和代码的耦合,所以提出了线程范围内的全局变量。同时除了极其简单的函数,大部分函数都是需要依赖一些外部变量的。换句话说:函数也是有上下文的。而context就可以通过携带值来完成这一点。第二个就是goroutine是用户态线程,我们使用goroutine并没有相应的协程ID,我们也很难控制协程的生命周期。这时带有超时取消功能的context就显得很方便了。

我们先回顾一下对于goroutine的控制:

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)

    //在没有添加default的情况下,整个goroutine会阻塞在这里
    //直到一个通道获得数据或者发生超时
    go func() {
       select {
       case <-ch1:
          fmt.Println("ch1")
       case <-ch2:
          fmt.Println("ch2")
       case <-time.After(5 * time.Second):
          fmt.Println("timeout")
       }
    }()

    time.Sleep(2 * time.Second)
    ch1 <- 1
    time.Sleep(2 * time.Second)
}

我们可以看到:这其实是通过chan来完成对goroutine的一种控制,借鉴这种思路,我们当然也可以通过给子groutine传递chan来完成对子协程的控制。但我们并没法让子goroutine取消。这时候带有取消功能的context的就派上用场。

package main

import (
    "fmt"
    "golang.org/x/net/context"
    "time"
)

func doSomething(ctx context.Context, ch chan int) error {
    for {
       // 模拟处理业务逻辑
       time.Sleep(1 * time.Second)
       // 业务的返回值
       ret := 1
       select {
       case <-ctx.Done():
          return ctx.Err()
       case ch <- ret:
          return nil
       }
    }
}

func main() {
    ctx := context.Background()
    ch := make(chan int)
    go doSomething(ctx, ch)
    fmt.Println(<-ch)
}

atomic

该包主要提供一些原子操作。它分为以下两大类别。

1. 原子类型的变量

  • 布尔类型atomic.Bool
  • 有符号整数atomic.Int32、atomic.Int64
  • 无符号整数atomic.Uint32、atomic.Uint64
  • 两种指针类型atomic.Uintptr、atomic.Pointer
  • 通用类型atomic.Value

每种类型都提供了以下四种方法:

  • Load()、Store():原子的存取变量
  • CompareAndSwap()和Swap():CAS可以实现自旋锁,另一个则实现了原子性的交换操作

对于可计算的类型,还提供了Add()方法用于原子的加减。

2. 原子操作的函数

这些原子操作的函数和上面的方法类似,只不过它不局限于原子类型的变量。而是任意的类型。

3. 应用

我们可以利用CAS实现乐观锁。首先CAS方法的原理如下:

if *addr == old {
	*addr = new
	return true
}
return false

只有当该变量为预期的旧值的时候才会修改它,并返回TRUE。实际上,CPU中是利用了LOCK指令来实现原子操作的。在多处理器环境中,LOCK指令可以确保在执行LOCK随后的指令的时候,处理器拥有对数据的独占使用。通过锁总线或缓存的方式来实现原子性。 我们简单的实现一个自旋锁:

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

// 类型别名,定义一个自旋锁类型
// 注意:别名的类型无法调用原类型的方法!!!
type spin int32

// 上锁失败则一直自旋
func (s *spin) Lock() {
    for !atomic.CompareAndSwapInt32((*int32)(s), 0, 1) {
    }
}

// 释放锁
func (s *spin) Unlock() {
    atomic.StoreInt32((*int32)(s), 0)
}

func main() {
    // 锁必须正确初始化
    s := spin(0)
    cnt := 0

    var wg sync.WaitGroup
    wg.Add(2)

    go func() {
       for i := 0; i < 1000000; i++ {
          s.Lock()
          cnt++
          s.Unlock()
       }
       wg.Done()
    }()

    go func() {
       for i := 0; i < 1000000; i++ {
          s.Lock()
          cnt--
          s.Unlock()
       }
       wg.Done()
    }()

    wg.Wait()
    fmt.Println(cnt)
}

sync包

sync.Cond和sync.Mutex

sync.Mutex就是普通的互斥锁,需要注意的是它并不是可重入的,对一个已经加锁的互斥锁再次加锁会导致死锁,需要注意。

sync.Cond是条件变量,它更多的用来协调多个线程之间对共享资源的访问。需要注意的是,条件变量也是在互斥锁的基础上实现的。当条件不符合时,该线程会阻塞并等待其它线程的唤醒。我以一个常见的题目为例。两个线程交替打印A和B

package main

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

var mutex sync.Mutex
// 创建sync.Cond的实例
var cond = sync.NewCond(&mutex)
var t = 0

func printA() {
	for i := 0; i < 10; i++ {
		cond.L.Lock()
		for {
			if t != 0 {
				cond.Wait()
			} else {
				break
			}
		}
		fmt.Print("A")
		t = 1
		cond.L.Unlock()
          // 这里和c++有所区别哈
		cond.Broadcast()
	}
}

func printB() {
	for i := 0; i < 10; i++ {
		cond.L.Lock()
		for {
			if t != 1 {
				cond.Wait()
			} else {
				break
			}
		}
		fmt.Print("B")
		t = 0
		cond.L.Unlock()
		cond.Broadcast()
	}
}

func main() {
	go printA()
	go printB()
	time.Sleep(5 * time.Second)
}

不过对于go语言,更建议使用channel来通信,我们使用channel来同样实现它:

package main

import (
    "fmt"
    "sync"
)

func main() {
    chA := make(chan struct{})
    chB := make(chan struct{})
    var wg sync.WaitGroup
    wg.Add(2)

    go func() {
       for i := 0; i < 10; i++ {
          fmt.Print("A")
          chB <- struct{}{}
          <-chA
       }
       wg.Done()
    }()

    go func() {
       for i := 0; i < 10; i++ {
          <-chB
          fmt.Print("B")
          chA <- struct{}{}
       }
       wg.Done()
    }()

    wg.Wait()
}

sync.WaitGroup

该工具主要用来协调多个协程之间的先后顺序。最常见的用法就是我们在主协程开启多个协程后,使用WaitGroup来确保主协程不会在子协程前结束。需要注意的是,wg.Add()不要再新开启的协程里执行。

sync.Once

sync.Once主要是用来做一些一次性操作。例如配置的加载或者单例模式等。它通过一个标志位来记录操作是否已经完成和互斥锁来确保操作的原子性。

type Once struct {
	done uint32 // 用来标志操作是否操作
	m    Mutex // 锁,用来第一操作时候,加锁处理
}


func (o *Once) Do(f func()) {
	if atomic.LoadUint32(&o.done) == 0 {// 原子性加载o.done,若值为1,说明已完成操作,若为0,说明未完成操作
		o.doSlow(f)
	}
}

func (o *Once) doSlow(f func()) {
	o.m.Lock() // 加锁
	defer o.m.Unlock()
	if o.done == 0 { // 再次进行o.done是否等于0判断,因为存在并发调用doSlow的情况
		defer atomic.StoreUint32(&o.done, 1) // 将o.done值设置为1,用来标志操作完成
		f() // 执行操作
	}
}

这种设计非常巧妙,如果是我写的话可能上来就直接加锁,然后根据标志位判断是否需要执行函数。为了避免加锁的操作。

  1. 先通过原子操作判断标志位是否为0
  2. 若标志为0,再去申请加锁,同时再次判断标志位是否为0,避免重复操作,然后执行函数

需要注意的是:如果我们传入的函数f执行的时候发生了panic等问题,由于defer的存在,此时defer依然会被置为1,导致初始化失败。所以我们传入的函数要确保可以操作成功