介绍
Golang是谷歌设计开发的语言,在Golang的设计之初就把高并发的性能作为Golang的主要特性之一,也是面向大规模后端服务程序。在服务器端网络通信是必不可少的也是至关重要的一部分。Golang内置的包例如 net 、net/http中的底层就是对TCP socket方法的封装。
这里简单介绍一下 TCP 。TCP(Transmission Control Protocol)传输控制协议,是一种面向连接的、可靠的、基于字节流的 传输层 通信协议,也叫做可靠的传输协议。属于OSI七层模型中的传输层协议。相比可靠的就会有不可靠的—— UDP (User Datagram Protocol)用户数据报协议,也叫做不可靠的传输协议。这里的可靠和不可靠只是它们的侧重点不同。TCP强调数据的完整性,UDP注重数据的实时行。目前大部分的数据传输都使用的是TCP协议,而在一些视频、直播、或者网络电话采用的就是UDP协议。
模型
言归正传,今天主要介绍如何使用Go语言进行TCP socket编程。目前主流web server一般均采用的都是”Non-Block + I/O 多路复用 ”。不过I/O多路复用使用起来依旧很复杂,以至于后续出现了许多高性能的I/O多路复用框架, 比如 libevent 、libev、libuv等大大降低了开发的成本。不过Go的设计者似乎认为I/O多路复用的这种通过回调机制割裂控制流 的方式还是很复杂,为此Go语言将该“复杂性”隐藏在Runtime中了:开发者无需关注 socket 是否是 non-block的,也无需亲自注册文件描述符的回调,只需在每个连接对应的goroutine中以“block I/O”的方式对待socket处理即可。一个经典的服务端例子如下:
虽然用户层眼中看到的goroutine中的“block socket”,实际上是通过Go runtime中的netpoller通过Non-block socket + I/O多路复用机制“模拟”出来的,真实的underlying socket实际上是non-block的,只是runtime拦截了底层socket系统调用的错误码,并通过netpoller和goroutine 调度让goroutine“阻塞”在用户层得到的Socket fd上。
TCP连接建立
TCP Socket的连接的建立需要经历客户端和服务端的三次握手的过程。三次握手大致流程如下:
连接建立过程中,服务端是一个标准的Listen + Accept的结构(可参考上面的代码),而在客户端Go语言使用net.Dial()或net.DialTimeout()进行连接建立
阻塞Dial:
超时机制的Dial:
Socket套接字读写
连接建立起来后,我们就要在conn上进行读写,以完成业务逻辑。前面说过Go runtime隐藏了I/O多路复用的复杂性。语言使用者只需采用goroutine+Block I/O的模式即可满足大部分场景需求。Dial成功后,方法返回一个net.Conn接口类型变量值,这个接口变量的动态类型为一个*TCPConn:
TCPConn内嵌了一个unexported类型:conn,因此TCPConn”继承”了conn的Read和Write方法,后续通过Dial返回值调用的Write和Read方法均是net.conn的方法:
基于goroutine的网络架构模型,存在在不同goroutine间共享conn的情况,那么conn的读写是否是goroutine safe的呢?在深入这个问题之前,我们先从应用意义上来看read操作和write操作的goroutine-safe必要性。对于read操作而言,由于TCP是面向字节流,conn.Read无法正确区分数据的业务边界,因此多个goroutine对同一个conn进行read的意义不大,goroutine读到不完整的业务包反倒是增加了业务处理的难度。对与Write操作而言,倒是有多个goroutine并发写的情况。每次Write操作都是受lock保护,直到此次数据全部write完。因此在应用层面,要想保证多个goroutine在一个conn上write操作的Safe,需要一次write完整写入一个“业务包”;一旦将业务包的写入拆分为多次write,那就无法保证某个Goroutine的某“业务包”数据在conn发送的连续性