Go 并发:带缓冲的通道

在上一篇文章 《Go 并发:Channel 通道》中,我们讨论了通道的一些基本特性,需要指出的是,之前讨论的通道都是不带缓冲的通道,也就是说从通道接收数据和往通道发送数据都会阻塞,直到有其他 goroutine 往通道发送数据或从通道接收数据。

在这篇文章中,我们继续讨论带缓冲的通道的使用。

带缓冲的通道

可以创建带缓冲的通道。往通道发送数据时,只有当缓冲满时才会阻塞。同理,从通道接收数据时,只有当缓冲为空时才会阻塞。

可以使用 make 函数来创建带缓冲的通道:

ch := make(chan type, capacity)  

其中,capacity 大于 0 时表示通道带缓冲。capacity 默认为 0 ,所以如果省略 capacity 参数则创建不带缓冲的通道。

例子一

package main

import "fmt"

func main() {
	ch := make(chan string, 2)
	ch <- "lee"
	ch <- "leo"
	fmt.Println(<-ch)
	fmt.Println(<-ch)
}

在上面的程序中,ch := make(chan string, 2) 创建了一个带缓冲的字符串类型通道,缓冲大小为 2,因此,可以连续往该通道发送 2 个字符串数据也不会阻塞。
接下来,往通道发送 2 个字符串,然后从通道读取这两个字符串,并打印出来:

lee
leo

例子二

接着我们来实现一个更复杂点的例子。

package main

import (
	"fmt"
	"time"
)

func write(ch chan int) {
	for i := 0; i < 5; i++ {
		ch <- i
		fmt.Println("wrote ", i, "to ch")
	}
	close(ch)
}

func main() {
	ch := make(chan int, 2)
	go write(ch)
	time.Sleep(2 * time.Second)
	for v := range ch {
		fmt.Println("read value", v, "from ch")
		time.Sleep(2 * time.Second)
	}
}

在上面的程序中,创建了一个带缓冲的整形通道,缓冲大小为 2 。然后在 main goroutine 中启动了 write goroutine。在 write goroutine 中,循环往通道发送数字 0 - 4。由于通道缓冲大小为 2,故发送数字 0 和 1 后,write goroutine 将阻塞。write goroutine 阻塞前打印:

wrote  0 to ch
wrote  1 to ch

write goroutine 打印上面两行输出后,将会阻塞。main goroutine 在睡眠 2 秒后,从通道 ch 接收数字 0。这时,write goroutine 可以继续往通道发送数字 2:

read value 0 from ch
wrote  2 to ch

接下来,write goroutine 将剩余数字发送,main goroutine 将剩余数字打印出来:

wrote  0 to ch
wrote  1 to ch
read value 0 from ch
wrote  2 to ch
wrote  3 to ch
read value 1 from ch
read value 2 from ch
wrote  4 to ch
read value 3 from ch
read value 4 from ch

死锁

带缓冲的通道同样会发生死锁的情况。例如以下代码:

package main

import (
	"fmt"
)

func main() {
	ch := make(chan string, 2)
	ch <- "leo"
	ch <- "lee"
	ch <- "hao"
	fmt.Println(<-ch)
	fmt.Println(<-ch)
}

运行程序,将会发生报错:

fatal error: all goroutines are asleep - deadlock!

在上面的程序中,我们往一个缓冲大小为 2 的通道连续发送了3 个字符串数据。由于通道缓冲大小为 2,且不存其他 goroutine 从该通道接收数据,故程序会出现死锁的问题。

关闭带缓冲的通道

在上一篇文章 《Go 并发:Channel 通道》中,我们讨论了关闭通道的操作。同样地,我们可以对带缓冲的通道进行关闭操作。

当关闭带缓冲的通道后,可以继续从该通道接收数据,直到缓冲的数据读取完毕,这时,通道将会返回“零值”。

以程序来说明这一过程:

package main

import (
	"fmt"
)

func main() {
	ch := make(chan int, 5)
	ch <- 5
	ch <- 6
	close(ch)
	n, open := <-ch
	fmt.Printf("Received: %d, open: %t\n", n, open)
	n, open = <-ch
	fmt.Printf("Received: %d, open: %t\n", n, open)
	n, open = <-ch
	fmt.Printf("Received: %d, open: %t\n", n, open)
}

在上面的程序中,我们创建了一个带缓冲的通道,并往该通道发送数字 5 和 6 ,然后关闭通道。关闭通道后,可以继续从该通道接收数据,当接收到数字 5 和 6 后,最后接收到数字 0 ,即该整型通道的零值。程序输出:

Received: 5, open: true
Received: 6, open: true
Received: 0, open: false

同样可以使用 for range 循环来改写上面的程序:

package main

import (
	"fmt"
)

func main() {
	ch := make(chan int, 5)
	ch <- 5
	ch <- 6
	close(ch)
	for n := range ch {
		fmt.Println("Received:", n)
	}
}

程序输出:

Received: 5
Received: 6

长度和容量

带缓冲通道的容量是指通道可以容纳的数据的数量,这个数值在我们创建通道时使用 make 函数指定。

带缓冲通道的长度是指缓冲中实际数据的数量。

说得有点拗,通过程序来说明长度和容量的区别。

package main

import (
	"fmt"
)

func main() {
	ch := make(chan string, 3)
	ch <- "leo"
	ch <- "lee"
	fmt.Println("capacity is", cap(ch))
	fmt.Println("length is", len(ch))
	fmt.Println("read value", <-ch)
	fmt.Println("new length is", len(ch))
}

运行程序,得到输出:

capacity is 3
length is 2
read value leo
new length is 1

在创建通道时,我们指定了缓冲的容量大小为 3,所以容量输出为 3。由于往通道中发送了两个数据,所以长度输出为 2。从通道接收一个数据后,这时通道的缓冲长度为 1。

