likes
comments
collection
share

Socket 网络编程:从基础到实践的全面解析(上)

作者站长头像
站长
· 阅读数 15

01-Socket 编程基本概念

Socket(套接字)是什么?套接字是操作系统供应用实现网络 I/O 的一组编程接口。 通过这个接口,应用可以方便地进行网络通信和交换信息。 而且,受益于 Unix 对虚拟文件系统(VFS)的支持,应用使用套接字进行网络 I/O 时,就和打开本地文件进行读写一样方便。 关于这部分更详细的内容,在后面 socketfs 部分会再深入介绍。 现在只需要记住 Unix 或 Linux 系统在实现套接字时,把它当作是一种特殊类型的文件,应用可以通过与读写本地文件类似的方式来完成网络通讯。

根据底层网络协议的不同,套接字分为多种不同类型,例如 TCP 套接字、UDP 套接字、SCTP 套接字。 其中 TCP 套接字又被称为是流套接字(Stream Socket),UDP 套接字也被称为是数据报套接字(Datagram Socket)。 这篇文章中,我只会介绍 TCP 套接字相关的内容,感兴趣的读者可以自己对比了解下数据报套接字相关的内容。

01.1-TCP/IP 套接字

流套接字和数据报套接字有时也被称为 TCP/IP 套接字。 每个套接字实例由三个关键元素确定,一个 IP 地址,一个端口,以及一个端到端的网络传输协议(TCP/UPD)。 下图描述了应用与套接字之间的关系。

Socket 网络编程:从基础到实践的全面解析(上)

Socket 在 Unix 或 Linux 操作系统中作为一种特殊的文件类型存在。 多个应用可以共享同一个 Socket。 基于 TCP 协议的 Socket 是面向连接,而基于 UDP 协议的 Socket 是无连接的。

Socket 与常说的 TCP/IP 之间什么关系呢?如何区别两者?

  1. TCP/IP 指代的是一个网络协议簇,由网际协议(IP)、传输层控制协议(TCP)等一系列的网络协议组成。
  2. Socket(套接字)是操作系统实现网络通讯时,封装的网络编程接口。基于这些接口,应用程序之间能够方便地进行网络通讯和数据交换。

了解过网络传输层协议的读者一定不会陌生,TCP 是面向连接的协议,而 UDP 是无连接的协议。 在 Java 中(1.8 之前),提供了两个接口用来进行基于 TCP 的套接字网络编程:Socket 和 ServerSocket。 每个 Socket 实例表示一个端到端的 TCP 连接的一端。 应用通过 Socket 实例进行网络通讯之前,需要先通过 TCP 三次握手( 3-way handshake)建立 TCP 连接,然后通过 read/write 方法发送消息到对端(或接受对端发送的消息)。 下图描述了客户端与服务端通过三次握手机制建立 TCP 连接的过程。

Socket 网络编程:从基础到实践的全面解析(上)

使用 TCP 套接字通讯的应用,是典型的客户端-服务端(C/S)架构。 ServerSocket 是服务端特有的套接字类型,它不负责在应用之间传输消息,只负责接受客户端的连接请求,并创建客户端与服务端之间的连接。 ServerSocket 也被称为被动套接字,Socket 被称为主动套接字。

操作系统内核实现基于套接字的网络 I/O 提供了以下的系统调用,它们的基本功能描述如下所示(参考《UNIX 网络编程卷1:套接字联网 API》):

  • socket(),在 socketfs 中创建一个 socket fd。该系统调用的返回值是一个整数,它表示当前进程打开的一个文件描述符。
  • connect(),主动套接字或 TCP 客户端通过这个系统调用与服务端套接字建立连接。
  • bind(),将本地地址与套接字绑定,一般是 TCP 服务端调用这个方法; 并没有约束 TCP 客户端不能调用这个方法,只不过客户端一般都通过 connect 时绑定一个随机未占用端口以及网络出口地址的方式绑定到一个本地地址上。
  • listen(),只有被动套接字或 TCP 服务端套接字才会调用。
  • accept(),只有被动套接字或 TCP 服务端套接字才会调用。它与 listen() 的关系,参考下面关于两个队列的描述。
  • read/write(),从前面 socket() 的描述中可以知道,内核将 socket fd 当作是一种特殊的文件类型,read/write 就是对该文件的读、写操作。
  • colse(),关闭套接字(四次挥手过程)。

