Skip to content

Server端是如何实现高并发的?

一句话概括

miniRPC Server 通过三级 goroutine 并发模型实现高并发:连接级并发(每个连接一个 goroutine)、请求级并发(每个请求一个 goroutine)、响应串行化(互斥锁保证写入安全),同时用 sync.WaitGroup 实现优雅关闭。


三级并发模型全景

                         ┌─────────────────────────────────────────────────┐
                         │                  Server.Accept                  │
                         │                  (主 goroutine)                 │
                         │               for { lis.Accept() }              │
                         └───────┬──────────────┬──────────────┬───────────┘
                                 │              │              │
                          go ServeConn    go ServeConn    go ServeConn     ← 第 1 级:连接级并发
                                 │              │              │
                         ┌───────▼───┐   ┌──────▼────┐  ┌─────▼─────┐
                         │ serveCodec │   │ serveCodec │  │ serveCodec │
                         │ (连接 1)   │   │ (连接 2)   │  │ (连接 3)   │
                         └───┬───────┘   └───────────┘  └───────────┘

              ┌──────────────┼──────────────┐
              │              │              │
        go handleReq   go handleReq   go handleReq                        ← 第 2 级:请求级并发
              │              │              │
              ▼              ▼              ▼
         svc.call()     svc.call()     svc.call()
              │              │              │
              └──────────────┼──────────────┘

                        sending.Lock()                                     ← 第 3 级:响应串行化
                        cc.Write(h, body)
                        sending.Unlock()

第 1 级:连接级并发 —— 每个连接独占一个 goroutine

go
func (server *Server) Accept(lis net.Listener) {
    for {
        conn, err := lis.Accept()
        if err != nil {
            log.Println("rpc server: accept error:", err)
            return
        }
        go server.ServeConn(conn)    // 每个新连接启动一个独立 goroutine
    }
}

为什么这样设计

  • lis.Accept() 是阻塞调用,如果在主 goroutine 中同步处理连接,那在处理一个客户端时其他客户端就得排队等待。
  • go server.ServeConn(conn) 把每个连接的处理交给独立 goroutine,主 goroutine 立即回到 Accept 等待下一个连接。
  • 这意味着 N 个客户端同时连接,就有 N 个 goroutine 并行处理,互不干扰。

为什么用 goroutine 而不是线程池

Go 的 goroutine 是用户态轻量级线程,初始栈只有几 KB(而 OS 线程通常 1~8 MB),创建和切换的开销极小。即使同时有上万个连接,上万个 goroutine 也不会成为问题。这是 Go 语言天然适合高并发网络服务的根本原因。


第 2 级:请求级并发 —— 同一连接上的请求并行处理

单个连接上,serveCodec 在一个循环中串行读取请求,但并行处理请求:

go
func (server *Server) serveCodec(cc codec.Codec, opt *codec.Option) {
    sending := new(sync.Mutex)     // 发送锁
    wg := new(sync.WaitGroup)      // 等待所有请求完成
    for {
        req, err := server.readRequest(cc)   // 串行读取
        if err != nil {
            if req == nil {
                break
            }
            req.h.Error = err.Error()
            server.sendResponse(cc, req.h, invalidRequest, sending)
            continue
        }
        wg.Add(1)
        go server.handleRequest(cc, req, sending, wg, opt.HandleTimeout)   // 并行处理
    }
    wg.Wait()      // 等所有 handleRequest 完成
    _ = cc.Close()
}

关键问题:为什么读取必须串行,处理可以并行?

读取必须串行的原因:

TCP 是字节流协议,数据在连接上是严格有序的。一条消息由 Header 和 Body 组成,如果两个 goroutine 同时从连接读数据,可能出现 goroutine A 读了 Header,goroutine B 读了本该属于 A 的 Body,整个协议就错乱了。所以 readRequest 必须在单个 goroutine 中串行执行。

TCP 连接上的字节流(严格有序):
[Header1][Body1][Header2][Body2][Header3][Body3]...

    单个 goroutine 按顺序一个一个读

处理可以并行的原因:

readRequest 完成后,请求的所有数据(Header、argv、replyv)已经完整地读到了内存中的 request 结构体里。此时这个请求就是一个自包含的独立单元,它的处理(调用服务方法、计算结果)跟其他请求没有数据依赖,所以完全可以放到独立 goroutine 中并行处理。

时间线:

主 goroutine:  读req1 → 读req2 → 读req3 → ... → 读完(EOF) → wg.Wait()
                  ↓         ↓         ↓
