Golang中Goroutines与I/O操作详解

Golang中Goroutines与I/O操作详解 最近在一次面试中,我被问到:“在一台拥有 16 个 CPU 的机器上,你会创建多少个 goroutine 来发送 100 万个 HTTP 请求?”。经过一番思考后,我意识到自己并不知道答案,因为这很大程度上取决于许多不同的因素:

  • 请求的延迟(将有多少 goroutine 会处于等待状态)
  • 负载和响应的大小(序列化/反序列化所需的 CPU 负载)
  • 等等。

所以,我脑海中最先浮现的答案是“视情况而定”和“需要进行测试”。

我很想听听大家对这个问题的看法。

12 回复

16个goroutine就足够了。将请求添加到任务队列。

更多关于Golang中Goroutines与I/O操作详解的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


是的,对于预筛选来说,这个问题确实定义不清,而且过于开放了。

这是一份由招聘人员提供的15分钟7个问题的问卷,只是为了由技术负责人进行初步验证,以便开始讨论技术面试。😊 这正是我感到困惑并开始深入研究,试图弄清楚我遗漏了什么的原因。 感谢您的详尽解释。

我认为,一般来说,如果你不知道答案,你会想开始探索。

我会先编写一个不限数量的100万个协程的实现,看看它是否会出问题以及如何出问题。

根据我的经验,通常你遇到的第一个障碍是你向其发出请求的服务器的速率限制。

我不这么认为。Goroutine 不仅仅是一个异步调用,也不仅仅是像 JavaScript 中的 Promise 那样的抽象。它们并非没有代价。它们具有诸如栈大小之类的物理参数。因此,在处理 goroutine 时,你必须注意你的代码运行在什么环境(CPU、内存等)上。虽然你的假设在理论上可能是合理的,但在我看来,它并不实用。

依我之见,这个问题的答案是:我们掌握的信息不足,因为协程调度器是非确定性的,我们讨论的并非一个可以大致计算出正确数字的固定算法(例如分治算法)。做出如此多的假设与现实相去甚远。理论适用于那些可以施加抽象概念的固定算法。在这里,你至少需要知道在该CPU上调度所花费的时间、准备和发送请求、接收结果并丢弃它……这完全是徒劳的,没有哪个真正的工程师会在一个严肃的问题中提出这样的要求。

要发送100万个并发HTTP请求,您恰好需要100万个goroutine。

至于必要的资源,例如带宽、处理器、内存是否可用,则是另一个话题。无论如何,带宽取决于您发送的信息量,内存可能取决于您接收的信息量,处理器的数量并不重要,因为它们会被复用(goroutine不是线程!)。操作系统中描述符的数量必须增加以支持100万个请求,等等。但goroutine的数量始终等于并发请求的数量。

如果我们讨论的不是并发请求,而是在一个时间间隔内的100万个请求,那么可能需要根据我上面所说的进行一些计算。

这是一个陷阱问题吗?据我所知,每个请求都会创建一个新的 goroutine(假设我们使用的是标准库的 HTTP 服务器)。

编辑:哎呀。是发送请求;不是接收请求 :upside_down_face:

问题是要“发送请求”,而不是“接收请求”。

无论如何,对于“应该并行处理多少个请求”这类问题,我通常从以下假设开始:

  1. 可用带宽是无限的(网络和磁盘!)
  2. 延迟为零
  3. 远程服务器没有连接限制
  4. 我们有无限的文件描述符
  5. 我们完全拥有CPU

基于这些假设,我通常从 numprocs 的2到3倍开始,然后根据实验和观察进行调整。

对于任何其他“池应该多大”这类问题,我也会做类似的考量。

向面试官解释你的思考链、基础假设以及最初数字的推理过程,通常比你给出的具体答案重要得多。

如果我是面试官并提出这个问题,而你只是回答“32到48,然后调整”,那会是一个负面信号。我可能会试着引导你给出一些推理,但如果我必须直接问“你为什么认为这个数字合适”,那就不太妙了。

如果面试官真的只关心你说出他们问卷上写的那个数字,那么这份工作本身就不值得你花时间去面试……