从操作系统内核角度看,它为每个被动套接字(ServerSocket)维护两个队列(结合 TCP 三次握手理解这两个队列):

  1. 未完成连接队列, server 收到 SYN_i 后,创建项加入队列,直到收到 ACK_k+1 或者 超时;
  2. 已完成连接队列,收到 ACK_k+1 后,从未完成连接队列转移到当前队列;accept 时从队头取走项,若队列为空时,阻塞 accept 所在线程;

基于套接字进行网络通讯的应用进行系统调用情况如下图所示。

Socket 网络编程:从基础到实践的全面解析(上)

注: 仔细看上图,你可能会发现,这里并没 ServerSocket 相关的系统调用,那操作系统是如何区分 Socket 与 ServerSocket 呢? socket() 系统调用默认会返回一个主动套接字,当调用 listen() 方法时,会转变成被动套接字。 当服务端应用调用 accept() 时,如果已完成连接队列中没有可用的 Socket 对象,应用会阻塞直到任意 Socket 可用。

TCP 连接断开时(注意区分主动断开方和被动断开方),发起断开请求的一方会调用 close() 方法。 之后,发起方会在发送队列 SendQ 中内容全部发送到对端的 ReceiveQ 中后,再发送 FIN,表示已无更多内容要发送给对端。此时,套接字处于半关闭(half closed)状态。 当对端处理完所有消息后,调用 close() 方法,会在它的 SendQ 所有内容全部发送到发起方的 ReceiveQ 后,发送 FIN,表示已无更多内容要发送,它的状态变为 close 状态。 更详细的过程,参考下面的四次挥手过程分析。

01.2-套接字的生命周期

假设服务器地址为“a.b.c.d”,端口为“p”,本地地址为“w.x.y.z”,本地端口为“q”。 Socket 实例的状态可以表示为三元组 <state, remote_socket_address, local_address>,其中:对端套接字和本地套接字可以用二元组表示 <ip, port><nil, nil> 表示空。

  1. 从 client 的视角看: new Socket() 时,套接字实例的状态表示为 <Closed, <a.b.c.d, p>, <nil, nil>>; 当客户端调用 connect() 尝试与服务端建立连接时,client 先发送 SYN_i 给 server,此时的状态变化为 <Closed, <a.b.c.d, p>, <nil, nil>> -> <Connecting, <a.b.c.d, p>, <w.x.y.z, q>>; 通过 netstat 命令查看,State 的状态为 SYN_SENT。 client 收到 server 的 ACK_i+1, SYN_k 后,此时的状态变化为 <Connecting, <a.b.c.d, p>, <w.x.y.z, q>> -> <Established, <a.b.c.d, p>, <w.x.y.z, q>>; 通过 netstat 命令查看,State 的状态为 ESTABLISHED。

假设 client 先发起断开请求。 client 调用 close() 时,发送 FIN 给 server。此时的状态变化为 <Established, <a.b.c.d, p>, <w.x.y.z, q>> -> <Closing, <a.b.c.d, p>, <w.x.y.z, q>>。 如果使用 netstat 查看,此时的状态为 FIN_WAIT_1。 当收到 server 的 ACK 后,client 的状态变为半关闭,此时的状态变化为 <Closing, <a.b.c.d, p>, <w.x.y.z, q>> -> <Half–Closed, <a.b.c.d, p>, <w.x.y.z, q>>。 如果使用 netstat 查看,此时的状态为 FIN_WAIT_2。 如果 client 后续收到 server 的断开请求 FIN,它会回复 ACK。此时的状态变化为 <Half–Closed, <a.b.c.d, p>, <w.x.y.z, q>> -> <Time–Wait, <a.b.c.d, p>, <w.x.y.z, q>>。 使用 netstat 查看,此时的状态为 TIME_WAIT。 等到两个最大同步延迟(2MSL)长度时间过后,状态变为关闭,此时的状态变化为 <Time–Wait, <a.b.c.d, p>, <w.x.y.z, q>> -> <Closed, <a.b.c.d, p>, <w.x.y.z, q>>

  1. 从 server 的视角看: server 端有两类套接字, ServerSocket & Socket,或者也可以称为监听 Socket 和普通 Socket。 监听 Socket 也可以通过三元组表示:<state, remote_socket_address, local_address>,其中 remote_socket_address 和 local_address 的 ip 可以是通配符 * 当 socket_address 不是通配符 * 而是某个特定 ip 时,表示仅接受该特定 ip 的连接信息; 当 local_address 不是通配符 * 而是某个特定 ip 时,表示仅接受请求到服务器该特定 ip 对应的网卡的连接;

