《Java高并发程序设计》学习 --5.10 网络NIO

45 篇文章 0 订阅
JavaNIO中涉及的基础内容有通道(Channel)和缓冲区(Buffer)、文件IO和网络IO。
1)基于Socket的服务端的多线程模式
这里,以Echo服务器为例。对于Echo服务器,它会读取客户端的一个输入,并将这个输入原封不动地返回给客户端。服务器会为每一个客户端连接启用一个线程,这个新的线程将全心全意为这个客户端服务。同时,为了接受客户端连接,服务器还会额外使用一个派发线程。
下面的代码实现了服务器:
public class MultiThreadEchoServer {
	private static ExecutorService tp = Executors.newCachedThreadPool();
	static class HandleMsg implements Runnable {
		Socket clientSocket;
		public HandleMsg(Socket clientSocket) {
			this.clientSocket = clientSocket;
		}
		
		@Override
		public void run() {
			BufferedReader is = null;
			PrintWriter os = null;
			try {
				is = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
				os = new PrintWriter(clientSocket.getOutputStream(),true);
				String inputLine = null;
				long b = System.currentTimeMillis();
				while((inputLine = is.readLine()) != null) {
					os.println(inputLine);
				}
				long e = System.currentTimeMillis();
				System.out.println("spend:" + (e-b)+"ms");
				
			} catch (Exception e) {
				e.printStackTrace();
			} finally {
				try {
					if(is!=null) is.close();
					if(os!=null) os.close();
					clientSocket.close();
				} catch (Exception e2) {
					e2.printStackTrace();
				}
			}
		}
	}
	
	public static void main(String[] args) {
		ServerSocket echoServer = null;
		Socket clientSocket = null;
		try {
			echoServer = new ServerSocket(8000);
		} catch (Exception e) {
			System.out.println(e);
		}
		
		while(true) {
			try {
				clientSocket = echoServer.accept();
				System.out.println(clientSocket.getRemoteSocketAddress() + " connect!");
				tp.execute(new HandleMsg(clientSocket));
			} catch (Exception e) {
				System.out.println(e);
			}
		}
	}
}
使用了一个线程池来处理一个客户端连接。并定义了HandleMsg线程,它由一个客户端Socket构成,它的任务是读取这个Socket的内容并将其进行返回,返回成功后,任务完成,客户端Socket就被正常关闭。程序中统计并输出了服务端线程处理一次客户端请求所花费的时间(包括读取数据和回写数据的时间)。主线程main的主要作用是在8000端口上进行等待。一旦有新的客户端连接,它就根据这个连接创建HandleMsg线程进行处理。
这就是一个支持多线程的服务端的核心内容。它的特点是,在相同可支持的线程范围内,可以尽量多地支持客户端的数量,同时和单线程服务器相比,它也可以更好地使用多核CPU,可以尽量多地支持客户端的数量,同时和单线程服务器相比,它可以更好地使用多核CPU。
下面为一个客户端的参考实现:
public static void main(String[] args) throws IOException {
	Socket client = null;
	PrintWriter writer = null;
	BufferedReader reader = null;
	try {
		client = new Socket();
		client.connect(new InetSocketAddress("localhost", 8000));
		writer = new PrintWriter(client.getOutputStream(),true);
		writer.println("Hello!");
		writer.flush();
		reader = new BufferedReader(new InputStreamReader(client.getInputStream()));
		System.out.println("from server:" + reader.readLine());
	} catch (Exception e) {
		e.printStackTrace();
	} finally {
		if(writer!=null)writer.close();
		if(reader!=null)reader.close();
		if(client!=null)client.close();
	}
}
下面为一个让CPU等待IO的极端例子:
public class HeavySocketClient {
	private static ExecutorService tp = Executors.newCachedThreadPool();
	private static final int sleep_time = 1000*1000*1000;
	public static class EchoClient implements Runnable {
		@Override
		public void run() {
			Socket client = null;
			PrintWriter writer = null;
			BufferedReader reader = null;
			try {
				client = new Socket();
				client.connect(new InetSocketAddress("localhost",8000));
				writer = new PrintWriter(client.getOutputStream(),true);
				writer.write("H");
				LockSupport.parkNanos(sleep_time);
				writer.write("e");
				LockSupport.parkNanos(sleep_time);
				writer.write("l");
				LockSupport.parkNanos(sleep_time);
				writer.write("l");
				LockSupport.parkNanos(sleep_time);
				writer.write("o");
				LockSupport.parkNanos(sleep_time);
				writer.write("!");
				LockSupport.parkNanos(sleep_time);
				writer.println();
				writer.flush();
				
				reader = new BufferedReader(new InputStreamReader(client.getInputStream()));
				System.out.println("from server:" + reader.readLine());	
			} catch (Exception e) {
				e.printStackTrace();
			} finally {
				try {
					if(writer!=null)writer.close();
					if(reader!=null)reader.close();
					if(client!=null)client.close();
				} catch (Exception e2) {
					// TODO: handle exception
				}
			}
		}
	}
	public static void main(String[] args) {
		EchoClient ec = new EchoClient();
		for(int i=0; i<10; i++) {
			tp.execute(ec);
		}
	}
}
上述代码定义了一个新的客户端,它会进行10次请求。每一次请求都会访问8000端口。连接成功后,会向服务器输出“Hello!”字符串,但是在这一次交互中,客户端会慢慢地进行输出,每次只输出一个字符,之后进行1秒的等待。因此,整个过程会持续6秒。
开启多线程池的服务和上述客户端。服务端的部分输出如下:
spend:5987ms
spend:5985ms
spend:5992ms
spend:5992ms
spend:5995ms
spend:5989ms
spend:6000ms
spend:5997ms
spend:6000ms
spend:6004ms
对于服务端来说,每一个请求的处理时间都在6秒左右。这很容易理解,因为服务器要先读入客户端的输入,而客户端缓慢的处理速度(也可能是一个拥塞的网络环境)使得服务器花费了不少等待时间。
这这个案例中,服务器处理请求之所以慢,并不是因为在服务器端有繁重的任务,而仅仅是因为服务线程在等待IO。

