MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Go并发编程中并发和并行的性能对比

2021-04-303.4k 阅读

Go并发编程基础概念

并发与并行的定义

在深入探讨Go语言中并发和并行的性能对比之前,我们首先需要明确这两个概念。并发(Concurrency)是指在同一时间段内,多个任务可以交替执行,但不一定是同时执行。这就好比你在看电视的同时还可以听音乐,虽然你不能同时把注意力完全放在这两件事上,但可以在它们之间快速切换。在计算机领域,单核CPU通过时间片轮转的方式实现并发,操作系统会为每个任务分配一定的时间片,在时间片内执行该任务,时间片用完后切换到其他任务。

而并行(Parallelism)则是指在同一时刻,多个任务可以同时执行。这需要多个处理器或多核CPU的支持,每个处理器可以同时处理不同的任务。例如,一个拥有多个核心的CPU,每个核心可以同时运行一个独立的任务,实现真正的并行处理。

Go语言的并发模型

Go语言以其出色的并发编程支持而闻名,它的并发模型基于CSP(Communicating Sequential Processes)理论。在Go中,并发编程主要通过goroutine和channel来实现。

goroutine是Go语言中轻量级的线程实现。与传统线程相比,goroutine的创建和销毁开销非常小,并且可以轻松创建成千上万的goroutine。例如,下面的代码展示了如何创建一个简单的goroutine:

package main

import (
    "fmt"
)

func printHello() {
    fmt.Println("Hello from goroutine")
}

func main() {
    go printHello()
    fmt.Println("Main function")
}

在上述代码中,go printHello()语句创建了一个新的goroutine来执行printHello函数。主函数在创建goroutine后不会等待它完成,而是继续执行自己的代码,输出Main function。这里主函数和新创建的goroutine是并发执行的。

channel是Go语言中用于在goroutine之间进行通信和同步的机制。它就像是一个管道,数据可以在不同的goroutine之间通过这个管道传递。例如:

package main

import (
    "fmt"
)

func sendData(ch chan int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}

func receiveData(ch chan int) {
    for num := range ch {
        fmt.Println("Received:", num)
    }
}

func main() {
    ch := make(chan int)
    go sendData(ch)
    go receiveData(ch)
    select {}
}

在这段代码中,sendData函数通过ch <- i将数据发送到channel中,receiveData函数通过for num := range ch从channel中接收数据。这里两个goroutine通过channel进行通信,实现了数据的传递和同步。

性能测试环境搭建

硬件环境

为了准确测试并发和并行在Go语言中的性能差异,我们需要搭建合适的测试环境。在本次测试中,我们使用一台具有多核CPU的服务器,具体配置如下:

  • CPU:Intel Xeon E5 - 2620 v4 @ 2.10GHz,具有12个物理核心,24个逻辑核心。
  • 内存:32GB DDR4 2400MHz。
  • 操作系统:Ubuntu 18.04 LTS 64 - bit。

这样的硬件配置可以充分展示多核CPU在并行处理时的优势,同时也能测试在高并发场景下单核CPU的性能表现。

软件环境

