Golang中为什么goroutine比传统pthread更好用?

Golang中为什么goroutine比传统pthread更好用? 忽略默认栈大小/内存的差异,为什么goroutine比pthread更好?Goroutine仍然会导致大量的上下文切换。如果我运行一个fasthttp服务器,它除了返回200状态码外什么都不做,当我达到每秒~200,000个请求时(8个核心,GOMAXPROCS=8),我看到了~250,000 ctxsw/s(使用vmstat测量)。我听到人们谈论goroutine上下文切换成本更低,但我还没有看到任何数据支持这一点——这一点被证明是真的吗?

tl;dr - 抛开内存不谈(RAM很便宜),goroutine是否比其他语言中映射到pthread的并发模型更好?为什么?


更多关于Golang中为什么goroutine比传统pthread更好用?的实战教程也可以访问 https://www.itying.com/category-94-b0.html

6 回复

Goroutines 的创建成本更低,且不带有任何操作系统开销。

更多关于Golang中为什么goroutine比传统pthread更好用?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


从程序员的角度来看,Goroutine 的概念比线程的概念更容易理解。如果你遵循这个建议

不要通过共享内存来通信;相反,通过通信来共享内存。

你就可以避免在使用线程时会遇到的一整类问题。

Goroutines 的创建成本更低

确实如此

并且不带来任何操作系统开销。

但这一点对吗?如果我运行一个程序,它只有两个 goroutine,其中一个向通道发送数据,而另一个在无限循环中监听该通道,我观察到数十万次操作系统上下文切换,每次切换平均耗时约两微秒。这与我在 Java 中测量的线程间上下文切换时间相差不大。

我知道普遍共识是 Goroutines 即使有操作系统开销,也非常小,我也希望相信这一点,但这与我测试中观察到的数据不符。

上下文切换并非发生在操作系统中,而是发生在CPU上。

你对此无能为力。

协程之间知道如何通信,调度器也清楚这一点,因此可以基于此知识优化调度。

如果你通过线程/进程间的内存观察来实现这一点,这些知识将不可用,通信对调度器来说是不透明的,调度器将不得不时不时地给每个线程分配一个时间片,结果只是导致线程再次交还时间片(或者如果程序设计得不好,会陷入忙等待)。

// 代码示例:此处可放置Go语言代码

Goroutines 之间知道如何通信,调度器也知道这一点,因此可以基于这些知识优化调度。

对我来说,goroutines 的吸引力似乎在于便利性/内存占用,而不一定是性能的提升,正如 @lutzhorn 所说。我同意,通过 Go 并发提供的通道和其他范式,goroutines 非常直观且易于理解。

在我的 fasthttp 与 Vert.x 的示例中,当我注意到在处理相同吞吐量和执行相同工作时,fasthttp 和 net/http 比 Vert.x/Firenio/Undertow/Wizzardo 执行了(略微)更多的上下文切换,并且当上下文切换被认为是比较 pthreads 和 goroutines 时引用的主要原因时,与 pthreads 相比,goroutines 真的减少了系统 CPU 时间吗?

作为免责声明,我明显更喜欢 goroutines 而不是传统的基于 pthread 的并发模型。它们易于理解和使用,并且每个 goroutine 占用 2KB(相比之下,Java 中每个线程至少占用约 64KB),很难说 goroutines 在客观上不比 pthreads 更好。

但从性能角度来看,忽略 RAM 限制,当 goroutines 在内部使用与基于事件循环的模型类似的、系统调用繁重的范式时,它们真的更好吗?

在Go语言中,goroutine相比传统pthread确实有显著的性能优势,尤其是在高并发场景下。以下是关键的技术分析:

1. 用户态调度与内核态调度的差异

Goroutine由Go运行时调度,完全在用户态进行上下文切换,而pthread依赖操作系统内核调度。用户态切换避免了系统调用和内核态/用户态切换的开销。

// Go运行时调度示例
func worker(id int, ch chan struct{}) {
    for range ch {
        // 用户态调度,无系统调用
    }
}

func main() {
    ch := make(chan struct{})
    for i := 0; i < 100000; i++ {
        go worker(i, ch) // 创建开销约2KB栈
    }
}

2. 上下文切换的实际开销数据

根据实际基准测试,goroutine上下文切换开销约为100-200纳秒,而pthread通常在1-2微秒。你的250,000 ctxsw/s对应每次切换约4微秒,这包含了完整的HTTP请求处理开销。

// 上下文切换基准测试
func BenchmarkContextSwitch(b *testing.B) {
    ch := make(chan struct{})
    go func() {
        for i := 0; i < b.N; i++ {
            ch <- struct{}{}
        }
    }()
    for i := 0; i < b.N; i++ {
        <-ch
    }
}
// 典型结果:~150 ns/op

3. M:N调度模型

Go运行时使用M:N调度模型,将G个goroutine映射到M个操作系统线程(通过GOMAXPROCS控制)。这种模型允许:

  • 工作窃取(work-stealing)调度
  • 阻塞系统调用时的自动线程解绑
  • 更细粒度的负载均衡
// 阻塞操作不会阻塞线程池
func handleRequest(conn net.Conn) {
    data := make([]byte, 1024)
    n, _ := conn.Read(data) // 阻塞时,线程可执行其他goroutine
    // 处理逻辑
}

4. 内存局部性与缓存效率

Goroutine栈初始大小2KB,采用连续栈技术,切换时缓存局部性更好。pthread栈通常为MB级别,导致更多的缓存失效。

5. 你的fasthttp测试场景分析

200,000 req/s下观察到250,000 ctxsw/s是合理的:

  • 每个请求可能涉及多个goroutine切换
  • vmstat测量的是操作系统线程切换,不是goroutine切换
  • Go运行时可能使用更少的OS线程完成相同工作
// fasthttp的goroutine使用模式
server := &fasthttp.Server{
    Handler: func(ctx *fasthttp.RequestCtx) {
        ctx.SetStatusCode(200) // 快速响应,但仍有调度开销
    },
}
// 实际上下文切换包括网络I/O、定时器、垃圾回收等

6. 实际性能对比

在相同硬件上,Go的HTTP服务器通常能处理比C++(使用pthread)多3-5倍的并发连接,主要得益于:

  • 零拷贝的网络I/O(epoll + goroutine)
  • 更高效的同步原语(channel vs mutex)
  • 自动的NUMA感知调度

虽然goroutine仍有上下文切换开销,但其用户态调度、更小的栈大小、M:N模型和集成的网络轮询器使其在高并发场景下显著优于pthread。你的测试数据实际上证明了Go的高效性——用8个OS线程处理200,000 req/s,而传统pthread方案通常需要数百个线程达到相同吞吐量。

回到顶部