Goroutine+Channel+WaitGroup使用

Go语言中的并发程序可以用两种手段来实现,第一种是传统的并发模型,多线程共享内存,第二种则是现代的并发模型,顺序通信进程(CSP),Go语言使用goroutine和channel来支持顺序通信进程。

一、goroutine

golang语言中最有特色之一的东东就是这个goroutine了,很多时候问起别人为什么golang的好用,golang的网络性能可以那么好,一般都会多多少少想到goroutine,提起goroutine。在linux中内核的调度最小单位是就是thread,同一个进程中的多个thread线程就对应内核中的多个thread实体。所以thread是内核级的,而gorountine是一个不同于thread的概念,gorountine是一个用户态,另外一种说法也就携程,是用户态的一种调度粒度,每个gorountine也有自己的栈空间,而且是在用户内存中的。golang中实现了对用户态的一种代码片段的高效调度执行,就目前来看是非常有效的,而且给用户编程带来了极大的方便。

  1. 在Go语言中,每一个并发的执行单元叫作一个goroutine。
  2. main goroutine:当一个程序启动时,其主函数即在一个单独的goroutine中运行,称为main goroutine。
  3. goroutine创建:新的goroutine使用go语句来创建。在语法上,go语句是一个普通的函数或方法调用前加上关键字go。go语句会使其语句中的函数在一个新创建的goroutine中运行,而go语句本身会迅速地完成。
  4. 主函数返回时,所有的goroutine都会被直接打断,程序退出。

二、channel

channel是golang中另外一个最有特色的东东,在c中我们都是用管道,文件,信号等作为线程间通信的手段,但是在golang中几乎都是清一色的使用channel这个东东。channel,即“管道”,是用来传递数据的一个类型,即可以向channel里放入数据,也可以从中获取数据。在golang也有其它的方式作为线程间或者说gorountine之间进行通信,但是golang的编程指导中强烈建议任何地方需要通信都要使用channel,所以channel加上goroutine,就可以组合成一种简单而又强大的处理模型,即N个工作goroutine将处理的中间结果或者最终结果放入一个channel,另外有M个工作goroutine从这个channel拿数据,再进行进一步加工,通过组合这种过程,从而胜任各种复杂的业务。

1. channel创建

channel是引用类型变量。

2. channel比较

两个相同类型的channel可以使用==运算符比较。如果两个channel引用的是相同的对象,那么比较的结果为真。一个channel也可以和nil进行比较

3. channel数据发送与接收

一个不使用接收结果的接收操作也是合法的。

4. channel关闭

基于已经关闭的channel的任何发送操作都将导致panic异常。
基于已经关闭的channel执行接收操作依然可以接收到之前已经发送成功的数据,如果channel中已经没有数据的话,后续接收操作将不再阻塞,而是立即返回一个零值。

5. 不带缓存的channel

一个基于无缓存channel的发送操作将导致发送者goroutine阻塞,直到另一个goroutine在相同的channel上执行接收操作,当发送的值通过channel成功传输以后,两个goroutine才能继续执行后面的语句。反之,如果接收操作先发生,那么接收者goroutine也将阻塞,直至有另一个goroutine在相同的channel上执行发送操作。
基于无缓存channel的发送和接收操作将导致两个goroutine做一次同步操作,因为这个原因,无缓存channel有时也被称为同步channel。
注:
有些消息事件并不携带额外的信息,它们仅仅是用作两个goroutine之间的同步,这时候可以使用struct{}空结构体作为Channels元素的类型。

6. 串联的channels(pipeline)

channels也可以用于将多个goroutine链接在一起,一个channel的输出作为下一个channel的输入。这种串联的channels就是所谓的管道(pipeline)。

7. 单方向的channel

chan<- int:只发送int的channel,只能发送不能接收
<-chan int:只接收int的channel,只能接收不能发送
这种限制将在编译期检测。
注:
因为关闭操作只用于断言不再向channel发送新的数据,所以只有在发送者所在的goroutine才会调用close函数,因此对一个只接收的channel调用close将是一个编译错误。
任何双向channel向单向channel变量的赋值都将导致隐式转换,从双向channel转换为单向channel。不存在反向转换的语法。

8. 带缓存的channel

带缓存的channel内部持有一个元素队列。

channel内部缓存的容量:cap(ch)
channel内部缓存的长度:len(ch)

