Go 中怎么实现 chan 读写超时?

Go 的 chan 读写默认是阻塞的:没有数据时读会一直等,缓冲区满时写也会一直等。

实际业务里往往需要“等一会儿就放弃”,也就是带超时的读写。

做法就是用 select:在“正常读写”和“超时”两个分支里二选一,谁先满足就执行谁。

下面按超时检测方式分类说明。


一、基于 time 包

time.After:单次读超时或写超时最简写法,在 select 里用 case <-time.After(d) 与读写 case 二选一。读超时:同时监听“从 chan 收数据”和“超时”,先到先执行;写超时同理,case ch <- valuecase <-time.After(d) 二选一。

读超时示例:

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string, 1)
    go func() {
        time.Sleep(2 * time.Second)
        ch <- "result"
    }()

    select {
    case v := <-ch:
        fmt.Println("收到:", v)
    case <-time.After(1 * time.Second):
        fmt.Println("读超时")
    }
}

写超时示例:

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int, 1)
    ch <- 1 // 已满,再写会阻塞

    select {
    case ch <- 2:
        fmt.Println("写入成功")
    case <-time.After(100 * time.Millisecond):
        fmt.Println("写超时") // 无消费者时会走这里
    }
}

注意:time.After(d) 每次调用都会新建一个 time.Timer,到期前不会被 GC 回收,在长循环或高并发里反复调用会泄漏。循环里做超时请用下面的显式 Timer 或 context。

推荐:单次读/写超时直接用。 不推荐:循环或高并发里反复用,此时应改用显式 Timer 或 context。

显式 Timer:用 time.NewTimer(d),在 selectcase <-timer.C。用完后 timer.Stop() 回收,适合循环里复用。

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string)
    timer := time.NewTimer(1 * time.Second)
    defer timer.Stop()

    select {
    case v := <-ch:
        fmt.Println("收到:", v)
    case <-timer.C:
        fmt.Println("读超时")
    }
}

推荐:循环或需要复用同一 Timer 时用,记得 defer timer.Stop(),无泄漏问题。

Timer.Reset 循环复用:循环里多次做超时检测时,只建一个 Timer,每轮开始时 timer.Reset(d) 复用,避免每轮 NewTimer 或 time.After 泄漏。

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string)
    go func() {
        time.Sleep(2 * time.Second)
        ch <- "result"
    }()

    timer := time.NewTimer(1 * time.Second)
    defer timer.Stop()
    for {
        timer.Reset(1 * time.Second)
        select {
        case v := <-ch:
            fmt.Println("收到:", v)
            return
        case <-timer.C:
            fmt.Println("本轮超时,继续等")
        }
    }
}

推荐:循环里多次做超时时用,一个 Timer 反复 Reset,既避免泄漏又清晰。 不推荐:每轮都 NewTimertime.After

time.AfterFunc:超时后执行回调,在回调里关闭或向 done chan 发送,适合超时后做清理或通知多处。

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string)
    done := make(chan struct{})
    time.AfterFunc(1*time.Second, func() { close(done) })

    select {
    case v := <-ch:
        fmt.Println("收到:", v)
    case <-done:
        fmt.Println("读超时")
    }
}

推荐:超时后需要执行回调、或通知多个 goroutine 时用。 不推荐:只做"到点就停"的简单超时,用 Timer/context 更直观。

二、基于 context

WithTimeout:最常用,超时在调用链里传递、统一取消都方便。读超时:selectcase v := <-chcase <-ctx.Done();写超时把前者换成 case ch <- value 即可。

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ch := make(chan string)
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()

    select {
    case v := <-ch:
        fmt.Println("收到:", v)
    case <-ctx.Done():
        fmt.Println("超时:", ctx.Err())
    }
}

推荐:超时需在调用链传递或统一取消时首选;代码清晰、无 Timer 泄漏。

