深入Socket-TCP

本文部分内容借鉴此博客

什么是TCP

TCP-Transmission Control Protocal-即传输控制协议,是一种面向连接的,可靠的,基于字节流的传输层通信协议。(UDP是无连接,不可靠,基于数据报文的传输层通信协议)

TCP的机制

连接可靠性-三次握手

深入Socket-TCP
所谓三次握手,是指建立一个TCP连接时,需要客户端和服务器总共发送三个包

  1. 第一次握手(SYN=1,seq=x)
    客户端发送一个TCP的SYN标志位置为1的包,指明客户端打算连接的服务器的端口,以及初始化序号x(用于在多客户端申请的场景下标识客户端,是随机生成的,叫做ISN序号)保存在包头的序列化(Sequence Number)字段里。发送完毕,客户端进入SYN_SEND状态
  2. 第二次握手(SYN=1,ACK=1,seq=y,ACKnum=x+1)
    服务器发回确认包(ACK)应答,即SYN标志位和ACK标志位均为1.服务器选择自己的ISN***放到Seq域内同时将确认序号(Acknowledgement Number)设置为客户的ISN+1,即x+1。发送完毕后 ,服务器端进入SYN_RCVD状态
  3. 第三次握手(ACK=1,ACKnum=y+1)
    客户端再次发送确认包(ACK),SYN标志位为0,并且将确认序号(ACKnum)放服务器端ISN的+1.发送完毕后,客户端进入ESTABLTISHED状态,当服务器接收到这个包时,也进入ESTABLTISHED,代表TCP握手结束。
连接可靠性-四次挥手

深入Socket-TCP
TCP连接的拆除需要发送四个包,因此称为四次挥手。客户端和服务器均可发起挥手的行为。

  1. 第一次挥手(FIN=1,seq=x)
    假设客户端想要关闭连接,客户端发送一个FIN标志位置为1的包,表示自己没有数据可以发送了,但是仍然可以接收数据。发送完毕后,客户端进入FIN_WAIT_1状态

  2. 第二次挥手(ACK=1,ACKnum=x+1)
    服务器确认客户端的FIN包,发送一个确认包,表明自己接收到了来自客户端的关闭连接的请求,但还没有准备好关闭连接,此时可能仍然有未传输完的数据。发送完毕后,服务器端进入CLOSE_WAIT状态。客户端接收到这个确认包之后,进入FIN_WAIT_2状态,等待服务器关闭连接

  3. 第三次挥手(FIN=1,seq=y)
    服务器准备好关闭连接时,向客户端发送结束连接请求,FIN置为1.发送完毕后,服务器进入LAST_ACK状态,等待来自客户端的最后一个ACK

  4. 第四次挥手(ACK=1,ACKnum=y+1)
    客户端接收到来自服务器的关闭请求,发送一个确认包,并进入TIME_WAIT状态,等待可能要出现的要求重传的ACK包。服务器接收到这个确认包后,关闭连接,进入CLOSED状态。
    客户端等待某个固定时间(两个最大段生命周期,2MSL–2 Maximum Segment Lifetime)之后,没有收到服务器端的ACK,认为服务器端已经正常关闭,于是自己也关闭连接,进入CLOSED状态

关于握手挥手中的字段
  • SYN-创建一个连接
  • FIN-终结一个链接
  • ACK-确认接收到数据
  • Sequence Number-当前连接的初始***
  • ACKnum-指接收方期待的下一个报文段的***

不管客户端是想要建立连接还是断开连接,要么就发送请求时将SYN标志位置1,要么就将FIN标志位置1,并发送当前的***,即Seq。服务器端接收到来自客户端的请求后,会先将ACK置1,表示已经接收到了来自客户端的包,并将来自客户端的***+1赋给ACKnum同时返回给客户端,之后就根据是握手请求还是挥手请求进行不同的操作:

  1. 握手请求:除了ACK置1确认响应以及ACKnum=x+1外,服务器重复客户端先前的行为,期待来自客户端的响应。这就好比打电话时:A问"你听到我说话了吗(询问)",B说"我听到了(响应),那你听到我说话了吗(询问)",A又回答"听到了(响应)",之后A和B就可以说正事了
  2. 挥手请求:客户端申请挥手请求后,服务器端第一次响应了ACK=1和ACKnum=x+1外没有响应其他的,等待剩余的数据发送完,服务器才继续发送FIN=1,Seq=y去和客户端挥手。这就好比,A和B说拜拜,B没有说其他的,只是先说我知道了,之后B把手上的东西全部交给A后,B才继续说“那你可以走了”,之后A响应B就关闭连接