goroutine 1:  处理req1 ────────────────────── 发响应1
goroutine 2:       处理req2 ──── 发响应2                       ← 先完成的先发
goroutine 3:            处理req3 ──────────────── 发响应3

并行处理的收益

假设客户端在一个连接上发了 5 个请求,每个请求处理耗时 100ms:

  • 串行处理:总耗时 = 5 × 100ms = 500ms
  • 并行处理:总耗时 ≈ 100ms(5 个请求同时执行)

这就是 example/main.go 中 5 个 Greeter.SayHello 调用能"几乎同时"返回的原因。


第 3 级:响应串行化 —— 互斥锁保证写入完整性

处理是并行的,但写响应必须串行,否则两个 goroutine 同时往连接写数据会导致 Header 和 Body 交错错乱:

go
func (server *Server) sendResponse(cc codec.Codec, h *codec.Header, body interface{}, sending *sync.Mutex) {
    sending.Lock()         // 同一时刻只能有一个 goroutine 写响应
    defer sending.Unlock()
    if err := cc.Write(h, body); err != nil {
        log.Println("rpc server: write response error:", err)
    }
}

如果不加锁会怎样

无锁的灾难场景:

goroutine 1 正在写:[Header1]          [Body1]
goroutine 2 同时写:          [Header2]         [Body2]

客户端收到的字节流:[Header1][Header2][Body1][Body2]

                    这不是合法消息!客户端无法正确解析。

加锁后的正确行为

有锁的正确行为:

goroutine 1 获得锁:[Header1][Body1]
                                    goroutine 2 获得锁:[Header2][Body2]

客户端收到的字节流:[Header1][Body1][Header2][Body2]
                   ↑              ↑
              完整的消息 1     完整的消息 2    ✓ 可正确解析

为什么用 sync.Mutex 而不是 channel

这里的需求是"互斥访问共享资源(TCP 连接)",Mutex 语义上最直接。但用 channel 同样可以实现,核心思路是:不让多个 goroutine 直接写连接,而是把"要写的东西"发到一个 channel,由唯一一个消费者 goroutine 负责写入

channel 实现互斥写入的原理

go
type response struct {
    h    *codec.Header
    body interface{}
}

// channel 方案的 serveCodec
func (server *Server) serveCodec(cc codec.Codec, opt *codec.Option) {
    respCh := make(chan *response, 16)   // 带缓冲的 channel
    wg := new(sync.WaitGroup)

    // 唯一的写入者 goroutine —— 只有它能碰 cc.Write
    go func() {
        for resp := range respCh {
            cc.Write(resp.h, resp.body)  // 单 goroutine 顺序写,天然不会交叉
        }
    }()

    for {
        req, err := server.readRequest(cc)
        if err != nil { break }
        wg.Add(1)
        go func(req *request) {
            defer wg.Done()
            req.svc.call(req.mtype, req.argv, req.replyv)
            // 不直接写连接,而是把响应"投递"到 channel
            respCh <- &response{h: req.h, body: req.replyv.Interface()}
        }(req)
    }
    wg.Wait()
    close(respCh)   // 所有请求处理完后关闭 channel,写入者 goroutine 退出
    cc.Close()
}

为什么这样就互斥了?关键在于:

Mutex 方案:多个 goroutine 竞争同一把锁,谁抢到谁写
  goroutine 1 ──Lock()──Write()──Unlock()──────────────
  goroutine 2 ──────────Lock()(阻塞等待)──Write()──Unlock()──
  goroutine 3 ──────────────────Lock()(阻塞等待)──Write()──Unlock()──

channel 方案:多个 goroutine 只负责"投递",唯一的消费者负责写
  goroutine 1 ──respCh <- resp1──(完事)
  goroutine 2 ──respCh <- resp2──(完事)
  goroutine 3 ──respCh <- resp3──(完事)


  写入者 goroutine:for resp := range respCh {
                      cc.Write(resp.h, resp.body)   ← 永远只有这一个 goroutine 在写
                    }

channel 方案之所以互斥,不是因为 channel 本身有锁的语义,而是因为它把"写连接"这个操作收敛到了唯一一个 goroutine 中。只有一个 goroutine 在执行 cc.Write,自然就不可能有两个 goroutine 同时写,互斥就天然成立了。这就是 Go 的经典哲学:

Don't communicate by sharing memory; share memory by communicating.

不要通过共享内存来通信(Mutex 方案),而是通过通信来共享内存(channel 方案)。

为什么 miniRPC 选了 Mutex