在软件方面,我们需要安装Go语言开发环境。可以通过以下步骤在Ubuntu系统上安装Go:

  1. 下载Go安装包:从Go官方网站(https://golang.org/dl/)下载适合你系统的Go安装包,例如`go1.17.3.linux - amd64.tar.gz`。
  2. 解压安装包:将下载的安装包解压到/usr/local目录下,命令如下:
sudo tar -C /usr/local -xzf go1.17.3.linux - amd64.tar.gz
  1. 设置环境变量:在~/.bashrc文件中添加以下内容,将Go的二进制目录添加到系统路径中:
export PATH=$PATH:/usr/local/go/bin

然后执行source ~/.bashrc使设置生效。

安装完成后,可以通过go version命令验证Go是否安装成功。

性能测试工具

在Go语言中,我们可以使用testing包来进行性能测试。testing包提供了一系列的函数和结构体,方便我们编写和运行性能测试用例。例如,下面是一个简单的性能测试函数模板:

package main

import (
    "testing"
)

func BenchmarkFunction(b *testing.B) {
    for n := 0; n < b.N; n++ {
        // 要测试的代码
    }
}

在上述代码中,BenchmarkFunction是性能测试函数的名称,函数名必须以Benchmark开头。b *testing.B是性能测试的上下文,b.N表示测试的循环次数。在for循环中,我们放入要测试的代码。

运行性能测试时,可以使用go test -bench=.命令,其中-bench=.表示运行所有的性能测试函数。testing包会自动调整b.N的值,以确保测试结果具有统计意义。

并发性能测试

单任务并发测试

我们首先进行单任务并发性能测试,即创建多个goroutine执行相同的简单任务,观察其性能表现。考虑一个简单的任务,计算从1到1000000的整数之和。以下是实现代码:

package main

import (
    "fmt"
    "sync"
)

func sumTask(start, end int, resultChan chan int, wg *sync.WaitGroup) {
    sum := 0
    for i := start; i <= end; i++ {
        sum += i
    }
    resultChan <- sum
    wg.Done()
}

func main() {
    const numGoroutines = 10
    const taskRange = 1000000
    step := taskRange / numGoroutines
    resultChan := make(chan int, numGoroutines)
    var wg sync.WaitGroup

    for i := 0; i < numGoroutines; i++ {
        start := i * step + 1
        end := (i + 1) * step
        if i == numGoroutines - 1 {
            end = taskRange
        }
        wg.Add(1)
        go sumTask(start, end, resultChan, &wg)
    }

    go func() {
        wg.Wait()
        close(resultChan)
    }()

    totalSum := 0
    for sum := range resultChan {
        totalSum += sum
    }
    fmt.Println("Total sum:", totalSum)
}

在上述代码中,我们将任务范围11000000分成10个小任务,每个小任务由一个goroutine执行。sumTask函数负责计算每个小任务的和,并通过resultChan将结果发送出去。主函数通过等待所有goroutine完成(使用wg.Wait()),然后关闭resultChan,并从resultChan中接收所有结果,计算总和。

接下来,我们编写性能测试代码:

package main

import (
    "sync"
    "testing"
)

func BenchmarkSingleTaskConcurrency(b *testing.B) {
    const numGoroutines = 10
    const taskRange = 1000000
    step := taskRange / numGoroutines
    for n := 0; n < b.N; n++ {
        resultChan := make(chan int, numGoroutines)
        var wg sync.WaitGroup

        for i := 0; i < numGoroutines; i++ {
            start := i * step + 1
            end := (i + 1) * step
            if i == numGoroutines - 1 {
                end = taskRange
            }
            wg.Add(1)
            go sumTask(start, end, resultChan, &wg)
        }

        go func() {
            wg.Wait()
            close(resultChan)
        }()

        totalSum := 0
        for sum := range resultChan {
            totalSum += sum
        }
    }
}

运行性能测试go test -bench=BenchmarkSingleTaskConcurrency,可以得到单任务并发执行的性能数据。

多任务并发测试

除了单任务并发,我们还测试多任务并发的性能。假设我们有多个不同类型的任务,例如计算任务、文件读取任务和网络请求任务。以下是一个简化的示例代码,模拟这些不同任务的并发执行:

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
    "sync"
)

func calculationTask(resultChan chan int, wg *sync.WaitGroup) {
    sum := 0
    for i := 0; i < 1000000; i++ {
        sum += i
    }
    resultChan <- sum
    wg.Done()
}

func fileReadTask(resultChan chan string, wg *sync.WaitGroup) {
    data, err := ioutil.ReadFile("test.txt")
    if err != nil {
        resultChan <- fmt.Sprintf("Error: %v", err)
    } else {
        resultChan <- string(data)
    }
    wg.Done()
}