基于带缓存channel的发送操作就是向其内部缓存队列的尾部插入元素,接收操作则是从队列的头部删除元素。如果内部缓存队列是满的,那么发送操作将阻塞直到另一个goroutine执行接收操作而释放了新的队列空间。相反,如果channel是空的,接收操作将阻塞直到有另一个goroutine执行发送操作而向队列插入元素。
channel的缓存队列解耦了接收和发送的goroutine。WaitGroup

go提供了sync包和channel来解决协程同步和通讯。sync.WaitGroup是等待一组协程结束。它实现了一个类似任务队列的结构,你可以向队列中加入任务,任务完成后就把任务从队列中移除,如果队列中的任务没有全部完成,队列就会触发阻塞以阻止程序继续运行。当需要阻塞当前执行线程,等待一组goroutine执行完毕之后再继续执行当前线程时,就需要用到WaitGroup。
sync.WaitGroup只有3个方法,Add(),Done(),Wait()。 其中Done()是Add(-1)的别名。简单的来说,Add就是添加或者减少等待goroutine的数量;Done:相当于Add(-1)使用Add()添加计数,Done()减掉一个计数; Wait:执行阻塞,直到所有的WaitGroup数量变成0。

9、用法介绍

1、最常用的方式:作为信号传递

下面的示例中,在子goroutine中进行一个需要一段时间的操作,主goroutine可以做一些别的事情,然后等待子goroutine完成。接收方会一直阻塞直到有数据到来。如果channel是无缓冲的,发送方会一直阻塞直到接收方将数据取出。如果channel带有缓冲区,发送方会一直阻塞直到数据被拷贝到缓冲区;如果缓冲区已满,则发送方只能在接收方取走数据后才能从阻塞状态恢复。

c := make(chan int)  // Allocate a channel.    
// 启动一个goroutine,来做一些事情,事情做完后发送消息到channel
go func() {    
   doSomethingForAWhile1()    
    c <- 1  // 事情做完了,发送一个消息,这里说是消息其实就是一个值,这个值不重要,下面也不会使用,关键就是有这样一个操作   
}()    
doSomethingForAWhile2()    
<-c   // 这里会一直阻塞,直到上面有消息发送进去,这里就是等待排序完成,多goroutine协作的时候可以用

2、生产真消费者模式:作为消息队列

这是一种经典用法,一个或者多个生产者,消费者也有可能是一个或者多个,在c++中的经典解法就是使用阻塞队列,生产者直接往队列中发送数据,消费者从队列中消费数据,在生产者遇到队列满就阻塞,消费者在遇到队列空的时候也阻塞。在golang中的解决方案就是使用channel:将请求都转发给一个channel,然后初始化多个goroutine读取这个channel中的内容,并进行处理。简单的代码如下

// 建立一个全局的channel
var task_channel = make(chan net.Conn)

然后,启动多个goroutine进行消费

for i := 0; i < 5; i ++ {
    go func() {
        for {
            select {
            case task := <- task_channel:
                process(task)
            }
        }
    } ()
}

服务端接收到请求之后,将任务传入channel中即可:

for i := 0; i < 5; i++ {
		go func() {
			for {
				select {
					case conn := <- task_channel:
						handleConn(conn)
				}
			}
		}
	}
}
for {
		conn, err := listener.Accept()
		if err != nil {
			continue
		}
		count += 1
		task_chanel <- conn
	}
}

3、处理channel满的情况

不过,上面方案也有一个问题:就是channel初始化时是没有设置长度,而channel的默认长度是0,即只能写入一个元素,再写入就会被阻塞,因此当所有处理请求的goroutine都正在处理请求时,再有请求过来的话,就会被block。因此,需要在channel初始化时增加一个长度:

var task_channel = make(chan net.Conn,task_channel_len)

这样一来,我们将task_channel_len设置得足够大,请求就可以同时接收task_channel_len个请求而不用担心被block。不过,这其实还是有问题的:那如果真的同时有大于task_channel_len个请求过来呢?一方面,这就应该算是架构方面的问题了,可以通过对模块进行扩容等操作进行解决。另一方面,模块本身也要考虑如何进行“优雅降级了”。遇到这种情况,我们应该希望模块能够及时告知调用方,“我已经达到处理极限了,无法给你处理请求了”。其实,这种问题,可以很简单的在Golang中实现:如果channel发送以及接收操作在select语句中执行并且发生阻塞,default语句就会立即执行。

select {
case task_channel <- task:
    //do something
default:
    //warnning!
    return fmt.Errorf("task_channel is full!")
}

4、channel的超时处理