维度Mutex 方案channel 方案
额外 goroutine需要一个专门的写入者 goroutine
内存开销一个 Mutex(8 字节)channel 结构 + 缓冲区 + goroutine 栈
代码复杂度3 行(Lock/Write/Unlock)需要定义 response 结构体、启动/关闭 goroutine
背压控制无(写完就释放锁)有(channel 满时 goroutine 自动阻塞)
适用场景简单互斥需要解耦生产/消费、需要排队、需要背压

对于 miniRPC 这种"只需要互斥写入"的简单场景,Mutex 方案代码更少、心智负担更小。channel 方案的优势在更复杂的场景中才能体现,比如需要对响应排队、限流、或者做批量合并写入时。


优雅关闭:WaitGroup 的作用

go
wg := new(sync.WaitGroup)
for {
    req, err := server.readRequest(cc)
    // ...
    wg.Add(1)                                                    // 读到一个请求,计数 +1
    go server.handleRequest(cc, req, sending, wg, opt.HandleTimeout)
}
wg.Wait()        // 所有 handleRequest 完成后才继续
_ = cc.Close()   // 关闭连接

为什么需要 WaitGroup

readRequest 返回 EOF(客户端关闭连接或所有请求已发送完毕),主循环退出。但此时可能还有一些 handleRequest goroutine 正在执行中:

时间线:

主 goroutine:  读req1 → 读req2 → 读req3 → EOF退出循环
                  ↓         ↓         ↓
goroutine 1:  处理req1 ─── 完成 ✓
goroutine 2:       处理req2 ───────── 还在处理中...
goroutine 3:            处理req3 ──────────── 还在处理中...

                                          ← 如果这里直接 cc.Close(),
                                            goroutine 2 和 3 的响应无法发出!

wg.Wait() 确保主 goroutine 阻塞等待所有 handleRequest 执行完毕(每个 handleRequest 在最后调用 defer wg.Done()),然后才关闭连接。这样不会丢失任何正在处理中的请求


超时控制与并发的关系

handleRequest 中的超时控制也是并发设计的一部分:

go
func (server *Server) handleRequest(cc codec.Codec, req *request, sending *sync.Mutex, wg *sync.WaitGroup, timeout time.Duration) {
    defer wg.Done()
    if timeout == 0 {
        // 不设超时,直接同步调用
        err := req.svc.call(req.mtype, req.argv, req.replyv)
        // ...
        return
    }

    // 设了超时:用子 goroutine 执行,主 goroutine 监控超时
    called := make(chan struct{})
    sent := make(chan struct{})
    go func() {
        err := req.svc.call(req.mtype, req.argv, req.replyv)
        close(called)
        // 发送响应...
        close(sent)
    }()

    select {
    case <-time.After(timeout):
        // 超时了,立即返回错误响应
        req.h.Error = fmt.Sprintf("rpc server: request handle timeout: expect within %s", timeout)
        server.sendResponse(cc, req.h, invalidRequest, sending)
    case <-called:
        <-sent    // 等待响应发送完成
    }
}

超时控制的并发模型:

handleRequest goroutine:                 子 goroutine:

     select {                             │ svc.call()(可能很慢)
       case <-time.After(timeout):        │
           // 超时,立即返回错误             │
       case <-called:                     │ close(called)  ← 调用完成
           <-sent                         │ sendResponse
     }                                    │ close(sent)   ← 发送完成
  • 如果 svc.call() 在超时前完成 → called channel 关闭,走 case <-called 分支,正常返回结果
  • 如果超时先到 → 走 case <-time.After(timeout) 分支,立即给客户端返回超时错误,不再等待慢请求

并发安全分析总结

操作并发方式保护机制为什么
接受连接主 goroutine 循环 Accept无需保护Accept 本身是串行的
处理连接每连接一个 goroutine连接间完全隔离各连接有独立的 Codec、锁、WaitGroup
读取请求串行(单 goroutine)无需保护TCP 字节流必须按序读取
处理请求并行(每请求一个 goroutine)请求数据自包含读完后各请求互不依赖
发送响应sync.Mutex 互斥sending防止响应数据交叉写入
等待完成sync.WaitGroupwg.Wait()确保关闭前所有请求处理完毕
服务注册表sync.Map无锁并发安全支持并发读写服务映射
方法调用计数atomic.AddUint64原子操作无锁计数,性能最优

与 net/rpc 标准库的并发模型对比

miniRPC 的并发模型与 Go 标准库 net/rpc 几乎一致,都是:

Accept 循环 → 每连接 go ServeConn → 串行读 + 并行处理 + 互斥写