SYN攻击
什么是SYN攻击?

在三次握手过程中,服务器发送 SYN-ACK 之后,收到客户端的 ACK 之前的 TCP 连接称为半连接(half-open connect)。此时服务器处于 SYN_RCVD 状态。当收到 ACK 后,服务器才能转入 ESTABLISHED 状态
SYN攻击指的是,攻击客户端在短时间内伪造大量不存在的IP地址,向服务器不断地发送SYN包,服务器回复确认包,并等待客户的确认。由于源地址是不存在的,服务器需要不断的重发直至超时,这些伪造的SYN包将长时间占用未连接队列,正常的SYN请求被丢弃,导致目标系统运行缓慢,严重者会引起网络堵塞甚至系统瘫痪,SYN 攻击是一种典型的 DoS/DDoS 攻击

如何防御 SYN 攻击?
  • 缩短超时(SYN Timeout)时间
  • 增加最大半连接数
  • 过滤网关防护
  • SYN cookies技术
TCP KeepAlive

TCP 的连接,实际上是一种纯软件层面的概念,在物理层面并没有“连接”这种概念。TCP 通信双方建立交互的连接,但是并不是一直存在数据交互,有些连接会在数据交互完毕后,主动释放连接,而有些不会。在长时间无数据交互的时间段内,交互双方都有可能出现掉电、死机、异常重启等各种意外,当这些意外发生之后,这些 TCP 连接并未来得及正常释放,在软件层面上,连接的另一方并不知道对端的情况,它会一直维护这个连接,长时间的积累会导致非常多的半打开连接,造成端系统资源的消耗和浪费,为了解决这个问题,在传输层可以利用 TCP 的 KeepAlive 机制实现来实现。主流的操作系统基本都在内核里支持了这个特性。

TCP KeepAlive 的基本原理是,隔一段时间给连接对端发送一个探测包,如果收到对方回应的 ACK,则认为连接还是存活的,在超过一定重试次数之后还是没有收到对方的回应,则丢弃该 TCP 连接。

传输可靠性

当用TCP进行一条数据发送的时候,首先TCP会将这条数据拆分成不同的片段,然后把片段进行排序,把排序好的片段顺序的进行组装进行发送

  • 应用数据被分割成TCP认为最合适发送的块。这点和UDP不同,应用程序产生的数据报长度将保持不变。由TCP传递给IP的信息单位称为报文段或者段(segment)
  • 当TCP发出一个段之后,它会启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段
  • 当TCP收到发自TCP连接另一端的数据,它将发送一个确认。这个确认不是立即发送,通常将推迟几分之一秒
  • TCP将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检验数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP将丢弃这个报文段,并且不确认收到此报文段,从而希望发送端超时并重发
  • 既然TCP报文段作为IP数据报来传输,而IP数据报的到达可能会失序,因此TCP报文段的到达也可能会失序。如果必要,TCP将对收到的数据进行重新排序,将收到的数据以正确的顺序交给应用层
  • 既然IP数据报会发生重复,TCP的接收端必须丢弃重复的数据
  • TCP还能提供流量控制。TCP连接的每一方都有固定大小的缓冲空间。TCP的接收端只允许另一端发送接收端缓冲区所能接纳的数据。这将防止较快主机致使较慢主机缓冲区溢出

Sokcet中的IO流以及套接字配置

当客户端的socket连接到服务器的套接字时,类似:

socket.connect(new InetSocketAddress(Inet4Address.getLocalHost(), PORT), 3000);

当套接字建立后,类似一条通信线路已经建立。socket可以使用getInputStream()方法获得一个输入流,用此输入流读取服务器放入"线路"中的信息(注意不能读取自己放入线路中的信息,就像打电话的听筒一样),与此对应的socket还可以用getOutputStream方法获得一个输出流,即拿到了自己的话筒,将自己的信息放入"线路"中。

ServerSocket类

客户端建立socket连接只是负责呼叫服务器,那么服务器也要建立一个等待接收客户的套接字serversocket对象

//与上面的端口号一致
ServerSocket server=new ServerSocket(30000);
Socket sc=server_socket.accept();

服务器的ServerSocket对象server建立后,可以使用accept()方法接收客户的套接字响应。
所谓接收"客户"的套接字连接就是accept()方法会返回一个和客户端Scoket对象相连接的Socket对象(注意:server调用accept方法返回的不是客户端Socket对象,而是和客户端Socket对象相连接的Socket对象,听起来有点绕,但是只有真正理解这个才能理解IO流的流向),服务端的Socket对象sc使用getOutputStream()方法获得的输出流将指向客户端Socket对象使用getInputStream()方法获得的输入流,这就像打电话时那样接通彼此,同样sc使用getInputStream()方法获得的输入流将指向客户端socket对象的输出流
深入Socket-TCP