即使是复杂、耗时的任务,也必须设置超时时间。一方面可能是业务对此有时限要求(用户必须在XX分钟内看到结果),另一方面模块本身也不能都消耗在一直无法结束的任务上,使得其他请求无法得到正常处理。因此,也需要对处理流程增加超时机制。
我一般设置超时的方案是:和之前提到的“接收发送给channel之后返回的结果”结合起来,在等待返回channel的外层添加select,并在其中通过time.After()来判断超时。

select {
	case conn := <- task_channel:
		handleConn(conn)
	case <- time.After(time.Second * 3):
    //处理超时
}

5、传递channel的channel

channel作为go语言的一种原生类型,自然可以通过channel进行传递。通过channel传递channel,可以非常简单优美的解决一些实际中的问题。我们可以在主gorountine中通过channel将请求传递给工作goroutine。同样,我们也可以通过channel将处理结果返回给主goroutine。
主goroutine:

type Request struct {
    args        []int
    resultChan  chan int
}
request := &Request{[]int{3, 4, 5}, make(chan int)}
// Send request
clientRequests <- request
// Wait for response.
fmt.Printf("answer: %d\n", <-request.resultChan)

主goroutine将请求发给request channel,然后等待result channel。子goroutine完成处理后,将结果写到result channel。

func handle(queue chan *Request) {
    for req := range queue {
 result := do_something()
        req.resultChan <- result
    }
}

三、WaitGroup的用法

下面是一个最简单的用法,也是一般常用的方式。

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
	i := 0
    for ; i < 10; i++ {
        wg.Add(1)
        go func(i int){
            fmt.Println("Hello world test",i)
            wg.Done()
        }(i)
    }
    wg.Wait()
	fmt.Println("run done: ",i)
}

四、并发的循环

1. goroutine泄露

某个channel的内部缓存队列满了,但是没有goroutine向这个channel执行接收操作,从而导致向这个channel发送值的所有goroutine都永远阻塞下去,并且永远都不会退出。这种情况,称为goroutine泄露,这将是一个bug。和垃圾变量不同,泄露的goroutine并不会被自动回收,因此确保每个不再需要的goroutine都能正常退出是很重要的。

2. sync.WaitGroup

有时候,我们需要多个goroutine都运行结束以后做一些事情,比如关闭一个channel。Go语言提供了一种特殊的计数器,用于检测多个goroutine是否都已运行结束。它在每一个goroutine启动时自增1,在每一个goroutine退出时自减1,且会一直等待直至计数器自减为0,即表示所有goroutine都已运行结束。
使用示例:

func makeThumbnails(filenames <-chan string) int64 {
    sizes := make(chan int64)
    var wg sync.WaitGroup//number of working goroutines
    for f := range filenames {
        wg.Add(1)//必需在worker goroutines开始之前调用,才能保证Add()是在closer goroutine调用Wait()之前被调用
        //worker goroutines
        go func(f string) {
            defer wg.Done()//使用defer来确保计数器即使是在程序出错的情况下依然能够正确地自减
            thumb, err := thumbnail.ImageFile(f)
            if err != nil {
                log.Println(err)
                return
            }
            info, _ := os.Stat(thumb)
            sizes <- info.Size()
        }(f)
    }
    //closer goroutine
    go func() {
        wg.Wait()
        close(sizes)
    }()
    var total int64
    for size := range sizes {
        total += size
    }
    return total
}

五、基于select的多路复用

有时候我们需要等待多个channel的其中一个返回事件,但是我们又无法做到从每一个channel中接收信息。假如我们试图从其中一个channel中接收信息,而这个channel又没有返回信息,那么程序会立刻被阻塞,从而无法收到其他channel返回的信息。这时,基于select的多路复用就派上用场了。

select会一直等待直至有能够执行的case分支才去执行这个case分支。当条件满足时,select才会去通信并执行case之后的语句;这时候其它通信是不会执行的。一个没有任何case的select语句写作select{},会永远地等待下去。
如果多个case同时就绪时,select会随机地选择一个执行,这样来保证每一个channel都有平等的被select的机会。
channel的零值是nil,对一个nil的channel发送和接收操作会永远阻塞,在select语句中操作nil的channel永远都不会被select到。

六、消息广播

有时候,我们需要将某个消息广播通知所有运行中的goroutine,但是我们又无法知道goroutine的数量,这时候可以使用这种策略:创建一个空的channel,将从这个channel中接收信息的操作加入基于select的多路复用,由于这时channel是空的,所有的goroutine都无法从中接收到值;当我们需要广播消息的时候,关闭这个channel,这样所有的goroutine都能从中接收到零值,以此表示该消息已传达。(一个channel只能用于表达一种消息)

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值