关闭

golang channel的设计瑕疵

2060人阅读 评论(1) 收藏 举报
分类:

    首先说一下关于瑕疵并不是说golang 的channel的设计不好,它作为golang的线程间主要通信机制已经很不错了,但是我认为仍有一些地方设计的不够完美。

     

package main

import (
	"fmt"
	"time"
)

func dgucoproducer(c chan int, max int) {
	for i := 0; i < max; i++ {
		c <- i
	}
	//close(c)
}

func dgucoconsumer(c chan int) {
	ok := true
	value := 0
	for ok {
		fmt.Println("Wait receive")
		if value, ok = <-c; ok {
			fmt.Println(value)
		}
		if ok == false{
			fmt.Println("*******Break********")
		}
	}
}

func main() {
	c := make(chan int)
	defer close(c)
	go dgucoproducer(c, 10)
	go dgucoconsumer(c)
	time.Sleep(time.Second * 5)
	fmt.Println("Done")
}

    这样的用法应该是我们平时用的最多的,n个goroutine写channel另外有n个groutine读,当channel满了不能写的时候尝试写的groutine挂起,当channel空的时候尝试读的goroutine挂起。说白了就是经典的生产者消费者问题。那channel是怎么处理这个问题的?

   

type hchan struct {
	qcount   uint           //当前元素个数
	dataqsiz uint           //channel大小
	buf      unsafe.Pointer //元素指针数组
	elemsize uint16			//元素大小
	closed   uint32			
	elemtype *_type			//元素类型
	sendx    uint   		//send index
	recvx    uint   		//receive index
	recvq    waitq  		//等待接收的goroutine
	sendq    waitq 			//等待发送的goroutine
	lock mutex				//锁
}

   看channel的数据结构,一些基本的信息,数据类型,当前数据个数,channel大小,数据指针数组,读写索引,读写goroutine和读写锁,看到这里大概就猜到它的实现方式了,通过队列和一个锁来实现channel的多线程读写安全。

(盗用一下别人的图)

  

 一个新的channel(buff为channel的数据),初始状态读写索引都为0,队列为空,此时只能send不能read,当一个goroutine调用send向channel发送一个数据时,sendx加一,此时recvx小于sendx可读,读取一个数据时,recv也加一,其实就是一个基本的队列的操作,只是把我们熟悉的real和front指针换成了recvx和sendx。唯一不同的就是我们这里在读写前要用锁锁住队列保证它的线程安全。总结起来它的流程就是这样的,lock->recv->unlock,lock->send->unlock,当然这知识基本的流程,在操作过程中还有很多细节的东西,改变读写索引,数据拷贝,清除buff等等。

     关于锁的问题这才是我们今天要说的重点  首先锁是个非常浪费性能的问题,况且会随着锁的粒度增大变得越来越慢,也就是说的我们的channel的越大它的读和写也会越来越慢,因为锁的lock和unlock变慢了。

   首先我们分析下queue的缺点。如果有超过一个生产者想要往队列里放东西,尾指针就将成为一个冲突点,因为有多个线程要更新它。如果有多个消费者,那么头指针就会产生竞争,因为元素被消费之后,需要更新指针,所以不仅有读操作还有写操作了。还有一个问题就是当头指针和尾指针重合时我们不得不保存一个关于大小的变量,以便区分队列是空还是满。否则,它需要根据队列中的元素的内容来判断,这样的话,消费一个节点(Entry)后需要做一次写入来清除标记,或者标记节点已经被消费过了。无论采用何种方式实现,在头、尾和大小变量上总是会有很多竞争,或者如果消费操作移除元素时需要使用一个写操作,那元素本身也包含竞争。基于以上,这三个变量常常在一个cache line里面,有可能导致false sharing。因此,不仅要担心生产者和消费者同时写size变量(或者元素),还要注意由于头指针尾指针在同一位置,当头指针更新时,更新尾指针会导致缓存不命中。太多的竞争导致了channel即使在单生产者单消费者情况下也要通上锁。

    那么有没有一种不加锁呢,甚至在单一生产者消费者下完全无消耗的实现呢

   我们可以参考Disruptor的原理采用一个环形队列来替代,它的读和写策略万全不一样 。

  

      首先不要介意我粗糙的画风。加入我们申请10个大小的某个类型的channel,我们需要一个volatile 64位的全局的cusor来标记生产者生产好的最大索引,该变量会被多个线程频繁的读写防止false sharing产生最好通过cache补齐防止和其他变量在同一个cache line中,volatile保证线程间的可见性。整个写入的流程是:

     首先申请一个可写的位置,这里如果是单生产者不需要任何的同步操作,我们直接把cusor加一即可,如果是多生产者我们需要另外一个atmoic变量(就是一个cas操作)claimSequence来标记当前可写的位置,每次要写数据时先申请位置,加入当前的claimSequence为8,两个生产者都要申请写9,CAS(&claimSequence,8, 9)执行成功的写9,失败的申请下一个位置,当执行成功后对cusor执行CAS(&cusor,8,9)将cusor改为9写入完成,每写入一个值cusor就加一,然后通过取模运算得到具体在环形队列中的位置,供消费者读取。

    另外,为了防止生产者生产过快,在环形队列中覆盖消费者的数据,生产者要对消费者的消费情况进行跟踪,实现上就是去读取一下每个消费者当前的消费位置。例如一个环形队列的大小是10,有两个消费者的分别消费到第4号元素,那么生产者生产的新元素是不能超过13的,此时就可以挂起生产者goroutine了。这就需要每个消费者身上纪录一下自己的消费位置信息,这有两个个好处。

    1假设有以下场景一个数据需要通过消费者a->b->c,这样一个串行的消费过程,生产者p1生产事件写到ringbuffer中,消费者a需要根据队尾位置来进行判断是否有可消费事件即可,消费者b则需要根据消费者a的位置来判断是否有可消费事件,同理c需要根据b来判断。生产者需要跟踪c的位置,防止覆盖未消费数据,如果用队列我们就不得不为生产者->a创建一个channel,a->b创建一个channel,b->c创建一个,每个多一个过程就多一个channel,如果channel很大无故浪费很多的内存。当然这样也有不好的地方,就会增加生产到消费的时间消耗,因为整个流程变长了,但是如果不考虑这种情况它的效率决定比上面的实现快很多,最起码在单一生产者和消费者的时候要快一个数量级。

   2.现在的channel如果有一个生产者多个消费者,我们需要这样一个功能生产者生产的数据需要所有的消费者都得到,我门就不得不为生产者和每个消费者之间建立一个channel,然后把数据send到所有的channel中,只能说太蠢了,如果我门每个消费者记录了自己的消费位置,所有的消费就可以根据自己的信息在一个channel中获取数据了,而生产者只要跟踪最慢的消费者放置覆盖为消费数据了,当然他也有自己缺点。如果我门只有所有的消费者之一收到即可,我们只要向多个生产者那样加一个claimSequence来标记当前读的位置信息,每次申请消费就可以了。

    最后生产者如何指知道没有可读数据了,很简单当自己的位置信息等于cusor的时候表示没有数据可读此时挂起自己,当cusor大于自己的位置信息时唤醒自己。

     当然这种实现只是在对于channel的读取速度要求极为苛刻的条件下可以采用这种实现,当然他也有很多不好的地方,这只是我自己的意淫,官方并没有才用这种实现,我只是把我对于channel的思考写下来供大家来评判而已。



   

   

    

3
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场