网络与IO知识扫盲(4):从系统调用的角度,剖析 Socket 的连接过程、BIO 的连接过程

本文深入探讨了Java Socket的连接过程,包括TCP三次握手、服务端监听与accept、客户端连接请求的处理。通过示例代码展示了Socket服务端和客户端的实现,并分析了TCP相关参数如接收缓冲区、超时时间、套接字选项等。同时,讲解了系统调用如accept、recv在BIO模型中的应用,以及线程的创建和资源分配。最后讨论了TCP连接的四元组、内核级别的Socket管理和资源限制。
摘要由CSDN通过智能技术生成

Socket的连接过程、TCP的一些参数

用到的命令
netstat -natp 查看网络连接和占用的端口
tcpdump -nn -i eth0 port 9090 开监听抓取数据包
lsof -p <进程号>查看某个进程已经打开的文件状态

Socket服务端代码

package com.bjmashibing.system.io;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;

public class SocketIOPropertites {
    //server socket listen property: 这些配置不是JVM层级的,是关联到内核的TCP协议栈的一些选项参数。
    private static final int RECEIVE_BUFFER = 10;
    private static final int SO_TIMEOUT = 0;  // 服务端的超时时间
    private static final boolean REUSE_ADDR = false;
    private static final int BACK_LOG = 2; // 多少个连接可以被积压
    //client socket listen property on server endpoint:
    private static final boolean CLI_KEEPALIVE = false;
    private static final boolean CLI_OOB = false;
    private static final int CLI_REC_BUF = 20;
    private static final boolean CLI_REUSE_ADDR = false;
    private static final int CLI_SEND_BUF = 20;
    private static final boolean CLI_LINGER = true;
    private static final int CLI_LINGER_N = 0;
    private static final int CLI_TIMEOUT = 0;  // 客户端的超时时间
    private static final boolean CLI_NO_DELAY = false;
/*

    StandardSocketOptions.TCP_NODELAY
    StandardSocketOptions.SO_KEEPALIVE
    StandardSocketOptions.SO_LINGER
    StandardSocketOptions.SO_RCVBUF
    StandardSocketOptions.SO_SNDBUF
    StandardSocketOptions.SO_REUSEADDR
 */
    public static void main(String[] args) {

        ServerSocket server = null;
        try {
            server = new ServerSocket();
            server.bind(new InetSocketAddress(9090), BACK_LOG);
            server.setReceiveBufferSize(RECEIVE_BUFFER);
            server.setReuseAddress(REUSE_ADDR);
            server.setSoTimeout(SO_TIMEOUT);

        } catch (IOException e) {
            e.printStackTrace();
        }
        System.out.println("server up use 9090!");
        try {
            while (true) {
                // System.in.read();  //分水岭:
                Socket client = server.accept();  //阻塞的,没有 -1  一直卡着不动  accept(4,
                System.out.println("client port: " + client.getPort());
                client.setKeepAlive(CLI_KEEPALIVE);
                client.setOOBInline(CLI_OOB);
                client.setReceiveBufferSize(CLI_REC_BUF);
                client.setReuseAddress(CLI_REUSE_ADDR);
                client.setSendBufferSize(CLI_SEND_BUF);
                client.setSoLinger(CLI_LINGER, CLI_LINGER_N);
                client.setSoTimeout(CLI_TIMEOUT);
                client.setTcpNoDelay(CLI_NO_DELAY);
                //client.read   //阻塞   没有  -1 0
                new Thread(
                        () -> {
                            try {
                                InputStream in = client.getInputStream();
                                BufferedReader reader = new BufferedReader(new InputStreamReader(in));
                                char[] data = new char[1024];
                                while (true) {

                                    int num = reader.read(data);

                                    if (num > 0) {
                                        System.out.println("client read some data is :" + num + " val :" + new String(data, 0, num));
                                    } else if (num == 0) {
                                        System.out.println("client readed nothing!");
                                        continue;
                                    } else {
                                        System.out.println("client readed -1...");
                                        System.in.read();
                                        client.close();
                                        break;
                                    }
                                }

                            } catch (IOException e) {
                                e.printStackTrace();
                            }
                        }
                ).start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                server.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

Socket客户端代码

package com.bjmashibing.system.io;

import java.io.*;
import java.net.Socket;

public class SocketClient {

    public static void main(String[] args) {
        try {
            Socket client = new Socket("192.168.150.11",9090);

            client.setSendBufferSize(20);
            client.setTcpNoDelay(true);  // 如果数据量比较小,会不会积攒起来再发,默认是true
            client.setOOBInLine(true);
            OutputStream out = client.getOutputStream();

            InputStream in = System.in;
            BufferedReader reader = new BufferedReader(new InputStreamReader(in));

            while(true){
                String line = reader.readLine();
                if(line != null ){
                    byte[] bb = line.getBytes();
                    for (byte b : bb) {
                        out.write(b);
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}


上面就是一段很普通的BIO下的服务端和客户端的java代码实现。
一般来说,我们直观上会觉得一段连接的建立,是发生在服务端调用了accpet方法之后,此时客户端发来了连接请求,此时才被建立连接,那假如客户端的请求到来了,但服务器却还没来得及调用accpet,此时客户的socket请求被放在哪了呢?

详细跟踪建立连接的过程
首先开启tcp对9090端口号的监听:
在这里插入图片描述

启动服务端
在这里插入图片描述
开启服务端后,出现了一个对于 9090 的 listen 状态。
TCP 三次握手是走 listen 的,建立连接之后,后面走文件描述符,那就是另外一个环节了,我们后面再讲。
在这里插入图片描述
使用jps得到服务端的进程id号:7932
在这里插入图片描述
使用lsof -p 7932查看7932端口的文件描述符的分配情况。
在这里插入图片描述
发现开启了一个监听的文件描述符。
此时服务端的代码被System.in阻塞,因此没有到调用accpet方法的阶段,那这时候,客户端发来请求呢?

启动客户端
客户端启动,进入代码的阻塞等待用户输入逻辑
在这里插入图片描述
在服务端抓到了三次握手的包
在这里插入图片描述
发现这里监听到了客户端11与服务端12建立三次握手的信息。
此时再通过netstat查看,发现也确实多了一条关于这个连接的socket描述:
在这里插入图片描述
只不过这个socket并未分配给任何的进程,此时只是存储于内核本身中,但确实说明了,此时双方都已经完成了握手,也在各自方进行了分配资源。

在客户端进行用户输入之后(服务端也有的阻塞的逻辑,需要回车才能接收client的数据)
在这里插入图片描述
继续查看服务端抓包监听
在这里插入图片描述
此时,观察服务端这边,虽然连接没有分配给任何进程,但自身的接受队列中已经积压了客户端发送的内容:
在这里插入图片描述
服务端输入回车之后
接受到了客户端发过来的数据
在这里插入图片描述
接着,我们服务端代码继续执行,解除system.in 的阻塞,执行accept,此时再看netstat:

发现,这时候这个socket资源就已经分配给我们启动的服务端java进程了 7932。
在这里插入图片描述
并且,服务端的java进程的文件描述符,也已经多了关于这个socket连接的文件描述符 6:
在这里插入图片描述
总结一下
TCP:面向连接的,可靠的传输协议
在这里插入图片描述

socket是什么?

Socket:是一个四元组。ip:port ip:port四元组的任何一个元的不同,都可以区分不同的连接。
是一个四元组(ClientIP:ClientPort,ServerIP:ServerPort) ,由四个维度来唯一确定一个socket。
且socket的概念是内核级别的,而非进程,也就是说不是必须要java端调用一个accpet才行,而是内核去首先接手。
在这里插入图片描述
每一个socket最终可能会被分配到不同的进程中,然后以不同的文件描述符进行表示。

**面试题 1:**服务端80端口接收客户端连接之后,是否需要为客户端的连接分配一个随机端口号?
答:不需要。

面试题 2:现在,有一个客户端,有一个服务端,
客户端的ip地址是AIP,程序使用端口号CPORT想要建立连接。
服务端的IP地址是XIP,端口号是XPORT。
现在假设某一个客户端A开了很多连接占满了自己的65535个端口号,那客户端A是否还能与另一个服务端建立建立连接?
答:可以,因为只要能保证四元组唯一即可

在这里插入图片描述
注:一台服务器是可以与超过65535个客户端保持长连接的,调优到超过百万连接都没问题,只要四元组唯一就可以了。客户端来了之后,服务端是不需要单独给它开辟一个端口号的。

TCP通信模型图
进程(端口)与 fd 对应
程序通过建立连接得到文件描述符,通过文件描述符找到socket,通过socket 找到 buffer,当数据来到buffer时,就可以读取到了。

客户端与服务端,通信时,内核会先进行三次握手,并开辟资源(文件描述符和buffer),等待 accept后时,将文件描述符分配给进程

在这里插入图片描述

TCP参数设置

BACK_LOG
服务端TCP设置参数:

private static final int BACK_LOG = 2;

上面我们可以看到,socket最初都是被内核先接纳的,如果没有分配给进程,都是存放在内核的,但也不能无休止的一直堆积下去,不然会撑爆内核了。
那内核可以存多少个未分配的socket呢?通过这个参数指定,如果是2的话,就是最多有3个socket可以被暂存,如果超过3个了呢?会被拒绝,换言之,TCP握手的时候不给予回应.

实验
启动服务端socket
服务端socket不按回车,则一直阻塞
在这里插入图片描述
查看网络连接情况
9090端口的进程已启动
在这里插入图片描述

启动客户端
会连接服务端socket
在这里插入图片描述
查看三次握手信息
内核进行了三次握手
在这里插入图片描述
查看网络连接情况
但此时socket 连接还没有分配给相应的进程在这里插入图片描述
再开启一个socket 客户端连接
在这里插入图片描述
也会有三次握手信息

查看网络连接
socket 连接还是没有分配给相应的进程
在这里插入图片描述

再开启一个客户端连接时,查看网路连接如下 SYN_RECV
在这里插入图片描述

前三个成功建立连接,状态是ESTABLISHED,而第四个连接到来,是SYN_RECV,就是虽然请求来了,但是服务端没有给予回应,说明不再建立起连接

SO_TIMEOUT

private static final int SO_TIMEOUT = 0;

服务端accept超时时间,超过这个时间,则会中断并抛出异常。

CLI_SEND_BUF

private static final int CLI_SEND_BUF = 20;

socket包发送的缓冲区大小

CLI_NO_DELAY

private static final boolean CLI_NO_DELAY = false;

是否尽可能进行延时发送优化,开启优化,发送的包大小将可能会超过缓冲区大小。
在这里插入图片描述
在这里插入图片描述
通过客户端不断的发送数据,大于缓存区设置的大小时,则服务端可以接受的字节数量超出缓冲区的大小,这也是优化出的效果

没有优化时,会将一个数据包分成多个数据包,发给服务端,也就是有数据马上发给服务端
在这里插入图片描述
在这里插入图片描述

CLI_OOB

   private static final boolean CLI_OOB = false;

是否积极的预先发送确认数据(少量1个字节)。

CLI_KEEPALIVE

private static final boolean CLI_KEEPALIVE = false;

在这里插入图片描述
开启后,传输控制层会周期性双方互相发送一些心跳包,来保证确认存活。
在这里插入图片描述
keepalive
三个不同层级的 keepalive

  • TCP协议中规定,如果双方建立的连接(虚无的,并不是物理的连接),如果双方很久都不说话,你能确定对方还活着吗?不能,因为可能突然断电。所以规定了这么一种机制,哪怕是周期性的消耗一些网络资源,也要及时把无效的连接踢掉,节省内存。
  • HTTP级别
  • 负载均衡keepalived

三次握手的细节
启动服务端socket
在这里插入图片描述
在这里插入图片描述
连接服务端socket 9090端口
在这里插入图片描述

可以发现每一次握手请求都带会有seq,win。
每一端自身都维护一个序列号seq 回复的请求的ack会基于对方发送过来的seq进行+1操作。
那win是什么?
窗口的概念,数据的传输最终都会作为一个数据包进行发送,而这个包多大呢?也就是一次发多少呢?
通过ifconfig可以查看网卡的MTU大小。
MTU 传输包总大小(包含请求头+数据)
在这里插入图片描述

MSS 传输包的数据大小
在这里插入图片描述
而在两端进行通信的时候,会带上自己的剩余缓存窗口大小,而发送方下次在发送数据包的时候,会根据上次收到的窗口大小来决定本次要发生多大的数据包合适,这样是为了在提高传输效率的同时避免拥塞。(拥塞:就是接收方因为可用接受空间不足,此时发送方需要阻塞等待接收方一点点接收完数据,而接收方满了的话,就需要剔除空间中的数据,剔除规则就是将后面接到数据进行丢弃,只收到了前面的数据,也就是会发生数据丢失。)

实验
启动服务端
在这里插入图片描述
连接服务端,并一直发送数据
在这里插入图片描述
查看网络连接,服务端接受的数据到 1920 字节时,客户端再发送的数据,服务端就不再接受了
在这里插入图片描述
服务端通过回车,accept后 ,接受客户端的数据,发现客户端发送的数据已有丢失。


网络IO的变化 演进模型(BIO)
一句话概括BIO?
BIO就是,客户端来一个连接,抛出一个线程,来一个连接,抛出一个线程…

几个维度
同步、异步、阻塞、非阻塞

追踪线程的系统调用到的命令:
strace -ff -o out /usr/java TestSocket
用来追踪Java程序和内核进行了哪些交互(进行了哪些系统调用)
-ff:追踪进程的所有线程
-o:输出文件的前缀
strace追踪每一个线程的系统调用,并单独输出文件。

详细追踪 BIO 的连接过程
BIO,accpet之后,抛出一个新线程去执行,这样的流程,我们通过系统调用的角度来观察。

TestSocket.java

public class TestStocket {
    public static void main(String[] args) throws IOException {
        ServerSocket server = new ServerSocket(8090);
        System.out.println("step1:new ServerSocket(8090)");
        while (true) {
            Socket client = server.accept();
            System.out.println("setp2:\t" + client.getPort());

            new Thread((new Runnable() {
                Socket ss;

                public Runnable setSS(Socket s) {
                    ss = s;
                    return this;
                }

                @Override
                public void run() {
                    try {
                        InputStream in = ss.getInputStream();
                        BufferedReader reader = new BufferedReader(new InputStreamReader(in));

                        while (true) {
                            System.out.println(reader.readLine());
                        }

                    } catch (IOException e) {
                        e.printStackTrace();
                    }

                }
            }).setSS(client)).start();
        }
    }
}

用JDK1.4跑起来,并通过 strace
在这里插入图片描述
在服务端用jps找到进程的id号是8384
在这里插入图片描述
在服务端使用tail监控out.8384文件的输出(8384是main线程的输出,其他的out可能是一些垃圾回收线程等其他线程的输出)
(这里注意一下一共有8个线程,待会儿建立连接之后再看)
在这里插入图片描述
可以看到JVM用到了内核系统调用的accept,main线程正在阻塞,服务端启动之后,会进行如下操作

lsof查看进程文件描述符
在这里插入图片描述

在这里插入图片描述
通过sokcet系统调用,得到3号文件描述符。
调用bind系统调用,将3号文件描述符绑定8090端口号。
调用listen,对3号文件描述符进行开启监听。
设用accept, 然后3号文件描述符进行accept阻塞等待:

在一个客户端上建立一个连接
在这里插入图片描述
在服务端我们看到,刚才阻塞 accept(3, 的位置继续执行。34178是客户端连接进来的随机端口号,192.1618.150.12是来自于客户端的ip地址
在这里插入图片描述
accpet系统调用调用完成,返回一个5号文件描述符,也就是代表的建立连接的socket。

lsof 查看进程文件描述符,得到一个socket连接
在服务端可以看到,多了一个文件描述符5,表示的是从node01(服务端机器名称)到node02(客户端机器名称)的已连通的状态(socket四元组)
在这里插入图片描述

我们的代码中,accpet之后会将这个socket放入一个新启动的线程(8447)中,在linux中,对线程的实现就是一个子进程,而linux进程创建的本质是fork系统调用
在这里插入图片描述
clone是linux的一个系统调用。Java当中的一个线程,就是操作系统的一个子线程。下图我们看到,(客户端连接进来之后),服务端调用clone函数,开启了一个线程号为8447的新线程。flags里面记录的是子线程共享的文件系统、打开的文件等父线程的系统资源。
紧接着,主进程又进入accpet等待.

查看用strace输出的out文件,也可以证明8447这个新线程的存在。
在这里插入图片描述

服务端 8447.out 正在recv阻塞接收
在这里插入图片描述
发现它可以直接在读取父线程的一些内容,比如读取刚才的5号文件描述符socket。

想学好Linux,去学习文档中这些man帮助手册,有时候比网络上的博客文章更准确(也可以 man man 查看帮助文档本身的帮助文档)
使用man 2 socket,你会发现所谓socket系统调用,其实就是调用了一个有返回值(文件描述符)的函数(用于LISTEN)
在这里插入图片描述
socket 帮助手册
在这里插入图片描述

稍稍总结一下
BIO 模型的整个连接过程
无论哪种IO模型,application想要和外界通信,都要进行上面所展示的一系列的(3步)系统调用,都是不可缺少的。
之后服务端进入阻塞状态accept(3,等待客户端的连接。此次阻塞被成功地连接之后,又进入一的新的阻塞,等待新的客户端连接。
一旦连接成功之后,会为这个连接抛出去一个新的线程,新的线程中又进入一个阻塞状态recv(5,等待接收消息。
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值