目前主流服务器一般均采用的都是”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(): 设置将来写调用以及当前阻塞的写调用的超时最后期限。即设置写数据时最大允许时间。
3 TCP连接的建立
服务端是一个标准的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)
}
}
例如我在客户端往服务器发送了3行的数据,结果:
go中的Read内部调用底层的read,双方没数据时都会阻塞,但是阻塞层面不一样。Read是阻塞在go语言层面,不会阻塞在调用read的时候。read阻塞是Linux内核方面的阻塞。面试可能经常会问到。
5 客户端连接异常情况分析 1、网络不可达或对方服务未启动。2、对方服务的listen backlog满 。3、网络延迟较大,Dial阻塞并超时。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要知道大概是什么原因。
6 Socket读写Dial成功后,方法返回一个net.Conn接口类型变量值。
6.1 conn.Read的行为特点
1 Socket中无数据连接建立后,如果对方未发送数据到socket,接收方(Server)会阻塞在Read *** 作上,这和前面提到的“模型”原理是一致的。执行该Read *** 作的goroutine也会被挂起。runtime会监视该socket,直到其有数据才会重新调度该socket对应的Goroutine完成read。2 Socket中有部分数据
如果socket中有部分数据,且长度小于一次Read *** 作所期望读出的数据长度,那么Read将会成功读出这部分数据并返回,而不是等待所有期望数据全部读取后再返回。3 Socket中有足够数据
如果socket中有数据,且长度大于等于一次Read *** 作所期望读出的数据长度,那么Read将会成功读出这部分数据并返回。这个情景是最符合我们对Read的期待的了:Read将用Socket中的数据将我们传入的slice填满后返回:n = 10, err = nil。4 Socket关闭
1)有数据关闭是指在client关闭时,socket中还有server端未读取的数据。
当client端close socket退出后,server依旧没有开始Read,10s后第一次Read成功读出了所有的数据,当第二次Read时,由于client端 socket关闭,Read返回EOF error。
这里可能会有人问,为什么client关闭了,服务器10s后第一次还能从client读取,这是因为TCP连接是全双工的,一端发起关闭连接后,并不是马上就能关闭,有一个2ML时长,正常大约40s左右,具体需要读者自行看TCP四次挥手的知识,看双方是如何通过4次挥手断开连接的。 更完整可以看TCP三次握手、四次挥手的状态时序图。
2)无数据关闭情形下的结果,那就是Read直接返回EOF error。5 读取 *** 作超时
有些场合对Read的阻塞时间有严格限制,在这种情况下,Read的行为到底是什么样的呢?在返回超时错误时,是否也同时Read了一部分数据了呢?
答:不会出现“读出部分数据且返回超时错误”的情况。因为只有err=nil时才会读取到数据,有错误时不会读取到数据。
6.2 conn.Write的行为特点
1 成功写前面例子着重于Read,client端在Write时并未判断Write的返回值。所谓“成功写”指的就是Write调用返回的n与预期要写入的数据长度相等,且error = nil。这是我们在调用Write时遇到的最常见的情形,这里不再举例了。2 写阻塞
TCP连接通信两端的OS都会为该连接保留数据缓冲,一端调用Write后,实际上数据是写入到OS的协议栈的数据缓冲的。TCP是全双工通信,因此每个方向都有独立的数据缓冲。当发送方将对方的接收缓冲区以及自身的发送缓冲区写满后,Write就会阻塞。
即不管服务器还是客户端,在程序调用Write后,都是先写到 *** 作系统的缓冲区后,再发送到对方的。所以缓存满的话,再进行写 *** 作时就需要阻塞。
关于全双工的概念,可以参考我这篇文章:02LinuxC进程间通信之管道pipe概念和双向半双工概念。3 写入部分数据
Write *** 作存在写入部分数据的情况。没有按照预期的写入所有数据。这时候循环写入便是综上例子,虽然Go给我们提供了阻塞I/O的便利,但在调用Read和Write时依旧要综合需要方法返回的n和err的结果,以做出正确处理。net.conn实现了io.Reader和io.Writer接口,因此可以试用一些wrapper包进行socket读写,比如bufio包下面的Writer和Reader、io/ioutil下的函数等。 7 Goroutine safe
基于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)
9 关闭连接
socket是全双工的,client和server端在己方已关闭的socket和对方关闭的socket上 *** 作的结果有不同。
上面也说过,这涉及到TCP的四次挥手知识。
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)