电子说
创建socket这一步和客户端没啥区别,不同的是这个socket我们称之为 等待连接socket(或监听socket) 。
bind()
函数会将端口号写入上一步生成的监听socket中,这样一来,监听socket就完整保存了服务端的IP
和端口号
。
listen(<Server描述符>, <最大连接数>);
很多小伙伴一定会对这个listen()
有疑问,监听socket都已经创建完了,端口也已经绑定完了,为什么还要多调用一个listen()
呢?
我们刚说过监听socket和客户端创建的socket没什么区别,问题就出在这个没什么区别上。
socket被创建出来的时候都默认是一个 主动socket ,也就说,内核会认为这个socket之后某个时候会调用connect()
主动向别的设备发起连接。这个默认对客户端socket来说很合理,但是监听socket可不行,它只能等着客户端连接自己,因此我们需要调用listen()
将监听socket从主动设置为被动,明确告诉内核:你要接受指向这个监听socket的连接请求!
此外,listen()
的第2个参数也大有来头!监听socket真正接受的应该是已经完整完成3次握手的客户端,那么还没完成的怎么办?总得找个地方放着吧。于是内核为每一个监听socket都维护了两个队列:
这里存放着暂未彻底完成3次握手的socket(为了防止半连接攻击,这里存放的其实是占用内存极小的request _sock,但是我们直接理解成socket就行了),这些socket的状态称为SYN_RCVD
。
每个已完成TCP3次握手的客户端连接对应的socket就放在这里,这些socket的状态为ESTABLISHED
。
文字太多了,有点干,上个图!
listen与3次握手
解释一下动图中的内容:
connect()
函数,开始3次握手,首先发送一个SYN X
的报文(X
是个数字,下同);SYN
,然后在监听socket对应的半连接队列中创建一个新的socket,然后对客户端发回响应SYN Y
,捎带手对客户端的报文给个ACK
;accept()
时,会将已连接队列头部的socket返回;如果已连接队列为空,那么进程将被睡眠,直到已连接队列中有新的socket,进程才会被唤醒,将这个socket返回 。第4步就是阻塞的本质啊,朋友们!
呃。。。乖,咱就把它当成socket就好了,这样容易理解,其实具体里边存放的数据结构是啥,我也很想知道,等我写完这篇文章,我研究完了告诉你。
accept()
函数是由服务端调用的,用于从已连接队列中返回一个socket描述符;如果socket为阻塞式的,那么如果已连接队列为空,accept()
进程就会被睡眠。BIO恰好就是这个样子。
因为在队列中的socket经过3次握手过程的控制信息交换,socket的4元组的信息已经完整了,用做socket完全没问题。
监听socket就像一个客服,我们给客服打电话,然后客服找到解决问题的人,帮助我们和解决问题的人建立联系,如果直接把监听socket返回,而不使用连接socket,就没有socket继续等待连接了。
哦对了,accept()
返回的socket也有个名字,叫 连接socket 。
拿Server端的BIO来说明这个问题,阻塞在了serverSocket.accept()
以及bufferedReader.readLine()
这两个地方。有什么办法可以证明阻塞吗?
简单的很!你在serverSocket.accept();
的下一行打个断点,然后debug模式运行BIOServerSocket
,在没有客户端连接的情况下,这个断点绝不会触发!同样,在bufferedReader.readLine();
下一行打个断点,在已连接的客户端发送数据之前,这个断点绝不会触发!
readLine()
的阻塞还带来一个非常严重的问题,如果已经连接的客户端一直不发送消息,readLine()
进程就会一直阻塞(处于睡眠状态),结果就是代码不会再次运行到accept()
,这个ServerSocket
没办法接受新的客户端连接。
解决这个问题的核心就是别让代码卡在readLine()
就可以了,我们可以使用新的线程来readLine()
,这样代码就不会阻塞在readLine()
上了。
改造之后的BIO长这样,这下子服务端就可以随时接受客户端的连接了,至于啥时候能read到客户端的数据,那就让线程去处理这个事情吧。
public class BIOServerSocketWithThread {
public static void main(String[] args) {
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket(8099);
System.out.println("启动服务:监听端口:8099");
// 等待客户端的连接过来,如果没有连接过来,就会阻塞
while (true) {
// 表示阻塞等待监听一个客户端连接,返回的socket表示连接的客户端信息
Socket socket = serverSocket.accept(); //连接阻塞
System.out.println("客户端:" + socket.getPort());
// 表示获取客户端的请求报文
new Thread(new Runnable() {
@Override
public void run() {
try {
BufferedReader bufferedReader = new BufferedReader(
new InputStreamReader(socket.getInputStream())
);
String clientStr = bufferedReader.readLine();
System.out.println("收到客户端发送的消息:" + clientStr);
BufferedWriter bufferedWriter = new BufferedWriter(
new OutputStreamWriter(socket.getOutputStream())
);
bufferedWriter.write("ok\\n");
bufferedWriter.flush();
} catch (Exception e) {
//...
}
}
}).start();
}
} catch (IOException e) {
// 错误处理
} finally {
// 其他处理
}
}
}
事情的顺利进展不禁让我们飘飘然,我们居然是使用高阶的多线程技术解决了BIO的阻塞问题,虽然目前每个客户端都需要一个单独的线程来处理,但accept()
总归不会被readLine()
卡死了。
BIO改造之后
所以我们改造完之后的程序是不是就是非阻塞IO了呢?
想多了。。。我们只是用了点奇技淫巧罢了,改造完的代码在系统调用层面该阻塞的地方还是阻塞,说白了,Java提供的API完全受限于操作系统提供的系统调用,在Java语言级别没能力改变底层BIO的事实!
Java没这个能力!
接下来带大家看一下改造之后的BIO代码在底层都调用了哪一些系统调用,让我们在底层上对上文的内容加深一下理解。
给大家打个气,接下来的内容其实非常好理解,大家跟着文章一步步地走,一定能看得懂,如果自己动手操作一遍,那就更好了。
对了,我下来使用的JDK版本是JDK8。
strace
是Linux上的一个程序,该程序可以追踪并记录参数后边运行的进程对内核进行了哪些系统调用。
strace -ff -o out java BIOServerSocketWithThread
其中:
-o
:将系统调用的追踪信息输出到out
文件中,不加这个参数,默认会输出到标准错误stderr
。
-ff
如果指定了-o
选项,strace
会追踪和程序相关的每一个进程的系统调用,并将信息输出到以进程id为后缀的out文件中。举个例子,比如BIOServerSocketWithThread
程序运行过程中有一个ID为30792的进程,那么该进程的系统调用日志会输出到out.30792这个文件中。
我们运行strace
命令之后,生成了很多个out文件。
这么多进程怎么知道哪个是我们需要追踪的呢?我就挑了一个容量最大的文件进行查看,也就是out.30792,事实上,这个文件也恰好是我们需要的,截取一下里边的内容给大家看一下。
可以看到图中的有非常多的行,说明我们写的这么几行代码其实默默调用了非常多的系统调用,抛开细枝末节,看一下上图中我重点标注的系统调用,是不是就是上文中我解释过的函数?我再详细解释一下每一步,大家联系上文,会对BIO的底层理解的更加通透。
7
,接下来对socket进行操作的函数都会有一个参数为7
;8099
端口绑定到监听socket,bind
的第一个参数就是7
,说明就是对监听socket进行的操作;listen()
将监听socket(参数为7)设置为被动接受连接的socket,并且将队列的长度设置为50;System.out.println("启动服务:监听端口:8099");
这一句的系统调用,只不过中文被编码了,所以我特意把:8099
圈出来证明一下;额外说两点:
其一:可以看到,这么一句简单的打印输出在底层实际调用了两次
write
系统调用,这就是为什么不推荐在生产环境下使用打印语句的原因,多少会影响系统性能;其二:
write()
的第一个参数为1
,也是文件描述符,表示的是标准输出stdout
。
poll()
函数,怎么看出来的阻塞?out文件的每一行运行完毕都会有一个 = 返回值
,而poll()
目前没有返回值,因此阻塞了。实际上poll()
系统调用对应的Java语句就是serverSocket.accept();
。不对啊?为什么底层调用的不是accept()
而是poll()
?poll()
应该是多路复用才是啊。在JDK4之前,底层确实直接调用的是accept()
,但是之后的JDK对这一步进行了优化,除了调用accept()
,还加上了poll()
。poll()
的细节我们下文再说,这里可以起码证明了poll()
函数依然是阻塞的,所以整个BIO的阻塞逻辑没有改变。
接下来我们起一个客户端对程序发起连接,直接用Linux上的nc
程序即可,比较简单:
nc localhost 8099
发起连接之后(但并未主动发送信息),out.30792的内容发生了变化:
poll()
函数结束阻塞,程序接着调用accept()
函数返回一个连接socket,该socket的描述符为8
;System.out.println("客户端:" + socket.getPort());
的底层调用;clone()
创造了一个新进程去处理连接socket,该进程的pid为31168
,因此JDK8的线程在底层其实就是轻量级进程;poll()
函数继续阻塞等待新客户端连接。由于创建了一个新的进程,因此在目录下对多出一个out.31168的文件,我们看一下该文件的内容:
发现子进程阻塞在了recvfrom()
这个系统调用上,对应的Java源码就是bufferedReader.readLine();
,直到客户端主动给服务端发送消息,阻塞才会结束。
到此为止,我们就通过底层的系统调用证明了BIO在accept()
以及readLine()
上的阻塞。最后用一张图来结束BIO之旅。
BIO模型
BIO之所以是BIO,是因为系统底层调用是阻塞的,上图中的进程调用recv
,其系统调用直到数据包准备好并且被复制到应用程序的缓冲区或者发生错误为止才会返回,在此整个期间,进程是被阻塞的,啥也干不了。
全部0条评论
快来发表一下你的评论吧 !