Skip to content

并发系统:go 调度器的实现和Erlang 调度器实现 #11

@htoooth

Description

@htoooth

我们可以从整体设计目标开始理解这两种调度器的实现和设计理念。虽然 Go 和 Erlang 都致力于支持数以百万计的轻量级并发任务,但它们在调度器的实现细节和设计侧重点上截然不同。下面我们从实现细节、设计理念以及达到目标的方式三个层面来探讨这一问题。


1. 实现细节概述

Go 调度器

  • M、P、G 模型
    Go 的运行时采用 M:N 调度模型,即将操作系统线程(M)、逻辑处理器(P)和 goroutine(G)三者结合。

    • M(Machine):代表实际的操作系统线程。
    • P(Processor):类似逻辑处理器,为 goroutine 调度提供上下文,每个 P 持有一个本地运行队列。
    • G(Goroutine):用户级简单并发单元,成本极低。
      在这种设计中,goroutine 不会直接与操作系统线程绑定,而是由 P 管理分派给某个 M 执行。
  • 本地队列与全局队列
    每个 P 都有自己的本地运行队列,存放待执行的 goroutine。当本地队列为空时,会使用工作窃取(work stealing)策略从其他 P 的队列中获取任务;此外,当负载不均衡或者某些情况(如创建了新的 goroutine)发生时,还会借助全局队列做统一调度。

  • 抢占与异步调度
    早期 Go 调度器偏重于协作式调度,goroutine 通常主动让出控制权(例如函数调用 runtime.Gosched())。近些版本中,为了解决长时间运行 goroutine 的问题,Go 引入了异步抢占机制,在检测到某个 goroutine 运行时间过长时,会在合适的检查点将其抢占,保证系统整体响应性。

  • 处理阻塞与系统调用
    如果某个 goroutine 因系统调用或者阻塞操作而挂起,Go 调度器会将对应的 M 标记为处于阻塞状态,并安排其它 goroutine 执行。这种设计确保了少数阻塞操作不会拖累整个系统的并发性能。


Erlang 调度器

  • 轻量级进程模型
    Erlang 的调度器是 BEAM 虚拟机的一部分,采用了与 Go 类似的 M:N 模型,但更侧重于极轻量级的“进程”调度。每个 Erlang 进程都是独立的并发实体,内存开销非常低,创建和销毁都极其高效。

  • 独立的调度器线程与本地队列
    BEAM 启动时会创建多个调度器线程(通常与物理核心数相当),每个调度器线程都有自己的运行队列。调度器之间也支持工作窃取,当某个调度器队列空闲时,会窃取其他队列中的任务,实现负载均衡。

  • 抢占式调度与“reductions”
    Erlang 采用严格的抢占式调度,每个进程在一次连续运行中只允许执行一定数量的“reductions”(可以理解为轻量级指令计数)。当进程执行的 reductions 达到预设值后,调度器自动中断该进程,将 CPU 分配给其他进程。这样避免了任一进程长时间霸占 CPU,保证了系统的响应性和公平性。

  • 内建的负载均衡
    除了工作窃取外,Erlang 调度器还配有负载均衡策略,确保在多核和多调度器环境下,各个调度器之间的任务可以平滑迁移,防止某个核心过载。


