Go TCP

网络编程

Golang主要设计目标之一是面向大规模后端服务程序,网络通信是服务端程序必不可少且至关重要的一环。

网络应用程序的设计模式可分为两种结构,分别是C/S结构和B/S结构。

  • C/S结构是传统的网络应用设计模式,即客户端(Client)和服务端(Server)模式。此模式需在通讯两端各自部署客户机和服务器来完成数据通信。
  • B/S结构表示浏览器(Browser)和服务器(Server)模式,此模式只需在一端部署服务器,另一端使用操作系统自带的浏览器即可完成数据传输。

因此网络编程也可分为两种方式

  • C/S结构中的TCP Socket编程
    TCP Socket主流的网络编程,底层基于TCP/IP协议。
  • B/S结构中的HTTP编程
    浏览器访问服务器使用的是HTTP协议,HTTP底层依旧使用的是TCP Socket实现的。

网络通信

网络中进程之间是如何通信的呢?理解网络进程间通信之前,先来看下本地进程间通信(IPC),本地进程间通信可分为四种:

  • 消息传递:管道、FIFO、消息队列
  • 同步:互斥量、条件变量、读写锁、文件和写记录锁、信号量
  • 共享内存:匿名、具名
  • 远程过程调用:Solaris门、Sun RPC

网络进程间通信首要解决的问题是如何唯一地在网络中标识一个进程,在本地可通过进程的PID来唯一的标识一个进程,但在网络中却不行。由于TCP/IP协议族中网络层的IP地址可以唯一地标识网络中的主机,传输层的协议与端口可以唯一地标识主机中应用程序所属的进程。因此利用协议、IP地址、端口组成的三元组可以实现网络中进程的唯一标识,进而网络进程间通信时即可使用它来与其它进程进行交互。

使用TCP/IP协议簇的应用程序通常会采用应用编程接口来实现网络进程间的通信,网络应用程序编程接口分为两种

  • UNIX System V的TLI,现已淘汰。
  • UNIX BSD的Socket套接字

Socket

Socket又名套接字,用于描述IP地址和端口,以实现网络中不同应用程序之间的数据通信。Socket最早起源于UNIX,UNIX基本哲学之一是“一切皆文件”,文件操作流程会经过打开(open)、读写(read/write)、关闭(close)三个环节。网络进程间通信时所使用的应用程序编程接口Socket也是该模式的一种实现,即open-read/write-close

Socket编程类型

  • SOCK_STREAM:流式Socket
    流式Socket是一种面向连接的Socket,针对面向连接的TCP服务应用。
  • SOCK_DGRAM:数据报文Socket
    数据报文Socket是一种无连接的Socket,对应于无连接的UDP服务应用。

UDP传输的是数据包,传输时不会建立实际的连接,因此UDP传输数据不会保证可靠性。TCP会维持客户端和服务端之间的连接,并保证数据传输的可靠性,采用流的方式进行数据传输。因此,UDP客户端接收到的是一个个的数据包,而TCP客户端则接收到的是流。由于TCP采用流因此会存在数据粘包的问题。

UDP服务端只需要监听主机的IP和端口即可,TCP服务端则会经历Listen和Accept后才能通过连接进行通信,Listen阶段服务器会监听主机的IP和端口,Accept环节服务器会发生阻塞以等待客户端与之连接。当客户端与服务器经过三次握手建立连接后,就可以基于Accept返回的连接进行通信。

Socket编程流程

  1. 建立Socket(socket)
  2. 绑定Socket(bind)
  3. 监听启动(listen)
  4. 接受连接(accept)
  5. 收发消息(recv/send)
TCP Socket

架构模型

网络编程中最常用的是TCP Socket编程,在POSIX标准出现后Socket在各大主流操作系统上都得到了很好的支持。伴随着网络编程架构模型的演化,服务端程序愈加强大,可以支持更多的连接,获得更好的处理性能。

从TCP Socket诞生后,网络编程架构模型几经演化,大致路线为:

  1. 每进程一个连接
  2. 每线程一个连接
  3. Non-Block非阻塞 + I/O多路复用

I/O多路复用技术

  • Linux下的epoll
  • Windows下的iocp
  • FreeBSD中的darwin kqueue
  • Solaris中的Event Port

