目前主流服务器一般均采用的都是”Non-Block + I/O多路复用”(有的也结合了多线程、多进程)。不过I/O多路复用也给使用者带来了不小的复杂度,以至于后续出现了许多高性能的I/O多路复用框架, 比如libevent、libev、libuv等,以帮助开发者简化开发复杂性,降低心智负担。
不过Go的设计者似乎认为I/O多路复用的这种通过回调机制割裂控制流 的方式依旧复杂,且有悖于“一般逻辑”设计,为此Go语言将该“复杂性”隐藏在Runtime中了:Go开发者无需关注socket是否是 non-block的,也无需亲自注册文件描述符的回调,只需在每个连接对应的goroutine中以“block I/O”的方式对待socket处理即可。
•Read(): 从连接上读取数据。
•Write(): 向连接上写入数据。
•Close(): 关闭连接。
•LocalAddr(): 返回本地网络地址。
•RemoteAddr(): 返回远程网络地址。
•SetDeadline(): 设置连接相关的读写最后期限。等价于同时调用SetReadDeadline()和SetWriteDeadline()。 •SetReadDeadline(): 设置将来的读调用和当前阻塞的读调用的超时最后期限。即设置读数据时最大允许时间。
•SetWriteDeadline(): 设置将来写调用以及当前阻塞的写调用的超时最后期限。即设置写数据时最大允许时间。
服务端是一个标准的Listen + Accept的结构,而在客户端Go语言使用net.Dial()或net.DialTimeout()进行连接建立。
下面给出go实现的简单的TCP网络编程例子。
服务端代码:
package main
import (
"fmt"
"net"
_ "time"
)
func process(conn net.Conn) {
// 1. 客户端退出要关闭通信连接。
defer conn.Close()
// 2. 循环读取客户端的数据。
for {
//创建一个新的切片
buf := make([]byte, 1024)
// 3. 等待客户端发送信息,如果客户端没发送,协程就阻塞Read()。
// 问:go中的net.Conn的Read接口与Linux底层的系统调用函数read有何区别?
// 答:go中的Read内部调用底层的read,双方没数据时都会阻塞,但是阻塞层面不一样。Read是阻塞在go语言层面,不会阻塞在调用read的时候。
// read阻塞是Linux内核方面的阻塞。面试可能经常会问到。
// fmt.Printf("服务器在等待客户端%v的输入\n", conn.RemoteAddr().String())
// 可以设置读超时,但超过这个时间客户端没发送数据,那么会超时返回,我们按需进行处理。
// conn.SetReadDeadline(time.Now().Add(time.Duration(1) * time.Second))
n, err := conn.Read(buf) // 默认是阻塞的
if err != nil {
fmt.Println("服务器read err=", err)
fmt.Println("客户端退出了")
return
}
// 4. 显示客户端发送内容到服务器的终端
fmt.Print(string(buf[:n]) + "\n")
}
}
func main() {
ipPort := "0.0.0.0:8888"
fmt.Println("服务器开始监听在:", ipPort)
// 1. Linsten监听。
listen, err := net.Listen("tcp", ipPort)
if err != nil {
fmt.Println("监听失败,err=", err)
return
}
// 2. 延时关闭Linsten。
defer listen.Close() // 函数退出的时候调用
for {
// 3. 循环等待客户端连接
fmt.Println("等待客户端连接...")
conn, err := listen.Accept() // 与C的accept一样,没有新连接时,会阻塞在这里。
if err != nil {
fmt.Println("Accept() err=", err)
} else {
fmt.Printf("Accept() success con=%v,客户端Ip=%v\n", conn, conn.RemoteAddr().String())
}
// 4. 这里准备起个协程为客户端服务
go process(conn)
}
//fmt.Printf("监听成功,suv=%v\n", listen)
}
客户端代码:
package main
import (
"bufio"
"fmt"
"net"
"os"
"strings"
)
func main() {
// 1. 连接到服务器,并可以设置连接模式,ip和端口号。类似Linux C的Connect函数。
conn, err := net.Dial("tcp", "127.0.0.1:8888")
if err != nil {
fmt.Println("client dial err=", err)
return
}
// 2. 函数退出时关闭连接。
defer conn.Close()
// 3. 从命令行中读取数据,循环发送给服务器。
// 获取输入IO对象,方便读取用户输入的数据。在命令行输入单行数据。
reader := bufio.NewReader(os.Stdin)
for {
//从终端读取一行用户的输入,并发给服务器。每次发完会在ReadString等待用户输入数据。
fmt.Println("请输入内容:")
line, err := reader.ReadString('\n')
if err != nil {
fmt.Println("readString err=", err)
}
//去掉输入后的换行符
line = strings.Trim(line, "\r\n")
// 如果是exit,则退出客户端
if line == "exit" {
fmt.Println("客户端退出了")
break
}
// 将line发送给服务器
n, e := conn.Write([]byte(line))
if e != nil {
fmt.Println("conn.write err = ", e)
}
fmt.Printf("客户端发送了%d字节的数据\n", n)
}
}
go中的Read内部调用底层的read,双方没数据时都会阻塞,但是阻塞层面不一样。Read是阻塞在go语言层面,不会阻塞在调用read的时候。read阻塞是Linux内核方面的阻塞。面试可能经常会问到。
5.1 客户端连接异常-网络不可达或对方服务未启动
如果传给Dial的Addr是可以立即判断出网络不可达,或者Addr中端口对应的服务没有启动,端口未被监听,Dial会几乎立即返回错误,比如:
下面我们来测试看看 客户端连接异常-网络不可达或对方服务未启动的情况。go会报什么样的错误,方便以后我们排查错误。
首先测试Addr地址错误的情况:
服务器代码:用回上面的即可。
客户端代码:
package main
import (
"log"
"net"
)
func main() {
log.Println("begin dial...")
conn, err := net.Dial("tcp", "a:8888") // Addr错误。
// conn, err := net.Dial("tcp", ":8888") // 正常写法,等价于conn, err := net.Dial("tcp", "127.0.0.1:8888")。此时让服务器的8888端口不打开。
if err != nil {
log.Println("dial error:", err)
return
}
defer conn.Close()
log.Println("dial ok")
}
测试服务器没有开启服务或者端口未被监听的情况:
服务器代码:服务器直接不运行即可。
客户端代码:
package main
import (
"log"
"net"
)
func main() {
log.Println("begin dial...")
//conn, err := net.Dial("tcp", "a:8888") // Addr错误。
conn, err := net.Dial("tcp", ":8888") // 正常写法,等价于conn, err := net.Dial("tcp", "127.0.0.1:8888")。此时让服务器的8888端口不打开。
if err != nil {
log.Println("dial error:", err)
return
}
defer conn.Close()
log.Println("dial ok")
}
5.2 客户端连接异常-对方服务的listen backlog满
listen backlog满,即客户端的连接数到达了服务器的最大限制。所以这里的连接数应该是指服务器未处理的连接数,因为服务器的Accept还没处理呢。(和Linux的backlog一样?Linux的backlog是指已经连接的最大连接数???我太久没用Linux C的网络编程接口了,有点忘记了。)
对方服务器很忙,瞬间有大量client端连接尝试向server建立,server端的listen backlog队列满,server accept不及时((即便服务端不accept,那么在backlog数量范畴里面,客户端的connect都会是成功的,因为new conn已经加入到server side的listen queue中了,accept只是从queue中取出一个conn而 已),这将导致client端Dial阻塞。
服务器代码:
package main
import (
"log"
"net"
"time"
)
func main() {
// 1. Linsten监听。
l, err := net.Listen("tcp", ":8888")
if err != nil {
log.Println("error listen:", err)
return
}
// 2. 延时关闭Linsten。
defer l.Close()
log.Println("listen ok")
var i int
for {
// 3. 循环等待客户端连接
time.Sleep(time.Second * 10) // 休眠,模拟来不及处理backlog.客户端一旦发送连接请求,就会放入到listen的队列中,
// accept只是从队列里面获取。所以当客户端发送过多,到达backlog大小后(队列大小),那么客户端就会报错。
// 这样就能模拟 客户端连接异常-对方服务的listen backlog满的情况。
if _, err := l.Accept(); err != nil {
log.Println("accept error:", err)
break
}
i++
log.Printf("%d: accept a new connection\n", i)
}
}
客户端代码:
package main
import (
"log"
"net"
"time"
)
// 成功连接到服务器返回通信连接,否则返回nil。
func establishConn(i int) net.Conn {
conn, err := net.Dial("tcp", ":8888")
if err != nil {
log.Printf("%d: dial error: %s", i, err)
return nil
}
log.Println(i, ":connect to server ok")
return conn
}
func main() {
// 1. 定义一个切片,用于存放连接服务器成功的客户端。
var sl []net.Conn
// 2. 循环像服务器请求连接,模拟大量的客户端像服务器发送连接请求。成功连接会放入切片中,失败这里则不做处理。
for i := 1; i < 1000; i++ {
conn := establishConn(i)
if conn != nil {
sl = append(sl, conn)
}
}
time.Sleep(time.Second * 10000)
}
结果看到,和服务器没开启或者端口没监听的结果是一样的:
客户端结果:
2022/02/13 18:26:37 1 :connect to server ok
2022/02/13 18:26:37 2 :connect to server ok
// 中间一直都是 connect to server ok的打印,所以省略了它。
2022/02/13 18:26:39 201 :connect to server ok
2022/02/13 18:26:41 202: dial error: dial tcp :8888: connectex: No connection could be made because the target machine actively refused it.
2022/02/13 18:26:43 203: dial error: dial tcp :8888: connectex: No connection could be made because the target machine actively refused it.
2022/02/13 18:26:45 204: dial error: dial tcp :8888: connectex: No connection could be made because the target machine actively refused it.
2022/02/13 18:26:47 205: dial error: dial tcp :8888: connectex: No connection could be made because the target machine actively refused it.
2022/02/13 18:26:49 206 :connect to server ok
2022/02/13 18:26:51 207: dial error: dial tcp :8888: connectex: No connection could be made because the target machine actively refused it.
2022/02/13 18:26:53 208: dial error: dial tcp :8888: connectex: No connection could be made because the target machine actively refused it.
2022/02/13 18:26:55 209: dial error: dial tcp :8888: connectex: No connection could be made because the target machine actively refused it.
2022/02/13 18:26:57 210: dial error: dial tcp :8888: connectex: No connection could be made because the target machine actively refused it.
2022/02/13 18:26:59 211 :connect to server ok
2022/02/13 18:27:01 212: dial error: dial tcp :8888: connectex: No connection could be made because the target machine actively refused it.
2022/02/13 18:27:03 213: dial error: dial tcp :8888: connectex: No connection could be made because the target machine actively refused it.
2022/02/13 18:27:05 214: dial error: dial tcp :8888: connectex: No connection could be made because the target machine actively refused it.
2022/02/13 18:27:07 215: dial error: dial tcp :8888: connectex: No connection could be made because the target machine actively refused it.
2022/02/13 18:27:08 216 :connect to server ok
服务器打印:看时间,由于服务器10s处理一个连接,所以打印是非常少的,也没啥好分析的,主要看上面客户端的打印即可。
5.3 客户端连接异常-网络延迟较大,Dial阻塞并超时
如果网络延迟较大,TCP握手过程将更加艰难坎坷(各种丢包),时间消耗的自然也会更长。Dial这
时会阻塞,如果长时间依旧无法建立连接,则Dial也会返回“ getsockopt: operation timed out”错误
在连接建立阶段,多数情况下,Dial是可以满足需求的,即便阻塞一小会儿。
但对于某些程序而言,需要有严格的连接时间限定,如果一定时间内没能成功建立连接,程序可能会需要执行一段“异常”处理逻辑,为此我们就需要DialTimeout()了。
因为要模拟网络延迟,所以我们将服务器弄到Linux上面运行。
Linux安装go可以参考这篇文章:Linux系统下安装Go语言环境。
服务器代码:
package main
import (
"fmt"
"net"
_ "time"
)
func process(conn net.Conn) {
// 1. 客户端退出要关闭通信连接。
defer conn.Close()
// 2. 循环读取客户端的数据。
for {
//创建一个新的切片
buf := make([]byte, 1024)
// 3. 等待客户端发送信息,如果客户端没发送,协程就阻塞Read()。
// 问:go中的net.Conn的Read接口与Linux底层的系统调用函数read有何区别?
// 答:go中的Read内部调用底层的read,双方没数据时都会阻塞,但是阻塞层面不一样。Read是阻塞在go语言层面,不会阻塞在调用read的时候。
// read阻塞是Linux内核方面的阻塞。面试可能经常会问到。
// fmt.Printf("服务器在等待客户端%v的输入\n", conn.RemoteAddr().String())
// 可以设置读超时,但超过这个时间客户端没发送数据,那么会超时返回,我们按需进行处理。
// conn.SetReadDeadline(time.Now().Add(time.Duration(1) * time.Second))
n, err := conn.Read(buf) // 默认是阻塞的
if err != nil {
fmt.Println("服务器read err=", err)
fmt.Println("客户端退出了")
return
}
// 4. 显示客户端发送内容到服务器的终端
fmt.Print(string(buf[:n]) + "\n")
}
}
func main() {
ipPort := "0.0.0.0:8888"
fmt.Println("服务器开始监听在:", ipPort)
// 1. Linsten监听。
listen, err := net.Listen("tcp", ipPort)
if err != nil {
fmt.Println("监听失败,err=", err)
return
}
// 2. 延时关闭Linsten。
defer listen.Close() // 函数退出的时候调用
for {
// 3. 循环等待客户端连接
fmt.Println("等待客户端连接...")
conn, err := listen.Accept() // 与C的accept一样,没有新连接时,会阻塞在这里。
if err != nil {
fmt.Println("Accept() err=", err)
} else {
fmt.Printf("Accept() success con=%v,客户端Ip=%v\n", conn, conn.RemoteAddr().String())
}
// 4. 这里准备起个协程为客户端服务
go process(conn)
}
//fmt.Printf("监听成功,suv=%v\n", listen)
}
客户端代码:
package main
import (
"log"
"net"
"time"
)
func main() {
log.Println("begin dial...")
// 1. 连接到服务器
conn, err := net.DialTimeout("tcp", "192.168.2.132:8888", 2*time.Second) // 设置超时返回。例如当因为网络卡顿超过2秒而连
// 接不上服务器时,那么会直接返回。
if err != nil {
log.Println("dial error:", err)
return
}
// 2. 函数退出时关闭连接。
defer conn.Close()
log.Println("dial ok")
}
首先模拟正常的情况下,打印结果:
服务器的打印:
客户端的打印:
然后再linux输入命令,模拟网络延迟。
// ens33使用ifconfig查看网卡。延迟不要太卡和最好不要在云服务器上执行,不然服务器会变得很卡,导致命令都执行得很慢。这里延迟3000ms,实际上已经会很卡了。
sudo tc qdisc add dev ens33 root netem delay 3000ms
添加了网络延迟后,可以看到服务器是没有收到客户端的连接请求的,导致客户端DialTimeout超时返回了。这就是服务器网络延迟造成的结果,以后看到i/o timeout要知道大概是什么原因。
Dial成功后,方法返回一个net.Conn接口类型变量值。
6.1 conn.Read的行为特点
6.2 conn.Write的行为特点
基于goroutine的网络架构模型,存在在不同goroutine间共享conn的情况,那么conn的读写是
否是goroutine safe的呢?
答:go的conn.Write、conn.Read 内部是goroutine安全的,内部都有Lock保护。
SetKeepAlive
SetKeepAlivePeriod
SetLinger
SetNoDelay (默认no delay)
SetWriteBuffer
SetReadBuffer
要使用上面的Method的,需要type assertion。例如:
tcpConn, ok := conn.(*TCPConn)
if !ok { //error handle }
tcpConn.SetNoDelay(true)
socket是全双工的,client和server端在己方已关闭的socket和对方关闭的socket上操作的结果有不同。
上面也说过,这涉及到TCP的四次挥手知识。