func networkRequestTask(resultChan chan string, wg *sync.WaitGroup) {
    resp, err := http.Get("http://example.com")
    if err != nil {
        resultChan <- fmt.Sprintf("Error: %v", err)
    } else {
        defer resp.Body.Close()
        data, _ := ioutil.ReadAll(resp.Body)
        resultChan <- string(data)
    }
    wg.Done()
}

func main() {
    calculationResultChan := make(chan int)
    fileReadResultChan := make(chan string)
    networkRequestResultChan := make(chan string)
    var wg sync.WaitGroup

    wg.Add(1)
    go calculationTask(calculationResultChan, &wg)
    wg.Add(1)
    go fileReadTask(fileReadResultChan, &wg)
    wg.Add(1)
    go networkRequestTask(networkRequestResultChan, &wg)

    go func() {
        wg.Wait()
        close(calculationResultChan)
        close(fileReadResultChan)
        close(networkRequestResultChan)
    }()

    for i := 0; i < 3; i++ {
        select {
        case sum := <-calculationResultChan:
            fmt.Println("Calculation result:", sum)
        case fileData := <-fileReadResultChan:
            fmt.Println("File read result:", fileData)
        case networkData := <-networkRequestResultChan:
            fmt.Println("Network request result:", networkData)
        }
    }
}

在这段代码中,我们定义了三个不同类型的任务:calculationTask进行计算,fileReadTask读取文件,networkRequestTask发起网络请求。每个任务在单独的goroutine中执行,主函数通过select语句从不同的channel中接收任务结果。

性能测试代码如下:

package main

import (
    "sync"
    "testing"
)

func BenchmarkMultiTaskConcurrency(b *testing.B) {
    for n := 0; n < b.N; n++ {
        calculationResultChan := make(chan int)
        fileReadResultChan := make(chan string)
        networkRequestResultChan := make(chan string)
        var wg sync.WaitGroup

        wg.Add(1)
        go calculationTask(calculationResultChan, &wg)
        wg.Add(1)
        go fileReadTask(fileReadResultChan, &wg)
        wg.Add(1)
        go networkRequestTask(networkRequestResultChan, &wg)

        go func() {
            wg.Wait()
            close(calculationResultChan)
            close(fileReadResultChan)
            close(networkRequestResultChan)
        }()

        for i := 0; i < 3; i++ {
            select {
            case <-calculationResultChan:
            case <-fileReadResultChan:
            case <-networkRequestResultChan:
            }
        }
    }
}

运行go test -bench=BenchmarkMultiTaskConcurrency,可以得到多任务并发执行的性能数据。

并行性能测试

基于多核的并行任务测试

在Go语言中,虽然goroutine本身并不直接等同于并行,但通过合理利用多核CPU,我们可以实现并行处理。Go语言的运行时(runtime)会自动将goroutine调度到不同的CPU核心上执行。以下是一个利用多核CPU进行并行计算的示例,同样是计算从1到1000000的整数之和,但这次我们通过设置GOMAXPROCS来充分利用多核:

package main

import (
    "fmt"
    "runtime"
    "sync"
)

func sumTask(start, end int, resultChan chan int, wg *sync.WaitGroup) {
    sum := 0
    for i := start; i <= end; i++ {
        sum += i
    }
    resultChan <- sum
    wg.Done()
}

func main() {
    runtime.GOMAXPROCS(runtime.NumCPU())
    const numGoroutines = 10
    const taskRange = 1000000
    step := taskRange / numGoroutines
    resultChan := make(chan int, numGoroutines)
    var wg sync.WaitGroup

    for i := 0; i < numGoroutines; i++ {
        start := i * step + 1
        end := (i + 1) * step
        if i == numGoroutines - 1 {
            end = taskRange
        }
        wg.Add(1)
        go sumTask(start, end, resultChan, &wg)
    }

    go func() {
        wg.Wait()
        close(resultChan)
    }()

    totalSum := 0
    for sum := range resultChan {
        totalSum += sum
    }
    fmt.Println("Total sum:", totalSum)
}

