Non Blocking IO vs Blocking IO in simple client-server architecture

Non Blocking IO (NIO) API is provided in java.nio package. It offers an alternative approach regarding handling input and output operations. One particular possibility is very interesting - performing non blocking IO operations. We will analyze this by impelmenting a simple client-server application and compare NIO approach with 'classical' blocking IO approach.


Before we go through an example lets have a quick reminder of the most important NIO structures:
  • Buffers - containers used to store data. 
  • Channels - represent connections to resources with which we can perform IO operations. Channels use Buffers to read data from an external entity (file, socket etc.) into Buffer or write data into external resource from Buffer. 
  • Selectors - enable taking advantage of non blocking IO operations. They are used together with SelectionKeys. 

Lets cut to the chase and have a look at client-server example in classical blocking IO version. As an IO entity we will use sockets which enable us to perform communication between server and its clients. Server's task will be to perform a schift on on a string of letters provided by its clients. Shifting a letter means incrementing/decrementing its ASCII code to obtain a different letter. Such operation is for example used in implementation of Caesar Cipher.



Lets have a look at the start() method. We use 'try with resources' structure with ServerSocket class that will act as a listener of any client connections.  Listener has a timeout of 100 ms. Then we create an ExecutorService which will handle our thread pool. This is important aspect of using blocking IO. We have to create a separate thread for each IO operation if we want to take advantage of asynchronous execution. After creating thread pool we use while loop that will break only when server collects all the poison pills. Poison pills are being sent by client when it finishes its work. Collecting all poison pills means, that server should shutdown (it will be especially usable during experiments). Inside while loop we pass a new Runnable instances of LettersShifter class to be executed in ExecutorService. This will happen when listener.accept() method unblocks which means that new client is requesting our server's service.

LetterShifter class is an interior class of IOCharacterShiftServer. It implements Runnable interface which means it can be executed asynchronously in a separate thread. In its run() method, which will be executed asynchronously, new Scanner class instance is created. It will be connected to the client's socket input stream. Next we create PrintWriter that will be used to pass data to the client using its socket's output strean. After that we iterate in while loop untill Scanner has no lines left to read. To check whether there are next lines to read we use hasNextLine() scanner method, which may block waiting for client input. If current line contains poison code, it means that given client has finished its work. The poison pills counter is incremented. Otherwise current line is shifted and passed to the output stream using out.println(shift(in.nextLine())) method. Shift method operates on ASCII codes of each letter. It will convert every letter into letter placed x places further in the alphabet, where x value is the stored in the final char shift variable of the server class.

Now lets analyze client class - IOCharacterShiftClient. Is is shown below.

Everything starts in main method. First we read all the parameters that define the work of client:

  • Amount of all data (in bytes) that have to be sent to server.
  • Size of the buffer which is a size of each block of data that will be sent to server and read from server
  • Port of server. 
Again we use try with resources structure to easier handle new Socket instance that is created to communicate with server. Inside try with resources block new Scanner instance is being created which will read data from socket's input stream. Next PrintWriter instance is being created to handle sending data to the server using socket's output stream. Then, until all the data is sent, client uses generated randomly string to send it to the server. Then it calls nextLine()method to read server's answer. Its is blocking method which means that client main thread is being blocked until the new line is read.

In client's class there is a static start() method which is used to create new client Process. It will be helpfull in experiments.

As we have seen, Java IO classes may block during read and write operations. Keeping one thread for a server would be very innefective when we want to handle multiple client connections. Thats why we have created thread pool on the server side, where thread's responsibility is to handle read and write operations in asynchronous mode.

Lets move to the non blocking IO implementations. First we will analyze non blocking server class - NIOCharacterShiftServer.

The start() method is used to activate the server. First new ServerSocketChannel instance is created. It is a selectable channel capable of communicating with stream-oriented listening sockets. In this example it will communicate with clients requesting letters-shift service. Selectable means that it can be examined by a Selector which will determine whether channel is ready for reading or writing. In line 10 we bind ServerSocketChannel to an InetSocketAddress of our server's socket. Next we call serverSocket.configureBlocking(false) method to enable using this channel without blocking main thread. In this case it means that one server thread will be able to manage multiple client connections without being blocked. In line 13 we create new selector using static Selector.open() method. Next we call serverSocket.register(selector, SelectionKey.OP_ACCEPT) in order to use created  channel with given selector to perform socket-accept operations. This is only possible when channel is in non blocking mode. Next we allocate parametrized amount of bytes for a ByteBuffer which will be used as a container for server responses. In while loop selector.select() method is called. It retrieves keys of channels that are ready fo IO operations. In default mode it is blocked until any of the registered selector channels is ready to perform operations defined in register function. It means that certain event has occured (for example client is sending data to server using socket channel). Keep in mind that there is selectNow() function that does not block at all. It checks whether there are channels ready to perform operations and then program execution is being continued - with or without any ready channels. Next we iterate through retrieved SelectionKey instances that represent registered channels ready for IO operations. Inside SelectionKey loop first it is checked whether corresponding channel is ready to accept a new socket connection (key.isAcceptable()). If it is, then new socket connection is accepted and SocketChannel representing client is created. It is configured to be non blocking and registered with already existing selector. In the second 'if ' statement it is checked whether the key's channel is ready for reading (key.isReadable()). If yes, then it means that client has sent data that server can read and answer to.