2. 设计理念与侧重点的对比

  • 目标与应用场景

    • Go 的主要设计目标是为开发者提供一种简单而高效的并发编程模型,让开发者能够以接近操作系统线程的性能写出数以万计甚至更多的并发任务。Go 的设计面向常规应用开发,强调吞吐量与多核利用率,同时希望隐藏大部分底层复杂性。
    • Erlang 则从一开始就被设计为构建高可用、高并发以及分布式容错系统的语言。其调度器更加关注实时性、公平性以及进程间严格隔离,确保即使在极端负载下,每个进程都能在规定时间内获得调度,从而支持“让它崩溃”(let it crash)的容错理念。
  • 调度策略

    • Go 依赖于本地队列与全局队列相互结合的工作窃取策略,以及后期引入的异步抢占机制,使得 goroutine 能够在高并发场景下高效运行。但早期的设计更多依赖于协作式调度,某些长运行任务需要通过显式调用让出控制权。
    • Erlang 使用基于 reductions 的硬性时间片控制,每个进程都有明确的执行“配额”,在用完后自动让出,从调度器角度保证了公平性。这种机制在大规模调度上非常适合高并发、低延迟应用场景。
  • 资源管理与隔离性

    • Go 的 goroutine 设计注重低内存开销和快速创建,但在错误隔离上则更多依靠开发者通过 panic/recover 模型处理。
    • Erlang 通过轻量级进程与内建的监控/重启机制(比如监督树)实现了严格的错误隔离与容错能力,使得单个进程崩溃不会影响整个系统。

3. 如何达到设计目标

Go 调度器如何实现高并发与高吞吐量

  • 分层调度模型:
    通过将调度工作分布在 P、本地队列和全局队列上,Go 调度器既减少了竞争开销,又能够灵活地平衡负载。工作窃取机制进一步保障了在任务不均衡情况下,空闲的 P 能主动获取任务,使资源利用更高效。

  • 异步抢占与预留检查点:
    为防止单个 goroutine 独占 CPU,经过 Go 1.14 后,调度器在运行时会定期检测长时间运行的 goroutine,并在合适的抢占点进行中断,从而保证系统整体响应性不受个别任务影响。

  • 对阻塞调用的智能处理:
    当 goroutine 调用可能会阻塞的系统调用时,运行时会将其转入等待状态,让出所占用的 M,以便其他 goroutine 得以利用,这样既不会浪费核心资源,也减少了线程上下文切换的开销。

Erlang 调度器如何实现高实时性与容错

  • 严格的抢占式调度:
    基于 reductions 的计数机制确保了每个进程在每个调度周期内只能运行有限的指令,使得多个进程可以在极短时间内轮流获得 CPU 执行机会,这对于实时系统及服务型应用至关重要。

  • 多调度器线程与本地队列:
    每个 CPU 核心通常绑定一个调度器线程,各自独立运行,减少了锁竞争。而工作窃取机制在某个线程任务不足时进行负载均衡,有效避免了局部过载现象。

  • 轻量级内存与进程隔离:
    Erlang 的进程不仅在内存占用上极为轻量,而且各自拥有独立的堆,这种设计不仅方便调度器高效切换进程,还使得错误可以被局部化,符合 Erlang 的容错设计理念。


总结

  • Go:通过 M/P/G 模型、局部与全局队列、工作窃取与异步抢占等机制,实现了在多核环境下高效调度海量 goroutine。这种设计旨在为通用应用提供低成本并发、简洁易用的编程模型,适合高吞吐量以及多任务协作的场景。

  • Erlang:依托 BEAM 虚拟机,利用轻量级进程、固定 reductions 数量的抢占式调度、独立调度器线程以及工作窃取,实现了严格公平与高实时性调度。其设计重点在于构建高容错、分布式实时系统,确保即使在极高负载下,各个小进程也能快速获得执行机会,从而构建稳定、可靠的系统。


衍生讨论

除此之外,深入理解这两种调度器的细节还可以引出许多有趣的话题:

  • 内存管理与垃圾回收:Go 与 Erlang 分别在运作时如何配合调度器进行内存分配与垃圾回收。
  • 调试与性能调优:如何通过分析调度器的行为来优化并发性能,以及各自调度机制在高并发压力下表现出的瓶颈。
  • 跨语言调度对比:对比其他语言(如 Rust 异步编程模型、Java 虚拟机调度等)与 Go/Erlang 的区别,从而更全面地理解现代并发系统设计的多样性。

这些都是非常值得深入探讨的方向,若你对这些问题感兴趣,我们能进一步展开讨论。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions