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 Socket

CS网络分布

服务端处理流程

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

客户端处理流程

  • 建立连接
  • 收发消息
  • 关闭连接
$ 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

有疑问加站长微信联系(非本文作者)