Golang 越过关闭 Channel留下的坑

题外话

目前CPU的核是越来越多,单个核的计算能力并没有大的提升。主要是硅晶片加工工艺快到头了,不能够提质,那就只能堆量。对于NodeJs这种单线程或少线程应用就很受限制。单线程都是在一个核上跑,虽然能够充分发挥单核性能,但很难发挥多多核优势,对于整体服务器利用率不高(有异议,请无视这个举例)。

我们可以通过缩小单个处理任务拆分为大量的小任务然后并发执行来充分发挥服务器多核性能。这就涉及两个问题:一个是操作系统线程数量有限,占用资源大,且数量太多会导致的线程切换性能损失;另一个是并发编程数据同步的问题。第一个问题golang提供了新概念”goroutine”来解决。这是一个比线程更轻,可同时运行数量更多,占用资源更少的伪线程。在此不详细介绍。第二个问题解决起来要困难得多。一般情况为了安全地修改数据需要大量的线程锁。越多的线程锁反而让应用变得不安全。程序猿总在打盹,一旦存在并发下的数据不安全修改,这颗大地雷可就埋好了。golang在此为我们准备了”Channel”。Channel并不是为了替代线程锁,而是为我们提供一条安全地加工在各个线程中流转的数据的便捷道路。


关闭 Channel后访问Channel带来的异常

当channel关闭后,有两种情况会抛出异常

  • 向已经关闭的channel发送数据
  • 关闭已经被关闭的channel

有一种情况不会抛出异常

  • 向已经关闭的channel获取数据,且总能得到一个空值

如何安静地 发送\ 接收\ 关闭Channel相关代码如下,其中屏蔽了出现的两个异常。

func SafeClose(ch chan T) (justClosed bool) {
    defer func() {
        if recover() != nil { 
            justClosed = false
        }
    }()

    close(ch)   
    return true  
}

func SafeSend(ch chan T, value T) (closed bool) {
    defer func() {
        if recover() != nil {
            closed = true
        }
    }() 
    ch <- value  
    return false  
}

func SafeReceive(ch chan bool){
    i, ok := <- ch 
    if ok { 
        println(i) 
    } else { 
        println("channel closed") 
    } 
}

案例

以上做法并不能适应各种场景。还是从一些案例来阐述最佳实践。首先从Channel的标准流程说起。多线程开发中,生产线程产生数据包并被放到Channel中。消费线程从Channel取数据包并处理。直到生产线程发送完最后一个数据包后关闭Channel终止生产任务。消费线程处理完最后一个数据包发现Channel已关闭,终止消费任务。

1. 消费线程发生无法继续消费

消费线程出现异常无法再继续处理数据包。这种情况下,消费线程可能留着一个未关闭的Channel就退出了,这会导致生产线程死锁。这时可以通过关闭Channel引发生产线程异常来终止发送行为

func receive(ch chan int) {
    defer func() {
        if err := recover(); err != nil {
            close(ch)
        }
    }()

    for x := range ch {
        //消费数据 
        time.Sleep(time.Second) 
        fmt.Println(x)
        if x == 3 {
            //异常中止消费数据
            panic("网络中断,无法继续...")
        } 
    }
    fmt.Println("任务结束")
}

func send(ch chan int) {

    x := 0

    defer func() {
        if err := recover(); err != nil && (err.(runtime.Error)).Error() == "send on closed channel" { 
            //处理未发送数据
            fmt.Print("遗留数据>>>")
            fmt.Println(x) 
        }else {
            //生产完成
            close(ch)
        }
    }()

    for i := 0; i < 10; i++ {
        //生产数据
        x++
        ch <- x
    }
}

output:

1
2
3
遗留数据>>>4

 
另外一种实现方式。通过新加一条Channel进行状态通知。
这种方式能够适用更多的场景,但同样也会让程序变得复杂。


func receive(ch, quit chan int) {
    for x := range ch {
        //消费数据
        time.Sleep(time.Second)
        fmt.Println(x)
        if x == 3 {
            //终止生产
            close(quit)
            return
        }
    }
    fmt.Println("任务结束")
}

func send(ch, quit chan int) {
    defer func() {
        //生产结束
        close(ch)
    }()

    x := 0
    for i := 0; i < 10; i++ {
        //生产数据
        x++
        select {
        case ch <- x:

        case <-quit:
            fmt.Println("提前终止生产")
            return
        }
    }
}

output:

1
2
3
提前终止生产

 

2. 消费线程主动终止数据包继续生产

上面的例子会出现最后生成的数据包未被处理的情况。 消费线程主动终止数据包继续生产,但已经生产出的数据包必须被处理掉以免出现遗留。 我们只需要把上面的例子稍微改改。

func receive(ch, quit chan int) {
    defer func() {
        if err := recover(); err != nil {
            //异常
            close(quit)
        }

    }()

    r := rand.New(rand.NewSource(time.Now().UnixNano()))
    for x := range ch {
        //消费数据
        time.Sleep(time.Second)
        fmt.Println(x)

        if x == 5 {
            //提前终止生产,继续处理数据直到Channel关闭
            quit <- 0
        } else if x == r.Intn(10) {
            //随机抛出异常,无法继续处理数据
            panic("error")
        }

    }
    fmt.Println("任务结束")
}

func send(ch, quit chan int) {
    defer func() {
        //生产结束
        close(ch)
    }()

    x := 0
    for i := 0; i < 10; i++ {
        //生产数据
        x++
        select {
        case ch <- x:
            fmt.Printf("%d>>", x)
        case _, ok := <-quit:
            if ok {
                fmt.Println("提前终止")
                //发送遗留数据包
                ch <- x
                fmt.Printf("%v--", x)
                return
            } else {
                fmt.Println("异常终止")
                return
            }
        }
    }
}

output:

1>>1
2>>2
3>>3
4>>4
5>>5
提前终止
6–6
任务结束

或者

1>>1
异常终止

 

3. 应从设计上明确关闭Channel的线程

正常关闭Channel当然是已经明确了不会再新数据包需要发送。因此应从程序设计上避免Channel被多次关闭的情况。不管发送与接收是1对N,N对1还是N对N。总有一个线程获取到发送已完成标记。同样也仅需要第一个接收到发送完成标记的线程对Channel进行关闭操作。反之,不能从程序设计上去避免多次关闭Channel,也就意味着各个线程关闭Channel的时机可能存在混乱,也就可能发生有数据未发送被遗留的情况。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值