初探 Go 的网络编程

27 Jan, 2014

最近趁着寒假在家空闲比较多,改进了一下 Fakio,并给它写了个 Go 语言的客户端。很早之前也了解过 Go 语言,不过当时并没有使用的地方,所以也就不了了之。这几天重新学习了一下,并一边 Google,一边 Godoc,最后勉强写出来了。Go 的资料较少,文档基本没用,好在代码结构清晰,查看文档不得要领,还可以直接打开源码,一探究竟。

不过以前网络编程基本使用的是 C,Python 也只是用来做做 Web 而已,对于层层封装和抽象过的 socket,在使用时不免会产生诸多疑问,说好听一点知其然也要知其所以然,说的不好听一点就是纠缠在细节之中而无法提高一个层次思考。不过为了消除疑虑,就大致看了一下 Go net 包里的东西,下面就扯一点心得。不过不先说 Go,说说其它的。

CallBack

以前接触最多的网络编程模式还是基于回调函数的,比如 Node.js 就是很明显的采用回调函数。这个模式简单易懂,将文件描述符、事件、处理事件的回调函数作为一个整体加入一个队列,当文件描述符上发生指定的事件后,就调用相应的回调函数,而获得文件描述符上发生的事件, 可以使用 select、epoll 这类系统调用。虽然模式简单,但使用这种模式经常需要配合非阻塞 I/O 来使用,因此真要这么写一个大一点或者流程复杂一点的应用,真是困难重重,即使现在有 libevent\libev\libuv 这种将事件处理封装起来的库,使用上也无法真正的将应用与这些实现细节脱离开来。

比如应用里一些数据的状态保存,因为执行某个回调函数后,某些处理可能并没用完成,此时需要返回去执行其它事件,因此不能使用函数栈来保存信息,必须将需要保存状态的结构使用指针传给回调函数,当然还有一些用户数据,比如下面这个接受信息的回调函数

void readable_cb(event_loop *loop, int fd, void *evdata)
{
    buffer *buf = evdata;

    for (;;) {
        int rc = recv(fd, WRITE_AT(buf), WRITE_LEN(buf), 0);
        if (rc < 0) {
            if (errno == EAGAIN) {
                return;
            }
            // 出现错误,执行一些清理工作,省略
            return;
        }
        if (rc == 0) {
            // 对端关闭,执行一些清理工作,省略
            return;
        }

        COMMIT_WRITE(buf, rc);

        if (WRITE_LEN(buf) == 0) {
            // 终于 buf 满了,可以做其它事了
        }
    }
}

上面的 buf 就是用户数据,因为你必须在 buf 还没能接收满,函数就从第9行返回时保存好已经接收的数据。libev 使用了一个 ev_io 结构来得到用户数据的指针,一般将这个作为希望传递给回调函数的结构体的第一个域,这样它的地址也就是这个结构体的地址。

因为状态问题,如果处理更加复杂的网络协议,比如 HTTP,则更加的麻烦,回调过去回调过来,真是让人痛苦。如果能在切换出函数时可以保存函数的栈信息就好了,比如使用线程呢?

回到 Go

一般学习网络编程时就会介绍一种多线程模型,其中有一种方法是主线程负责监听新的请求,有一个请求到来时就拿一个线程来处理这个请求,因此请求之间就不会相互阻塞,线程的调度则由操作系统来进行,也就不用在用户态去检测文件描述符上的状态了。不过线程的问题就是资源消耗和上下文切换开销过大。为了解决这种多线程问题,于是开始大量使用上面的 CallBack 模式,看来改用线程是在开历史倒车啊。