Reading clients data is contained in read() function. First client's SocketChannel is retrieved from its corresponding key. Then we call client.read(buffer) method which reads all the data from SocketChannel into ByteBuffer. Since this SocketChannel is configured as non blocking then read operation will load all the available bytes and won't block while waiting for any data. After reading data into buffer, buffer content is loaded into string and returned.

Then answerShifted()is called. Client's SocketChannel is again retrieved from its corresponding key. Then it is checked whether buffer content contains poison pill. If yes, then poison pills counter is incremented and method finishes its work. Otherwise, shift(bufferContent) method is called, and its result is put into buffer. Now buffer contains a proper answer - a shifted version of String that client has send to the server. Then we flip the buffer to enable writing its content into SocketChannel. Writing data is also done in non blocking way which means that server's main thread won't be blocked until writing all the data. Writing is done using client.write(buffer) method.

At last it is checked whether current amount of poison pills equals clients amount. If yes, then ServerSocket is closed and server can shut down. 

The last piece of code to analyze is the implementation of non blocking client. It is shown in the NIOCharacterShiftClient class.

Again, client implementation is contained in main() method and first we read all the parameters that define the work of the client:

  • Amount of all data (in bytes) that have to be sent to server.
  • Size of the buffer which is a size of each block of data that will be sent to server and read from server
  • Port of server. 
Next, SocketChannel is opened  with server's socket address. Then space for ByteBuffer is allocated. Untill all the data is sent, client is calling sendMessage(generateRandomString())- a method to send messages with random String parameter.

In the sendMessage method we first use ByteBuffer.wrap(msg.getBytes()) method to insert all the generated random String content into previously created ByteBuffer. Then we use write method to send buffer content using SocketChannel to the server. Since we have not configured blocking/not blocking SocketChannel operations, the write method will block until all the data is written (it is a default approach).

After sending message, client calls readMessage() method to read server's response.  Inside  readMessage() method we call method client.read(buffer) to read the response into the buffer. Again it is blocking client's thread until operation is done. Then, in line 51, we convert buffer content into String.  After that, the buffer is cleared and its content is returned.

When client obtains the server's response, it prints it to the standard output. After exiting from the while loop in the main() method, client sends the last message to the server - the poison pill. It will inform the server, that another client has done its work. At the end client closes its channel using client.close() method. 

This NIO client implementation similarly as the previous IO client implementation uses start() method to create new client process.

We can see significant differences between these two implementations. Both in code and way of working. In IO server implementation we have to create thread pool and run functions in separate thread for each client connection. Otherwise one thread would block during IO operation causing all client connections to be blocked. However in NIO implementation we do not create addidtional threads since communication between client and server is non blocking. Instead we use Channels, Buffers and Selectors. We can still see that NIO Client performs blocking operations but the most important place where we did get rid of blocking the thread is the writing and reading data in server's implementation. There still could be done more optimizations to get rid of thread-blocking communication however the most important ones have already been made.


Now lets move to the last part - testing performance of both implementations. We will measure how long will it take for server to process all of the data that is being sent by a its clients. Size of the buffer will vary in every experiment - the bigger buffer size, the more data can be sent at once. In all of the experiments the amount of clients has been contstant and equal to 5.

To test how IO client server application behaves the test class has been created. Its key part looks as follows:



Experiments are run in loop for every of ExperimentData instance. First the list of clients is created, then a server's instance. After that server is started its processing time is being measured.

Very similar is the implementation of NIO key experiment part:



Below are the results of experiments:






In all of the experiments non blocking IO approach has obtained a better performance results than blocking IO. The biggest difference is visible in the first experiment, where buffer size was the smallest. From this observation we can draw a conclusion that  NIO approach outperforms IO approach the most in a situations where we deal with a lots of short client-server connections. In that case the necessity to create a separate thread per connection in IO method can cause a serious drawback in appliaction performance.

Komentarze

Popularne posty z tego bloga

Spring Data 1# - how repositories work under the hood

Hibernate 1# - entity states overview

Data consistency and performance in web applciation