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

Go 语言协程(Goroutine)与线程的性能对比与适用场景

2023-02-221.7k 阅读

一、Go 语言协程(Goroutine)与线程基础概念

  1. 线程基础 线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一个进程可以包含多个线程,这些线程共享进程的资源,如内存空间、文件描述符等。线程的创建、销毁和切换都需要操作系统内核的参与,这一过程需要耗费较多的系统资源。例如,在传统的多线程编程中,我们需要手动管理线程的生命周期,通过锁机制来避免多个线程同时访问共享资源时产生的数据竞争问题。以 C++ 语言为例,使用 std::thread 创建线程:
#include <iostream>
#include <thread>

void thread_function() {
    std::cout << "This is a thread." << std::endl;
}

int main() {
    std::thread t(thread_function);
    t.join();
    return 0;
}

在上述代码中,通过 std::thread 创建了一个新线程,并调用 join 方法等待线程执行完毕。这里可以看到线程创建和管理的相对复杂性。

  1. Go 语言协程(Goroutine)基础 Goroutine 是 Go 语言中实现并发编程的核心机制。它是一种轻量级的线程模型,由 Go 语言运行时(runtime)进行调度管理,而不是由操作系统内核直接调度。这意味着创建和销毁 Goroutine 的开销非常小。在 Go 语言中,只需要使用 go 关键字就可以轻松创建一个 Goroutine。例如:
package main

import (
    "fmt"
)

func goroutine_function() {
    fmt.Println("This is a goroutine.")
}

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

在上述代码中,使用 go 关键字启动了一个 Goroutine,主函数不会等待该 Goroutine 执行完毕就会继续执行,这体现了 Goroutine 的异步执行特性。

二、性能对比

  1. 创建开销对比
    • 线程创建开销:线程的创建需要操作系统内核的参与,内核需要为线程分配栈空间、寄存器等资源,还需要在内核数据结构中进行登记等操作。这一过程涉及到系统调用,开销较大。例如,在一个典型的 Linux 系统中,创建一个普通线程可能需要几十微秒到几百微秒的时间,具体时间取决于系统的负载和硬件性能。
    • Goroutine 创建开销:Goroutine 的创建由 Go 语言运行时管理,其创建过程非常轻量级。运行时只需要为 Goroutine 分配一个很小的栈空间(初始栈通常只有 2KB 左右,并且栈空间可以根据需要动态增长和收缩),以及一些必要的控制结构。在 Go 语言中,创建一个 Goroutine 几乎是瞬间完成的,其开销比创建线程要小几个数量级,通常创建一个 Goroutine 只需要几百纳秒。以下代码用于测试创建线程和 Goroutine 的时间开销对比(使用 Go 语言测试创建 Goroutine,使用 C++ 测试创建线程):
package main

import (
    "fmt"
    "time"
)

func main() {
    start := time.Now()
    for i := 0; i < 100000; i++ {
        go func() {}()
    }
    elapsed := time.Since(start)
    fmt.Printf("Time to create 100,000 goroutines: %s\n", elapsed)
}
#include <iostream>
#include <thread>
#include <chrono>

void empty_thread_function() {}

int main() {
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 100000; i++) {
        std::thread t(empty_thread_function);
        t.detach();
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
    std::cout << "Time to create 100,000 threads: " << duration << " microseconds" << std::endl;
    return 0;
}

在实际运行中,可以明显看到创建相同数量的 Goroutine 比创建线程所需的时间短得多。

  1. 调度开销对比
    • 线程调度开销:线程的调度由操作系统内核负责。当一个线程的时间片用完或者被其他更高优先级的线程抢占时,内核需要进行上下文切换。上下文切换需要保存当前线程的寄存器状态、栈指针等信息,并恢复下一个要执行线程的相关信息。这一过程涉及到内核态和用户态的切换,开销较大。在多核系统中,线程调度还需要考虑缓存一致性等问题,进一步增加了调度的复杂性和开销。
    • Goroutine 调度开销:Go 语言运行时采用了一种称为 M:N 调度模型(也称为多路复用模型)来调度 Goroutine。在这种模型下,多个 Goroutine 可以被映射到多个操作系统线程(M)上,运行时通过自己的调度器来管理 Goroutine 的调度。Goroutine 的调度发生在用户态,不需要陷入内核态,因此调度开销相对较小。运行时的调度器使用协作式调度(cooperative scheduling),即 Goroutine 在执行过程中会主动让出 CPU 给其他 Goroutine,而不是像线程那样由操作系统强制抢占。这种调度方式避免了线程调度中的一些开销,如频繁的上下文切换和缓存失效等问题。以下通过一个简单的例子来展示 Goroutine 调度的优势:
package main

import (
    "fmt"
    "time"
)

func heavy_work() {
    for i := 0; i < 1000000000; i++ {
        // 模拟一些计算工作
        _ = i * i
    }
}

