Golang实用教程:协程的应用技巧

401 阅读3分钟

序言

本文将简单介绍Golang协程的特点以及常见的使用方式,循序渐进的实现一个简单的协程池。

Goroutine介绍

程序运行中有进程、线程、协程的概念。其中进程和线程有操作系统调度,比较耗费时间和资源,协程由Go运行时调度,在用户态执行,成本较低。goroutine本身十分轻量,协程的创建和调度开销都远远小于线程的对应操作,这也是Go语言在云原生时代能够快速占据市场的最核心优势。

Goroutine的使用

我们在正常的业务开发中对于需要并发执行的代码逻辑,最简单的实现方式就是启动一个goroutine, 下面是代码示例:

package pool

import (
    "context"
    "sync"
)

type Task func(context.Context) error

func Parallel(ctx context.Context, tasks []Task) chan error {
    var wg sync.WaitGroup
    var errch = make(chan error, len(tasks))

    for _, task := range tasks {
        wg.Add(1)
        go func(t Task) {
            defer func() { wg.Done() }()
            errch <- t(ctx)
        }(task)
    }

    wg.Wait()
    close(errch)

    return errch
}

func Run(ctx context.Context, tasks []Task) error {
    for err := range Parallel(ctx, tasks) {
        if err != nil {
            return err
        }
    }
    return nil
}

上面的代码没有控制协程的数量,在大量任务时会造成协程的膨胀,goroutine是有栈协程,大量的协程会造成内存的浪费,同时由于服务的CPU有限,同时运行的协程会比较少,大量的协程都在挂起状态。

协程池

我们在日常编码中使用原生的Go协程并不会成为性能瓶颈,通常需要优化的地方更多集中在业务逻辑、IO处理问题上。但是当服务流量上升之后,任何微小的性能损失都会被放大,就需要对程序进行极致优化。 因此这时候就需要协程池来控制程序中协程的数量以及提高已有协程的复用效率。

我们对上面的代码稍作调整

func FixedParallel(ctx context.Context, tasks []Task, maxNum int) chan error {
    var wg sync.WaitGroup
    var errch = make(chan error, len(tasks))
    var tokens = make(chan struct{}, maxNum)
    
    for _, task := range tasks {
        tokens <- struct{}{}
        wg.Add(1)
        go func(t Task) {
            defer func() {
                <-tokens
                wg.Done()
            }()
            errch <- t(ctx)
        }(task)
    }

    wg.Wait()
    close(errch)

    return errch
}

FixedParallel相对于原函数增加了一个tokens channel用来控制同时并发的协程数量,这样就解决了协程过多的问题,这也是Go编程中控制并发度常见的方法。

上面的改造只是解决了协程过多的问题,但是并没有做到协程的复用,在性能优化过程中我们有时候需要能够将协程复用起来以达到更极致的优化效果。 我们再对上面的代码做一下调整

func ReusedFixParallel(ctx context.Context, tasks []Task, maxNum int) chan error {
    var (
        wg     sync.WaitGroup
        errch  = make(chan error, len(tasks))
        tokens = make(chan struct{}, maxNum)
        taskch = make(chan Task)
    )

    var worker = func(t Task) {
        defer func() {
            <-tokens
            log.Println("worker done.")
        }()

        var f = func(t Task) {
            defer wg.Done()
            errch <- t(ctx)
        }

        f(t)

        for {
            select {
            case t = <-taskch:
                // 这里注意要判空,通道关闭后会接收到零值。
                if t == nil {
                    return
                }
                f(t)
            }
        }
    }

    for _, task := range tasks {
        wg.Add(1)
        select {
        case taskch <- task:
        case tokens <- struct{}{}:
            go worker(task)
        }
    }

    wg.Wait()
    close(errch)
    // 注意关闭channel,否则会造成协程泄露
    close(taskch)

    return errch
}

func Run(ctx context.Context, tasks []Task) error {
    for err := range ReusedFixParallel(ctx, tasks, 3) {
        if err != nil {
            return err
        }
    }
    time.Sleep(time.Second)
    return nil
}

ReusedFixParallel通过tokens完成协程数量的控制,引入taskch来完成协程的复用,这样既控制了协程的数量,又能完成对协程的复用。

总结

由于时间有限,本文先通过几个代码片段讲解了Go协程在日常开发中的常见使用方式。 下一篇文章我们会参照上面代码的思路将协程控制相关的代码抽出来,以协程池的方式在程序中使用。