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

Go Goroutine与线程的调度策略差异

2023-10-276.7k 阅读

Go Goroutine基础

Goroutine简介

Goroutine是Go语言中实现并发编程的核心机制。它类似于线程,但又有着本质的区别。从开发者的角度看,创建一个Goroutine非常简单,只需在函数调用前加上go关键字即可。例如:

package main

import (
    "fmt"
    "time"
)

func printNumbers() {
    for i := 1; i <= 5; i++ {
        fmt.Printf("Number: %d\n", i)
        time.Sleep(100 * time.Millisecond)
    }
}

func printLetters() {
    for i := 'a'; i <= 'e'; i++ {
        fmt.Printf("Letter: %c\n", i)
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    go printNumbers()
    go printLetters()

    time.Sleep(1000 * time.Millisecond)
    fmt.Println("Main function exiting")
}

在上述代码中,printNumbersprintLetters函数在各自的Goroutine中并发执行。main函数中通过go关键字启动这两个Goroutine,然后通过time.Sleep等待一段时间,确保这两个Goroutine有足够的时间执行,最后输出"Main function exiting"。

Goroutine的轻量级特性

与传统线程相比,Goroutine非常轻量级。在Go语言中,创建和销毁Goroutine的开销极小。一个程序中可以轻松创建数以万计的Goroutine,而在传统线程模型下,创建大量线程会导致系统资源耗尽。这是因为Goroutine的栈空间是动态分配的,初始栈空间很小(通常只有2KB左右),随着需求增长才会动态扩展,而线程的栈空间在创建时就需要分配较大的固定大小(一般为几MB)。

线程基础

线程的概念

线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一个进程可以包含多个线程,这些线程共享进程的资源,如内存空间、文件描述符等。在多线程编程中,每个线程可以独立执行不同的任务,从而实现并发执行。

例如,在C++ 中使用std::thread库创建线程:

#include <iostream>
#include <thread>

void printNumbers() {
    for (int i = 1; i <= 5; i++) {
        std::cout << "Number: " << i << std::endl;
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
}

void printLetters() {
    for (char i = 'a'; i <= 'e'; i++) {
        std::cout << "Letter: " << i << std::endl;
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
}

int main() {
    std::thread t1(printNumbers);
    std::thread t2(printLetters);

    t1.join();
    t2.join();

    std::cout << "Main function exiting" << std::endl;
    return 0;
}

在这段C++ 代码中,通过std::thread创建了两个线程t1t2,分别执行printNumbersprintLetters函数。join方法用于等待线程执行完毕,最后输出"Main function exiting"。

线程的调度方式

线程的调度通常由操作系统内核负责。操作系统采用不同的调度算法来决定在某个时刻哪个线程可以获得CPU时间片。常见的调度算法有先来先服务(FCFS)、短作业优先(SJF)、时间片轮转(Round - Robin)等。例如,时间片轮转算法为每个线程分配一个固定长度的时间片,当时间片用完后,操作系统将线程挂起,切换到下一个就绪线程执行。这种调度方式能够保证每个线程都有机会执行,避免某个线程长时间占用CPU资源。

Go调度器

Go调度器的架构

Go调度器采用了M:N的调度模型,即多个Goroutine映射到多个操作系统线程上。它主要由三个组件构成:M(Machine)、P(Processor)和G(Goroutine)。

  • M:代表操作系统线程,是真正执行代码的实体。M的数量一般与系统的CPU核心数相关,默认情况下,Go程序启动时会根据CPU核心数创建相应数量的M。例如,在一个4核CPU的系统上,Go调度器默认会创建4个M。
  • P:Processor,它包含了运行Goroutine的资源,如Goroutine队列等。P的数量可以通过runtime.GOMAXPROCS函数进行设置,它决定了同时可以运行的Goroutine的最大数量。例如,runtime.GOMAXPROCS(2)表示设置P的数量为2,即最多同时有2个Goroutine在不同的M上运行。
  • G:即Goroutine,它是用户级的轻量级线程。G被分配到P的本地队列或全局队列中等待执行,当一个M绑定到一个P时,它会从P的本地队列或全局队列中获取G并执行。

Go调度器的工作流程

  1. Goroutine创建:当使用go关键字创建一个新的Goroutine时,它首先被放入创建它的P的本地队列中。如果本地队列已满,则会被放入全局队列。
  2. M与P的绑定:M在启动时会尝试获取一个P,如果获取成功,则将P与自己绑定。绑定后,M从P的本地队列中获取Goroutine并执行。
  3. 调度执行:M执行Goroutine的过程中,如果遇到I/O操作、系统调用或主动调用runtime.Gosched()函数等情况,Goroutine会被暂停,M会将当前Goroutine放回P的队列,然后尝试从队列中获取下一个Goroutine执行。如果本地队列空了,M会尝试从全局队列或其他P的队列中窃取Goroutine来执行,这就是Go调度器的工作窃取(work - stealing)机制。例如,假设P1的本地队列空了,而P2的本地队列有大量Goroutine,M1(与P1绑定)就会从P2的队列中窃取一些Goroutine来执行,以充分利用CPU资源。

Go调度器的优势

这种调度模型使得Go在并发编程方面具有很高的效率和可扩展性。由于Goroutine的轻量级特性和高效的调度算法,Go程序能够在有限的系统资源下处理大量的并发任务。同时,工作窃取机制保证了CPU资源的充分利用,避免了某个P的队列任务堆积而其他P空闲的情况。例如,在一个高并发的网络服务器应用中,大量的Goroutine用于处理客户端连接,Go调度器能够快速调度这些Goroutine,高效地处理请求,而不会因为线程创建和调度的开销导致性能下降。

线程调度策略

操作系统线程调度策略

  1. 先来先服务(FCFS):按照线程进入就绪队列的先后顺序来分配CPU时间片。先进入队列的线程先获得CPU资源并执行,直到执行完毕或主动放弃CPU。这种调度策略实现简单,但对于长作业不利,因为它会使短作业等待较长时间。例如,假设有两个线程T1和T2,T1是一个长时间运行的计算任务,T2是一个短时间的I/O任务。如果采用FCFS调度策略,T2需要等待T1执行完毕才能获得CPU资源,这会导致T2的响应时间变长。
  2. 短作业优先(SJF):优先调度预计执行时间最短的线程。这种调度策略可以减少作业的平均等待时间,提高系统的吞吐量。但它需要事先知道每个线程的执行时间,在实际应用中往往难以准确获取。例如,在一个批处理系统中,如果能够准确估计每个作业的执行时间,采用SJF调度策略可以使整体的作业完成时间更短。
  3. 时间片轮转(Round - Robin):为每个线程分配一个固定长度的时间片,当时间片用完后,即使线程没有执行完毕,也会被挂起,放入就绪队列末尾,等待下一轮调度。这种调度策略保证了每个线程都有机会执行,公平性较好,适用于交互式系统。例如,在一个多用户的操作系统中,多个用户的任务以线程形式运行,采用时间片轮转调度策略可以使每个用户的任务都能及时得到响应。

线程调度的上下文切换开销

线程调度过程中的上下文切换开销较大。当操作系统从一个线程切换到另一个线程时,需要保存当前线程的寄存器状态、程序计数器等信息,然后恢复下一个线程的相关信息。这个过程涉及到内核态和用户态的切换,需要消耗一定的CPU时间。例如,在一个频繁进行线程上下文切换的程序中,大量的CPU时间会浪费在保存和恢复线程上下文上,导致实际执行任务的时间减少,从而降低系统性能。

Goroutine与线程调度策略差异

调度粒度差异

  1. 线程调度粒度:线程调度粒度相对较粗。操作系统内核在调度线程时,通常以时间片为单位,时间片的长度一般在几十毫秒到几百毫秒之间。在一个时间片内,线程独占CPU资源,直到时间片用完或主动放弃CPU。例如,在一个采用时间片轮转调度策略的系统中,假设时间片长度为100毫秒,一个线程在获得CPU后会连续执行100毫秒,期间其他线程无法执行。
  2. Goroutine调度粒度:Goroutine调度粒度更细。Go调度器在调度Goroutine时,并不依赖于操作系统的时间片,而是根据Goroutine自身的执行情况进行调度。例如,当一个Goroutine执行I/O操作或调用runtime.Gosched()函数时,会主动让出CPU,此时Go调度器可以立即调度其他Goroutine执行。这种细粒度的调度使得Go程序能够更高效地利用CPU资源,在相同的时间内可以处理更多的并发任务。

调度器实现差异

  1. 线程调度器:线程调度器由操作系统内核实现,是系统级的调度器。它管理系统中所有进程的线程,需要考虑系统资源的整体分配和平衡。例如,操作系统需要根据系统中所有进程的线程数量、优先级等因素,合理分配CPU时间片,以保证整个系统的稳定性和性能。
  2. Go调度器:Go调度器是用户级的调度器,运行在Go程序的用户空间内。它只负责调度本Go程序内的Goroutine,与操作系统的线程调度相互独立。Go调度器采用的M:N调度模型,使得它可以根据Goroutine的特点进行优化调度,例如通过工作窃取机制提高CPU利用率。

上下文切换开销差异

  1. 线程上下文切换开销:如前文所述,线程上下文切换涉及内核态和用户态的切换,需要保存和恢复大量的寄存器状态、程序计数器等信息,开销较大。频繁的线程上下文切换会严重影响系统性能。例如,在一个多线程的网络服务器中,如果线程数量过多且频繁进行上下文切换,会导致服务器的响应速度变慢,吞吐量降低。
  2. Goroutine上下文切换开销:Goroutine上下文切换开销相对较小。由于Goroutine是用户级线程,上下文切换发生在用户空间内,不需要陷入内核态。Go调度器在切换Goroutine时,只需要保存和恢复少量的寄存器信息,因此切换速度更快。例如,在一个高并发的Go程序中,即使创建了大量的Goroutine,由于其上下文切换开销小,程序依然能够高效运行。

资源占用差异

  1. 线程资源占用:线程的栈空间在创建时就需要分配较大的固定大小,一般为几MB。同时,每个线程都需要操作系统内核分配一定的资源,如内核栈等。因此,创建大量线程会消耗大量的系统资源,容易导致系统资源耗尽。例如,在一个内存有限的系统中,如果创建过多的线程,可能会导致内存不足,程序崩溃。
  2. Goroutine资源占用:Goroutine的栈空间初始很小(通常只有2KB左右),并且可以动态扩展。这使得在一个程序中可以轻松创建数以万计的Goroutine,而不会占用过多的系统资源。例如,在一个需要处理大量并发请求的Go网络应用中,可以为每个请求创建一个Goroutine,而不会对系统资源造成过大压力。

示例对比

  1. 线程示例(C++)
#include <iostream>
#include <thread>
#include <vector>

void heavyWork() {
    for (int i = 0; i < 1000000000; i++) {
        // 模拟繁重计算
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; i++) {
        threads.emplace_back(heavyWork);
    }

    for (auto& thread : threads) {
        thread.join();
    }

    std::cout << "All threads completed" << std::endl;
    return 0;
}

在这个C++ 示例中,创建了10个线程执行heavyWork函数,每个线程都需要分配较大的栈空间,并且线程上下文切换开销较大。如果创建更多线程,可能会导致系统资源紧张。

  1. Goroutine示例(Go)
package main

import (
    "fmt"
)

func heavyWork() {
    for i := 0; i < 1000000000; i++ {
        // 模拟繁重计算
    }
}

func main() {
    for i := 0; i < 10000; i++ {
        go heavyWork()
    }

    fmt.Println("All goroutines started")
    select {}
}

在这个Go示例中,轻松创建了10000个Goroutine执行heavyWork函数。由于Goroutine的轻量级特性和高效的调度策略,程序能够正常运行,不会因为资源占用过多或上下文切换开销过大而出现问题。

通过以上对比,可以清晰地看到Goroutine与线程在调度策略上的诸多差异,这些差异使得Go语言在并发编程方面具有独特的优势,能够更高效地处理大量的并发任务。