func main() {
    go heavy_work()
    time.Sleep(1 * time.Second)
    fmt.Println("Main function continues without waiting for heavy_work.")
}

在上述代码中,启动了一个执行大量计算的 Goroutine,主函数没有等待该 Goroutine 完成就继续执行了。如果是传统线程,在执行 heavy_work 函数时可能会阻塞主线程,而 Goroutine 的调度机制允许主函数继续运行。

  1. 内存开销对比
    • 线程内存开销:每个线程都需要有自己独立的栈空间,用于存储局部变量、函数调用信息等。线程栈的大小通常在创建线程时就确定下来,并且一般比较大,例如在 Linux 系统中,默认的线程栈大小可能是 8MB。这意味着如果创建大量线程,会消耗大量的内存资源。此外,操作系统还需要为每个线程维护一些内核数据结构,如线程控制块(TCB)等,这也会占用一定的内存。
    • Goroutine 内存开销:Goroutine 的初始栈空间非常小,通常只有 2KB 左右。而且 Goroutine 的栈空间是可以动态增长和收缩的,当 Goroutine 需要更多的栈空间时,运行时会自动为其分配,当栈空间不再使用时,又会回收。这种动态的栈管理方式使得在创建大量 Goroutine 时,内存开销相对较小。例如,我们可以创建数以万计的 Goroutine,而不会像创建相同数量的线程那样导致内存耗尽。以下代码通过计算创建一定数量的线程和 Goroutine 所占用的内存来进行对比(在 Go 语言中通过分析内存使用情况,在 C++ 中通过监控进程内存使用):
package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    var memStats runtime.MemStats
    runtime.ReadMemStats(&memStats)
    startMemory := memStats.Alloc
    for i := 0; i < 100000; i++ {
        go func() {}()
    }
    time.Sleep(1 * time.Second)
    runtime.ReadMemStats(&memStats)
    endMemory := memStats.Alloc
    fmt.Printf("Memory increase after creating 100,000 goroutines: %d bytes\n", endMemory - startMemory)
}

在 C++ 中,可以使用 getrusage 等系统调用来获取进程内存使用情况,通过创建大量线程前后的内存使用差值来分析线程的内存开销。实际运行中可以发现,创建相同数量的 Goroutine 比线程所增加的内存量要小很多。

三、适用场景

  1. 高并发 I/O 场景
    • Goroutine 的适用性:在高并发 I/O 场景中,如网络服务器处理大量客户端连接、文件读写等操作,Goroutine 表现出极大的优势。由于 I/O 操作通常是阻塞的,传统线程在进行 I/O 操作时会占用线程资源,导致线程无法执行其他任务。而 Goroutine 采用的协作式调度模型,当一个 Goroutine 进行 I/O 操作时,它会主动让出 CPU,让其他 Goroutine 有机会执行。这使得在处理大量 I/O 任务时,可以高效地利用系统资源,提高整体的并发性能。例如,在一个简单的 HTTP 服务器实现中:
package main

import (
    "fmt"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello, World!")
}

func main() {
    http.HandleFunc("/", handler)
    fmt.Println("Server is listening on :8080")
    go http.ListenAndServe(":8080", nil)
    // 模拟其他工作
    for {
        fmt.Println("Main function is doing other work.")
        time.Sleep(1 * time.Second)
    }
}

在上述代码中,通过 go 关键字启动了一个 HTTP 服务器 Goroutine,主函数可以继续执行其他任务。当有多个客户端连接时,每个请求都可以由一个新的 Goroutine 来处理,实现高并发的 I/O 处理。

  • 线程在该场景的不足:如果使用线程来处理高并发 I/O,由于线程的阻塞特性,当一个线程进行 I/O 操作时,其他线程可能处于等待状态,无法充分利用 CPU 资源。而且创建大量线程来处理高并发 I/O 会带来巨大的内存开销和调度开销,容易导致系统性能下降甚至崩溃。
  1. 计算密集型场景
    • 线程的适用性:对于计算密集型任务,由于任务主要消耗 CPU 资源,线程在某些情况下具有一定优势。在多核系统中,线程可以充分利用多核 CPU 的性能,通过将计算任务分配到不同的线程上并行执行,可以提高计算效率。例如,在进行矩阵乘法等大量数值计算的场景中,可以将矩阵划分成多个部分,每个部分由一个线程进行计算。以 C++ 实现简单的矩阵乘法多线程计算为例:
#include <iostream>
#include <thread>
#include <vector>

const int N = 1000;
std::vector<std::vector<int>> matrixA(N, std::vector<int>(N));
std::vector<std::vector<int>> matrixB(N, std::vector<int>(N));
std::vector<std::vector<int>> result(N, std::vector<int>(N));