目前主流的Web服务器一般采用的是“Non-Block非阻塞 + I/O多路复用”的方式,由于I/O多路复用会给使用者带来不小的复杂度,以至于后续出现了了很多高性能的I/O多路复用框架,比如libeventlibevlibuv等,以帮助开发者简化复杂性。

I/O多路复用框架

  • libevent
  • libev
  • libuv

Golang设计者认为I/O多路复用这种通过回调机制割裂控制流的方式仍然很复杂,而且有悖于一般逻辑,为此Golang将该复杂性隐藏在Runtime运行时中。因此,Golang开发者无需关注Socket是否是Non-Block非阻塞的,也无需亲自注册文件描述符的回调,只需要在每个连接对应的goroutine中以block I/O阻塞I/O的方式来对Socket进行处理即可。

TCP

TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议,由IETF的RFC793定义。

TCP的设计目标是能够动态地适应互联网络的特性,而且具备面向各种故障时的健壮性。互联网络和单个网络有着很大的不同,因为互联网络的不同部分可能存在截然不同的拓扑结构、宽带、延迟、数据包大小和其它参数。TCP是为了在不可靠的互联网上提供可靠的端到端字节流而专门设计的一种传输协议。

  • TCP是面向连接的传输层协议
  • 每条TCP连接只能有两个端点(endpoint),连接的端点是Socket。
  • 每条TCP连接只能是点对点或一对一的
  • TCP提供可靠交付的服务
  • TCP提供全双工通信,客户端和服务端可相互传输数据。
  • TCP面向字节流

数据传输

TCP连接
  • TCP连接的每一端都必须设有两个窗口,分别是发送窗口和接收窗口。
  • TCP两端的四个窗口经常处于动态变化之中
  • TCP可靠传输机制源自于使用字节的序号(SEQ)进行控制
  • TCP所有的确认(ACK)都是基于序号(SEQ)而非报文段的

缓冲区

当使用TCP进行通信时发送方会将数据拷贝到协议的发送缓冲区中,然后协议会将数据发送到接收方,等待接收方收到数据的ACK确认号,若没有ACK确认号。发送方会重发数据。在此过程中,发送方一直处于等待状态,直到接收到ACK确认码,当协议收到ACK确认码后才会将协议缓存中的数据删除,因此从协议上来讲是不会丢失数据的。但协议没有丢失数据并不能保证接收端的应用程序一定会处理了数据。

TCP的ACK机制可以保证通过TCP传输的数据被对端内核接受并放入对应的套接字接收缓冲区内,接下来进程会读取缓冲区进行逻辑处理。

每个TCP套接字都有一个发送缓冲区,当应用进程调用write系统调用向套接字写数据时,内核会从应用进程缓冲区中拷贝所有数据到套接字的发送缓冲区。若套接字发送缓冲区容纳不了应用程序的所有数据,应用进程将被挂起投入休眠,套接字默认是阻塞的,内核将不会从write返回,直到应用进程缓冲区中的所有数据都复制到套接字发送缓冲区。因此套接字write调用成功返回仅仅表示可以重新使用原来的应用进程缓冲区,并不代表对端TCP或应用进程已经接收到数据。

报文段

TCP提供是的面向连接且可靠的字节流服务,应用层会向TCP层发送用于网间传输的8位字节表示的数据流(Stream),TCP将字节流分区或分组切割为适当长度的报文段或段(Segment)。

报文段长度不得超过最大传输大小(MSS,Max Segment Size),MSS受该计算机连接网络数据链路层的最大传输单元MTU的限制。

序列号(SEQ)

TCP工作在传输控制层,是一种可靠的面向连接的字节流协议。TCP之所以可靠是因为它保证了传输报文段的顺序。而报文段的传输顺序则是使用序列号(SEQ)来保证的。

TCP为了保证不发生丢包会,为每个包生成一个唯一且长度为32Bit位的序列号(SEQ, Sequence Number),SEQ保证了传送到接收端实体的包能够按序被接收,以便接收方能按顺序还原,万一发生丢包也可知道丢失的是哪一个包。第一个包的SEQ会是一个随机数。