2)使用NIO进行网络编程
在NIO中的一个关键组件Channel(通道)。Channel有点类似于流,一个Channel可以和文件或者网络Socket对应。如果Channel对应着一个Socket,那么往这个Channel中写数据,就等同于向Socket中写入数据。
和Channel一起使用的另一个重要组件就是Buffer。可以简单地把Buffer理解成一个内存区域或者byte数组。数据需要包装成Buffer的形式才能和Channel交互(写入或者读取)。
另一个与Channel密切相关的是Selector(选择器)。在Channel的众多实现中,有一个SelectableChannel实现,表示可被选择的通道。任何一个SelectableChannel都可以将自己注册到一个Selector中。这样,这个Channel就能被Selector所管理。而一个Selector可以管理多个SelectableChannel。当SelectableChannel的数据准备好时,Selector就会接到通知,得到那些已经准备好的数据。而SocketChannel就是SelectableChannel的一种。
一个Selector可以由一个线程进行管理,而一个SelectableChannel则可以表示一个客户端连接,因此这就构成由一个或者极少数线程,来处理大量客户端连接的结构。当与客户端连接的数据没有准备好时,Selector会处于等待状态,而一旦有任何一个SelectableChannel准备好了数据,Selector就能立即得到通知,获取数据进行处理。
用NIO重构这个多线程的Echo服务器。
首先,需要定义一个Selector和线程池:
private Selector selector;
private ExecutorService tp = Executors.newCachedThreadPool();
其中,selector用于处理所有的网络连接。线程池tp用于对每一个客户端进行相应的处理。每一个请求都会委托给线程池中的线程进行实际的处理。
为了弄够统计服务器线程在一个客户端上花费了多少时间,还需要定义一个与时间统计有关的类:
public static Map<Socket, Long> time_stat = new HashMap<Socket, Long>(10240);
它用于统计在某一个Socket上花费的时间,time_start的key为Socket,value为时间戳(可以记录处理开始时间)。
下面为NIO服务器的核心线程,下面的startServer()方法用于启动NIO Server:
private void startServer() throws Exception {
	selector = SelectorProvider.provider().openSelector();
	ServerSocketChannel ssc = ServerSocketChannel.open();
	ssc.configureBlocking(false);
		
	InetSocketAddress isa = new InetSocketAddress("localhost", 8000);
	//InetSocketAddress isa = new InetSocketAddress(8000);
	ssc.socket().bind(isa);

	SelectionKey acceptKey = ssc.register(selector, SelectionKey.OP_ACCEPT);

	for(;;) {
		selector.select();
		Set readyKeys = selector.selectedKeys();
		Iterator i = readyKeys.iterator();
		long e = 0;
		while(i.hasNext()) {
			SelectionKey sk = (SelectionKey)i.next();
			i.remove();

			if(sk.isAcceptable()) {
				doAccept(sk);
			}
			else if(sk.isValid() && sk.isReadable()) {
				if(!time_stat.containsKey(((SocketChannel)sk.channel()).socket()))
					time_stat.put(((SocketChannel)sk.channel()).socket(), System.currentTimeMillis());
				doRead(sk);
			}
			else if(sk.isValid() && sk.isWritable()) {
				doWrite(sk);
				e = System.currentTimeMillis();
				long b = time_stat.remove(((SocketChannel)sk.channel()).socket());
				System.out.println("spend:" + (e-b) +"ms");
			}
		}
	}
}
上述代码中第2行,通过工厂方法获得一个Selector对象的实例。第3行,获得表示服务端的SocketChannel实例。第4行,将这个SocketChannel设置为非阻塞模式。实际上,Channel也可以像传统的Socket那样按照阻塞的方式工作。但在这里,更倾向于让其工作在非阻塞模式,在这种模式下,我们才可以向Channel注册感兴趣的事件,并且在数据准备好时,得到必要的通知。接着,在第6~8行进行端口绑定,将这个CHannel绑定到8000端口。
在第10行,将这个ServerSocketChannel绑定到Selector上,并注册它感兴趣的时间为Accept。这样,Selector就能为这个Channel服务了。当Selector发现ServerSocketChannel有新的客户端连接时,就会通知ServerSocketChannel进行处理。方法register()返回值是一个SelectionKey,SelectionKey表示一对Selector和Channel的关系。当Channel注册到Selector上时,就相当于确定了两者的服务关系,那么SelectionKey就是这个契约。当Selector或者Channel被关闭时,它们对应的SelectionKey就会失败。
第12~37行是一个无穷循环,它的主要任务就是等待-分发网络消息。
第13行的select()方法是一个阻塞方法。如果当前没有任何数据准备好,它就会等待。一旦有数据可读,它就会返回。它的返回值是已经准备就绪的SelectionKey的数量。这里简单地将其忽略。
第14行获取那些准备好的SelectionKey。因为Selector同时为多个Channel服务,因此已经准备就绪的Channel就有可能是多个。所以,这里得到的自然是一个集合。得到这个就绪集合后,剩下的就是遍历这个集合,挨个处理所有的Channel数据。
第15行得到这个集合的迭代器。第17行使用迭代器遍历整个集合。第18行根据迭代器获得一个集合内的SelectionKey实例。
第19行将这个元素移除!注意,这个非常重要,否则就会重复处理相同的SelectionKey。当处理完一个SelectionKey后,务必将其从集合内删除。
第21行将这个元素当前SelectionKey所代表的Channel是否在Acceptable状态,如果是,就进行客户端的接收(执行doAccept()方法)。
第24行判断Channel是否已经可以读了,如果是就进行读取(doRead()方法)。这里为了统计系统处理每一个连接的时间,在第25~27行记录了在读取数据之前的一个时间戳。
第30行判断通道是否准备好进行写。如果是就进行写入(doWrite()),同时在写入完成后,根据读取前的时间戳,输出处理这个Socket连接的耗时。
doAccept()方法,它与客户端建立连接:
private void doAccept(SelectionKey sk) {
	ServerSocketChannel server = (ServerSocketChannel)sk.channel();
	SocketChannel clientChannel;
	try {
		clientChannel = server.accept();
		clientChannel.configureBlocking(false);
		
  //Register this channel for reading
		SelectionKey clientKey = clientChannel.register(selector, SelectionKey.OP_READ);
		//Allocate an EchoClient instance and attach it to this selection key.
   EchoClient echoClient = new EchoClient();
		clientKey.attach(echoClient);
			
		InetAddress clientAddress = clientChannel.socket().getInetAddress();
		System.out.println("Accepted connection from " + clientAddress.getHostAddress() + ".");
	} catch (Exception e) {
		System.out.println("False to accept new client.");
		e.printStackTrace();
	}
}
和Socket编程很类似,当有一个新的客户端连接接入时,就会有一个新的Channel产生代表这个连接。上述代码第5行,生成的clientChannel就表示和客户端通信的通道。第6行,将这个Channel配置为非阻塞模式,也就是要求系统在准备好IO后,再通知我们的线程来读取或者写入。
第9行很关键,它将新生成的Channel注册到selector选择器上,并告诉Selector,我现在对读(OP_READ)操作感兴趣。这样,当Selector发现这个Channel已经准备好读时,就能给线程一个通知。
第11行新建一个对象实例,一个EchoClient实例代表一个客户端。在第12行,我们将这个客户端实例作为附件,附加到表示这个连接的SelectionKey上。这样在整个连接的处理过程中,我们都可以共享这个EchoClient实例。
EchoClient的定义很简单,它封装了一个队列,保存在需要回复给这个客户端的所有信息,这样,再进行回复时,只要outq对象中弹出元素即可。
public class EchoClient {
	private LinkedList<ByteBuffer> outq;
	EchoClient() {
		outq = new LinkedList<ByteBuffer>();
	}
	