在上述代码中,runtime.GOMAXPROCS(runtime.NumCPU())语句设置了Go运行时使用的CPU核心数为系统的CPU核心总数。这样,当创建多个goroutine时,运行时会尝试将它们调度到不同的核心上并行执行。

性能测试代码如下:

package main

import (
    "runtime"
    "sync"
    "testing"
)

func BenchmarkParallelTask(b *testing.B) {
    for n := 0; n < b.N; n++ {
        runtime.GOMAXPROCS(runtime.NumCPU())
        const numGoroutines = 10
        const taskRange = 1000000
        step := taskRange / numGoroutines
        resultChan := make(chan int, numGoroutines)
        var wg sync.WaitGroup

        for i := 0; i < numGoroutines; i++ {
            start := i * step + 1
            end := (i + 1) * step
            if i == numGoroutines - 1 {
                end = taskRange
            }
            wg.Add(1)
            go sumTask(start, end, resultChan, &wg)
        }

        go func() {
            wg.Wait()
            close(resultChan)
        }()

        totalSum := 0
        for sum := range resultChan {
            totalSum += sum
        }
    }
}

运行go test -bench=BenchmarkParallelTask,可以得到基于多核的并行任务执行的性能数据。

并行与并发混合场景测试

在实际应用中,我们常常会遇到并行和并发混合的场景。例如,在一个服务器应用中,可能有多个客户端连接(并发处理),而每个客户端请求的处理可能涉及到多个CPU密集型任务(并行处理)。以下是一个模拟这种混合场景的示例代码:

package main

import (
    "fmt"
    "runtime"
    "sync"
)

func cpuIntensiveTask(start, end int, resultChan chan int, wg *sync.WaitGroup) {
    sum := 0
    for i := start; i <= end; i++ {
        sum += i
    }
    resultChan <- sum
    wg.Done()
}

func handleClient(clientID int, resultChan chan string, wg *sync.WaitGroup) {
    const numSubTasks = 5
    const taskRange = 1000000
    step := taskRange / numSubTasks
    subResultChan := make(chan int, numSubTasks)
    var subWg sync.WaitGroup

    for i := 0; i < numSubTasks; i++ {
        start := i * step + 1
        end := (i + 1) * step
        if i == numSubTasks - 1 {
            end = taskRange
        }
        subWg.Add(1)
        go cpuIntensiveTask(start, end, subResultChan, &subWg)
    }

    go func() {
        subWg.Wait()
        close(subResultChan)
    }()

    totalSum := 0
    for sum := range subResultChan {
        totalSum += sum
    }

    resultChan <- fmt.Sprintf("Client %d: Total sum is %d", clientID, totalSum)
    wg.Done()
}

func main() {
    runtime.GOMAXPROCS(runtime.NumCPU())
    const numClients = 10
    resultChan := make(chan string, numClients)
    var wg sync.WaitGroup

    for i := 0; i < numClients; i++ {
        wg.Add(1)
        go handleClient(i, resultChan, &wg)
    }

    go func() {
        wg.Wait()
        close(resultChan)
    }()

    for result := range resultChan {
        fmt.Println(result)
    }
}

在这段代码中,handleClient函数模拟了对一个客户端请求的处理,其中包含了多个CPU密集型的子任务(通过cpuIntensiveTask函数),这些子任务在并行执行。而多个handleClient函数的调用则模拟了并发处理多个客户端请求。

性能测试代码如下:

package main

import (
    "runtime"
    "sync"
    "testing"
)

func BenchmarkConcurrentParallelMix(b *testing.B) {
    for n := 0; n < b.N; n++ {
        runtime.GOMAXPROCS(runtime.NumCPU())
        const numClients = 10
        resultChan := make(chan string, numClients)
        var wg sync.WaitGroup

        for i := 0; i < numClients; i++ {
            wg.Add(1)
            go handleClient(i, resultChan, &wg)
        }

        go func() {
            wg.Wait()
            close(resultChan)
        }()

        for range resultChan {
        }
    }
}

