go concurrent programming Channels

Keywords: Go Back-end

preface

Channels is a type safe message queue. It acts as a pipeline between two goroutines, through which any resources will be exchanged synchronously. chan's ability to control goroutines interaction creates a Go synchronization mechanism. When the created chan has no capacity, it is called an unbuffered channel. Conversely, a chan created using capacity is called a buffered channel. To understand the synchronization behavior of goroutine interacting through chan, we need to know the type and state of the channel. The scenarios will vary depending on whether we use unbuffered or buffered channels, so let's discuss each scenario separately.

Use skills

Channel read / write feature (15 word formula)

First, let's review the features of Channel?

  • Send data to a nil channel, causing permanent blocking

  • Receiving data from a nil channel causes permanent blocking

  • Send data to a closed channel, causing panic

  • Receive data from a closed channel. If the buffer is empty, a zero value is returned

  • Unbuffered channels are synchronous, while buffered channels are asynchronous

The above five features are dead things, which can also be memorized through the formula: "empty read-write blocking, write off exception, read off empty zero".

Use of channel

Unbuffered Channels -- Unbuffered Channels

Unbuffered chan has no capacity, so two goroutines need to be prepared at the same time before any exchange. When goroutine attempts to send a resource to an unbuffered channel and no goroutine is waiting to receive the resource, the channel will lock the sending goroutine and make it wait. When goroutine attempts to receive from an unbuffered channel and no goroutine is waiting to send resources, the channel will lock the receiving goroutine and make it wait.

  Synchronization is inherent in the interaction between sending and receiving. It can't happen without another. The essence of unbuffered channel is to ensure synchronization  

example

	ch := make(chan int)
	var wg  sync.WaitGroup

	wg.Add(2)
	go func() {
		defer wg.Done()
		ch <- 1
	}()
	go func() {
		defer wg.Done()
		id := <-ch
		fmt.Println(id)
	}()

	wg.Wait()

g2 must wait for the data sent by g1 to chen to be synchronized

A goroutine is blocked after sending the message foo because no receiver is ready. This behavior is well explained in the specification:

https://golang.org/ref/spec#Channel_types

If the capacity is zero or absent, the channel is unbuffered and communication succeeds only when both a sender and receiver are ready.

If the capacity is zero or does not exist, the channel is not buffered, and the communication can succeed only when both the sender and the receiver are ready.

https://golang.org/doc/effective_go.html#channels

If the channel is unbuffered, the sender blocks until the receiver has received the value

If the channel is not buffered, the sender will block until the receiver receives the value

Receive occurs before Send.

Benefits: 100% guaranteed.

Cost: unknown delay time.

Buffered Channels

Buffer channels have capacity, so they can behave a little differently. When goroutine attempts to send resources to the buffer channel and the channel is full, the channel will lock goroutine and wait until the buffer becomes available. If there is space in the channel, the transmission can be carried out immediately, and the channel can continue to move forward. When goroutine attempts to receive from the buffer channel, and the buffer channel is empty, the channel will lock goroutine and wait until the resource is sent

ch := make(chan int,2)
var wg  sync.WaitGroup

wg.Add(2)
go func() {
   defer wg.Done()
   ch <- 1
   ch <- 2
}()
go func() {
   defer wg.Done()
   id := <-ch  
   id1 := <-ch
   fmt.Println(id,id1)
}()

wg.Wait()

Delay due to insufficient buffer size

No, the bigger the cache, the better. See the following example

//give up
func main()  {
	const cap = 5
	ch := make(chan int,cap)
	go func() {
		for p := range ch{
			fmt.Println("Received:",p)
		}
	}()

	const work = 20
	for i := 0; i < work; i++ {
		select {
		case ch <- i:
			fmt.Println("afferent--",i)
		default:
			fmt.Println("drep")
		}
	}

}

If the cache is full, the data written in the next time will be lost, causing an impact

Let's look at the following example. What's the short processing time?

 