	public LinkedList<ByteBuffer> getOutQueue() {
		return outq;
	}
	
	public void enqueue(ByteBuffer bb) {
		outq.addFirst(bb);
	}
}
当Channel可以读取时,doRead()方法就会被调用。
private void doRead(SelectionKey sk) {
	SocketChannel channel = (SocketChannel)sk.channel();
	ByteBuffer bb = ByteBuffer.allocate(8192);
	int len;
		
	try {
		len = channel.read(bb);
		if(len < 0) {
			disconnect(sk);
			return;
		}
	} catch (Exception e) {
		System.out.println("Failed to read from client.");
		e.printStackTrace();
		disconnect(sk);
		return;
	}

	bb.flip();
	tp.execute(new HandleMsg(sk, bb, selector));
}
方法doRead()接收一个SelectionKey参数,通过这个SelectionKey可以得到当前的客户端Channel(第2行)。在这里,我们准备8K的缓冲区读取数据,所有读取的数据存放在变量bb中(第7行)。读取完毕后,重置缓冲区,为数据处理做准备(第19行)。
在这个示例中,我们对数据的处理很简单。但是为了模拟复杂的场景,还是使用了线程池进行数据处理(第20行)。这样,如果数据处理很复杂,就能在单独的线程中进行,而不用阻塞任务派发线程。
HandleMsg的实现也很简单:
public class HandleMsg implements Runnable {
	SelectionKey sk;
	ByteBuffer bb;
	Selector selector;
	
