一、阻塞和非阻塞
何为阻塞?
从该网络通讯过程来理解一下何为阻塞 :
在以上过程中若连接还没到来,那么 accept 会阻塞 , 程序运行到这里不得不挂起, CPU 转而执行其他线程。
在以上过程中若数据还没准备好, read 会一样也会阻塞。
阻塞式网络 IO 的特点:多线程处理多个连接。每个线程拥有自己的栈空间并且占用一些 CPU 时间。每个线程遇到外部为准备好的时候,都会阻塞掉。阻塞的结果就是会带来大量的进程上下文切换。且大部分进程上下文切换可能是无意义的。比如假设一个线程监听一个端口,一天只会有几次请求进来,但是该 cpu 不得不为该线程不断做上下文切换尝试,大部分的切换以阻塞告终。
何为非阻塞?
下面有个隐喻:
一辆从 A 开往 B 的公共汽车上,路上有很多点可能会有人下车。司机不知道哪些点会有哪些人会下车,对于需要下车的人,如何处理更好?
-
司机过程中定时询问每个乘客是否到达目的地,若有人说到了,那么司机停车,乘客下车。 ( 类似阻塞式 )
-
每个人告诉售票员自己的目的地,然后睡觉,司机只和售票员交互,到了某个点由售票员通知乘客下车。 ( 类似非阻塞 )
很显然,每个人要到达某个目的地可以认为是一个线程,司机可以认为是 CPU 。在阻塞式里面,每个线程需要不断的轮询,上下文切换,以达到找到目的地的结果。而在非阻塞方式里,每个乘客 ( 线程 ) 都在睡觉 ( 休眠 ) ,只在真正外部环境准备好了才唤醒,这样的唤醒肯定不会阻塞。
二、阻塞
1、阻塞
- 阻塞模式下,相关方法都会导致线程暂停
- ServerSocketChannel.accept 会在没有连接建立时让线程暂停
- SocketChannel.read 会在没有数据可读时让线程暂停
- 阻塞的表现其实就是线程暂停了,暂停期间不会占用 cpu,但线程相当于闲置
- 单线程下,阻塞方法之间相互影响,几乎不能正常工作,需要多线程支持
- 但多线程下,有新的问题,体现在以下方面
- 32 位 jvm 一个线程 320k,64 位 jvm 一个线程 1024k,如果连接数过多,必然导致 OOM,并且线程太多,反而会因为频繁上下文切换导致性能降低
- 可以采用线程池技术来减少线程数和线程上下文切换,但治标不治本,如果有很多连接建立,但长时间 inactive,会阻塞线程池中所有线程,因此不适合长连接,只适合短连接
2、代码演示
下面是案例:
首先我们写一个Socket客户端,代码如下,具体解析都加有相应的注释:
import com.example.nettydemo.c1.test.ByteBufferUtil;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.List;
@Slf4j
public class Server {
public static void main(String[] args) throws IOException {
// 使用nio来理解阻塞模式。这里使用的是单线程连接
// 全局ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(16);
// 1、创建服务器
ServerSocketChannel socketChannel = ServerSocketChannel.open();
// 2、绑定监听端口
socketChannel.bind(new InetSocketAddress(8080));
// 3、连接集合
List<SocketChannel> channels = new ArrayList<>();
while (true) {
// 4、accept与客户端建立连接,SocketChannel用来与客户端之间通信
log.debug("connecting..."); //accept() 默认是阻塞方法,线程停止运行等待
SocketChannel sc = socketChannel.accept();
log.debug("connected... {}", sc);
channels.add(sc);
for (SocketChannel channel : channels) {
// 5、接收客户端发送的数据
log.debug("before read... {}", channel);
channel.read(buffer); //read()也是一个阻塞方法,线程停止运行等待
buffer.flip(); //切换为读模式
ByteBufferUtil.debugRead(buffer); // 调试打印数据
buffer.clear(); //切换为写模式,并重置position
log.debug("after read... {}", channel);
}
}
}
}
服务端用来接收数据,我们还需要一个客户端进行发送数据,客户端比较简单,如下示例:
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SocketChannel;
public class Client {
public static void main(String[] args) throws IOException {
// 建立一个SocketChannel连接
SocketChannel socketChannel = SocketChannel.open();
// 连接本地,监听相应端口号
socketChannel.connect(new InetSocketAddress("localhost",8080));
// debug模式下
// 执行表达式 socketChannel.write(Charset.defaultCharset().encode("hello"))
// 可以看到运行的结果
System.out.println("waiting ....");
}
}
下面我们进行模拟客户端与服务端之间发送和接收数据(单线程),我们以正常模式启动Server服务端,控制台会打印以下信息:
16:22:44 [DEBUG] [main] c.e.n.c.t.Server - connecting...
这是因为accept()方法默认是阻塞方法,当线程执行到accept()方法的时候,线程会进入阻塞状态,等待客户端进行发送数据,此时线程不会进行其它操作。
接着我们以debug模式启动Client客户端,在输出语句的地方打上一个断点,保证不影响Socket的连接。
启动后,发现控制台又打印了两条信息,如下:
16:22:44 [DEBUG] [main] c.e.n.c.t.Server - connecting...
16:25:24 [DEBUG] [main] c.e.n.c.t.Server - connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:36948]
16:25:24 [DEBUG] [main] c.e.n.c.t.Server - before read... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:36948]
当accept()方法等待到客户端的连接之后,会继续向下执行,建立连接,准备读取数据,这时我们知道ByteBuffer的read()方法也是一个阻塞方法,此时该线程又会被阻塞,等待数据的读取。
我们可以使用debug的Evaluate Expression功能来执行模拟发送数据,操作如下:
我们执行这样一条表达式 socketChannel.write(Charset.defaultCharset().encode("hello"))
这就模拟发送了一条数据 “hello”,我们查看控制台打印信息,如下:
16:22:44 [DEBUG] [main] c.e.n.c.t.Server - connecting...
16:25:24 [DEBUG] [main] c.e.n.c.t.Server - connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:36948]
16:25:24 [DEBUG] [main] c.e.n.c.t.Server - before read... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:36948]
+--------+-------------------- read -----------------------+----------------+
position: [0], limit: [5]
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 68 65 6c 6c 6f |hello |
+--------+-------------------------------------------------+----------------+
16:29:44 [DEBUG] [main] c.e.n.c.t.Server - after read... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:36948]
16:29:44 [DEBUG] [main] c.e.n.c.t.Server - connecting...
数据发送成功,而且服务端也接收到了数据,并且读取数据完毕之后,又进入了连接等待状态。
那么我们尝试再次发送一条数据,执行如下表达式 socketChannel.write(Charset.defaultCharset().encode("hi"))
再次查看控制台,发现控制台什么也没有打印,说明服务端Server并未接收到客户端的数据,这是为什么呢 ?
我们看原来的服务端代码,发现,SocketChannel又执行了accept()方法,等待新的客户端建立连接,而我们并没有启动新的客户端,所以该线程被阻塞了,不会执行其它任何的操作。
发现了问题所在,那么我们再建立一个客户端不就好了么?
我们可以通过IDEA配置,进行多个程序的运行,如图:
(1)打开客户端编辑配置
(2)选择以下选项
我这里已经勾选过了。
再次以debug模式运行Client客户端,运行成功后,发现服务端Server控制台打印了相关信息,如下:
16:22:44 [DEBUG] [main] c.e.n.c.t.Server - connecting...
16:25:24 [DEBUG] [main] c.e.n.c.t.Server - connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:36948]
16:25:24 [DEBUG] [main] c.e.n.c.t.Server - before read... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:36948]
+--------+-------------------- read -----------------------+----------------+
position: [0], limit: [5]
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 68 65 6c 6c 6f |hello |
+--------+-------------------------------------------------+----------------+
16:29:44 [DEBUG] [main] c.e.n.c.t.Server - after read... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:36948]
16:29:44 [DEBUG] [main] c.e.n.c.t.Server - connecting...
16:37:06 [DEBUG] [main] c.e.n.c.t.Server - connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:37084]
16:37:06 [DEBUG] [main] c.e.n.c.t.Server - before read... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:36948]
+--------+-------------------- read -----------------------+----------------+
position: [0], limit: [2]
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 68 69 |hi |
+--------+-------------------------------------------------+----------------+
16:37:06 [DEBUG] [main] c.e.n.c.t.Server - after read... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:36948]
16:37:06 [DEBUG] [main] c.e.n.c.t.Server - before read... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:37084]
我们可以与之前打印的信息相比较,我们这次没有使用debug模拟发送数据,但是之前发送的数据(并未被接收的)现在被接收并且打印了。
3、结论
所以我们得出结论:
(1)以上过程是理解阻塞的过程。
(2)单线程下,阻塞方法之间相互相应,几乎不能正常工作。
(3)多线程方式下产生新的问题,这里不再演示
二、非阻塞
1、非阻塞
- 非阻塞模式下,相关方法都会不会让线程暂停
- 在 ServerSocketChannel.accept 在没有连接建立时,会返回 null,继续运行
- SocketChannel.read 在没有数据可读时,会返回 0,但线程不必阻塞,可以去执行其它 SocketChannel 的 read 或是去执行 ServerSocketChannel.accept
- 写数据时,线程只是等待数据写入 Channel 即可,无需等 Channel 通过网络把数据发送出去
- 但非阻塞模式下,即使没有连接建立,和可读数据,线程仍然在不断运行,白白浪费了 cpu
- 数据复制过程中,线程实际还是阻塞的(AIO 改进的地方)
二、代码演示
下面我们继续演示非阻塞模式的过程。
与阻塞模式相同,我们需要一个客户端和服务端,两者的代码差不多,这里就不再进行重写,给出服务端Server的代码,客户端的代码不变。
import com.example.nettydemo.c1.test.ByteBufferUtil;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.List;
@Slf4j
public class Server {
public static void main(String[] args) throws IOException {
// 使用nio来理解阻塞模式。这里使用的是单线程连接
// 全局ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(16);
// 1、创建服务器
ServerSocketChannel socketChannel = ServerSocketChannel.open();
socketChannel.configureBlocking(false); // 切换成非阻塞模式
// 2、绑定监听端口
socketChannel.bind(new InetSocketAddress(8080));
// 3、连接集合
List<SocketChannel> channels = new ArrayList<>();
while (true) {
// 4、accept与客户端建立连接,SocketChannel用来与客户端之间通信
//accept() 默认是阻塞方法,线程停止运行等待
//由于上面的设置,该方法就变成了非阻塞方法,线程会继续运行
//如果没有连接建立,那么sc返回的是null,建立连接则不是null
SocketChannel sc = socketChannel.accept();
if (sc != null) {
log.debug("connected... {}", sc);
//将SocketChannel设置成非阻塞方法
sc.configureBlocking(false);
channels.add(sc);
}
for (SocketChannel channel : channels) {
// 5、接收客户端发送的数据
//read()也是一个阻塞方法,线程停止运行等待
//SocketChannel设置成非阻塞方法,那么read()也就是非阻塞方法
//如果没有读取到数据,read返回0
int read = channel.read(buffer);
if (read > 0){
buffer.flip(); //切换为读模式
ByteBufferUtil.debugRead(buffer); // 调试打印数据
buffer.clear(); //切换为写模式,并重置position
log.debug("after read... {}", channel);
}
}
}
}
}
其实非阻塞模式只是将socket的模式设置成为了非阻塞模式,我们先以正常模式运行服务端Server,因为我们在客户端的代码中进行了非null判断,所以我们将一些debug的日志删掉了,防止刷屏影响我们查看控制台数据。
然后我们以debug模式启动三个客户端Client,启动后发现控制台输出信息:
16:49:10 [DEBUG] [main] c.e.n.c.t.Server - connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:37268]
16:49:32 [DEBUG] [main] c.e.n.c.t.Server - connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:37279]
16:49:55 [DEBUG] [main] c.e.n.c.t.Server - connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:37288]
说明服务端Server都能接收到客户端Client的连接。
然后我们打开端口号为37288的客户端,使用debug工具模拟发送消息,控制台打印如下:
socketChannel.write(Charset.defaultCharset().encode("hello"))
16:49:10 [DEBUG] [main] c.e.n.c.t.Server - connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:37268]
16:49:32 [DEBUG] [main] c.e.n.c.t.Server - connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:37279]
16:49:55 [DEBUG] [main] c.e.n.c.t.Server - connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:37288]
+--------+-------------------- read -----------------------+----------------+
position: [0], limit: [5]
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 68 65 6c 6c 6f |hello |
+--------+-------------------------------------------------+----------------+
16:50:47 [DEBUG] [main] c.e.n.c.t.Server - after read... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:37288]
我们再次发送一条数据,socketChannel.write(Charset.defaultCharset().encode("hi"))
,查看控制台,发现仍能打印出数据,说明服务端能够一直接收客户端的数据。
16:49:10 [DEBUG] [main] c.e.n.c.t.Server - connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:37268]
16:49:32 [DEBUG] [main] c.e.n.c.t.Server - connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:37279]
16:49:55 [DEBUG] [main] c.e.n.c.t.Server - connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:37288]
+--------+-------------------- read -----------------------+----------------+
position: [0], limit: [5]
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 68 65 6c 6c 6f |hello |
+--------+-------------------------------------------------+----------------+
16:50:47 [DEBUG] [main] c.e.n.c.t.Server - after read... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:37288]
+--------+-------------------- read -----------------------+----------------+
position: [0], limit: [2]
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 68 69 |hi |
+--------+-------------------------------------------------+----------------+
16:51:06 [DEBUG] [main] c.e.n.c.t.Server - after read... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:37288]
其他的客户端发送数据也是可以被服务端接收的到的。
3、结论
(1)非阻塞模式下,相关方法都会不会让线程暂停
(2)但非阻塞模式下,即使没有连接建立,和可读数据,线程仍然在不断运行,白白浪费了 cpu