TCP会话的每一端都会包含32位的序列号,序列号会被用来跟踪某一端发送的数据量,每个包中必须包含序列号,通常在接收端会通过ACK确认号来通知发送端数据成功接收。

SEQ序列号用来标识发送端向接收端发送的字节流数据,序列号是报文段中的第一个字节,TCP会使用序列号对每个字节进行计数。当建立新连接时SYN同步号标志会为1.

当主机开启TCP会话时其初始序列号(ISN, Initial Sequence Number)是随机的,发送数据时SEQ序列号的值是ISN + 1。

TCP数据包中SEQ序列号并不是以报文段来进行编号的,而是将连接生存周期内传输的所有数据当作一个字节流,SEQ是整个字节流中每个字节的编号。一个TCP数据包中可能包含多个字节流的数据(报文段),每个数据报中数据大小不一。建立连接的三次握手过程中,通信双方各自会确定初始的SEQ序列号,每次传送的报文段中的序列号字段值表示所要传送本报文中的第一个字节的序号。

确认号(ACK)

  • 确认机制/到达确认:接收端成功接收到包时会回应一个ACK确认号
  • ACK确认号表示期望对端接收后回应的报文中的SEQ序列号值为多少
  • ACK确认号的值等于上一个包的SEQ + LEN。

报文段首部的ACK标志位表示报文到达确认,是对接收到的数据最高序列号的确认。当ACK标志位为1确认连接时,接收端会向发送端返回一个下次接收时期望的序列号即ACK Number确认号。比如发送端发送的当前seq序号为400,数据长度为100,则接收端确认接收后会返回一个确认号为400+100+1=501的ack给发送端。

TCP具有两种确认方式,分别是快速确认(Quick ACK)和延迟确认(Delay ACK)

  • 快速确认
    本端接收到数据包后会立即发送ACK给对端
  • 延迟确认

本段接收到数据包后不会立即发送ACK给对端,而会等待一段时间后,在等待的时间内若发现

  1. 本段有数据包要发送给对端,则会在发送数据包时顺便捎带此ACK,如此一来会节省一个报文。
  2. 本段没有数据包要发送给对端,延迟确定定时器会超时,然后会发送纯ACK给对端。

总体而言,快速确认模式会用于比较紧急的场景,此时需要理解通知对端,比如收到异常的数据报、接收窗口显著增大等。延迟确认模式则希望通过减少纯ACK的发送来降低不必要的流量开销。

延迟确认机制(Delayed ACK)

接收方在收到数据后并不会立即回复ACK,而是延迟一段时间,一般ACK延迟发送时间为200ms,但这200ms并非是收到数据后需要延迟的时间,系统中存在一个固定的定时器会每隔200ms来检查是否需要发送ACK包。

TCP提供的确认机制可以在通信过程中不对每个数据包发出单独的确认包,而是在传送数据时顺便把确认信息传出,这样可大大提供网络的利用率和传输效率。

同时TCP的确认机制也可一次确认多个报文段以提高系统的效率,比如接收方收到201、301、401报文段后只需对401报文段进行确认即可,对401报文段的确认也就意味着401之前的所有报文段都已经确认。

重传机制

若发送方在规定时间内没有收到接收方的确认信息,就需要将未被确认的数据包重新发送。接收方若收到一个有差错的报文,则会丢弃并不会向发送方回应确认信息。因此TCP报文的重传机制是由设置的超时定时器来决定的。在规定的时间内没有接收到确认信息则进行重传,因此超时时间值设定非常重要,太大会造成重传延迟较大,太小可能还没来得及收到对方确认包就会再次重传,会使网络陷入无休止的重传过程。接收方如果收到重复报文,会丢弃重复的报文,但必须发回确认信息,否认对方会再次发送。

段首

TCP报文段首部
TCP Header

TCP虽然是面向字节流的,但TCP传输的数据单元确是报文段。TCP报文段分为首部和数据两部分,首部前20个字节是固定的,后有4n字节会根据需要增加选项。因此TCP首部最小长度为20字节。

第一行

  • 源端口(Source Port):2Byte
  • 目标端口(Destination Port):2Byte

端口是传输控制层与应用层的服务接口,传输控制层的复用和分用功能都需通过端口才能实现。