异步处理客户Socket

使用套接字连接时,可能在一端没发出数据之前,另一端就开始连接,这样就会堵塞线程,或者accept()方法在未获得客户Socket时,也会一直堵塞直到读取。通过将套接字放到线程中可以实现异步化处理,解决同步下阻塞的问题

  // 等待客户端连接
        for (; ; ) {
            // 得到客户端
            Socket client = server.accept();
            // 客户端构建异步线程
            ClientHandler clientHandler = new ClientHandler(client);
            // 启动线程
            clientHandler.start();
        }
	
		 private static class ClientHandler extends Thread {
        	//...
        	}

Demo

客户端

public class Client {
    private static final int PORT = 20000;
    private static final int LOCAL_PORT = 20001;

    public static void main(String[] args) throws IOException {
        Socket socket = createSocket();

        initSocket(socket);

        // 链接到本地20000端口,超时时间3秒,超过则抛出超时异常
        socket.connect(new InetSocketAddress(Inet4Address.getLocalHost(), PORT), 3000);

        System.out.println("已发起服务器连接,并进入后续流程~");
        System.out.println("客户端信息:" + socket.getLocalAddress() + " P:" + socket.getLocalPort());
        System.out.println("服务器信息:" + socket.getInetAddress() + " P:" + socket.getPort());

        try {
            // 发送接收数据
            todo(socket);
        } catch (Exception e) {
            System.out.println("异常关闭");
        }

        // 释放资源
        socket.close();
        System.out.println("客户端已退出~");

    }

    private static Socket createSocket() throws IOException {
        /*
        // 无代理模式,等效于空构造函数
        Socket socket = new Socket(Proxy.NO_PROXY);

        // 新建一份具有HTTP代理的套接字,传输数据将通过www.baidu.com:8080端口转发
        Proxy proxy = new Proxy(Proxy.Type.HTTP,
                new InetSocketAddress(Inet4Address.getByName("www.baidu.com"), 8800));
        socket = new Socket(proxy);

        // 新建一个套接字,并且直接链接到本地20000的服务器上
        socket = new Socket("localhost", PORT);

        // 新建一个套接字,并且直接链接到本地20000的服务器上
        socket = new Socket(Inet4Address.getLocalHost(), PORT);

        // 新建一个套接字,并且直接链接到本地20000的服务器上,并且绑定到本地20001端口上
        socket = new Socket("localhost", PORT, Inet4Address.getLocalHost(), LOCAL_PORT);
        socket = new Socket(Inet4Address.getLocalHost(), PORT, Inet4Address.getLocalHost(), LOCAL_PORT);
        */

        Socket socket = new Socket();
        // 绑定到本地20001端口
        socket.bind(new InetSocketAddress(Inet4Address.getLocalHost(), LOCAL_PORT));

        return socket;
    }

    private static void initSocket(Socket socket) throws SocketException {
        // 设置读取超时时间为2秒
        socket.setSoTimeout(2000);

        // 是否复用未完全关闭的Socket地址,对于指定bind操作后的套接字有效
        socket.setReuseAddress(true);

        // 是否开启Nagle算法
        socket.setTcpNoDelay(true);

        // 是否需要在长时无数据响应时发送确认数据(类似心跳包),时间大约为2小时
        socket.setKeepAlive(true);

        // 对于close关闭操作行为进行怎样的处理;默认为false,0
        // false、0:默认情况,关闭时立即返回,底层系统接管输出流,将缓冲区内的数据发送完成
        // true、0:关闭时立即返回,缓冲区数据抛弃,直接发送RST结束命令到对方,并无需经过2MSL等待
        // true、200:关闭时最长阻塞200毫秒,随后按第二情况处理
        socket.setSoLinger(true, 20);

        // 是否让紧急数据内敛,默认false;紧急数据通过 socket.sendUrgentData(1);发送
        socket.setOOBInline(true);

        // 设置接收发送缓冲器大小
        socket.setReceiveBufferSize(64 * 1024 * 1024);
        socket.setSendBufferSize(64 * 1024 * 1024);

        // 设置性能参数:短链接,延迟,带宽的相对重要性
        socket.setPerformancePreferences(1, 1, 0);
    }