package bench

import (
   "sync"
   "sync/atomic"
   "testing"
)

func BenchmarkWithNoBuffer(b *testing.B) {
   benchmarkWithBuffer(b, 0)
}

func BenchmarkWithBufferSizeOf1(b *testing.B) {
   benchmarkWithBuffer(b, 1)
}

func BenchmarkWithBufferSizeEqualsToNumberOfWorker(b *testing.B) {
   benchmarkWithBuffer(b, 5)
}

func BenchmarkWithBufferSizeExceedsNumberOfWorker(b *testing.B) {
   benchmarkWithBuffer(b, 25)
}

func benchmarkWithBuffer(b *testing.B, size int) {
   for i := 0; i < b.N; i++ {
      c := make(chan uint32, size)

      var wg sync.WaitGroup
      wg.Add(1)

      go func() {
         defer wg.Done()

         for i := uint32(0); i < 1000; i++ {
            c <- i%2
         }
         close(c)
      }()

      var total uint32
      for w := 0; w < 5; w++ {
         wg.Add(1)
         go func() {
            defer wg.Done()

            for {
               v, ok := <-c
               if !ok {
                  break
               }
               atomic.AddUint32(&total, v)
            }
         }()
      }

      wg.Wait()
   }
}

goos: windows
goarch: amd64
pkg: 03-go-concurrency/sync_test/07
BenchmarkWithNoBuffer
BenchmarkWithNoBuffer-6                                     4297            3527
02 ns/op
BenchmarkWithBufferSizeOf1
BenchmarkWithBufferSizeOf1-6                                4297            2780
72 ns/op
BenchmarkWithBufferSizeEqualsToNumberOfWorker
BenchmarkWithBufferSizeEqualsToNumberOfWorker-6             6015            2104
72 ns/op
BenchmarkWithBufferSizeExceedsNumberOfWorker
BenchmarkWithBufferSizeExceedsNumberOfWorker-6              8594            1412
05 ns/op
PASS

A properly sized buffer can really make your application faster! Let's analyze the trace of the benchmark to determine the location of the delay.

Send occurs before Receive.

Benefit: less latency.

Cost: data arrival is not guaranteed. The larger the buffer, the smaller the guaranteed arrival. When buffer = 1, you are guaranteed to delay a message.

Three examples

Timing out

timeout := make(chan bool, 1)
ch := make(chan int, 1)
go func() {
    time.Sleep(1 * time.Second)
    timeout <- true
}()
select {
case <-ch:
    // a read from ch has occurred
case <-timeout:
    // the read from ch has timed out
}

A good example is timeout. Although the Go channel does not directly support them, they are easy to implement. Say we want to receive ch from the channel, but it will take up to one second to get the value. We will first create a signaling channel and start a channel that is asleep before sending a signal through the channel

Then, we can use the select statement to receive from CH or timeout. If nothing reaches the ch after one second, select the timeout case and abandon the attempt to read from the ch.

The timeout channel is buffered with a space of 1, allowing the timeout channel to be sent to the channel and then exit. Goroutine does not know (or care) whether it receives a value. This means that goroutine will not stay here forever if ch reception occurs before timeout. The timeout channel will eventually be released by the garbage collector.

woker worker model

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

//Interface
type PoolWorker interface {
	 DoWork(WorkRoutine int)
}

//poolWork is passed to the queue of work to be performed.
type Poolwork struct {
	work          PoolWorker //Tasks to be performed
	resultChannel chan error //Task return information
}


var ErrCapacity =  errors.New("Thread Pool At Capacity")

type WorkPool struct {
	shutdownQueueChannel chan string     // Close the channel of the queue
	shutdownWorkChannel  chan struct{}   // Close the working channel
	shutdownWaitGroup    sync.WaitGroup  //Used to close an existing work
	queueChannel         chan Poolwork   // The channel used to synchronize access to the queue.
	workChannel          chan PoolWorker //Channels for working
	queuedWorkNum        int32           // Number of jobs queued
	activeRoutinesNum    int32           //Number of active work
	queueCapacityNum     int32           // The maximum number of items that can be stored in the queue.
}