第二行

  • 序列号(SEQ, Sequence Number):4Byte

TCP连接传输的数据流中每个字节都存在一个序号,序号字段值是报文段发送数据的第一个字节的序号。

TCP是面向字节流的,在一个TCP连接中传送的字节流中的每个字节的每个字节都会按顺序编号。整个要传送的字节流的起始序列号必须在连接建立时设置。首部中的序号字段值是本报文所发送数据的第一个字节的序号。

第三行

  • 确认号(ACK, Acknowledgement Number):4Byte

确认号是期望收到对方下一个报文段的第一个数据字节的序号。若确认号为N则表示到序列号N-1为止的所有数据报都已经正确接收。

第四行

  • 数据偏移(Data Offset):4Bit
    数据偏移指出TCP报文段的数据起始处距离TCP报文段的起始处有多远,数据偏移字段实际上指出TCP报文段首部长度。由于首部中存在长度不确认的选项字段,因此数据偏移字段是必要的。
  • 保留位(Reserved):6Bit
    保留为日后使用,目前置为0。
  • 控制位(Flag):1Bit
  • 窗口(Window Size):2Byte
    窗口是指报文段发送方的接受窗口,窗口值用于告知对方,从本报文首部中的确认号算起,接收方目前允许对象发送的字节量。之所有存在这个限制,是因为接收方的数据缓存空间是有限的。

控制位(Flag)

TCP在协议头中使用大量的标志位(1Bit位的布尔域)来控制连接状态

控制位 含义
URG 紧急
ACK 确认 响应
PSH 推送 数据传输
RST 复位 连接重置
SYN 同步 建立连接
FIN 终止 关闭连接

URG

当URG=1表示报文段中有紧急数据应尽快发送,不要按原来的排队顺序来传送。

ACK

当ACK=1确认有效,ACK=0则确认无效,连接建立后所有传送的报文段都必须设置ACK=1。

PSH

当应用程序双方进行交互式通信时,若一端希望在键入民工后就能收到对方响应。此时可采用推送操作。发送方将PSH=1会立即创建一个报文段并发送出去,接收方接收到PSH=1的报文段会进快递交付,而不会等到整个缓存都填满后在向上交付。

RST

当RST=1表示连接中出现严重错误必须释放连接再重新建立传输连接,也可用来拒绝一个非法报文段或拒绝打开一个连接。

FIN

用于释放连接,当FIN=1表示此报文段的发送方数据已发送完毕并要求释放连接。

SYN

SYN全称Synchronize Sequeue Numbers同步序列编号,是TCP/IP建立连接时使用的握手信号,SYN仅在三次握手建立TCP连接时有效。

客户端和服务端建立连接时,客户端首先会发出一个SYN报文段用来建立连接,服务端使用SYN+ACK应答表示接收到该消息,最后客户端再以ACK消息进行响应。

SYN

SYN用于请求和建立连接,也可用于设备间的SEQ序列号同步,SYN=1表示是一个连接请求或连接接收的报文段,当SYN=1且ACK=0表示连接请求报文段,若对方同意则响应SYN=1且ACK=1。

SYN攻击

三次握手过程中服务端发送SYN-ACK之后,接收到客户端ACK确认之前的TCP连接称为半连接(half-open connect)。此时服务端处于SYN_RECV状态,等待接收客户端ACK确认号后服务端才能转入ESTABLISHED状态。

SYN洪水攻击(SYN Flood)是客户端在短时间内伪造大量不存在的IP地址并向服务端不断地发送SYN包,服务端回复确认报并等待客户端确认,由于源地址是不存在的。因此服务端会不断地重发直至超时,伪造的SYN包会占用未连接队列,导致正常的DDOS攻击。

如何检测SYN洪水攻击,当服务端存在大量半连接状态且随机的源IP地址时可断定遭到SYN攻击。

$ netstat -anp | grep SYN_RECV

窗口

三次握手

三次握手的目的

  • 为了防止已失效的连接请求报文段突然有传送到服务端而产生错误
  • 为了解决网络中存在延迟的重复分组

