go底层TCP网络编程剖析


学习go 语言中的通信方式底层如何实现,需要提前了解下TCP 的基本原理

1. 前言

1.1 网络分层

在这里插入图片描述
我们都知道以太网层和IP 层是不可靠的,想要可靠的传输就必须到传输层。而传输层的重要协议就是TCP 协议了

如果我们想要在应用层上开发,也需要在基于TCP 层上开发,所以我们需要了解Go官方是如何去实现传输层的

1.2 TCP 通信过程

在这里插入图片描述

TCP 经典的三次握手和四次挥手过程,我们这节主要讲解go 的相关知识,这里的细节就不一一展开了

我们可以想象如果手动实现TCP 的三次握手和四次挥手会怎么样? 答案是很麻烦的, 于是在计算机中就有了socket 这个概念, 它能将三次握手和四次挥手简化几个函数connect(), close(), 屏蔽了系统底层的操作,使应用开发者更加便捷

1.3 socket

  1. 很多系统都提供socket 作为 TCP 网络连接的抽象
  2. Linux -> internet domain socket -> SOCK_STREAM
  3. Linux 中socket 以“文件描述符” FD 作为标识

1.4 socket 通信过程

其中简单的通信过程如下:
在这里插入图片描述
从socket 连接个数看客户端通信过程,可以画成这样
在这里插入图片描述
从图中可看到,通过socket 屏蔽了底层通信。如果一个server 服务两个客户端, 这时会有3个socket , 其中2个是的建立连接的socket, 另外一个是监听socket。这个概念对后面很重要,也对理解整个tcp 层很关键

1.5 IO 模型(种类)

在这里插入图片描述
这时上面两个客户端连接服务器的例子, 那么问题出现了
如果多个客户端连接服务器,在计算机看来就是多个socket 连接到服务器,这个服务端需要同时处理,于是这个同时处理的方法就产生了IO 模型

  • 阻塞模型
  • 非阻塞模型
  • 多路复用模型

2. IO模型

2.1 阻塞IO 模型

在这里插入图片描述
简单理解就是服务端处理3个socket 时,需要开辟三个线程

步骤:

  1. 同步读写socket时,线程陷入内核态
  2. 当读写成功后,切换回用户态,继续执行

优缺点:
优点: 开发难度小,代码简单
缺点: 内核态切换开销大 (如果有1000个线程, 开销就很大了)

正因为这个缺点就有了非阻塞IO 模型诞生了

2.2 非阻塞IO模型

在这里插入图片描述

  1. 如果暂时无法收发数据,会返回错误
  2. 应用会不断轮询,直到socket可以读写
    优点: 不会陷入内核态,自由度高
    缺点: 需要自旋轮询

现在使用非阻塞模型,避免了阻塞模型的缺点,但是必须时刻进行轮询,
那么问题来了,有没有一种方法不用轮询,就能帮助系统监控有没有数据过来,于是就诞生了多路复用模型

2.3 多路复用IO 模型 —linux 中的epoll

在这里插入图片描述
linux 有epoll, windows 和mac 中也有类似的东西;

这里以epoll 为例: 全称叫event poll。它可以将每个socket 的可读 可写的事件注册到里面, 现在这个场景是将三个socket 的可读事件注册到事件池里。放入里面后就是操作系统帮助我们实现的,然后我们会非阻塞的调用epoll,去询问这三个事件发生了什么, 然后epoll 返回socket2 可读事件,socket1、3 都没有数据。然后我们的业务直接调用第2个socket,然后处理对应的客户端就行了。也就是多路复用 是将 监听多个socket 的任务从业务转移到了操作系统。

步骤:

  1. 注册多个socket 事件
  2. 调用epoll, 当有事件发生,返回
    优点: 提供了事件列表,不需要查询各个socket
    缺点: 开发难度大,逻辑复杂

扩展知识:
Mac : kqueue
windows: IOCP

现在提出问题:
有没有能结合阻塞模型和多路复用的方法?(也就是说将两者的优点合二为一)
在go 中 类似架构如下:
在这里插入图片描述
这里可以将每一个协程对应一个socket,起到阻塞模型的特点
然后利用go 的网络层进行对epoll 层的封装,最终达到上面的效果

3. Go 网络层

接着上面的思考,更加细化go 中的架构图,我们把go 中的协程和业务系统联系起来,如下图:
在这里插入图片描述
这样就是把原来的线程阻塞模型换成协程阻塞模型底层也不是休眠线程了, 是休眠协程。而协程相比于线程来说,所消耗的系统资源小得多。

在go 中想要实现epoll, 必须得考虑到不同平台的差异,而为了解决这一差异,官方就在架构中加入了epoll 抽象层

3.1 epoll 抽象层

