模型
了解socket的模型也是非常重要的,可以帮助我们对socket编程有一个更宏观的了解。从tcp socket诞生后,网络编程架构也经过几轮演化,大致流程如下:
- 每个进程一个连接
- 每个线程一个连接
- Non block + I/O对路复用
伴随着模型的演化,服务器程序愈加强大,可以支持更多的连接,获得更好的性能处理。目前主流web server一般均采用的是Non-Block + I/O多路复用,有的也结合了多线程,多进程。不过I/O复用也给使用者带来不小的复杂度,以至于后续出现了许多高性能的I/O多路复用框架,以帮助开发者简化开发的复杂度,降低心智负担。不过Go的设计者似乎认为I/O多路复用的这种通过回调机制控制流的方式依旧复杂,并且有悖于“一般逻辑”设计,为此Go语言将改“复杂性”隐藏在runtime中。Go开发者无需关注socket是否是non-block的,也无需亲自注册文件描述符的回调,只需在每个连接对应的goroutine中以“block I/O”的方式对待socket处理即可,这可以说是大大降低了开发人员的心智负担,一个典型的Go server端程序大致如下:
type Conn struct {
rwc io.ReadWriteCloser
br *bufio.Reader
buffer []byte
}
func NewConn(rwc io.ReadWriteCloser) *Conn {
return &Conn{
rwc: rwc,
br: bufio.NewReader(rwc),
buffer: make([]byte, messageBufferLen),
}
}
func main(){
l, err: = net.Listen("tcp", ":8080")
if err != nil {
fmt.Println("listen error:", err)
return
}
for {
c, err := l.Accept()
if err != nil {
fmt.Println("accept error:", err)
return
}
conn := duss.NewConn(c)
go handler.Run()
}
}
用户空间中看到的goroutine的block socket, 实际上是通过Go runtime中的netpoller通过Non-block socket + I/O多路复用机制模拟出来的,真实的underlying socket实际上是non-block的,只是runtime拦截了底层socket系统调用的错误码,并通过netpoller和goroutine调度让goroutine阻塞在用户层得到的socket fd上。比如,当用户层针对某个socket fd发起read操作时,如果该socket fd中暂时没有数据,那么runtime会将socket id加入到netpoller中监听,同时对应的goroutine挂起,知道runtime收到socket fd数据ready通知,runtime才会重新唤醒等待在该socket fd上准备read的那个Goroutine。而这个过程从Gotouine的角度来看,就像是read一直block在那个socket fd上似的。
conn read的行为特点
socket中无数据
连接建立之后,如果对方未发送数据到socket,接收方server会阻塞在read操作上,这和前面提到的“模型”原理是一致的。执行该read操作的goroutine也会被挂起,runtime会监视该socket,直到其有数据才会重新调度该socket对应的goroutine完成read。
socket中有部分数据
如果socket中部分数据,而且长度小于一次read操作所期望读出的数据长度,那么read将会成功读出这部分数据并成功返回,而不是等待所有期望数据全部读取后再返回。
func handlerConn(c net.Conn) {
defer c.Close()
for {
var buf = make([]byte, 10)
log.Println("start to read from conn")
n, err := c.Read(buf)
if err != nil {
log.Println("conn read error:", err)
return
}
log.Printf("read %d bytes, contents is %s\n", n, string(buf[:n]))
}
}
//read 2 bytes, content is hi
socket中有足够的数据
如果socket中有数据,而且长度大于等于一次read操作所期望读出的长度,而且长度大于一次read操作所期望读出的数据长度,那么read将会成功读出这部分数据并返回。这个场景最符合我们对read的期待了,read将用socket中的数据将我们传入slice填满后返回,n=10, err != nil。
我们通过client2.go向server发送如下内容,abcdefghij12345,执行结果如下:
¥go run server2.go
// accept a new connection
// start to read from conn
// read 10 bytes, content is abcdefghij
// start to read from conn
// read 5 bytes, content is 12345
client端发送的内容长度为15个字节,server端read buffer的长度为10,因此serve read第一次返回时只会读取10个字节,socket中还剩下5个字节数据,server再次read时会把剩余的数据读出。
conn write的行为特点
写成功
所谓成功写就是write调用返回的n与预期要写入的数据长度相等,而且err = nil, 这是我们在调用write遇到的最常见的情形,这里就不用举例子了。
写阻塞
当服务端调用write后,实际上数据是写入到OS的协议栈的数据缓冲中。TCP是全双工通信,因此每个方向都有独立的数据缓冲,当发送方将对方的接收缓冲区以及自身的发送缓冲区写满后,write就会阻塞。