客户端发出的第一个连接请求报文段如果没有丢失,而在某个网络节点长时间的滞留将会导致延误到连接释放后的某个时间才到达服务端。虽然这是一个早已失效的报文段,但服务端接收到此失效的连接请求报文段后,会误认为为是客户端再次发送的新的连接请求,于是就会向客户端发出确认报文段,同一建立连接。若不采用三次握手,服务端发出确认,新的连接就会建立。采用三次握手后,由于客户端并没有发出建立连接的请求,因此不会理睬服务端的确认,也不会向服务端发送数据。

三次握手的优点

  • 阻止重复历史连接的初始化
  • 同步双方的初始序列号
  • 避免资源浪费
    三次握手的流程
  1. 第一次握手
  • 客户端向服务端发送请求报文段
  • 客户端设置SYN标志位为1,即SYN = 1
  • 客户端随机生成seq序列号 ,即seq = x
  • 客户端将报文段发送给服务端
  • 客户端进入SYN_SENT状态

连接请求报文段头部

SYN = 1, ACK = 0
seq = x
  • SYN = 1, ACK = 0表示报文段为连接请求报文
  • seq = xx表示本次TCP通信的字节流的初始序列号
  1. 第二次握手
  • 服务端接收到连接请求报文段后,通过报文段中SYN标志位为1得知客户端的目的 - 请求建立连接。
  • 服务端若同意连接则会发送一个应答报文段,服务端会设置标志位SYN=1,ACK=1以表示同意建立连接。
  • 服务端为报文段随机生成序列号seq = y
  • 服务端向客户端发送报文段以确认连接请求
  • 服务端发送完毕后会进入SYN_RECV状态

应答报文段头部包括:

SYN = 1, ACK = 1
seq = y, ack = x + 1
  • SYN = 1, ACK = 1表示该报文段为连接同意的应答报文
  • seq = y 表示服务端作为发送者时,发送字节流的初始序号。
  • ack = x + 1 表示服务端希望下一个数据报发送序号seqx + 1开始
  1. 第三次握手
  • 当客户端接收到连接同意应答报文后,首先会检查ack的值是否为x+1,然后再检查ACK标志位是否为1。
  • 若检查通过则会向服务端发送一个确认报文段,用来告知服务端:服务端发来的连接同意应答已经成功收到。
  • 客户端会设置ACK标志位为1,设置ack值为y+1后将报文段发给服务端。
  • 客户端发送完毕后进入ESTABLISHED状态
  • 服务端接收到报文段后检查ACK标志位是否为1,检查ack值是否为y+1,若通过则直接进行ESTABLISHED状态。
  • 到此为止,客户端和服务端之间完成完成握手,成功建立连接。下一步即可传输数据。

确认报文段头部包括

ACK = 1
seq = x + 1, ack = y + 1

客户端发送完毕后便会进入ESTABLISHED状态,服务端接收到确认报文段后也会进入ESTABLISHED,此时连接建立成功。

可靠性

  • TCP的可靠连接是靠SEQ(Sequence Number,序列号)来达成的
  1. 数据分片
  1. 到达确认:接收端实体对成功接收到包时会回应一个确认号(ACK)

  2. 超时重传

若发送端实体在合理的往返时延(RTT)内未能接收到确认,则对应的数据包会被假设为已丢失进而被重传。

  1. 数据校验

TCP会使用一个校验和函数来验证数据是否存在错误,在发送和接收时都需要计算校验。

  1. 然后TCP会将结果包传递给IP层,由IP层通过网络将包传递给接收端实体的TCP层。

IP层并不保证数据报一定会被正确地递交到接收方,也不会指示数据报的发送速度。因此TCP即要负责足够快地发送数据报但又不能引起网络阻塞。另外当TCP超时后,还需重传没有递交的数据报。即使被正确递交的数据报也可能存在错序问题,这都是TCP的责任。因此TCP必须提供可靠性和高效性。

状态机

三次握手状态转换

四次挥手

TCP

TCP Socket

CS网络分布

服务端处理流程

  • 监听端口
    调用Listen函数指定协议类型、IP地址、端口号后返回监听器实例
  • 建立连接
    监听器实例调用Accept函数等待客户端连接,Accept函数具有阻塞作用,成功后会返回一个连接对象。
  • 收发消息
    创建goroutine完成多客户端和服务端之间的并发通信