运行go test -bench=BenchmarkConcurrentParallelMix,可以得到并行与并发混合场景下的性能数据。

性能对比分析

单任务并发与并行对比

通过对单任务并发和基于多核的并行任务的性能测试数据进行分析,我们发现:在单核CPU环境下,单任务并发虽然可以通过时间片轮转在一定程度上提高任务执行效率,但由于同一时刻只能执行一个任务,其性能提升相对有限。而在多核CPU环境下,并行任务能够充分利用多个核心的计算能力,将任务分配到不同核心上同时执行,从而显著提高执行效率。例如,在我们的测试中,计算从1到1000000的整数之和的任务,并行执行的时间大约是单任务并发执行时间的1/4(具体倍数取决于CPU核心数和任务复杂度)。这是因为并行执行避免了单核环境下频繁的任务切换开销,并且能够充分发挥多核的计算能力。

多任务并发与并行对比

对于多任务并发和并行与并发混合场景的测试,情况更为复杂。多任务并发在处理I/O密集型任务(如文件读取和网络请求)时具有很大优势。由于I/O操作通常需要等待外部设备响应,在等待过程中,其他goroutine可以继续执行,从而提高了系统的整体利用率。而在并行与并发混合场景下,对于既有I/O密集型任务又有CPU密集型任务的情况,合理利用多核进行CPU密集型任务的并行处理,同时通过并发处理I/O密集型任务,可以达到较好的性能表现。例如,在我们模拟的服务器应用场景中,并行与并发混合的方式能够在处理多个客户端请求时,快速处理每个请求中的CPU密集型子任务,同时高效地处理I/O操作,使得整体响应时间比单纯的多任务并发或并行处理都更短。

影响性能的因素

  1. 任务类型:CPU密集型任务在并行处理时性能提升明显,因为多核CPU可以同时执行这些任务。而I/O密集型任务则更适合并发处理,通过在等待I/O操作完成时切换到其他任务,提高系统利用率。
  2. 资源竞争:在并发和并行编程中,如果多个goroutine竞争共享资源(如共享内存、文件句柄等),会引入锁机制,从而增加额外的开销,降低性能。合理设计数据结构和通信方式,减少资源竞争,可以提高性能。
  3. CPU核心数:显然,CPU核心数越多,并行处理的潜力就越大。但过多的goroutine可能会导致调度开销增大,因此需要根据CPU核心数和任务特点合理调整goroutine的数量。
  4. 网络和I/O延迟:在涉及网络和I/O操作的任务中,网络延迟和I/O设备的性能会对整体性能产生重要影响。优化网络配置和I/O操作方式,如使用异步I/O,可以提高性能。

代码优化策略

减少资源竞争

  1. 使用无锁数据结构:在Go语言中,有一些无锁数据结构库可供使用,如sync.Map。与传统的使用锁保护的map相比,sync.Map在高并发场景下具有更好的性能,因为它避免了锁的竞争。例如:
package main

import (
    "fmt"
    "sync"
)

func main() {
    var mu sync.Mutex
    m := make(map[string]int)

    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            key := fmt.Sprintf("key%d", id)
            mu.Lock()
            m[key] = id
            mu.Unlock()
        }(i)
    }
    wg.Wait()

    fmt.Println(m)
}

上述代码使用锁保护map,在高并发下可能会有性能问题。使用sync.Map可以改写为:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    var m sync.Map

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            key := fmt.Sprintf("key%d", id)
            m.Store(key, id)
        }(i)
    }
    wg.Wait()

    m.Range(func(key, value interface{}) bool {
        fmt.Printf("Key: %v, Value: %v\n", key, value)
        return true
    })
}
  1. 避免不必要的共享:尽量设计数据结构和算法,使得每个goroutine有自己独立的数据,避免共享数据带来的竞争。例如,在计算任务中,可以让每个goroutine计算一部分数据,最后再合并结果,而不是多个goroutine同时访问和修改同一个数据结构。