2.1 ServerSocket 的状态变化 new ServerSocket(p) 时,监听套接字的状态变化为 <Closed, <nil, nil>, <nil, nil>> -> <Closed, <*, *>, <*, p>>; 此时通过 netstat 查看,State 的状态为 LISTENING。

2.2 Socket 的状态变化 server 收到 client 的 SYN_i 时,创建一个 Socket:<Connecting, <w.x.y.z, q>, <a.b.c.d, p>>,ServerSocket 的状态不变; 此时通过 netstat 查看,ServerSocket 的状态为 LISTENING,Socket 的状态为 SYN_RCVD。 server 收到 client 的 ack_k+1 是,认为连接建立:<Connecting, <w.x.y.z, q>, <a.b.c.d, p>> -> <Established, <w.x.y.z, q>, <a.b.c.d, p>>; 此时通过 netstat 查看,ServerSocket 的状态为 LISTENING,Socket 的状态为 ESTABLISHED。

假设 client 首先断开连接。 server 收到 client 的 FIN 后,回复 ACK,并知道 client 不会再传输更多消息过来。 server 端的 Socket 状态变为等待关闭状态,<Established, <w.x.y.z, q>, <a.b.c.d, p>> -> <Close–Wait, <w.x.y.z, q>, <a.b.c.d, p>>。 如果使用 netstat 查看,此时的状态为 CLOSED_WAIT。 等 server 完成所有信息处理后,调用 close() 会立即返回,Socket 对象被销毁、回收。

02-Java 套接编程实例

本章通过具体的编程实例,展示如何使用Java套接字进行网络编程。 我将介绍如何使用 Java 套接字内置的输入流和输出流来处理网络通信中的数据读写。 旨在帮助读者熟练使用Java在套接字级别编写客户端-服务器应用程序,帮助程序员在实际应用程序开发中更加高效地实现网络通信功能。 希望这些介绍能够对您有所帮助!

02.1-TCP client

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;

public class TcpClient {
    public static void main(String[] args) throws IOException {
        String ip = "localhost";
        int port = 8098;


        Socket socket = new Socket(ip, port);
        System.out.println("Connecting to server " + socket.getInetAddress());

        InputStream in = socket.getInputStream();
        OutputStream out = socket.getOutputStream();

        Scanner scanner = new Scanner(System.in);
        while (scanner.hasNext()) {
            String line = scanner.nextLine();
            if ("quit".equalsIgnoreCase(line.trim()) || "q".equalsIgnoreCase(line.trim())) {
                break;
            }
            byte[] bytes = line.getBytes(StandardCharsets.UTF_8);
            out.write(bytes);

            int total = 0, len;
            while (total < bytes.length) {
                if ((len = in.read(bytes, total, bytes.length - total)) == -1) {
                    throw new SocketException("Connection closed prematurely");
                }
                total += len;
            }
            System.out.println("Received from server: " + new String(bytes));
        }

        socket.close();
        System.out.println("Disconnected with server " + socket.getInetAddress());
    }
}

TcpClient 连接到本地的 8098 端口(服务端监听端口),并且读取标准输入,根据输入不同选择退出或发送消息给 server 并打印 server 的响应。

