Vert.x, 异步编程和响应式系统

最近准备写一个Vert.x的系列专题,将自己的理解分享给大家,也想通过笔记整理编写加深自己的理解。

引子,一个餐厅的故事

假设我们经营一家餐厅,我们餐厅的活动抽象成:接客(accept), 客户下单(read), 上菜(write), 离店(disconnect)这4个步骤。那么这家餐厅这样运行:

1. 开门营业,当有客人到来, 进行接待;
2. 客人落座后,给客人菜单,让客户点单;
3. 客人点单完成后,将订单提交后厨,菜做好后,为客户上菜;
4. 客户结账离开;

假设餐厅只有一位服务员,而且服务员必须完成当前的工作后才能做其他的事情(阻塞调用),这时餐厅会出现以下问题:

1. 当服务员正在为客户点单时候,无法接待新的客户,客户不完成点单操作,服务员就只能一直在旁边等待;
2. 当服务上完菜回到门口接客时,无法响应客户的加菜(点单)请求,如果没有新客户到来,服务员只能一直在门口等待;

我们发现餐厅的接待(并发)能力非常低,几乎只能接待一个客户。这肯定不行,要想生意好,如何解决? 一服务员不行,多招一些服务员(多线程)能不能解决问题,假设我们这样做:

1. 安排1个服务员在店门, 负责接待新到的客户;
2. 为每一个进店的客户配备一个服务员, 响应客户的点餐,上菜,结账请求。

我们发现餐厅的接待能力上来了,看起来餐厅经营似乎有起色,可月底一结算竟然还亏损了,还不如一个服务员。那问题出在哪里?

1.  负责接待的员工太闲了,在大多来时候(没有新客户到来),什么都不干,在门口傻傻发呆;
2. 为每个客户配置一个服务员效率很低,在等待客户点菜,后厨正在做菜,客户吃饭的时候,客户其实并不需要什么服务,服务员只能傻等;

总结下来, 餐厅服务员太多了,工资成本太高(线程占用资源),服务员的工作效率很低,大部分时间都是在空闲等待。而且餐厅面积有限(服务器资源有限),大量的服务员还会相互干扰,出现混乱(大量线程切换),进一步影响了运行效率。

那如何优化才提高餐厅运营效率,扭亏为盈呢, 我们可以为餐厅加装一个设备(Selector), 并改变服务机制:

1. 当有新客户到达时候,设备给服务员发给通知,让服务员尽快进行接待,这样店门外就不需要一个专职的接待员;
2. 客户落座后,不需要为客户专门配置一个服务员,当客户需要服务(下单,上菜,结账)的时候,设备通知服务员, 这样一个服务员可以服务多个客户;

经过优化改造,需要的服务员数量大大的减少,服务员的工作更加饱满,餐厅各服务员工作有条不紊,经营改善,员工加薪,皆大欢喜。

我们看到优化的关键是用异步调用(非阻塞调用)替换掉了阻塞调用。服务员只需要把店门打开, 就可以做其它事情了, 不需要一直在门口等待客人到来, 当有客人到来了会通知服务员进行响应; 客户落座后, 服务员只要把菜单给客户, 就可以忙其它事情了, 当客户完成点单后, 会通知服务员进行响应; 在菜做好之前, 服务员不需要等待, 只要菜做好后通知服务员上菜即可; … 这就是异步编程和响应式系统(asynchronous programming, and reactive systems)的基本思想。

代码实例:应答服务器(Echo Server)

我们想使用传统的阻塞I/O实现。

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Objects;

public class SyncEchoServer {
	public static void main(String[] args) throws Throwable {
		try (ServerSocket server = new ServerSocket()) {
			server.bind(new InetSocketAddress(3000));
			while (true) {
				Socket socket = server.accept();
				int clientPort = socket.getPort();
				ClientHandler handler = new ClientHandler(socket);
				handler.setName("Client-" + clientPort);
				handler.start();
			}
		}
	}
}

class ClientHandler extends Thread {
	final Socket socket;
	public ClientHandler(Socket socket) {
		this.socket = socket;
	}
	
	@Override
	public void run() {
		try (BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
				PrintWriter writer = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()))) {
			String line = null;
			while (Objects.nonNull(line = reader.readLine())) {
				writer.write(line + "\n");
				writer.flush();
			}
			socket.close();
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
}