一些可能影响您决策的参数:

  1. 什么是“请求”?

    1. 一个小的UDP数据包,发送后不管,主要受限于网络接口的吞吐量。
    2. 如果是一个TCP HTTPS请求,您的协程将需要等待响应(握手),根据目标服务器的不同,这可能需要很长时间,并且会受到打开连接/出站端口数量的限制。
    3. 如果请求包含数据(例如,来自您驱动器的一个文件),您将面临额外的I/O限制(打开文件的数量、随机磁盘访问速度)。
    4. 如果所有请求都发送到同一台服务器,它们可以复用单个HTTPS/2连接来处理所有流量。
  2. 您的环境是什么?

    1. 您受限于CPU、内存还是网络I/O?
    2. 您是否处于按使用付费的云环境中?最经济的利用率是什么,CPU/流量峰值是否非常昂贵?
  3. 您的目标是什么?

    1. 这是一个只需尽可能快运行的一次性脚本吗?
    2. 这是一个应该易于维护、调试、启动/停止并定期运行的服务吗?
    3. 错误/重连是一个重要因素吗?

根据所有这些因素,最佳的协程数量可能从几个到实际上的一百万个不等。

一些限制因素:

单个 Go 协程目前使用的最小栈大小为 2KB。你的实际代码很可能还会为每个 goroutine 在堆上分配一些额外的内存(例如用于 JSON 序列化或类似操作)。这意味着 100 万个 Go 协程可能轻易需要 2-4 GB 的内存(对于一般环境来说应该没问题)。

大多数操作系统会以各种方式限制连接数。对于 TCP/IP,通常每个接口有开放端口的限制。在许多现代系统上,这个限制大约是 28K。通常每个进程还有一个额外的限制(例如,ulimit 对打开文件描述符数量的限制),默认情况下大约为 1000。因此,在不更改操作系统配置的情况下,在 Linux 上最多只能有 1000 个并发连接。

因此,根据系统情况,你可能不应该创建超过 1000 个 goroutine,因为它们可能会开始失败,并出现“达到最大文件描述符数量”的错误,甚至丢包。

如果你提高了限制,你仍然受到单个 IP 地址 28K 个连接的限制。因此,如果所有 100 万个请求都使用单个出站地址,这可能是你 goroutine 数量的上限。

所以,合理的答案可能是 32(16 核 + 超线程 = 32 个并发线程),或 1000,或大约 28K(取决于配置)。

这是一个非常好的面试问题,因为它考察了对并发模型、系统资源和I/O密集型任务之间平衡的深刻理解。你的直觉“视情况而定”和“需要进行测试”是完全正确的起点,但我们可以进一步深入,给出一个在16核机器上处理百万级HTTP请求的典型架构思路和具体实现策略。

核心原则是:利用 goroutine 的轻量级特性来管理大量 I/O 等待,但同时使用工作池(worker pool)或信号量(semaphore)来避免无限制的并发数,防止耗尽系统资源(如文件描述符、内存)或对目标服务造成拒绝服务攻击。

一个经典的实现模式是:创建数量远小于请求总数但略高于CPU核心数的“工作者 goroutine”,并通过一个缓冲通道(channel)来分发任务。

以下是一个示例代码,它演示了如何通过控制并发工作者(worker)的数量来高效、安全地发送大量请求:

package main

import (
    "context"
    "fmt"
    "net/http"
    "sync"
    "time"
)

// 任务结构体,代表一个HTTP请求
type Task struct {
    ID   int
    URL  string
    // 可以包含其他字段,如请求方法、头部、体等
}

// 结果结构体,用于收集任务执行结果
type Result struct {
    TaskID    int
    StatusCode int
    Error     error
    Duration  time.Duration
}

