Socket
计算机计算时需输入和输出,输入需计算的条件,输出需计算的结果,输入和输出可抽象为I/O
(Input/Output)。UNIX的设计哲学之一是”一切皆文件“,因此UNIX处理I/O
是通过对文件的抽象来实现的。由于不同的应用程序进程之间也存在输入输出(通信),因此这个通信也是通过文件的抽象文件描述符(FD, File Descriptor)来实现的。
Socket套接字是操作系统提供的一种进程间通信(IPC,Inter-Process Communication)的方式。IPC是指多个进程之间相互通信的方式,在操作系统中实际上是一组应用程序编程接口,通过接口可以让开发人员能过够协调不同进程,使其在一个操作系统中同时运行并相互传递、交换信息。
IPC分为很多种比如管道、信号、消息队列、共享内存、信号量等,根据运行环境不同又分为本地进程间通信(单机)和网络进程间通信两种(网络),Socket套接字与其它进程间通信机制不同之处在于可实现在不同机器之间的进程通信,允许位于同一主机或跨主机上的应用程序之间交换数据,即本地Socket和网络Socket。
通信标识型的数据结构是进程通信和网络通信的基本构件
进程间通信首先要解决的问题是如何唯一地标识一个进程?本地进程间通信可通过进程PID来唯一标识一个进程,在网络中直接使用进程PID则是行不通的。由于TCP/IP协议簇中网络层的IP地址可以唯一地标识网络中的主机,传输层的“协议 + 端口”可以唯一地标识主机中的应用程序(进程)。因此利用网络三元组即“IP地址+协议+端口”即可唯一地标识网络中的进程,网络上进程间通信就可以利用这种通信标识来相互进行交互。
网络Socket
网络Socket套接字是一个对TCP/IP协议簇进行封装的应用程序编程调用接口(API),注意Socket并非协议而是编程调用接口(API),主要用来解决数据是如何在网络中传输的问题,Socket API本身不负责通信,仅提供基础函数供应用层调用,底层通信则由TCP或UDP来实现。因此Socket可以看作是应用层与传输层之间的一个抽象层,它将TCP/IP复杂的操作抽象为简单的接口供应用层调用以实现进程在网路中通信。最常见是UNIX BSD的Socket。
支持Socket的操作系统一般都会对外提供一套API,使用这套API的应用程序可以与互联网上另一台支持Socket的操作系统中程序进行通信。例如,Linux内核中用于创建Socket实例的API是由一个名为socket
的系统调用代表的,这里可将系统调用简单地理解为特殊的C语言函数。它是应用程序和操作系统内核之间的桥梁,也是应用程序使用操作系统功能的唯一渠道。
描述符
Golang标准库syscall
包中有一个与操作系统socket
系统调用相对应的函数,两者函数签名基本一致,都会接收三个int
类型的参数并返回一个可以代表文件描述符的sockfd
。
socket
系统调用会通过分配新的描述符来创建套接字,并将新的描述符返回到调用进程,任何后续的系统调用都需使用创建的套接字标识。
syscall
包中的Socket
函数本身是平台不相关的,Golang为其支持的操作系统都做了适配,这样无论在哪个平台都是有效的。
syscall.Socket(domain int, type int, proto int) (fd Handle, err error)
使用socket
系统调用能够创建一个套接字,返回后续系统调用中引用该套接字的文件描述符(fd, file descriptor),而在网络中Socket传输的则是一种特殊的I/O。
注意Socket为什么返回的是一个文件描述符,由于Socket源自UNIX,UNIX基本哲学是一切皆文件,Socket实际会在服务端和客户端各自维护一个特殊的文件。由于文件操作都需要遵循“打开-读写-关闭”的模式,Socket作为该模式的实现,在建立的连接打开后,可以向自己维护的文件内写入内容供对方读取或读取对象的内容,通讯结束时则会关闭文件。
参数 | 描述 |
---|---|
domain |
Socket的通信域 |
type |
Socket的类型 |
proto |
Socket使用协议 |
通信域
通信域又称为通信协议族(AF, address family),协议族决定了套接字的地址类型,因为网络通信时客户端和服务端必须采用相应的地址。常见的协议族包括AF_LOCAL(AF_UNIX)、AF_INET、AF_INET6、AF_ROUTE等,比如,AF_UNIX决定了要采用一个绝对路径名作为地址,AF_INET决定了要使用IPv4地址,即32位地址和16位的端口号的组合。
Socket的通信域分为三种,分别对应syscall
包中的一个常量。
通信域 | 描述 |
---|---|
AF_UNIX | 旧称AF_LOCAL,允许位于同一主机上的应用程序之间进行通信。 |
AF_INET | 允许在使用IPv4网络连接的主机中的应用程序之间进行通信。 |
AF_INET6 | 允许在使用IPv6网络连接的主机中的应用程序之间进行通信。 |
通信域可用于识别套接字,即套接字对应地址的格式。不同的通信域规定了不同的通信范围,即判断是否是位于同一主机上的应用程序之间,还是位于跨主机的应用程序之间。
通信域 | 执行通道 | 应用程序间通信 | 地址格式 | 地址结构 |
---|---|---|---|---|
AF_UNIX | 内核中 | 同一主机 | 路径名 | sockaddr_un |
AF_INET | IPv4 | 使用IPv4网络连接的主机 | 32位地址+16位端口号 | sockaddr_in |
AF_INET6 | IPv6 | 使用IPv6网络连接的主机 | 128位地址+16位端口号 | sockaddr_in6 |
AF_UNIX
是一种类UNIX操作系统中特有的通信域,在安装有此类操作系统的同一台主机中,应用程序可以基于它建立Socket连接。UNIX Socket是POSIX操作系统中的一种组件,通过文件系统来实现Socket通信,常见的UNIX Socket文件有mysql.sock
、supervisor.sock
等。
套接字类型
Socket的类型分为四种,在syscall
包中具有同名的常量对应。
套接字类型 | 描述 |
---|---|
SOCK_STREAM | 基于TCP协议,采用流的方式,提供面向连接且稳定可靠的字节流服务。 |
SOCK_DGRAM | 基于UDP协议,采用数据报文,提供数据打包发送的服务,使用不连续不可靠的数据报连接。 |
SOCK_SEQPACKET | 提供连续可靠的数据包连接 |
SOCK_RAW | 提供原始网络协议存取 |
SOCK_DGRAM
SOCK_DGRAM
中的DGRAM
即datagram
表示数据报文,典型应用是UDP协议的网络通信。UDP协议全称User Datagram Protocol用户数据报协议,是OSI(Open System Interconnection, 开放式系统互联)参考模型中一种无连接的传输层协议。UDP无需建立连接就能直接进行数据发送和接收,属于不可靠的、无时序的通信。不过UDP的实时性较好,通常会用于视频直播等相关领域。
数据报套接字允许数据以数据报消息的形式进行交换,在数据包套接字中消息边界得到保留,由于数据传输是不可靠的,消息到达可能是无序或重复的,也可能根本就无法到达。
数据报文是一种有消息边界但没有逻辑连接的非可靠Socket类型
有消息边界意味着与Socket相关的操作系统内核程序在发送或接收数据时是以消息作为单位,消息可理解为带有固定边界的一段数据。内核程序可以自动识别和维护这种边界。在必要时会将数据切割成一个一个的消息,或将多个消息串接成连续的数据。这样,应用程序只需要面向消息进行处理即可,只要应用程序指定好对方的网络地址,内核程序就可以立即把数据报文发送出去。这种做法的优势在于发送速度块,不长期占用网络资源,而且每次发送都可以指定不同的网络地址。缺点在于会使数据报文更长,无法保证传输的可靠性,不能实现数据的有序性,以及数据只能单向传输。
SOCK_STREAM
SOCK_STREAM
类型是没有消息边界但有逻辑的连接,能够保证传输的可靠性和数据的有序性,同时还可以实现数据的双向传输,典型应用是TCP协议的网络通信。
字节流采用的是没有消息边界但有逻辑的连接
有逻辑连接表示通信双方在数据收发前必须先建立网络连接,网络连接建立完毕后,双方才能一对一数据传输。
SOCK_STREAM
网络通信传输数据的形式是字节流,而非数据报文。字节流是以字节为单位的。内核程序无法感知一段字节流中包含了多少个消息,以及消息是否完整,这些完全需要应用程序自己来把控。不过此类网络通信中的接收端总会忠实的按发送端发送数据时的字节排列顺序来接收并缓存它们。所以,应用程序只需根据双方的约定去数据中查找消息边界,并按照边界切割数据。
字节流套接字提供了一个可靠的且双向的字节流通信信道
- 可靠的:表示可以保证发送者传输的数据会完整无缺地到达接收者的应用程序,可能会接收到一个传输失败的通知。
- 双向的:表示数据可以在两个套接字之间的任意方向上进行传输
- 字节流:表示与管道一样不存在消息边界的概念
每个套接字都至少实现了两种类型:数据报和字节流,这两种类型在UNIX和Internet Domain中都得到了支持。
属性判断 | 数据报(UDP) | 字节流(TCP) |
---|---|---|
传递是否可靠 | 否 | 是 |
消息边界是否保留 | 是 | 否 |
是否面向连接 | 否 | 是 |
使用协议
Socket使用协议,只要明确指定了前两个参数值就无需确定此参数,默认将其置为0即可,内核程序会自行选择合适的协议。
通信流程
服务器
通常服务器在启动的时候都会绑定一个总所周知的地址组合(地址+端口),用于提供服务。客户端可通过地址组合来连接服务器,客户端启动时则无需指定,系统会使用自身IP并自动分配一个端口号。这也是为什么服务端套接字在监听(listen)前需绑定(bind),客户端套接字则直接在连接(connect)时由系统随机生成。
服务端首先需要初始化Socket,然后与端口绑定实现对端口进行监听,接着会调用accept
阻塞以等待客户端连接的到来,此时若有客户端初始化了一个Socket后连接到服务器,若连接成功则客户端和服务器的连接就会建立成功。
客户端
客户端发送数据请求,服务端接受请求并处理请求,然后将响应数据回写发送给客户端,客户端读取数据进行处理,最后关闭连接,到此为止一次交互结束。
本地Socket
Socket API原本是为网络通讯设计的,但后来在Socket框架上发展出了另一种IPC机制,即UNIX Domain Socket。虽然网络Socket也可以用于同一台主机的进程间通讯,即通过回环地址(LoopBack地址,127.0.0.1)来实现。
UNIX Domain Socket用于IPC更有效率,因为无需经过网络协议栈,无需打包拆包、计算校验和、维护序号和应答等,只需要将应用层数据从一个进程拷贝到另外一个进程即可。这也是IPC机制与网络协议之间的不同的地方,网络协议实际上是为不可靠的通讯设计的,而IPC机制的本质是可靠的通讯。
Go Socket
Golang内置标准库net
包实际上是对Socket接口的再次封装,以方便建立Socket连接并通信。
例如:模拟TCP客户端和服务端收发消息
创建项目
$ mkdir echo && cd echo
$ go mod init
创建配置
$ mkdir config && cd config
$ vim server.go
package config
const (
ServerNetworkType = "tcp"
ServerAddress = "127.0.0.1:9090"
MessageDelimiter = '\t'
)
创建工具函数
$ mkdir util && cd util
$ vim util.go
package util
import (
"base/config"
"bytes"
"fmt"
"net"
)
//Write 向连接中写入数据 用于客户端传递数据给服务端或服务端返回消息给客户端
func Write(conn net.Conn, content string) (int, error) {
fmt.Printf("send %v: %v\n", conn.RemoteAddr(), content)
var bytebuf bytes.Buffer
bytebuf.WriteString(content)
bytebuf.WriteByte(config.MessageDelimiter)
bytearr := bytebuf.Bytes()
return conn.Write(bytearr)
}
//Read 从连接中读取字节流 以结束符位标记
func Read(conn net.Conn) (string, error) {
var str string
var bytebuf bytes.Buffer
bytearr := make([]byte, 1)
for {
if _, err := conn.Read(bytearr); err != nil {
return str, err
}
item := bytearr[0]
if item == config.MessageDelimiter {
break
}
bytebuf.WriteByte(item)
}
str = bytebuf.String()
fmt.Printf("recv %v: %v\n", conn.RemoteAddr(), str)
return str, nil
}
创建服务端
$ mkdir server && cd server
$ vim main.go
package main
import (
"base/config"
"base/util"
"fmt"
"io"
"net"
"time"
)
func handleConnect(conn net.Conn) {
fmt.Printf("client %v connected\n", conn.RemoteAddr())
for {
conn.SetReadDeadline(time.Now().Add(time.Second * time.Duration(2))) //设置读取超时时间
if _, err := util.Read(conn); err != nil {
if err == io.EOF {
fmt.Printf("client %v closed\n", conn.RemoteAddr())
break
} else {
fmt.Printf("read error:%v\n", err.Error())
}
} else {
util.Write(conn, "welcome")
}
}
}
func main() {
fmt.Printf("server start %v\n", config.ServerAddress)
listener, err := net.Listen(config.ServerNetworkType, config.ServerAddress)
if err != nil {
panic(err)
}
defer listener.Close()
fmt.Printf("waiting client connect...\n")
for {
conn, err := listener.Accept()
if err != nil {
panic(err)
}
go handleConnect(conn)
}
}
创建客户端
$ mkdir client && cd client
$ vim main.go
package main
import (
"base/config"
"base/util"
"fmt"
"net"
)
func main() {
fmt.Printf("client connect %v\n", config.ServerAddress)
conn, err := net.Dial(config.ServerNetworkType, config.ServerAddress)
if err != nil {
panic(err)
}
defer conn.Close()
fmt.Printf("client %v connected\n", conn.LocalAddr())
util.Write(conn, "hello")
if _, err := util.Read(conn); err != nil {
fmt.Println(err)
}
}
分别运行服务端和客户端测试
$ go run main.go
IO.EOF
接收到EOF
错误实际上表示输入方已正常结束
var EOF = erros.New("EOF")
当读取数据时接收到一个IO.EOF
标识对端已经关闭了发送通道,通常来说是发起了FIN
。当客户端发起FIN
后服务器会进入CLOSE_WAIT
状态,此状态的意义在于若服务器存在没有发送完毕的数据,则需要继续发送。当服务端发送完毕再给客户端回复FIN
以关闭连接。
有疑问加站长微信联系(非本文作者)