064-Channel

Golang 里 Channel 是一种数据类型。

《goroutine 和 chan》 一文我们就探讨过 channel 的特性,它非常像线程安全的阻塞队列,只不过 Golang 里原生支持了它。

在 Golang 里,chan 用的最多的地方就是用于 goroutine 之间的通信。很久以前你在学习多线程的时候,线程与线程之间通信的办法,一般都使用共享内存,互斥锁和条件变量这些手段,而且极易出错。但是在 Golang 里,chan 帮你屏蔽了这些细节。

上一节我们编写了一个并发的 echo server,并使用 netcat 工具向echo server 发送数据。这一节,我们就写一个小小的客户端程序吧。

1. netcat 客户端(version 1.0)

看到 version 1.0 你心里应该就能感觉到,这个版本可能有问题,先看代码。

// nc01.go
package main

import (
    "io"
    "log"
    "net"
    "os"
)

func mustCopy(w io.Writer, r io.Reader) {
    buf := [64]byte{}
    for {
        n, err := r.Read(buf[:])
        if err != nil {
            log.Println(err)
            break
        }
        _, err = w.Write(buf[0:n])
        if err != nil {
            log.Println(err)
            break
        }
    }
}

func main() {
    conn, err := net.Dial("tcp", "localhost:8001")
    if err != nil {
        log.Println(err)
        return
    }

    // conn -> os.Stdout
    go func() {
        mustCopy(os.Stdout, conn)
        log.Println("done")
    }()

    mustCopy(conn, os.Stdin)
    conn.Close()
    log.Println("exit")
}

启动你的 echo server,运行 go run nc01.go. 随意输入一些字符,最后按 CTRL D 结束。(按下 CTRL D 后,从 stdin 会读取到 EOF)


这里写图片描述
图1 测试 echo server

注意,上面的 netcat 程序有 bug。看起来似乎是 log.Println("done") 没有执行。因为屏幕没有打印这一行。

仔细分析一下程序结束的过程:

  • CTRL D 按下,mustCopy(conn, os.Stdin) 在执行 Read 的时候,读取到 EOF 错误(这是正常的错误)
  • 执行 conn.Close()
  • 执行 log.Prinln("exit")

这三个动作几乎一气呵成,以致于协程没有机会正常退出。

有没有办法让协程也能正常退出呢?答案是有的。还记得我们曾经使用 channal 的例子吗?当读取一个空的 channel 时,程序会阻塞。

好了,机会来了。看 Version 2.0

2. netcat 客户端 (version 2.0)

// nc02.go
// 重复代码已经省略了
func main() {
    conn, err := net.Dial("tcp", "localhost:8001")
    if err != nil {
        log.Println(err)
        return
    }

    // 创建一个 struct{} 类型的 channel
    done := make(chan struct{})
    go func() {
        mustCopy(os.Stdout, conn)
        log.Println("done")
        // goroutine 执行完成,发送一个空对象到 channel
        done <- struct{}{}
    }()

    mustCopy(conn, os.Stdin)
    conn.Close()
    // 读取 channel 数据并丢弃。
    <-done
    log.Println("exit")
}


这里写图片描述
图2 测试 echo server

不过似乎又引起了新的问题。一个客户端有这么难写吗?是的,网络编程的确比你想的要复杂的多。再来分析一下图 2 的结果:

  • 从 stdin 读取到 EOF 后,执行 conn.Close()
  • 主协程(main 所在的协程)执行 <-done,主程阻塞
  • 子协程从 Read 调用中返回一个错误:read 错误,使用了已关闭的网络连接
  • 子协程打印 done,然后执行 done<-struct{}{}
  • 主协程 <-done 返回
  • 程序退出

虽然我们解决了一个 bug,但是却引来另一个 bug.

Golang 里,直接调用调用 Close 关闭连接,会导致 conn 变得不可读也不可写,否则会返回图 2 里那样的错误。

那怎么办?要不把 <-doneconn.Close() 这两行换个顺序?这肯定不行,这样程序就死锁了(想想为什么?)

稍微懂一点 tcp 协议的同学都知道,tcp 是一个全双工通信协议。你可以只关闭 tcp 通道的写端,也可以只关闭 tcp 通道的读端。当然,也可以两者都关闭。

一个比较优雅的做法就是关闭 tcp 通道的写端,而不关闭读端。因此我们可以改善一下我们的程序,看 version 3.0

3. netcat 客户端 (version 3.0)

func main() {
    co, err := net.Dial("tcp", "localhost:8001")
    if err != nil {
        log.Println(err)
        return
    }

    // 使用类型断言,拿到 *net.TCPConn 的值
    conn := co.(*net.TCPConn)

    done := make(chan struct{})
    go func() {
        mustCopy(os.Stdout, conn)
        log.Println("done")
        done <- struct{}{}
    }()

    mustCopy(conn, os.Stdin)
    // 调用写关闭。只有 *net.TCPConn 才有这个方法,这是类型断言的目的。
    conn.CloseWrite()
    <-done
    log.Println("exit")
}


这里写图片描述
图3 测试 echo server

这样一来,程序就完美啦!

4. 总结

  • 掌握使用通道进行协程同步的方法

本文虽然是讲 Channel,但是却花了大量笔墨在网络编程上。实际上,这一节只是讲解了 Channel 的一个简单应用。关于 Channel 的功能,相信你在读完 《goroutine 和 chan》 早已经了如指掌,因此重复这些知识点完全没有必要。

后面几篇文章也基本上是关于 Channel 的应用和更多的关于 Channel 的语法。

猜你喜欢

转载自blog.csdn.net/q1007729991/article/details/80726586
064