两者的核心差异不在并发模型上,而在编解码协商和超时控制上(net/rpc 无协商机制且不支持超时)。

这个模型简洁而高效,是 Go 网络编程的经典范式——用 goroutine 替代线程池,用 channel/Mutex 做同步,用 WaitGroup 做优雅关闭。

服务端是如何实现超时控制的?

超时控制的全局位置

miniRPC 中有两处超时,都由客户端在 Option 中指定:

go
type Option struct {
    MagicNumber    int
    CodecType      Type
    ConnectTimeout time.Duration   // 客户端连接超时(客户端自己控制)
    HandleTimeout  time.Duration   // 服务端处理超时(传给服务端,由服务端控制)
}
  • ConnectTimeout:控制的是客户端建立连接 + 握手的超时,完全在客户端 dialTimeout 中实现,与服务端无关。
  • HandleTimeout:控制的是服务端处理单个请求的超时,由客户端在握手时通过 Option 告知服务端,服务端在 handleRequest 中执行。

本题聚焦 HandleTimeout 在服务端的实现


超时值的传递链路

客户端                                              服务端

Option{HandleTimeout: 3s}                              

     │  json.Encode(Option)                            
     └─────────────────────────────►  json.Decode(&opt)

                                      opt.HandleTimeout = 3s

                                      serveCodec(cc, &opt)

                                      go handleRequest(..., opt.HandleTimeout)

                                      select + time.After(3s) 实现超时

serveCodec 中的传递:

go
func (server *Server) serveCodec(cc codec.Codec, opt *codec.Option) {
    // ...
    for {
        req, err := server.readRequest(cc)
        // ...
        wg.Add(1)
        go server.handleRequest(cc, req, sending, wg, opt.HandleTimeout)  // ← 传入超时值
    }
    // ...
}

注意:HandleTimeout 对该连接上的每个请求都生效,是连接级别的配置,而非单个请求可以独立设置。


handleRequest 的超时实现

handleRequest 根据 timeout 是否为 0 分为两条路径:

路径 1:timeout == 0(无超时)

go
if timeout == 0 {
    err := req.svc.call(req.mtype, req.argv, req.replyv)   // 同步调用,阻塞到完成
    if err != nil {
        req.h.Error = err.Error()
        server.sendResponse(cc, req.h, invalidRequest, sending)
        return
    }
    server.sendResponse(cc, req.h, req.replyv.Interface(), sending)
    return
}

直接在当前 goroutine 中同步调用服务方法。无论方法执行多久都等着,不做任何超时干预。

路径 2:timeout > 0(有超时控制)

go
called := make(chan struct{})
sent := make(chan struct{})
go func() {
    err := req.svc.call(req.mtype, req.argv, req.replyv)
    close(called)                                             // ① 标记"方法执行完毕"
    if err != nil {
        req.h.Error = err.Error()
        server.sendResponse(cc, req.h, invalidRequest, sending)
        close(sent)                                           // ② 标记"响应发送完毕"
        return
    }
    server.sendResponse(cc, req.h, req.replyv.Interface(), sending)
    close(sent)                                               // ② 标记"响应发送完毕"
}()

select {
case <-time.After(timeout):                                   // 超时先到
    req.h.Error = fmt.Sprintf("rpc server: request handle timeout: expect within %s", timeout)
    server.sendResponse(cc, req.h, invalidRequest, sending)
case <-called:                                                // 方法执行完毕先到
    <-sent                                                    // 等响应也发完
}

这是整个超时控制的核心,用到了 两个 channel + select 的经典模式。


逐步拆解:两个 channel 各自的职责

为什么需要两个 channel?

一个请求的处理分为两个阶段:

阶段 1:svc.call()  —— 执行服务方法(可能很耗时)
阶段 2:sendResponse() —— 写响应到连接(需要抢锁,可能等待)

calledsent 分别标记这两个阶段的完成:

子 goroutine 的执行流程:

  svc.call()                  ← 可能耗时很长
  close(called)               ← "方法调用完了"
  sendResponse()              ← 可能因为 sending 锁而等待
  close(sent)                 ← "响应也发完了"

select 的两个分支

select {
case <-time.After(timeout):    // 分支 A:超时先触发
case <-called:                 // 分支 B:方法先执行完
    <-sent
}

分支 A — 超时先到

时间线(timeout = 3s):