合理调整goroutine数量

  1. 根据CPU核心数调整:对于CPU密集型任务,一般可以将goroutine的数量设置为与CPU核心数相近。例如,在前面计算整数和的并行任务中,将goroutine数量设置为10,与服务器的CPU核心数相匹配,能够充分利用多核资源。但如果goroutine数量过多,会导致调度开销增大,反而降低性能。
  2. 动态调整:在实际应用中,任务的负载可能会动态变化。可以通过监控系统资源(如CPU使用率、内存使用率等),动态调整goroutine的数量。例如,当CPU使用率较低时,可以适当增加goroutine数量,以充分利用资源;当CPU使用率过高时,减少goroutine数量,避免过度竞争。

优化I/O操作

  1. 使用异步I/O:在进行文件读取或网络请求等I/O操作时,使用异步方式可以避免阻塞其他goroutine。在Go语言中,io/ioutil包中的ReadFile函数是同步的,而os包中的Read函数结合go关键字可以实现异步读取。例如:
package main

import (
    "fmt"
    "os"
)

func asyncFileRead(filePath string, resultChan chan string) {
    data, err := os.ReadFile(filePath)
    if err != nil {
        resultChan <- fmt.Sprintf("Error: %v", err)
    } else {
        resultChan <- string(data)
    }
}

func main() {
    resultChan := make(chan string)
    go asyncFileRead("test.txt", resultChan)
    // 可以在等待文件读取的同时执行其他任务
    data := <-resultChan
    fmt.Println(data)
}
  1. 批量处理I/O:对于多次I/O操作,可以尝试批量处理,减少I/O操作的次数。例如,在网络请求中,如果需要多次请求同一个服务器,可以将多个请求合并为一个,一次性发送,减少网络延迟。

应用场景选择

适合并发的场景

  1. I/O密集型应用:如网络服务器、文件处理系统等。在这些应用中,大部分时间都花在等待I/O操作完成上,并发编程可以在等待过程中切换到其他任务,提高系统的整体利用率。例如,一个Web服务器需要处理大量的HTTP请求,每个请求可能涉及到读取文件、数据库查询等I/O操作,通过并发处理这些请求,可以显著提高服务器的吞吐量。
  2. 任务调度和管理:当需要管理和调度多个任务时,并发编程可以方便地实现任务的创建、暂停、恢复和取消。例如,在一个自动化测试框架中,需要并发执行多个测试用例,并且能够根据测试结果动态调整任务的执行顺序,并发编程可以很好地满足这些需求。

适合并行的场景

  1. CPU密集型计算:如科学计算、数据分析等领域。在这些场景中,任务主要是进行大量的数值计算,需要充分利用多核CPU的计算能力。例如,在计算矩阵乘法、大数据集的统计分析等任务中,并行处理可以大大缩短计算时间。
  2. 分布式系统中的本地计算:在分布式系统中,每个节点可能需要进行一些本地的CPU密集型计算,如数据预处理、模型训练等。通过在每个节点上利用多核进行并行计算,可以提高整个分布式系统的性能。

混合场景的应对策略

在实际应用中,更多的是混合场景,既有I/O密集型任务,又有CPU密集型任务。对于这种情况,可以采用以下策略:

  1. 任务分类和调度:将任务分为I/O密集型和CPU密集型,对于I/O密集型任务,使用并发方式处理;对于CPU密集型任务,根据CPU核心数进行并行处理。例如,在一个数据处理管道中,数据的读取和写入是I/O密集型任务,而数据的转换和计算是CPU密集型任务,可以分别采用并发和并行的方式处理。
  2. 资源分配和隔离:为不同类型的任务分配不同的资源,避免资源竞争。例如,可以为CPU密集型任务分配固定数量的CPU核心,为I/O密集型任务分配足够的网络带宽和I/O资源,确保两类任务都能高效执行。