这一节我们就思考在go 中阻塞模型和多路复用模型架构图中epoll抽象层是如何做的
在这里插入图片描述
为什么需要引入go 抽象层呢?目的就是为了统一各个平台之间的差异,其实就是go 官方针对不同平台接入了不同api

用术语来讲: epoll 抽象层是为了统一各个操作系统对多路复用器的实现

3.2 多路复用器

各个系统的多路复用器都有以下功能

  1. 新建多路复用器 epoll_create()
  2. 往多路复用器里插入需要监听的事件 epoll_ctl()
  3. 查询发生了什么事件 epoll_wait()
    在这里插入图片描述
    其中可以地看到有一个总的netpoll文件, 其他的就是各个平台epoll 具体实现

3.3 Go Network Poller 多路复用器的抽象

go 的官方epoll 实现好了之后,应该具体做啥呢。
当然是实现epoll 得具体逻辑了

多路复用器得抽象适配:刚好对应各个系统应该拥有得功能

  1. Go Network Poller 对于多路复用器的抽象和适配
  2. epoll_create() --> netpollinit()
  3. epoll_ctl() --> netpollopen()
  4. epoll_wait() --> netpoll()
    这样做的目的就是上层不用关心平台的限制了
3.3.1 netpollinit() 新建多路复用器
  1. 新建Epoll
  2. 新建一个Pipe管道用于中断Epoll
  3. 将“管道有数据到达” 事件注册在Epoll 中
3.3.2 netpollopen() 插入事件

名字有混淆性, 不是打开一个epoll, 而是插入

  1. 传入一个socket 的fd, 和 pollDesc 指针
  2. pollDesc 指针是socket 相关详细信息
  3. pollDesc 中记录了哪个协程休眠在等待此socket
  4. 将socket 可读、可写、断开事件注册到epoll 中
3.3.3 netpoll() 查询发生了什么事件

返回的是协程列表

  1. 调用epoll_wait(), 查询有哪些事件发生
  2. 根据socket 相关的pollDesc 信息,返回哪些协程 可以唤醒

思考: pollDesc 是怎么来的呢?

3.4 Network Poller 如何工作

Network Poller 层与上面的关系
在这里插入图片描述
上一节讲了多路复用抽象层, 主要是实现了三个方法, 屏蔽了不同平台的差异性这一节继续讲解上面一层 networkPoller 具体是怎么实现的

3.4.1 NetworkPoller 初始化
  1. poll_runtime_pollServerInit()
  2. 使用原子操作保证 只初始化一次
  3. 调用netpollinit()

初始化之后就要工作了
不过在这之前需要了解两个数据结构 pollcache 和pollDesc

3.4.2 pollcache 和pollDesc 数据结构

在这里插入图片描述
在这里插入图片描述
pollDesc 不是描述多路复用器的,而是描述一个socket 的

  1. pollcache : 一个带锁的链表头
  2. pollDesc : 链表的成员
  3. pollDesc 是runtime 包对socket 的详细描述
  4. rg, wg : 1 , 2, 等待的协程G 地址

其中他们之间的关系如下:
在这里插入图片描述

3.4.3 network poller工作步骤

Network Poller 新增监听socket

  1. poll_runtime_pollOpen()
  2. 在pollcache 链表中分配一个pollDesc
  3. 初始化pollDesc (rg、wg 为0)
  4. 调用netpollopen() (在linux 调用epoll, 在windows 上调用iocp)

3.5 Network Poller 收发数据

收发数据分为两个场景

  1. 协程需要收发数据时,socket 已经可读写
  2. 协程需要收发数据时,socket 暂时无法读写(sokcet 在发别的数据,没有空闲下来)
3.5.1 场景1:socket 已经可读写

runtime 循环调用netpoll()方法(g0 协程)
比如 垃圾回收器 会循环调用
这个不是业务调用的, 而是runtime 调用

步骤:

  1. 发现socket 可读写时,给对应的rg, 或者wg 置为pdReady(1)
  2. 协程调用poll_runtime_pollWait()
  3. 判断rg 或者 wg 已经置为 pdReady(1), 返回0
3.5.2 场景2: socket 暂时无法读写

runtime 循环调用netpoll()方法(g0 协程)

  1. 协程调用poll_runtime_pollWait()
  2. 发现对应的rg 或者 wg 为 0
  3. 给对应的rg 或者 wg 置为协程地址
  4. 休眠等待
  5. runtime 循环调用netpoll()方法(g0 协程)
  6. 发现socket可读写时,给对应的查看对应的rg 或者 wg
  7. 若为协程地址,返回协程地址
  8. 调度器开始调度对应协程

此时可将之前的架构图画成这
在这里插入图片描述
思考:
我们知道了如何检测socket 状态,但是socket 从哪来,直到socket 可操作后,做什么呢

3.6 go 如何抽象socket 的

在network poller 的上一层就是net包,是go 官方实现的

net包:

  1. net 包是go 原生的网络包
  2. net 包实现了TCP、UDP、HTTP 等网络