handleRequest goroutine:                 子 goroutine:
     │                                      │
     │  select {                            │ svc.call() ← 在慢慢执行...
     │    case <-time.After(3s):            │
     │      // 3秒到了!方法还没完成          │
     │      sendResponse(超时错误)           │
     │    ...                               │ ...(仍在执行,但已经无人关心结果)
     │  }                                   │
     ▼ 函数返回                              ▼

超时后,handleRequest 立即给客户端返回一个超时错误响应,不再等待方法执行完成。

分支 B — 方法先执行完

时间线(timeout = 3s,方法 1s 就执行完):

handleRequest goroutine:                 子 goroutine:
     │                                      │
     │  select {                            │ svc.call()  ← 1s 完成
     │    ...                               │ close(called) ← 通知主 goroutine
     │    case <-called:                    │
     │      // 好的,方法执行完了              │ sendResponse(正常结果)
     │      <-sent                          │ close(sent) ← 响应也发完了
     │      // 响应也发完了,完美!            │
     │  }                                   │
     ▼ 函数返回                              ▼

收到 called 信号后,还要 <-sent 等响应发完。因为 sendResponse 需要抢 sending 锁,如果此时有其他响应正在写,需要排队。必须等写完才能安全返回(否则 wg.Done() 之后 WaitGroup 可能提前归零导致连接关闭)。


为什么不用 context.WithTimeout?

Go 中做超时控制最常见的方式是 context.WithTimeout,但 miniRPC 服务端没有用它,而是用了 select + time.After。原因是:

维度context 方案select + time.After 方案
需要方法签名支持是,服务方法必须接收 ctx context.Context 参数否,服务方法签名不变
取消正在执行的方法可以(方法内部检查 ctx.Done()不能(方法仍在后台跑完)
实现复杂度需要改方法签名 + 方法内部配合无侵入,纯框架层实现

miniRPC 的服务方法签名是 func (t *T) Method(args Args, reply *Reply) error没有 context 参数。框架无法把取消信号传递到方法内部,所以只能在外部用 select 做"超时后不等结果直接返回错误"。

这意味着超时后方法本身并不会被中断——子 goroutine 中的 svc.call() 仍然会跑完,只是它的结果不会再被使用(handleRequest 已经返回了超时响应)。

超时场景的完整时间线:

                  0s        3s(timeout)              10s(方法终于完成)
handleRequest:   |---select---|---返回超时响应---▼(函数结束)
                       等待中...  ↑ time.After触发
                       
子 goroutine:    |---svc.call()-----------------------------▼(执行完毕)
                                                close(called)
                                                sendResponse ← 这次写入仍会执行
                                                               但客户端已经收到了超时错误
                                                close(sent)

这是一个已知的局限:超时只是让客户端更快拿到错误响应,不能真正取消正在执行的服务方法。要实现真正的取消,需要:

  1. 修改方法签名,加入 context.Context 参数
  2. 方法内部配合检查 ctx.Done()

与客户端超时的对比

miniRPC 中有三层超时,各自控制不同的环节:

客户端                                  服务端
  │                                       │
  │ ① ConnectTimeout                      │
  │    net.DialTimeout(network, addr, t)  │
  │    ← 控制 TCP 握手 + Option 发送       │
  │                                       │
  │ ② context.Context 超时                 │
  │    client.Call(ctx, method, args, &r)  │
  │    ← 控制整个调用的端到端超时            │
  │                                       │
  │                                       │ ③ HandleTimeout
  │                                       │    select + time.After(t)
  │                                       │    ← 控制服务端单个请求处理的超时
  │                                       │
超时类型控制方作用范围实现方式
ConnectTimeout客户端TCP 连接建立 + 握手net.DialTimeout + select
context 超时客户端整个调用(从发送到收到响应)select { case <-ctx.Done() }
HandleTimeout服务端服务方法执行 + 响应发送select { case <-time.After(t) }

三层超时各司其职,客户端的 context 超时是最外层的兜底——即使服务端没有设置 HandleTimeout,客户端也可以通过 context.WithTimeout 控制自己愿意等多久:

go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := client.Call(ctx, "Greeter.SayHello", args, &reply)
// 5 秒内没收到响应 → err = "rpc client: call failed: context deadline exceeded"

总结

服务端超时控制的核心设计:

  1. 超时值由客户端指定:通过 Option.HandleTimeout 在握手时传递给服务端
  2. select + time.After 模式:不侵入服务方法签名,在框架层实现超时
  3. 两个 channel 分工called 标记方法执行完毕,sent 标记响应发送完毕,确保资源安全释放
  4. 局限性:只能做到"超时后不等结果",不能真正取消正在执行的方法