WaitGroup 使用

由于后面我们实现的工作线程池需要使用到 WaitGroup,接下来看下 WaitGroup 的使用。

WaitGroup 可以用来等待一组 goroutine 执行完毕的场景。假设在 main goroutine 中,创建了 3 个 goroutine,然后等待这 3 个 goroutine 执行完毕,可以使用 WaitGroup 这样实现:

package main

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

func process(i int, wg *sync.WaitGroup) {
	fmt.Println("started Goroutine ", i)
	time.Sleep(2 * time.Second)
	fmt.Printf("Goroutine %d ended\n", i)
	wg.Done()
}

func main() {
	no := 3
	var wg sync.WaitGroup
	for i := 0; i < no; i++ {
		wg.Add(1)
		go process(i, &wg)
	}
	wg.Wait()
	fmt.Println("All go routines finished executing")
}

WaitGroup 使用计数器来工作。当创建 WaitGroup 时,其计数器初始值为 0 ,当调用 Add 方法时,计数器增加 1,当调用 Done 方法时,计数器减少 1。当调用 Wait 方法时,goroutine 将会阻塞,直至计数器数值为 0。

在上面的程序中,在 main goroutine 中,创建了一个WaitGroup wg,并启动了 3 个 process goroutine。在启动 process goroutine 同时,调用 Addwg 计数器增加 1。
process goroutine 中,当执行完毕,调用 Donewg 计数器减少 1。

main goroutine 调用 wg.Wait() 等待 3 个process goroutine 执行完毕,然后打印结束语句。
程序输出:

started Goroutine  1
started Goroutine  0
started Goroutine  2
Goroutine 2 ended
Goroutine 0 ended
Goroutine 1 ended
All go routines finished executing

工作线程池实现

最后我们来看下如何实现一个工作线程池(worker pool)。工作线程池是一组用来执行任务的线程的集合,这些线程可以不断执行任务队列的任务,一旦执行完毕一个任务,马上可以执行另一个任务。

与传统线程池不同的是,我们使用带缓冲的通道与 goroutine 来实现工作线程池。其执行的任务是计算一个数包含的所有数字的和。例如,数 234 的输出为 (2 + 3 + 4)= 9。

下面的工作线程池需要实现的功能:

  • 创建一个 goroutine 池,这些 goroutine 监听任务通道,以等待任务的分配
  • 往工作任务通道发送工作任务
  • 任务执行完毕将结果发送至存放结果的通道
  • 从存放结果的通道接收结果,并打印出来

完整的源代码如下:

package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

type Job struct {
	id       int
	randomno int
}

type Result struct {
	job         Job
	sumofdigits int
}

var jobs = make(chan Job, 10)
var results = make(chan Result, 10)

func digits(number int) int {
	sum := 0
	no := number
	for no != 0 {
		digit := no % 10
		sum += digit
		no /= 10
	}
	time.Sleep(2 * time.Second)
	return sum
}

func worker(wg *sync.WaitGroup) {
	for job := range jobs {
		output := Result{job, digits(job.randomno)}
		results <- output
	}
	wg.Done()
}

func createWorkerPool(noOfWorkers int) {
	var wg sync.WaitGroup
	for i := 0; i < noOfWorkers; i++ {
		wg.Add(1)
		go worker(&wg)
	}
	wg.Wait()
	close(results)
}

func allocate(noOfJobs int) {
	for i := 0; i < noOfJobs; i++ {
		randomno := rand.Intn(999)
		job := Job{i, randomno}
		jobs <- job
	}
	close(jobs)
}

func result(done chan bool) {
	for result := range results {
		fmt.Printf("Job id %d, input random no %d , sum of digits %d\n", result.job.id, result.job.randomno, result.sumofdigits)
	}
	done <- true
}

func main() {
	startTime := time.Now()
	noOfJobs := 100
	go allocate(noOfJobs)
	done := make(chan bool)
	go result(done)
	noOfWorkers := 10
	createWorkerPool(noOfWorkers)
	<-done
	endTime := time.Now()
	diff := endTime.Sub(startTime)
	fmt.Println("total time taken ", diff.Seconds(), "seconds")
}

在上面的程序中,创建了一个工作任务通道 jobs 和存放结果的通道 results。接下来,创建了 100 个工作任务,10 个工作线程,这些工作线程实际是上 10 个 goroutine。

allocate 函数的作用是将这 100 个任务分配到 jobs 任务通道。
result 函数的作用是从存放结果的通道中接收结果并打印出来,完成结果打印后,通过 done 通道通知 main goroutine 所有任务执行结束。
createWorkerPool 函数的作用是创建工作线程池,共创建 10 个 worker goroutine。每个 worker goroutine 都会计算一个数所有数字的和,然后将结果发送至通道 results。为了等待所有的 goroutine 执行完毕,使用了 WaitGroup 。当所有 goroutine 执行完所有的任务,关闭存放结果的通道 resluts,这样,result goroutine 往 done 通道发送任务完成通知。main goroutine 结束运行。

运行程序,得到输出:

Job id 1, input random no 636 , sum of digits 15
Job id 9, input random no 150 , sum of digits 6
Job id 2, input random no 407 , sum of digits 11
Job id 3, input random no 983 , sum of digits 20
Job id 4, input random no 895 , sum of digits 22
...
total time taken  20.009264 seconds

即 100 个任务,每个执行 2 秒,由于工作线程数量为 10 ,每次可同时执行 10 个任务,实际总执行时间为 20 秒。
如果将工作线程数量调整为 20 ,可以看到总执行时间为 10 秒左右。

参考资料

  • https://golangbot.com/buffered-channels-worker-pools/
  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值