    private static void todo(Socket client) throws IOException {
        // 得到Socket输出流
        OutputStream outputStream = client.getOutputStream();


        // 得到Socket输入流
        InputStream inputStream = client.getInputStream();
        byte[] buffer = new byte[256];
        ByteBuffer byteBuffer = ByteBuffer.wrap(buffer);

        // byte
        byteBuffer.put((byte) 126);

        // char
        char c = 'a';
        byteBuffer.putChar(c);

        // int
        int i = 2323123;
        byteBuffer.putInt(i);

        // bool
        boolean b = true;
        byteBuffer.put(b ? (byte) 1 : (byte) 0);

        // Long
        long l = 298789739;
        byteBuffer.putLong(l);


        // float
        float f = 12.345f;
        byteBuffer.putFloat(f);


        // double
        double d = 13.31241248782973;
        byteBuffer.putDouble(d);

        // String
        String str = "Hello你好!";
        byteBuffer.put(str.getBytes());

        // 发送到服务器
        outputStream.write(buffer, 0, byteBuffer.position() + 1);

        // 接收服务器返回
        int read = inputStream.read(buffer);
        System.out.println("收到数量:" + read);

        // 资源释放
        outputStream.close();
        inputStream.close();
    }
}

服务器端

public class Server {
    private static final int PORT = 20000;

    public static void main(String[] args) throws IOException {
        ServerSocket server = createServerSocket();

        initServerSocket(server);

        // 绑定到本地端口上
        server.bind(new InetSocketAddress(Inet4Address.getLocalHost(), PORT), 50);


        System.out.println("服务器准备就绪~");
        System.out.println("服务器信息:" + server.getInetAddress() + " P:" + server.getLocalPort());


        // 等待客户端连接
        for (; ; ) {
            // 得到客户端
            Socket client = server.accept();
            // 客户端构建异步线程
            ClientHandler clientHandler = new ClientHandler(client);
            // 启动线程
            clientHandler.start();
        }

    }

    private static ServerSocket createServerSocket() throws IOException {
        // 创建基础的ServerSocket
        ServerSocket serverSocket = new ServerSocket();

        // 绑定到本地端口20000上,并且设置当前可允许等待链接的队列为50个
        //serverSocket = new ServerSocket(PORT);

        // 等效于上面的方案,队列设置为50个
        //serverSocket = new ServerSocket(PORT, 50);

        // 与上面等同
        // serverSocket = new ServerSocket(PORT, 50, Inet4Address.getLocalHost());

        return serverSocket;
    }

    private static void initServerSocket(ServerSocket serverSocket) throws IOException {
        // 是否复用未完全关闭的地址端口
        serverSocket.setReuseAddress(true);

        // 等效Socket#setReceiveBufferSize
        serverSocket.setReceiveBufferSize(64 * 1024 * 1024);

        // 设置serverSocket#accept超时时间
        // serverSocket.setSoTimeout(2000);

        // 设置性能参数:短链接,延迟,带宽的相对重要性
        //此处的设置等效客户端的设置
        serverSocket.setPerformancePreferences(1, 1, 1);
    }

    /**
     * 客户端消息处理
     */
    private static class ClientHandler extends Thread {
        private Socket socket;

        ClientHandler(Socket socket) {
            this.socket = socket;
        }

        @Override
        public void run() {
            super.run();
            System.out.println("新客户端连接:" + socket.getInetAddress() +
                    " P:" + socket.getPort());

            try {
                // 得到套接字流
                OutputStream outputStream = socket.getOutputStream();
                InputStream inputStream = socket.getInputStream();

                byte[] buffer = new byte[256];
                int readCount = inputStream.read(buffer);
                ByteBuffer byteBuffer = ByteBuffer.wrap(buffer, 0, readCount);

                // byte
                byte be = byteBuffer.get();

                // char
                char c = byteBuffer.getChar();

                // int
                int i = byteBuffer.getInt();

                // bool
                boolean b = byteBuffer.get() == 1;

                // Long
                long l = byteBuffer.getLong();

                // float
                float f = byteBuffer.getFloat();

                // double
                double d = byteBuffer.getDouble();

                // String
                int pos = byteBuffer.position();
                String str = new String(buffer, pos, readCount - pos - 1);

                System.out.println("收到数量:" + readCount + " 数据:"
                        + be + "\n"
                        + c + "\n"
                        + i + "\n"
                        + b + "\n"
                        + l + "\n"
                        + f + "\n"
                        + d + "\n"
                        + str + "\n");

                outputStream.write(buffer, 0, readCount);
                outputStream.close();
                inputStream.close();

            } catch (Exception e) {
                System.out.println("连接异常断开");
            } finally {
                // 连接关闭
                try {
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }

            System.out.println("客户端已退出:" + socket.getInetAddress() +
                    " P:" + socket.getPort());

        }
    }
}