于是协程(Coroutine)开始显示出了它的魅力,协程并非一个新概念,早在 1963 年就提出,现在很多语言都支持。协程类似于一种轻量级的用户态线程,操作系统对其一无所知,调度之类的由程序员搞定,切换成本比较低。不过要基于协程来达到类似线程的使用,还是困难重重,虽然 POSIX 中有 ucontext 这类可用来实现协程的函数,不过要将其用于网络编程之上,还有很多轮子需要造:比如一个调度器,现在操作系统帮不上你的忙了,你得自己设计调度策略,以及获取各种信息来进行决策,比如要在文件不可写时切换到其它协程去,那么得获取到文件的信息;协程间的通信,线程通信可以采用共享内存,也正因为此,得“处心积虑”,那么如何设计协程间通信就是个难题;常见的系统调用的封装,比如 read 之类的。

不过使用 Go 的话,上面的一切都有了,goroutine 就是一种协程的实现,在 Go 里面用起来也非常简单,于是类似于上面的多线程模型,Go 进行网络编程时也有一个常用的模式,比如下面这个 TCP Server:

func main() {
    ln, err := net.Listen("tcp", ":8080")
    checkError(err)
    for {
        conn, err := ln.Accept()
        if err != nil {
            continue
        }
        go run(conn)

    }
}

func run(conn net.Conn) {
    buffer := make([]byte, 1024)
    defer conn.Close()

    for {
        n, err := conn.Read(buffer)
        if err != nil {
            return
        }
        fmt.Println("read:", string(buffer[:n]))
    }
}

每到来一个连接,就启用一个新的 goroutine 来进行处理,因为 goroutine 非常轻量,所有能同时启动很多个,当然也就能同时处理大量请求。在 goroutine 内部,Read, Write 之类的调用是阻塞的,因此使用起来很是简单,不用各种回调。不过对于整个进程,当某个 goroutine 阻塞在 Read 之类上时,就会被调度器唤出,以便让其它 goroutine 执行。而 Go 后台会有一个线程监控这些网络连接(实际上是文件描述符),如果连接重新可读可写,就会唤醒这个 goroutine。

在 net 包中 fd_unix.go 这个文件实现了对文件描述符的封装,netFD 中的 pd pollDesc 用来实现对文件描述符的检测,当调用 net 包中需要对网络进行读写的函数时,最终都会调用 pd 的各种方法来对文件描述符状态进行查询或者修改,比如下面是 Read 的一部分:

if err := fd.pd.PrepareRead(); err != nil {
    return 0, &OpError{"read", fd.net, fd.raddr, err}
}

for {
    n, err = syscall.Read(int(fd.sysfd), p)
    if err != nil {
        n = 0
        if err == syscall.EAGAIN {
            if err = fd.pd.WaitRead(); err == nil {
                continue
            }
        }
    }
    err = chkReadErr(n, err, fd)
    break
}

其中 pollDesc 的实现在 fd_poll_runtime.go 文件中,最终实现是在 netpoll.goc 文件中,而在 Linux 下,是通过 epoll 来检测文件描符的,具体的实现细节可以看这里:Go 的非阻塞 I/OPrepareRead 方法用于检测文件是否关闭和读写是否已经超时。当发生 EAGAIN 错误时,表示暂时还没有资源可读,需要待会儿再试,于是调用 WaitRead 来等待可读,再继续。通过这种方式,在用户层编程时,只需要按照正常的思维编写程序即可,不用再考虑各种由非阻塞I/O带来的错误。

Go 的 net 包非常强大,其中 Conn 类型是主角,通过 Dial 来建立各种类型的连接,并且可以实现自己的 Dial,比如在其中加上一些验证之类的握手过程。

一点吐槽

Go 的库是基于包的,于是各种类型或者方法散布在很多文件中,看起来真是让人崩溃,为了去找个突然出现的类型或者方法,在没用明显特征时,需要打开一个一个文件查找,或者 find 搜索,还有基于组合的类型系统,常常让人摸不着头脑。

在运行时,如果出现未使用变量或者导入了但没有使用的包,就会报错,这在调试程序时真的很是麻烦,如果能在使用 go run 时加一个不用检测未用变量和包的参数,或者只有在最终编译时才检测就好了。