	public HandleMsg(SelectionKey sk, ByteBuffer bb, Selector selector) {
		this.sk = sk;
		this.bb = bb;
		this.selector = selector;
	}
	
	@Override
	public void run() {
		EchoClient echoClient = (EchoClient)sk.attachment();
		echoClient.enqueue(bb);
		sk.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
		//强迫selector立即返回
   selector.wakeup();
	}
}
上述代码中,简单地将接收到的数据压入EchoClient的队列。如果需要处理业务逻辑,就可以在这里进行处理。
在数据处理完成后,就可以准备将结果写回到客户端,因此,重新注册感兴趣的消息事件,将写操作(OP_WRITE)也作为感兴趣的事件进行提交。这样在通道准备好写入时,就能通知线程。
写入操作使用doWrite()函数实现:
private void doWrite(SelectionKey sk) {
	SocketChannel channel = (SocketChannel)sk.channel();
	EchoClient echoClient = (EchoClient)sk.attachment();
	LinkedList<ByteBuffer> outq = echoClient.getOutQueue();

	ByteBuffer bb = outq.getLast();
	try {
		int len = channel.write(bb);
		if(len == -1) {
			disconnect(sk);
			return;
		}

		if(bb.remaining() == 0) {
			outq.removeLast();
		}
	} catch (Exception e) {
		System.out.println("Failed to write to client");
		e.printStackTrace();
		disconnect(sk);
	}

	if(outq.size()==0) {
		sk.interestOps(SelectionKey.OP_READ);
	}
}
函数doWrite()也接收一个SelectionKey,当然针对一个客户端来说,这个SelectionKey实例和doRead()拿到的SelectionKey是同一个。因此,通过SelectionKey我们就可以在这两个操作中共享EchoClient实例。上述代码第3~4行,我们取得EchoClient实例以及它的发送内容列表。第6行,获得列表顶部元素,准备写回客户端。第8行进行写回操作。如果全部发送完成,则移除这个缓存对象(第16行)。
在doWrite()中最重要的,也是最容易被忽略的是在全部数据发送完成后(也就是outq的长度为0),需要将写事件(OP_WRITE)从感兴趣的操作中移除(第25行)。如果不这么做,每次Channel准备好写时,都会来执行doWrite()方法。而实际上,你又无数据可写,这显然是不合理的。因此,这个操作很重要。
现在使用NIO服务器来处理上一节中客户端的访问。同样的,客户端也是要花费将近6秒钟,才能完成一次消息的发送,使用NIO技术后,服务端线程需要花费的时间如下:
spend:1ms
spend:4ms
spend:1ms
spend:1ms
spend:1ms
spend:0ms
spend:0ms
spend:6ms
spend:1ms
spend:7ms
可以看到,在使用NIO技术后,即使客户端迟钝或者出现了网络延迟等现象,并不会给服务器带来太大的问题。