func main() {
    // 模拟生成100万个任务
    numTasks := 1_000_000
    tasks := make(chan Task, 1000) // 带缓冲的任务队列
    go func() {
        defer close(tasks)
        for i := 0; i < numTasks; i++ {
            tasks <- Task{ID: i, URL: "https://httpbin.org/get"}
        }
    }()

    // 关键参数:工作者(goroutine)数量
    // 对于I/O密集型任务,此数量通常设置为 CPU 核心数的若干倍(例如 2x 到 10x)。
    // 在16核机器上,可以从32或64开始测试调整。
    numWorkers := 64

    // 用于收集结果的通道
    results := make(chan Result, 1000)
    var wg sync.WaitGroup

    // 创建工作者池
    for w := 0; w < numWorkers; w++ {
        wg.Add(1)
        go worker(w, tasks, results, &wg)
    }

    // 等待所有工作者完成,然后关闭结果通道
    go func() {
        wg.Wait()
        close(results)
    }()

    // 处理结果(例如:统计、记录日志)
    successCount := 0
    for res := range results {
        if res.Error == nil && res.StatusCode == http.StatusOK {
            successCount++
        } else {
            // 处理错误或非200状态码
            // fmt.Printf("Task %d failed: %v\n", res.TaskID, res.Error)
        }
    }

    fmt.Printf("Completed. Success: %d/%d\n", successCount, numTasks)
}

func worker(id int, tasks <-chan Task, results chan<- Result, wg *sync.WaitGroup) {
    defer wg.Done()
    client := &http.Client{
        Timeout: 30 * time.Second,
    }
    for task := range tasks {
        start := time.Now()
        resp, err := client.Get(task.URL)
        duration := time.Since(start)

        res := Result{
            TaskID: task.ID,
            Duration: duration,
        }
        if err != nil {
            res.Error = err
        } else {
            res.StatusCode = resp.StatusCode
            resp.Body.Close() // 重要:及时关闭响应体
        }
        results <- res
    }
}

关键点解析:

  1. 工作者数量 (numWorkers):这是问题的核心。对于纯I/O密集型任务(如HTTP请求,大部分时间在等待网络),goroutine 在等待时会被调度器挂起,让出CPU。因此,我们可以运行比CPU核心数多得多的 goroutine。在16核机器上,设置 32256 个工作者是一个合理的起始范围。最佳值需要通过压测确定,目标是让CPU利用率保持在高位(如70%-80%),同时观察内存增长和网络延迟是否在可接受范围。
  2. 任务队列 (tasks chan):使用缓冲通道作为任务队列。这解耦了任务生成和任务执行,使得工作者可以按自己的速度消费任务,避免了生产者被阻塞。
  3. 资源管理
    • 每个工作者使用独立的 http.Client 或共享一个(需注意并发安全),并设置合理的超时(Timeout)。
    • 必须关闭响应体 (resp.Body.Close()),否则会导致 goroutine 泄漏和文件描述符耗尽。
    • 通过 sync.WaitGroup 优雅地等待所有工作者结束。
  4. 结果处理:使用单独的 results 通道收集结果,避免在工作者中直接进行复杂的处理(如写入数据库),以免阻塞工作者。如果结果处理也很耗时,可以考虑使用另一组工作者来处理结果。

进阶优化:

  • 连接池:Go 的 net/http 默认启用了连接池(通过 DefaultTransport)。确保使用同一个 http.Client 实例(或共享其 Transport)可以高效复用TCP连接,极大提升性能。
  • 上下文(Context):为整个批量操作和每个请求设置上下文,以便支持超时和取消。
  • 限速/背压:如果目标服务器有速率限制,可以使用 golang.org/x/time/rate 库或带缓冲的通道实现令牌桶进行限流。
  • 错误重试:在工作者逻辑中加入简单的指数退避重试机制,提高鲁棒性。

结论: 对于“16核机器发送100万请求”这个问题,一个专业且具体的回答是:“我会实现一个固定大小的 goroutine 工作池,池的大小并非直接等于CPU核心数,而是根据任务特性进行设置。对于网络I/O密集型任务,我会从 64 或 128 个工作者 goroutine 开始进行性能测试,同时配合缓冲通道来分发任务。通过监控系统资源(CPU、内存、网络连接数)和请求延迟,动态调整工作者数量,找到吞吐量最大且资源消耗稳定的最佳并发度。核心是避免创建百万个即时并发goroutine,而是让有限的工作者持续不断地处理队列中的任务。”

回到顶部