void multiply(int start, int end) {
    for (int i = start; i < end; i++) {
        for (int j = 0; j < N; j++) {
            for (int k = 0; k < N; k++) {
                result[i][j] += matrixA[i][k] * matrixB[k][j];
            }
        }
    }
}

int main() {
    // 初始化矩阵A和矩阵B
    for (int i = 0; i < N; i++) {
        for (int j = 0; j < N; j++) {
            matrixA[i][j] = i + j;
            matrixB[i][j] = i - j;
        }
    }
    std::thread t1(multiply, 0, N / 2);
    std::thread t2(multiply, N / 2, N);
    t1.join();
    t2.join();
    return 0;
}

在上述代码中,将矩阵乘法任务分成两部分,由两个线程并行执行,提高了计算效率。

  • Goroutine 在该场景的不足:虽然 Goroutine 可以通过使用多核 CPU 来实现并行计算,但是由于其协作式调度的特性,在计算密集型任务中,如果一个 Goroutine 长时间占用 CPU 而不主动让出,会导致其他 Goroutine 无法执行。Go 语言运行时提供了一些机制,如 runtime.Gosched() 函数可以让当前 Goroutine 主动让出 CPU,但在实际的计算密集型任务中,合理地插入这些调用并不容易,可能会影响代码的逻辑和性能。因此,在纯粹的计算密集型场景中,线程可能是更好的选择。
  1. 资源受限场景

    • Goroutine 的适用性:在资源受限的场景中,如嵌入式系统、移动设备等,内存和 CPU 资源都比较有限。Goroutine 的轻量级特性使其非常适合这种场景。由于创建和管理 Goroutine 的开销小,内存占用少,可以在有限的资源下创建更多的并发任务。例如,在一个小型的物联网设备中,可能需要同时处理多个传感器的数据采集和处理任务,使用 Goroutine 可以高效地实现这些并发操作,而不会过多地消耗设备资源。
    • 线程在该场景的不足:线程的创建和管理开销较大,在资源受限的环境中,创建大量线程可能会导致内存不足或者系统性能急剧下降。而且线程的调度开销也会占用一定的 CPU 资源,对于资源有限的设备来说,这可能是无法承受的。
  2. 分布式系统场景

    • Goroutine 的适用性:在分布式系统中,需要处理大量的网络通信和并发任务。Goroutine 与 Go 语言的网络编程库结合得非常好,能够轻松地实现高并发的网络通信。例如,在一个分布式文件系统中,客户端与多个服务器节点进行通信,获取和存储文件数据。可以使用 Goroutine 来处理每个客户端请求,实现高效的并发处理。同时,Goroutine 之间通过通道(channel)进行通信,这种基于消息传递的并发模型非常适合分布式系统中的数据交互和同步,能够有效地避免分布式系统中常见的数据竞争和一致性问题。以下是一个简单的分布式系统示例,使用 Goroutine 和通道进行节点间通信:
package main

import (
    "fmt"
)

func node1(ch chan int) {
    ch <- 10
}

func node2(ch chan int) {
    data := <-ch
    fmt.Printf("Node 2 received data: %d\n", data)
}

func main() {
    ch := make(chan int)
    go node1(ch)
    go node2(ch)
    // 防止主函数退出
    select {}
}

在上述代码中,模拟了两个分布式节点之间通过通道进行数据传递。

  • 线程在该场景的挑战:虽然线程也可以用于分布式系统中的网络通信和并发处理,但是线程的共享内存模型在分布式环境中容易导致数据竞争和一致性问题。在分布式系统中,不同节点之间的通信和同步更加复杂,使用线程的共享内存方式来处理可能会带来更多的调试和维护成本。而且线程的开销在分布式系统中也可能成为性能瓶颈,尤其是当系统规模较大时。

四、性能优化与注意事项

  1. Goroutine 性能优化
    • 合理设置 Goroutine 数量:虽然 Goroutine 的创建开销小,但如果创建过多的 Goroutine,也会带来性能问题。过多的 Goroutine 会导致调度器频繁调度,增加调度开销,同时也会消耗更多的内存资源。可以根据系统的 CPU 核心数、任务类型和可用内存等因素来合理设置 Goroutine 的数量。例如,对于 CPU 密集型任务,可以将 Goroutine 的数量设置为与 CPU 核心数相近,以充分利用 CPU 资源;对于 I/O 密集型任务,可以适当增加 Goroutine 的数量,以提高并发度。可以使用 runtime.GOMAXPROCS 函数来设置 Go 语言运行时使用的 CPU 核心数,进而影响 Goroutine 的调度。
    • 优化通道使用:通道(channel)是 Goroutine 之间通信和同步的重要工具。在使用通道时,要注意避免通道的阻塞和不必要的等待。例如,要合理设置通道的缓冲区大小,避免缓冲区过小导致频繁的阻塞,也避免缓冲区过大导致数据积压。同时,要注意通道关闭的时机,及时关闭通道可以避免 Goroutine 因等待接收已关闭通道的数据而陷入死锁。以下代码展示了如何合理设置通道缓冲区大小:
package main

import (
    "fmt"
)

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

func consumer(ch chan int) {
    for data := range ch {
        fmt.Printf("Consumed: %d\n", data)
    }
}

func main() {
    ch := make(chan int, 5)
    go producer(ch)
    go consumer(ch)
    // 防止主函数退出
    select {}
}

在上述代码中,通道 ch 的缓冲区大小设置为 5,可以根据实际情况进行调整。

  1. 线程性能优化
    • 减少上下文切换:上下文切换是线程调度中的主要开销之一。可以通过合理安排线程任务,减少线程的切换频率。例如,将相关的任务分配到同一个线程中执行,避免频繁地在不同线程之间切换。同时,在多核系统中,可以通过设置线程的亲和性(affinity),将线程绑定到特定的 CPU 核心上,减少跨核心的调度,提高缓存命中率,从而减少上下文切换的开销。在 Linux 系统中,可以使用 sched_setaffinity 函数来设置线程的亲和性。
    • 优化锁的使用:在多线程编程中,锁是保护共享资源的常用手段。但锁的使用不当会导致性能瓶颈,如死锁、锁争用等问题。要尽量减少锁的粒度,只在必要的代码段加锁,并且尽量缩短持有锁的时间。可以使用读写锁(如 std::shared_mutex 在 C++ 中)来提高并发读操作的性能,允许多个线程同时进行读操作,只有在写操作时才需要独占锁。以下是一个使用读写锁优化多线程读写操作的 C++ 示例:
#include <iostream>
#include <thread>
#include <shared_mutex>

std::shared_mutex rwMutex;
int data = 0;

void read() {
    std::shared_lock<std::shared_mutex> lock(rwMutex);
    std::cout << "Read data: " << data << std::endl;
}

void write() {
    std::unique_lock<std::shared_mutex> lock(rwMutex);
    data++;
    std::cout << "Write data: " << data << std::endl;
}

int main() {
    std::thread t1(read);
    std::thread t2(read);
    std::thread t3(write);
    t1.join();
    t2.join();
    t3.join();
    return 0;
}

在上述代码中,通过 std::shared_mutex 实现了读写锁,提高了多线程读写操作的性能。

  1. 通用注意事项
    • 避免数据竞争:无论是在多线程编程还是在使用 Goroutine 进行并发编程中,数据竞争都是一个常见的问题。数据竞争会导致程序出现不可预测的行为,如程序崩溃、数据错误等。在多线程编程中,要通过锁机制、原子操作等方式来保护共享资源;在 Go 语言中,虽然通道可以帮助避免一些数据竞争问题,但在使用共享变量时,也需要使用 sync 包中的工具(如 sync.Mutex)来保护共享资源。例如,在 Go 语言中:
package main

import (
    "fmt"
    "sync"
)

var (
    counter int
    mutex   sync.Mutex
)

func increment(wg *sync.WaitGroup) {
    defer wg.Done()
    mutex.Lock()
    counter++
    mutex.Unlock()
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go increment(&wg)
    }
    wg.Wait()
    fmt.Printf("Final counter value: %d\n", counter)
}

在上述代码中,通过 sync.Mutex 来保护共享变量 counter,避免数据竞争。

  • 错误处理:在并发编程中,错误处理尤为重要。由于并发任务的异步性,错误可能在不同的 Goroutine 或线程中发生,并且不容易被及时捕获和处理。在 Go 语言中,可以通过通道来传递错误信息,在多线程编程中,可以通过设置全局错误变量或者使用线程局部存储(TLS)来存储和传递错误信息。例如,在 Go 语言中:
package main

import (
    "fmt"
)

func divide(a, b int, resultChan chan int, errChan chan error) {
    if b == 0 {
        errChan <- fmt.Errorf("division by zero")
        return
    }
    resultChan <- a / b
}

func main() {
    resultChan := make(chan int)
    errChan := make(chan error)
    go divide(10, 2, resultChan, errChan)
    select {
    case result := <-resultChan:
        fmt.Printf("Result: %d\n", result)
    case err := <-errChan:
        fmt.Printf("Error: %v\n", err)
    }
}

在上述代码中,通过通道 errChan 传递错误信息,以便在主函数中进行处理。

通过对 Go 语言协程(Goroutine)与线程在性能和适用场景方面的详细对比和分析,开发者可以根据具体的应用需求选择更合适的并发编程模型,同时通过合理的性能优化和注意事项,提高并发程序的性能和稳定性。无论是在高并发 I/O 场景、计算密集型场景还是资源受限场景等,正确选择和使用 Goroutine 或线程都能为程序的性能带来显著提升。