3)使用NIO实现客户端
首先,需要初始化Selector和Channel:
private Selector selector;
public void init(String ip, int port) throws IOException {
	SocketChannel channel = SocketChannel.open();
	channel.configureBlocking(false);
	this.selector = SelectorProvider.provider().openSelector();
	channel.connect(new InetSocketAddress(ip, port));
	channel.register(selector, SelectionKey.OP_CONNECT);
}
上述代码第3行,创建一个SocketChannel实例,并设置为非阻塞模式。第5行创建了一个Selector。第6行,将SocketChannel绑定到Socket上。但由于当前Channel是非阻塞的,因此,connect()方法返回时,连接并不一定建立成功,在后续使用这个连接时,还需要使用finishConnect()再次确认。第7行,将这个Channel和Selector进行绑定,并注册了感兴趣的事件作为连接(OP_CONNECT)。
初始化完成后,就是程序的主要执行逻辑:
public void working() throws Exception {
	while(true) {
		if(!selector.isOpen()) {
			break;
		}
		selector.select();
		Iterator<SelectionKey> ite = this.selector.selectedKeys().iterator();
		while(ite.hasNext()) {
			SelectionKey key = ite.next();
			ite.remove();
			if(key.isConnectable()) {
				connect(key);
			} else if(key.isReadable()) {
				read(key);
			}
		}
	}
}
在上述代码中,第5行得到已经准备好的事件。如果当前没有任何事件准备就绪,这里就会阻塞。这里的整个处理机制和服务端非常类似,主要处理两个事件,首先是表示连接就绪的Connect事件(由connect()函数处理)以及表示通道可读的Read事件(由read()函数处理)。
函数connect()的实现如下:
public void connect(SelectionKey key) throws IOException {
	SocketChannel channel = (SocketChannel)key.channel();
 //如果正在连接,则完成连接
	if(channel.isConnectionPending()) {
		channel.finishConnect();
	}
	channel.configureBlocking(false);
	channel.write(ByteBuffer.wrap(new String("hello server!\r\n").getBytes()));
	channel.register(this.selector, SelectionKey.OP_READ);
}
上述connect()函数接收SelectionKey作为其参数。在第4~6行,它首先判断是否连接已经建立,如果没有,则调用finishConnect()完成连接。建立连接后,向Channel写入数据,并同时注册读事件为感兴趣事件(第10行)。
当Channel可读时,会执行read()方法,进行数据读取:
public void read(SelectionKey key) throws IOException {
	SocketChannel channel = (SocketChannel)key.channel();
       //创建读取的缓冲区
	ByteBuffer buffer = ByteBuffer.allocate(100);
	channel.read(buffer);
	byte[] data = buffer.array();
	String msg = new String(data).trim();
	System.out.println("客户端收到信息:" + msg);
	channel.close();
	key.selector().close();
}
上述read()函数首先创建了100字节的缓冲区(第4行),接着从Channel中读取数据,并将其打印到控制台上。最后,关闭Channel和Selector。


注:本篇博客内容总结自《 Java 高并发程序设计》
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值