客户端处理流程

  • 建立连接
  • 收发消息
  • 关闭连接

例如:实现TCP服务端和客户端,客户端连接到服务器后从命令行发送单行数据给服务器,服务器在终端输出打印。

$ mkdir base && cd base
$ go mod init 
$ mkdir config
$ mkdir server
$ mkdir client

创建配置文件

$ cd config 
$ vim server.go
package config

const (
    ServerAddr = "127.0.0.1:9090" //服务器主机地址
)

创建服务端

$ cd server && vim main.go
package main

import (
    "base/config"
    "fmt"
    "net"
)

//TCPServer TCP服务器结构
type TCPServer struct {
    listener net.Listener
}

//NewTCPServer 创建TCP服务器
func NewTCPServer(addr string) *TCPServer {
    //创建Socket端口监听
    fmt.Printf("TCP Server Start Listen %v\n", addr)
    listener, err := net.Listen("tcp", addr) //listener是一个用于面向流的网络协议的公用网络监听器接口,多个线程可能会同时调用同一个监听器的方法。
    if err != nil {
        panic(err)
    }
    //返回实例
    return &TCPServer{listener: listener}
}

//Accept 等待客户端连接
func (t *TCPServer) Accept() {
    //关闭接口解除阻塞的Accept操作并返回错误
    defer t.listener.Close()
    //循环等待客户端连接
    fmt.Printf("Waiting for clients...\n")
    for {
        conn, err := t.listener.Accept() //等待客户端连接
        if err != nil {
            fmt.Printf("Accept Error: %v\n", err)
        } else {
            remoteAddr := conn.RemoteAddr().String() //获取远程客户端网络地址
            fmt.Printf("TCP Client %v connected\n", remoteAddr)
        }
        //处理客户端连接
        go t.Handle(conn)
    }
}

//Handle 处理客户端连接
func (t *TCPServer) Handle(conn net.Conn) {
    //获取客户端远程地址
    remoteAddr := conn.RemoteAddr().String()
    //延迟关闭客户端连接
    defer conn.Close()
    //循环接收客户端发送的数据
    for {
        //创建字节切片
        buf := make([]byte, 1024)
        //读取时若无消息协程会发生阻塞
        fmt.Printf("TCP Client %v read block\n", remoteAddr)
        //读取客户端发送的数据
        n, err := conn.Read(buf)
        if err != nil {
            fmt.Printf("TCP Server Read Error: %v\n", err)
            return //退出协程
        }
        //显示客户端发送的数据到服务器终端
        str := string(buf[:n])
        fmt.Printf("TCP Client %v send message: %v", remoteAddr, str)

    }
}

func main() {
    tcpServer := NewTCPServer(config.ServerAddr)
    tcpServer.Accept()
}

创建客户端

$ cd client && vim main.go
package main

import (
    "base/config"
    "bufio"
    "fmt"
    "net"
    "os"
)

//TCPClient 客户端数据结构
type TCPClient struct {
    conn net.Conn
}

//NewTCPClient 创建TCP客户端
func NewTCPClient(addr string) *TCPClient {
    fmt.Printf("TCP Client Dial %v\n", addr)
    conn, err := net.Dial("tcp", addr)
    if err != nil {
        panic(err)
    }
    return &TCPClient{conn: conn}
}

//Send 向TCP服务器发送消息
func (t *TCPClient) Send(str string) bool {
    n, err := t.conn.Write([]byte(str))
    if err != nil || n <= 0 {
        fmt.Printf("TCP Client Send Error: %v\n", err)
        return false
    }
    fmt.Printf("TCP Client Send: %v\n", str)
    return true
}

//ReadStdin 获取终端单行输入
func ReadStdin() string {
    reader := bufio.NewReader(os.Stdin)
    str, err := reader.ReadString('\n')
    if err != nil {
        fmt.Printf("Read Stdin Error: %v\n", err)
        return ""
    }
    return str
}

func main() {
    tcpClient := NewTCPClient(config.ServerAddr)
    //获取终端输入
    str := ReadStdin()
    //发送消息给服务器
    tcpClient.Send(str)
}

命令行运行测试

$ cd server && go run main.go
$ cd client && go run main.go

推荐阅读更多精彩内容