一、Socket的连接过程、TCP的一些参数
-
前置知识
用到的命令
netstat -natp
查看网络连接和占用的端口
tcpdump -nn -i eth0 port 9090
开监听抓取数据包
lsof -p <进程号>
查看某个进程已经打开的文件状态 -
Socket服务端代码
package com.haizhang.netty.bio;
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(); //分水岭:
final Socket client = server.accept(); //阻塞1,没有 -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(new Runnable() {
@Override
public void run() {
try {
InputStream in = client.getInputStream(); //阻塞2
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();
}
}
}
}
客户端代码
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();
}
}
}
下面详细跟踪建立连接的过程
启动服务端
开启服务端后,出现了一个对于 9090 的 listen 状态。
TCP 三次握手是走 listen 的,建立连接之后,后面走文件描述符,那就是另外一个环节了,我们后面再讲。
使用jps得到服务端的进程id号:7932
使用lsof -p 7932查看7932端口的文件描述符的分配情况。
参数详解:
COMMAND:进程的名称
PID:进程标识符
USER:进程所有者
FD:文件描述符,应用程序通过文件描述符识别该文件。如cwd、txt等
TYPE:文件类型,如DIR、REG等
DEVICE:指定磁盘的名称
SIZE:文件的大小
NODE:索引节点(文件在磁盘上的标识)
NAME:打开文件的确切名称
FD列中的文件描述符:
cwd
值表示应用程序的当前工作目录,这是该应用程序启动的目录,除非它本身对这个目录进行更改,txt类型的文件是程序代码,如应用程序二进制文件本身或共享库,如上列表中显示的/sbin/init程序。其次数值表示应用程序的文件描述符,这是打开该文件时返回的一个整数。如上的最后一行文件/dev/initctl,其文件描述符为 10。
u 表示该文件被打开并处于读取/写入模式,而不是只读 ? 或只写 (w) 模式。同时还有大写 的W 表示该应用程序具有对整个文件的写锁。该文件描述符用于确保每次只能打开一个应用程序实例。初始打开每个应用程序时,都具有三个文件描述符,从0到2,分别表示标准输入、输出和错误流。所以大多数应用程序所打开的文件的FD都是从3开始。
Type列:
文件和目录分别称为REG和DIR。
CHR表示字符;(fopen,打开文件)
BLK表示块设备;
UNIX、FIFO和IPv4,分别表示UNIX 域套接字、先进先出(FIFO)队列和网际协议(IP)套接字。
FIFO表示先进先出;(popen,pipe)
inet表示网际协议(IP)套接字tcp/udp;(socket)
netlink表示netlink
启动客户端
客户端启动,进入代码的阻塞等待用户输入逻辑
开启监听之后,我们使用nc命令,连接本地的9090端口
[root@node1 ~]# nc localhost 9090
nc命令介绍https://blog.youkuaiyun.com/u012486730/article/details/82019996
在服务端抓到了三次握手的包
在服务端看到建立了连接,虽然连接还未被使用。
在客户端进行用户输入之后(服务端也有的阻塞的逻辑,需要回车才能接收client的数据)
继续查看服务端抓包监听
查看服务端的连接状态:双方开辟了资源。即便你程序不要我,我也在内核里有资源用来接收或者等待一类的。
netstat -antp | head -n 2 ;netstat -antp | grep 9090
服务端输入回车之后
接受到了客户端发过来的数据
刚才的socket连接已经被分配给7932了
lsof 得到了新的文件描述符 6
总结一下
TCP:面向连接的,可靠的传输协议
Socket:是一个四元组。ip:port ip:port四元组的任何一个元的不同,都可以区分不同的连接。
面试题 1:服务端80端口接收客户端连接之后,是否需要为客户端的连接分配一个随机端口号?
答:不需要。
面试题 2:现在,有一个客户端,有一个服务端,
客户端的ip地址是AIP,程序使用端口号CPORT想要建立连接。
服务端的IP地址是XIP,端口号是XPORT。
现在假设某一个客户端A开了很多连接占满了自己的65535个端口号,那客户端A是否还能与另一个服务端建立建立连接?
答:可以,因为只要能保证四元组唯一即可
注:一台服务器是可以与超过65535个客户端保持长连接的,调优到超过百万连接都没问题,只要四元组唯一就可以了。客户端来了之后,服务端是不需要单独给它开辟一个端口号的。
下面这个图可以说明,无论再多的连接,服务端始终是使用的同一个ip:端口
那么,我们常见的报错“端口号被占用”是什么原因?
我们常见的报错“端口号被占用”实际上是在启动SocketSocket的时候,而不是Socket,两者不是一个概念。如果两个服务使用了相同的端口号,这时如果来了一个数据包,内核无法区分是哪一个服务在LISTEN,不知道要发给哪一个服务了,如下图例子
每一个独立的进程只要维护它自己的文件描述符唯一即可。
keepalive
三个不同层级的 keepalive
- TCP协议中规定,如果双方建立的连接(虚无的,并不是物理的连接),如果双方很久都不说话,你能确定对方还活着吗?不能,因为可能突然断电。所以规定了这么一种机制,哪怕是周期性的消耗一些网络资源,也要及时把无效的连接踢掉,节省内存。
- HTTP级别
- 负载均衡keepalived
网络IO的变化 演进模型(BIO)
一句话概括BIO?
BIO就是,客户端来一个连接,抛出一个线程,来一个连接,抛出一个线程…
BIO存在两个方面的阻塞,第一个方面就是ServerSocket在开启监听端口后accept客户端等待连接时,会阻塞当前线程。第二个就是,当ServerSocket接受到客户端连接时,后续要阻塞的等待客户端读数据的操作。
几个维度
同步、异步、阻塞、非阻塞
用到的命令:
strace -ff -o out /usr/java TestSocket
用来追踪Java程序和内核进行了哪些交互(进行了哪些系统调用)
strace -e strace=内核函数 -p pid
可以指定strace只追踪某个内核调用事件。参考strace用法介绍
详细追踪 BIO 的连接过程
public class TestServer {
//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(8090), 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 8090!");
try {
while (true) {
// System.in.read(); //分水岭:
final Socket client = server.accept(); //阻塞1,没有 -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(new Runnable() {
@Override
public void run() {
try {
InputStream in = client.getInputStream(); //阻塞2
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
while (true) {
System.out.println(reader.readLine());
}
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
server.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
上面的代码使用jdk1.4跑起来
我们需要安装低版本的jdk才可以看清楚Socket连接时发生的详细细节,这里需要安装j2sdk-1_4_2_18-linux-i586.bin
参考https://blog.youkuaiyun.com/developerof/article/details/38455399
注意,如果是64位的机器,会报异常 bad ELF interpreter问题 ,解决办法:
情况一:64位系统中安装了32位程序解决办法
是因为64位系统中安装了32位程序
解决方法:
yum install glibc.i686
情况二:解决交叉编译环境错误
# arm-linux-gcc hello.c -o tt
/home/gl/usr/local/arm/4.3.2/bin/arm-linux-gcc: /home/gl/usr/local/arm/4.3.2/bin/arm-none-linux-gnueabi-gcc: /lib/ld-linux.so.2: bad ELF interpreter: 没有那个文件或目录
/home/gl/usr/local/arm/4.3.2/bin/arm-linux-gcc:行3: /home/gl/usr/local/arm/4.3.2/bin/arm-none-linux-gnueabi-gcc: 成功
[root@austgl gl]# yum install ld-linux.so.2
在服务端用jps找到进程的id号是8384
在服务端使用tail监控out.8384文件的输出(8384是main线程的输出,其他的out可能是一些垃圾回收线程等其他线程的输出)
(这里注意一下一共有8个线程,待会儿建立连接之后再看)
可以看到JVM用到了内核系统调用的accept,main线程正在阻塞
注意上面的accept后面紧跟着一个数字3,linux内核为每个进程提供了一个文件描述符表,而文件描述符都有自己的编号,0表示标准输入、1表示标准输出、2表示标准错误输出。故此这里打开一个Socket端口,会调用linux内核生成一个文件描述符,并产生一个编号为3的文件描述符编号。
紧着这,在一个客户端上建立一个连接
在服务端我们看到,刚才阻塞accept(3,
的位置继续执行。34178是客户端连接进来的随机端口号,192.1618.150.12是来自于客户端的ip地址
当我们客户端连上的时候,会生成一个线程,那么线程究竟在操作系统中是怎么生成的呢?可以关注到accept(3,执行后,内核实际上调用了clone:
clone
是linux的一个系统调用。Java当中的一个线程,就是操作系统的一个子线程。下图我们看到,(客户端连接进来之后),服务端调用clone函数,开启了一个线程号为8447
的新线程。flags里面记录的是子线程共享的文件系统、打开的文件等父线程的系统资源。
在clone之前,还会有一个系统调用指令mmap2,它负责将文件和设备映射到内存。mmap2系统调用使得使用32位off_t的应用程序能够映射大文件(最多2^44字节)。
参考https://man7.org/linux/man-pages/man2/mmap2.2.html
查看用strace输出的out文件,也可以证明8447这个新线程的存在。
在服务端可以看到,多了一个文件描述符5,表示的是从node01(服务端机器名称)到node02(客户端机器名称)的已连通的状态(socket四元组)
服务端 8447.out 正在recv
阻塞接收
想学好Linux,去学习文档中这些man帮助手册,有时候比网络上的博客文章更准确(也可以 man man 查看帮助文档本身的帮助文档)
使用man 2 socket
,你会发现所谓socket
系统调用,其实就是调用了一个有返回值(文件描述符)的函数(用于LISTEN)
稍稍总结一下
BIO 模型的整个连接过程
无论哪种IO模型,application想要和外界通信,都要进行上面所展示的一系列的(3步)系统调用,都是不可缺少的。
之后服务端进入阻塞状态accept(3,等待客户端的连接。此次阻塞被成功地连接之后,又进入一的新的阻塞,等待新的客户端连接。
一旦连接成功之后,会为这个连接抛出去一个新的线程,新的线程中又进入一个阻塞状态recv(5,等待接收消息。