回顾一下之前讲过的socket 通信过程
在这里插入图片描述

3.6.1 net.Listen() 函数
  1. 新建socket, 并执行bind 操作
  2. 新建一个fd(net 包对socket 的详情描述)
  3. 返回一个TCP Listener 对象
  4. 将TCPListener 的FD 信息加入监听
  5. TCPListener 对象本质上是一个LISTEN 状态的socket
3.6.2 TCPListener.Accept() 函数

现在是执行了bind 和 listen ,就将socket 放到系统的epoll 中去监听新的连接,下一步就调用accept()去监听了
下一步可以有两个思路:

  • 直接调用系统底层的accept 监听新的连接
  • 用go net 中的封装的方法去监听新的连接

TCPListener.Accept() 函数

  1. 直接调用socket 的accept
  2. 如果失败,休眠等待新的链接
  3. 将新的socket 包装为TCPConn 变量返回
  4. 将TCPConn 的FD 信息加入监听
  5. TCPConn 本质上是一个ESTABLISHED 状态的socket
3.6.3 TCPConn.Read()/ Write() 函数

拿到conn 之后就可以和客户端进行通信了,下面讲解read 、write 函数

  1. 直接调用socket 原生读写方法
  2. 如果失败,休眠等待可读、可写
  3. 被唤醒后调用系统socket
3.6.4 简单代码演示

下面演示用go 中的net 实现一个简单tcp服务器, 监听9999端口

func main() {
	lis, err := net.Listen("tcp", ":9999")
	if err != nil {
	panic(err)
	} 
	conn, err := lis.Accept()
		if err != nil {
		panic(err)
	}
	var body [100]byte
	for true {
		_, err := conn.Read(body[:])
		if err != nil {
		break
		}
		fmt.Printf("recv msg = %s\n", string(body[:]))
		_, err = conn.Write(body[:])
		if err != nil {
		break
		}
	}
}

整理一下之前的架构图:
在这里插入图片描述

4. 结合阻塞模型和多路复用—goroutine-per-connection 编程风格

4.1 goroutine-per-connection 编程风格

由于之前的代码只能服务于一个客户端,也就是说只能和一个socket 进行通信
在这里插入图片描述
基于上面这种情况,go 推出了一个编程风格,可理解成每一个协程一个连接

思路:

  1. 用主协程监听Listener
  2. 每个Conn 使用一个新协程处理

4.2 代码演示

  1. goroutine-per-connection 编程风格
  2. 结合多路复用的性能和阻塞模型的简洁
func handleConnection(conn net.Conn) {
	defer conn.Close()
	var body [100]byte
	for true {
		_, err := conn.Read(body[:])
		if err != nil {
			break
		}
	fmt.Printf("recv msg = %s\n", string(body[:]))
	_, err = conn.Write(body[:])
		if err != nil {
			break
		}
	}
} 

func main() {
	lis, err := net.Listen("tcp", ":9999")
	if err != nil {
	panic(err)
	} 
	//实时处理新的连接
	for true {
		conn, err := lis.Accept()
		if err != nil {
		panic(err)
		} 
		//	用新的协程处理新的连接
		go handleConnection(conn)
	}
}

5. 总结

5.1 系统IO 模型

  1. 操作系统提供了sokcet 作为TCP通信的抽象
  2. IO 模型的指的是操作socket 的方案
  3. 阻塞模型最鲤鱼业务编写,但是性能最差
  4. 多路复用性能好,但业务编写麻烦

5.2 epoll 抽象

  • GO 将多路复用器的操作进行了抽象和适配
  1. 将新建多路复用器抽象为了netpollinit()
  2. 将插入监听事件抽象为了netpollopen()
  3. 将查询事件抽象为了netpoll()
  4. 查询事件不是返回事件, 而是返回等待事件的协程列表

5.3 Network Poller 的工作原理

  1. Network Poller 是 Runtime 的强大工具
  2. 抽象了多路复用器的操作
  3. Network Poller 可以自动检测多个socket 状态
  4. 在socket 状态可用时,快速返回成功
  5. 在socket 状态不可用时,休眠等待

5.4 net 包

  1. net包抽象了TCP 网络操作
  2. 使用net.Listen() 得到TCPListener(LISTEN 状态的socket)
  3. 使用TCPListener.Accept() 得到TCPConn (ESTABLISHED)
  4. TCPConn.Read() 、Write() 进行读写scket 的操作
  5. Network Poller 作为上述功能的底层支撑

5.5 goroutine-per-connection 编程风格

  1. 用主协程监听Listener
  2. 每个Conn 使用一个新协程处理
  3. 结合了多路复用的性能和阻塞模型的简洁

推荐一个零声学院免费公开课程,个人觉得老师讲得不错,分享给大家:Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,立即学习

  • 13
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 9
    评论
评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值