func New(WorkRoutinesNum int,queueCapacityNum int32)  *WorkPool {
	workPool := WorkPool{
		shutdownQueueChannel: make(chan string),
		shutdownWorkChannel: make(chan struct{}),
		queueChannel: make(chan Poolwork),
		workChannel: make(chan PoolWorker),
		queuedWorkNum: 0,
		activeRoutinesNum: 0,
		queueCapacityNum: queueCapacityNum,
	}

	//Open group
	workPool.shutdownWaitGroup.Add(WorkRoutinesNum)

	//Start work
	for i := 0; i < WorkRoutinesNum; i++ {
		go workPool.workRoutine(i)
	}

	//Start queue
	go workPool.queueRoutine()

	return &workPool

}

//stop it
func (this *WorkPool) Shutdown() (err error) {
    //Capture error
	defer func() {
		if r := recover(); r != nil {
			buf := make([]byte, 10000)
			runtime.Stack(buf, false)
			fmt.Println(string(buf))
			err = fmt.Errorf("%v", r)
		}
	}()
	fmt.Println("Shutdown : Queue Routine")
	
    //Notify the process accepting the task to stop
	this.shutdownQueueChannel <- "down"
    //After the task acceptance process returns
	<-this.shutdownQueueChannel
    //Close the shutdown queuechannel
	close(this.shutdownQueueChannel)
    //accept an assignment
	close(this.queueChannel)
	//Close working Routine
	close(this.shutdownWorkChannel)
    //Waiting for work process reduction
	this.shutdownWaitGroup.Wait()
    //Close working chen
	close(this.workChannel)
	return  err
}

//work
func (this *WorkPool) workRoutine(workRoutineId int)  {

	for  {
		select {
		case <-this.shutdownWorkChannel:  //Accept close
			fmt.Println("workRoutine : ",workRoutineId,"Ending")
			this.shutdownWaitGroup.Done() //Reduce synergy
			return
		case item := <- this.workChannel: //accept an assignment
			this.doWork(item,workRoutineId) 
			break
		}
	}

}

//Perform tasks
func (this *WorkPool) doWork(work PoolWorker,workRoutineId int)  {
	defer fmt.Println("Execution complete")
	defer atomic.AddInt32(&this.activeRoutinesNum,-1)  //Reduction of work coordination
	atomic.AddInt32(&this.activeRoutinesNum,1)   //Increase of work coordination process
	atomic.AddInt32(&this.queuedWorkNum,-1)      //Waiting task reduction
	work.DoWork(workRoutineId)
}

//Add task
func (this *WorkPool) PostWork (goRoutine string,worker PoolWorker) (err error) {
	defer fmt.Println("goRoutine err: ",err)
	poolwork := Poolwork{work: worker,resultChannel: make(chan error)}
	this.queueChannel <- poolwork //Add task to task chen
	err = <- poolwork.resultChannel  //Synchronization wait error
	close(poolwork.resultChannel)  //Close result chen
	return err //Return error
}


func (this *WorkPool) queueRoutine()  {
	for  {
		select {
		case <-this.shutdownQueueChannel:  //Accept task chen termination signal
			fmt.Println("Terminate accepting tasks")    
			this.shutdownQueueChannel <- "down"  //Reply received termination signal
			return
		case queueItem := <- this.queueChannel:   //Accept tasks synchronously

            //Whether the maximum number of items that can be stored in the processing task queue has been reached.
			if atomic.AddInt32(&this.queuedWorkNum,0) == this.queueCapacityNum{
					queueItem.resultChannel <- ErrCapacity
					break
			}
			atomic.AddInt32(&this.queuedWorkNum,1) //Increase the maximum number of items stored
			this.workChannel <- queueItem.work  //Deliver to work chen
			queueItem.resultChannel <- nil  // Return nil
			break
		}
	}

}