02.2-TCP server

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class TcpEchoServer {
    public static void main(String[] args) throws IOException {
        int port = 8098;
        ServerSocket serverSocket = new ServerSocket(port);
        System.out.println("Server listening on " + port);

        byte[] bytes = new byte[32];

        while (true) {
            Socket socket = serverSocket.accept();
            System.out.println("Handling client at " + socket.getRemoteSocketAddress());

            InputStream in = socket.getInputStream();
            OutputStream out = socket.getOutputStream();
            int len;
            while ((len = in.read(bytes)) != -1) {
                out.write(bytes, 0, len);
            }
            System.out.println("Client " + socket.getRemoteSocketAddress() + " disconnect");
            socket.close();
        }
    }
}

TcpEchoServer 监听本地 8098 端口,当 client 连接上后,只是将 client 的输入原样返回。

03-相关知识扩展

03.1-TCP 连接的创建过程(三次握手)

TCP 是面向连接的传输层协议,因此应用之间需要先建立起 TCP 连接,再进行网络 I/O。 TCP 连接的建立过程即所谓的三次握手(3 way handshake)过程。

  1. TCP 连接的建立是由 TCP 客户端发起的。 客户端通过调用 connect() 系统调用,发送 SYN_i 消息给服务端。客户端线程被阻塞。 消息发送完毕后,客户端的套接字状态变成连接中状态。如果使用 netstat 查看,此时的状态为 SYN_SEND。
  2. 服务端收到客户端的 SYN_i 后,回复收到 ACK_i+1 以及握手信息 SYN_k。 服务端的监听套接字状态保持 LISTENING 不变。同时,创建一个 Socket,它的状态设置为 SYNC_RCVD,并将该对象放置到未完成连接队列中,等待客户端的响应 ACK_k+1。 在收到客户端的 ACK_k+1 后,将未完成连接队列中的对应 Socket 设置为 ESTABLISHED 状态,并移动到已完成连接队列中,等待 accept() 系统调用取走。
  3. 客户端收到服务端的 ACK_i+1 后,状态变为连接完成。如果使用 netstat 查看,此时的状态为 ESTABLISHED。客户端线程从调用 connect() 的阻塞中恢复。 客户端收到服务端的 SYN_K 后,回复 ACK_k+1。

TCP 三次握手过程,如下图所示:

03.2-TCP 连接的断开过程(四次挥手)

TCP 设计了一个优雅的断开机制,即所谓的四次挥手过程。 有了这个机制,应用在断开 TCP 连接时不必担心正在传输过程中的数据丢失。 而且,这个机制允许 TCP 连接的两端分别断开。 另外需要额外注意的是,TCP 连接的断开是区别发起方和对应方的。 首先调用 close() 断开连接的一端,称为发起方,它的对端称为对应方。 在 Java 中,断开 TCP 连接可以通过 close() 方法,也可通过 shutdownOutput() 方法。

假设发起方为 I,对应方为 A。

  1. 当 I.close() 断开连接时,会等 I.SendQ 中所有的消息都发送到 A.ReceiveQ 中后,发送 FIN。 然后,发起方 I 的状态变为断开中,如果用 netstat 查看,此时的状态为 FIN_WAIT_1。 等到 I 收到 A 的 ACK 后,状态会变成半关闭,用 netstat 查看,为 FIN_WAIT_2。 半关闭状态,意味着 I 不会再发送更多的消息给 A。
  2. 对应方 A 收到 FIN 后,会回复 ACK。然后,A 的状态通过 netstat 查看为 CLOSED_WAIT。 但是 A 仍可能继续向 TCP 连接(或 Socket)中写消息,或继续其他的操作。
  3. 当 A 处理完其他事宜,然后调用 close() 关闭 Socket 时,会发送 FIN 给 I,然后自己的 Socket 状态为 LASK_ACK。 等收到 I 的 ACK 后,Socket 就变为 CLOSE 状态,连接关闭。
  4. I 收到 A 的 FIN 消息后,知道 A 不会发送更多消息过来,响应 ACK,Socket 状态变为 TIME_WAIT。

四次回收的过程,如下图所示:

Socket 网络编程:从基础到实践的全面解析(上)

1 2