go语言基础-----20-----TCP网络编程

1 网络编程介绍

目前主流服务器一般均采用的都是”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处理即可。

2 TCP socket api

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语言基础-----20-----TCP网络编程_第1张图片

4 go中的net.Conn的Read接口与Linux底层的系统调用函数read有何区别?

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)
}

结果看到,和服务器没开启或者端口没监听的结果是一样的:
客户端结果:

  • 1)分析它,打connect to server ok的原因是,上面已经讲过了,即便服务端不accept,那么在backlog数量范畴里面,客户端的connect都会是成功的,因为new conn已经加入到server side的listen queue中了,accept只是从queue中取出一个conn而已。
  • 2)重要的是看到那个错误,即“connectex: No connection could be made because the target machine actively refused it. ”,和服务器没开启或者端口没监听的结果是一样的。
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处理一个连接,所以打印是非常少的,也没啥好分析的,主要看上面客户端的打印即可。
go语言基础-----20-----TCP网络编程_第2张图片

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")
}

首先模拟正常的情况下,打印结果:
服务器的打印:
go语言基础-----20-----TCP网络编程_第3张图片
客户端的打印:
在这里插入图片描述

然后再linux输入命令,模拟网络延迟。

// ens33使用ifconfig查看网卡。延迟不要太卡和最好不要在云服务器上执行,不然服务器会变得很卡,导致命令都执行得很慢。这里延迟3000ms,实际上已经会很卡了。
sudo tc qdisc add dev ens33 root netem delay 3000ms

服务器的打印:
go语言基础-----20-----TCP网络编程_第4张图片
客户端的打印:
在这里插入图片描述

添加了网络延迟后,可以看到服务器是没有收到客户端的连接请求的,导致客户端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保护。

8 Socket属性

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的四次挥手知识。

你可能感兴趣的:(Go,网络,tcp/ip,golang)