传统BIO下,监听套接字(ServerSocket)的accept0方法是阻塞的, 主线程扮演餐厅接待员角色,当有新连接连入时候,方法才返回,主线程继续往下执行,创建客户端套接字(Socket)建立连接。客户端Socket的socketRead0方法也是阻塞的, 仅当接收到客户端数据时候方法才返回。如果客户端一直不向服务器端发数据,那么主线程一直会阻塞,无法响应其它请求(参考餐馆一个服务员的场景)。所以,我们为支持并发,就需要为每一个客户端连接创建一个线程(参考餐馆为每个客户配置一个服务员场景)。

我们通过Linux的命令nc(安装nmap-ncat包)来模拟客户端连接,然后通过arthas工具观察服务端执行情况:

### Windows WSL下模拟客户端, 172.18.240.1是echo server地址, 3000是监听端口
### 模拟两个客户端连接
[root@LIXIANG-T14 Think]# nc 172.18.240.1 3000
client1
client1

[root@LIXIANG-T14 Think]#  nc 172.18.240.1 3000
client2
client2

通过thread命令可以看到有3个用户线程。
[arthas@5928]$ thread
Threads Total: 42, NEW: 0, RUNNABLE: 11, BLOCKED: 0, WAITING: 3, TIMED_WAITING: 3, TERMINATED: 0, Internal threads: 25
ID   NAME                          GROUP          PRIORITY  STATE     %CPU      DELTA_TIM TIME      INTERRUPT DAEMON    
1    main                          main           5         RUNNABLE  0.0       0.000     0:0.125   false     false 
21   Client-53730                  main           5         RUNNABLE  0.0       0.000     0:0.000   false     false 
22   Client-41988                  main           5         RUNNABLE  0.0       0.000     0:0.000   false     false 
...
主线程main阻塞在accept0本地方法, 等待客户端接入。
[arthas@5928]$ thread 1
"main" Id=1 RUNNABLE (in native)
    at java.base@11.0.21/java.net.PlainSocketImpl.accept0(Native Method)
    at java.base@11.0.21/java.net.PlainSocketImpl.socketAccept(PlainSocketImpl.java:159)
    at java.base@11.0.21/java.net.AbstractPlainSocketImpl.accept(AbstractPlainSocketImpl.java:474)
    at java.base@11.0.21/java.net.ServerSocket.implAccept(ServerSocket.java:551)
    at java.base@11.0.21/java.net.ServerSocket.accept(ServerSocket.java:519)
    at app//learning.vertx.c1.SyncEchoServer.main(SyncEchoServer.java:18)
 
 每个连接都有一个对应的线程(21,22),线程阻塞在socketRead0, 等待客户端发送数据。
 [arthas@5928]$ thread 21
"Client-53730" Id=21 RUNNABLE (in native)
    at java.base@11.0.21/java.net.SocketInputStream.socketRead0(Native Method)
    at java.base@11.0.21/java.net.SocketInputStream.socketRead(SocketInputStream.java:115)
    at java.base@11.0.21/java.net.SocketInputStream.read(SocketInputStream.java:168)
    at java.base@11.0.21/java.net.SocketInputStream.read(SocketInputStream.java:140)
    at java.base@11.0.21/sun.nio.cs.StreamDecoder.readBytes(StreamDecoder.java:284)
    at java.base@11.0.21/sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:326)
    at java.base@11.0.21/sun.nio.cs.StreamDecoder.read(StreamDecoder.java:178)
    at java.base@11.0.21/java.io.InputStreamReader.read(InputStreamReader.java:181)
    at java.base@11.0.21/java.io.BufferedReader.fill(BufferedReader.java:161)
    at java.base@11.0.21/java.io.BufferedReader.readLine(BufferedReader.java:326)
    at java.base@11.0.21/java.io.BufferedReader.readLine(BufferedReader.java:392)
    at app//learning.vertx.c1.ClientHandler.run(SyncEchoServer.java:39)

同步模式下,每个连接都需要创建一个线程,如果有5000个连接,那么就需要创建5000个线程,假设1个线程需要100KB的内存,那么应用程序花费这一块就需要500MB内存,而且大部分时候,这些线程都只是在等待接收客户端数据,实际线程要处理的逻辑非常简单,仅仅是将接收到的数据发送回客户端,造成了不必要的浪费。更大的问题是,你的服务器通常没有5000个CPU,那么就会出现线程切换,我们知道线程切换的花费是很高的,需要保存线程执行的上下文。通过观察服务器CPU使用情况,我们可以看到sys%的使用率非常高,而真正处理业务逻辑的usr%只占一小部分, CPU使用的效率非常低。通过连接池可以减少线程的创建,当无法减少线程数量。

