从归并排序来看Go与Python的协程实现方式

457 阅读1分钟

协程,是基于线程的更轻量级存在,它的设计就是为了并发而存在的。

关于协程的详细概念,我不打算在本文里做记录,本文更类似一种实验对比

归并排序

想要体现出协程的高效性,那么就必须寻找一个应用场景,而归并排序可以采用协程实现吗?当然是可以的。

归并排序是的经典『分而治之』算法之一,先将数组均分到不能在分为止,然后再对小数组两两归并,算法伪代码可以写成:

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