协程,是基于线程的更轻量级存在,它的设计就是为了并发而存在的。
关于协程的详细概念,我不打算在本文里做记录,本文更类似一种实验对比
归并排序
想要体现出协程的高效性,那么就必须寻找一个应用场景,而归并排序可以采用协程实现吗?当然是可以的。
归并排序是的经典『分而治之』算法之一,先将数组均分到不能在分为止,然后再对小数组两两归并,算法伪代码可以写成:
int[] mergesort(int[] a) {
if (a.length <= 1) return a
mid = a.length / 2
left = mergesort(a[:mid])
right = mergesort(a[mid:])
return merge(left, right)
}
这里的merge
函数主要就是合并两个有序数组,不在这里展开,观察上述代码,我们可以很容易地引入并发性的优势:
left = mergesort(a[:mid])
right = mergesort(a[mid:])
这个地方其实没有必要同步计算,两个函数可以异步执行,只要保证在最后归并之前执行完毕就可以了。
Go的协程实现
有了以上的基础,我们再来看golang中是如何实现以上功能的。go自带了goroutine,有天然的并发优势,使用也十分简单,只需要在你想要并发执行的函数前加上go
关键字即可。
func run() {
fmt.Println("goroutine is running")
}
func main {
go run()
}
当然,以上程序大概是没有输出的,因为主函数的goroutine运行得太快了,run
方法的goroutine还没执行到打印字符的代码时,程序就已经结束退出了。
之前也说了,要保证在最终归并之前递归划分都要结束,如果简单加上go
关键字那也不能满足要求。
不过,go也提供了解决方案,其实也很简单,就加上一把锁,什么时候goroutine执行完毕了再释放锁,这样在goroutine没有结束的时候,程序因为锁也不会退出。
var wg = sync.WaitGroup{}
func run() {
fmt.Println("goroutine is running")
wg.Done()
}
func main {
wg.Add(1)
go run()
wg.Wait()
}
wg
变量是一种同步方式,可以这样使用它,当我们申请了一个goroutine,就加1,当goroutine执行完毕就减1,它的Wait()
会阻塞等待,直到变量值变为0。
这样,我们就能用go写出第一版mergesort:
// MergeSortConcurrently merge sort with goroutine
func MergeSortConcurrently(array []int) []int {
if len(array) <= 1 {
return array
}
mid := len(array) / 2
wg := sync.WaitGroup{}
wg.Add(2)
var left []int
var right []int
go func() {
left = MergeSortConcurrently(array[:mid])
wg.Done()
}()
go func() {
right = MergeSortConcurrently(array[mid:])
wg.Done()
}()
wg.Wait()
return merge(left, right)
}
会有问题吗?我们可以写一个单元测试,来与不加goroutine的mergesort来比较。
// MergeSort merge sort implement
func MergeSort(array []int) []int {
if len(array) <= 1 {
return array
}
mid := len(array) / 2
var left []int
var right []int
left = MergeSort(array[:mid])
right = MergeSort(array[mid:])
return merge(left, right)
}
单元测试如下:
var a []int
func init() {
for i := 0; i < 1000000; i++ {
a = append(a, rand.Int())
}
}
func BenchmarkMergeSort(b *testing.B) {
for i := 0; i < b.N; i++ {
MergeSort(a)
}
}
func BenchmarkMergeSortConcurrently(b *testing.B) {
for i := 0; i < b.N; i++ {
MergeSortConcurrently(a)
}
}
运行单元测试go test -test.bench=".*"
发现:
goos: darwin
goarch: amd64
pkg: go-py-coroutine
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkMergeSort
BenchmarkMergeSort-4 7 153933946 ns/op
BenchmarkMergeSortConcurrently
BenchmarkMergeSortConcurrently-4 1 1348671941 ns/op
PASS
结果并没有像我们预料那样,加上goroutine之后性能反而变差了。这是怎么回事?看起来在我们寻求使计算并发的过程中,我们疯狂地申请了goroutine。对于递归的每一步,我们都会申请两个goroutine。这最终会产生数以百万计的goroutine以排队机制争夺CPU,结果让代码更慢。
那么我们如何在不设置大量goroutine的情况下获得并发代码的性能优势呢?在go中限制并发的一种好方法是使用Buffered Channel Semaphore。go中的缓冲通道是一种很好且简单的方法,可以根据我们想要的并发操作单元的数量来阻止执行。
我们设置了一个容量为100的Channel,当我们生成goroutine来执行异步计算时,如果已经有100个goroutine忙于计算,我们将恢复使用MergeSort的同步版本:
var sem = make(chan struct{}, 100)
// MergeSortConcurrentlyWithChannel merge sort with goroutine and channel
func MergeSortConcurrentlyWithChannel(array []int) []int {
if len(array) <= 1 {
return array
}
mid := len(array) / 2
wg := sync.WaitGroup{}
wg.Add(2)
var left []int
var right []int
select {
case sem <- struct{}{}:
go func() {
left = MergeSortConcurrentlyWithChannel(array[:mid])
<-sem
wg.Done()
}()
default:
left = MergeSort(array[:mid])
wg.Done()
}
select {
case sem <- struct{}{}:
go func() {
right = MergeSortConcurrentlyWithChannel(array[mid:])
<-sem
wg.Done()
}()
default:
right = MergeSort(array[mid:])
wg.Done()
}
wg.Wait()
return merge(left, right)
}
单元测试结果:
goos: darwin
goarch: amd64
pkg: go-py-coroutine
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkMergeSort
BenchmarkMergeSort-4 7 154325019 ns/op
BenchmarkMergeSortConcurrently
BenchmarkMergeSortConcurrently-4 1 1138546544 ns/op
BenchmarkMergeSortConcurrentlyWithChannel
BenchmarkMergeSortConcurrentlyWithChannel-4 12 101670450 ns/op
PASS
可以看出,性能得到了提升。
Python的协程实现
Python的协程通过async/await
语法进行声明,是编写asyncio
应用的推荐方式。
import asyncio
async def main():
print('hello')
await asyncio.sleep(1)
print('world')
asyncio.run(main())
这里需要注意的点就是:
async
:来声明一个函数是协程await
:来等待一个协程函数的结束
可以结合go
关键字以及WaitGroup
来对比理解。这样我们很容易写出Python协程版本的归并排序:
async def merge_sort_concurrently(array: list) -> list:
if len(array) <= 1:
return array
mid = int(len(array) / 2)
left = await merge_sort_concurrently(array[:mid])
right = await merge_sort_concurrently(array[mid:])
return merge(left, right)
def merge(left: list, right: list) -> list:
ret = [0 for _ in range(len(left) + len(right))]
l_ptr, r_ptr, ptr = 0, 0, 0
while l_ptr < len(left) or r_ptr < len(right):
if l_ptr == len(left):
ret[ptr:] = right[r_ptr:]
break
if r_ptr == len(right):
ret[ptr:] = left[l_ptr:]
break
if left[l_ptr] < right[r_ptr]:
ret[ptr] = left[l_ptr]
l_ptr += 1
else:
ret[ptr] = right[r_ptr]
r_ptr += 1
ptr += 1
return ret