WithDeadline:传入绝对截止时间,与 WithTimeout 等价,适合“在某时刻前必须完成”的场景。用法同上,仅创建 ctx 时用 context.WithDeadline(parent, time)

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ch := make(chan string)
    deadline := time.Now().Add(1 * time.Second) // 绝对截止时刻
    ctx, cancel := context.WithDeadline(context.Background(), deadline)
    defer cancel()

    select {
    case v := <-ch:
        fmt.Println("收到:", v)
    case <-ctx.Done():
        fmt.Println("超时:", ctx.Err())
    }
}

推荐:按绝对截止时间控制时用。

WithTimeoutCause(Go 1.20+):与 WithTimeout 相同,但可传入自定义"取消原因",便于区分超时、主动取消等。

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ch := make(chan string)
    ctx, cancel := context.WithTimeoutCause(context.Background(), 1*time.Second, fmt.Errorf("自定义超时原因"))
    defer cancel()

    select {
    case v := <-ch:
        fmt.Println("收到:", v)
    case <-ctx.Done():
        fmt.Println("超时:", context.Cause(ctx)) // 拿到 WithTimeoutCause 传入的 error
    }
}

推荐:需要区分"超时"与"主动取消"、或在日志/监控里记录具体原因时用。Go 1.20 以下不可用。

三、自建超时 chan

单独 goroutine 在 sleep 后 close 或发送,select 监听该 chan,便于复用。

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string)
    timeout := make(chan struct{})
    go func() {
        time.Sleep(1 * time.Second)
        close(timeout)
    }()

    select {
    case v := <-ch:
        fmt.Println("收到:", v)
    case <-timeout:
        fmt.Println("读超时")
    }
}

推荐:需要复用同一个 timeout channel、或自定义超时前后逻辑时用。不推荐仅为了“单次超时”而写,用 time.After 或 context 更简单。

四、基于 reflect

reflect.Select:channel 或 case 数量在运行时才确定时,用 reflect.Select 构造 select,把超时(如 timer.C 或 done chan)作为一个 case 加入即可。

package main

import (
    "fmt"
    "reflect"
    "time"
)

func main() {
    ch := make(chan string)
    timer := time.NewTimer(1 * time.Second)
    defer timer.Stop()
    cases := []reflect.SelectCase{
        {Dir: reflect.SelectRecv, Chan: reflect.ValueOf(ch)},
        {Dir: reflect.SelectRecv, Chan: reflect.ValueOf(timer.C)},
    }
    chosen, v, _ := reflect.Select(cases)
    if chosen == 0 {
        fmt.Println("收到:", v.Interface())
    } else {
        fmt.Println("读超时")
    }
}

推荐:仅当 chan 或 case 数量在运行时才确定、必须动态构造 select 时用。不推荐常规业务用,可读性差、有反射开销。

五、轮询方式

轮询 + 截止时间:循环里用 default 非阻塞读,再 time.Sleep 一小段,重复直到超时或读到数据。

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string)
    deadline := time.Now().Add(1 * time.Second)
    for time.Now().Before(deadline) {
        select {
        case v := <-ch:
            fmt.Println("收到:", v)
            return
        default:
        }
        time.Sleep(10 * time.Millisecond)
    }
    fmt.Println("读超时")
}

不推荐:占用 CPU 做空转、间隔不好选(太短浪费,太长响应慢)、代码啰嗦。仅在没有 select 的极少数环境或历史代码里可能见到,新代码用 select + Timer/context。


小结

chan 读/写超时的本质都是:用 select 在“正常读写”和“超时信号”之间二选一。

场景/方式 推荐 不推荐
单次超时 time.Aftercontext.WithTimeout
循环/高并发超时 显式 NewTimer+Stop/Reset、context 循环里用 time.After(泄漏)
需区分取消原因 WithTimeoutCause(Go 1.20+)
动态多 chan reflect.Select
简单超时 自建 timeout chan、AfterFunc(过度设计)
轮询实现 轮询+截止时间(浪费 CPU、难维护)