The following is the logic of the call. Please note that the workflow should not be opened too much

type MyTask struct {
	Name string
	OrderId int
	WP *workpool.WorkPool
}

func (mt *MyTask) DoWork(workRoutine int) {
	fmt.Println(mt.Name)

	fmt.Printf("*******> WR: %d QW : %d AR: %d\n",
		workRoutine,
		1,
		2)
	fmt.Println(mt.OrderId)
	time.Sleep(100 * time.Millisecond)
}

func main()  {
	runtime.GOMAXPROCS(runtime .NumCPU())

	workPool := workpool.New(runtime.NumCPU() * 3, 100)

	task := MyTask{
		Name: "A" + strconv.Itoa(1),
		OrderId : 1,
		WP: workPool,
	}
	err := workPool.PostWork("main",&task)


	workPool.Shutdown()
	fmt.Println(err)
}

summary

When using channels (or concurrency), it is important to understand and understand the signaling attributes surrounding guarantees, channel states, and transmissions. They will help guide you through the best behavior required for the concurrent programs and algorithms you are writing. They will help you find errors and sniff out potential error codes.

stages close their outbound channels when all the send operations are done.

When all sending operations are completed, stages closes their outbound channels.

stages keep receiving values from inbound channels until those channels are closed or the senders are unblocked.

The phase keeps receiving values from inbound channels until they are closed or unblocked by the sender.

Pipelines unblock senders either by ensuring there's enough buffer for all the values that are sent or by explicitly signalling senders when the receiver may abandon the channel.

The pipeline can unblock the sender by ensuring that there is sufficient buffer to accommodate all transmitted values, or by explicitly sending the sender's signal when the receiver may abandon the channel

  • Use channels to orchestrate and coordinate goroutine.
    • Focus on channel properties rather than data sharing.
    • Signaling with or without data.
    • Question their use for synchronous access to shared state.
      • In some cases, the channel can be simpler, but it is initially questionable.
  • Unbuffered channel:
    • Reception occurs before transmission.
    • Advantages: 100% guarantee that the signal has been received.
    • Cost: unknown delay when receiving signal.
  • Buffer channel:
    • Sending occurs before receiving.
    • Benefits: reduce blocking delay between signals.
    • Cost: there is no guarantee when the signal will be received.
      • The larger the buffer, the less guaranteed.
      • A buffer of 1 can provide you with a guarantee of delayed sending.

Design concept

  • If any given Send on the channel can cause the Send goroutine to block:
    • Buffered channels greater than 1 are not allowed.
      • Buffers greater than 1 must have a reason / measure.
    • You must know what happens when sending goroutine blocking.
  • If any given Send on the channel does not cause the Send goroutine to block:
    • You have the exact number of buffers per send.
      • Fan out mode
    • You measured the maximum size of the buffer.
      • Drop pattern
  • Fewer buffers means more.
    • Do not consider performance when considering buffers.
    • Buffers can help reduce blocking delays between signals.
      • Reducing blocking latency to zero does not necessarily mean better throughput.
      • If a buffer provides you with good enough throughput, keep it.
      • The problem buffer is greater than 1 and the size is measured.
      • Find the smallest buffer that may provide good enough throughput.

literature

https://medium.com/a-journey-with-go/go-buffered-and-unbuffered-channels-29a107c00268

https://www.ardanlabs.com/blog/2014/02/the-nature-of-channels-in-go.htm

https://www.ardanlabs.com/blog/2013/10/my-channel-select-bug.html

https://blog.golang.org/concurrency-timeouts

https://blog.golang.org/pipelines

https://talks.golang.org/2013/advconc.slide#1

https://github.com/go-kratos/kratos/tree/master/pkg/sync

Posted by Sneef on Sat, 06 Nov 2021 01:24:57 -0700