所以Java在1.4后引入了非阻塞I/O,也就是New I/O(NIO),我们使用NIO改写Echo Server。

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Set;

public class AsyncEchoServer {
	static final HashMap<SocketChannel, ByteBuffer> contexts = new HashMap<>();
	public static void main(String[] args) throws IOException {
		Selector selector = Selector.open();
		ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
		serverSocketChannel.bind(new InetSocketAddress(3000));
		serverSocketChannel.configureBlocking(false);
		serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
		while (true) {
			selector.select();
			Set<SelectionKey> keySet = selector.selectedKeys();
			Iterator<SelectionKey> keyIterator = keySet.iterator();
			while (keyIterator.hasNext()) {
				SelectionKey key = keyIterator.next();
				if (key.isAcceptable()) {
					SocketChannel socketChannel = serverSocketChannel.accept();
					socketChannel.configureBlocking(false);
					socketChannel.register(selector, SelectionKey.OP_READ);
					contexts.put(socketChannel, ByteBuffer.allocate(2));
				} else if (key.isReadable()) {
					SocketChannel socketChannel = (SocketChannel) key.channel();
					ByteBuffer byteBuffer = contexts.get(socketChannel);
					int readBytes = socketChannel.read(byteBuffer);
					if (readBytes == -1) {
						socketChannel.close();
						contexts.remove(socketChannel);
					} else {
						socketChannel.register(key.selector(), SelectionKey.OP_WRITE);
					}
				} else if (key.isWritable()) {
					SocketChannel socketChannel = (SocketChannel) key.channel();
					ByteBuffer byteBuffer = contexts.get(socketChannel);
					byteBuffer.flip();
					socketChannel.write(byteBuffer);
					byteBuffer.clear();
					socketChannel.register(key.selector(), SelectionKey.OP_READ);
				}
				keyIterator.remove();
			}
		}
	}
}

同样的,我们通过nc和arthas观察程序的执行:

##同样的nc模拟两个客户端连接。
D:\Program Files\arthas>jps
1972
12744 Jps
21448 Eclipse
21996 AsyncEchoServer
D:\Program Files\arthas>as.bat 21996 --ignore-tools
[arthas@21996]$ thread
Threads Total: 40, NEW: 0, RUNNABLE: 9, BLOCKED: 0, WAITING: 3, TIMED_WAITING: 3, TERMINATED: 0, Internal threads: 25
1    main                          main           5         RUNNABLE 0.0       0.000     0:0.375   false     false
...
我们可以观察到,echo server只有一个main的用户线程。
[arthas@21996]$ thread 1
"main" Id=1 RUNNABLE (in native)
    at java.base@11.0.21/sun.nio.ch.WindowsSelectorImpl$SubSelector.poll0(Native Method)
    at java.base@11.0.21/sun.nio.ch.WindowsSelectorImpl$SubSelector.poll(WindowsSelectorImpl.java:357)
    at java.base@11.0.21/sun.nio.ch.WindowsSelectorImpl.doSelect(WindowsSelectorImpl.java:182)
    at java.base@11.0.21/sun.nio.ch.SelectorImpl.lockAndDoSelect(SelectorImpl.java:124)
    at java.base@11.0.21/sun.nio.ch.SelectorImpl.select(SelectorImpl.java:141)
    at app//learning.vertx.c1.AsyncEchoServer.main(AsyncEchoServer.java:23)
    
可以看到,main线程阻塞并没有阻塞在等待用户连接accept或者接收客户端数据read上,而是阻塞在Selector.select()方法,也就是WindowsSelectorImpl$SubSelector.poll0本地方法上。

代码中的"Selector"可以理解为餐馆场景里的通知设备,当没有服务请求时,该select()方法会阻塞; 而当有服务请求时候,select()返回,继续执行selector.selectedKeys(), 可以获取服务请求的列表,我们只要遍历请求列表,根据列表请求的服务类型,执行相应的代码即可。

如果列表包含接待事件,那么就执行接客代码,包含点菜事件,服务员会去执行下单代码,菜做好事件则执行上菜代码。。。。这样我们只需要一个线程就可以处理多个用户请求,Selector是通过操作系统的I/O多路复用功能(如本例的WindowsSelectorImpl$SubSelector.poll0)具体实现的,不同的操作系统使用的多路复用技术可能不同(select/poll/epoll)。

对比同步编程代码,可以发现异步的代码更复杂,且代码可读性较差。这时候我们就需要一些框架或者说是库来帮助我们简化异步编程。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值