java.nio y java.io

Veo que no hay prácticamente nada información acerca de este tema en el sitio, así que decidí escribir esto esperando que a alguien le resulte útil.

Desde la versión 1.4 de Java apareció un paquete nuevo, java.nio, similar a java.io; esa n es de non-blocking. Los que hayan usado InputStream y OutputStream para leer por ejemplo de un socket, sabrán que los métodos de lectura y escritura bloquean el thread que los invoca, hasta que terminen su operación. Por lo tanto, en ambientes donde se tienen un alto volumen de intercambio de datos por medio de sockets, normalmente se tiene maneja una cola de mensajes que se deben enviar, junto con un thread dedicado a tomar mensajes de dicha cola y escribirlos al socket; y por otra parte se tiene un thread dedicado a leer del socket continuamente, poniendo en una cola los mensajes que van llegando (ojo: aquí cuando digo "mensajes" me refiero a tramas de datos que llegan por el socket que vienen delimitadas de alguna forma, pero aun sin parsear).

Pues bien, el problema es peor cuando se programa algun tipo de servidor, es decir, se tiene un ServerSocket aceptando conexiones entrantes y luego se tiene que hacer lo que describí en el párrafo anterior, para cada conexión. De hecho el ciclo de estar aceptando conexiones nuevas debe ir en un thread dedicado. Así que tenemos ese thread, y por cada conexión que llega, tendremos probablemente dos threads. Cuando ya manejamos 50 conexiones, estamos hablando de 101 threads. Es un problema de escalabilidad porque cada thread utiliza recursos: memoria, CPU, etc.

Este es precisamente el problema que java.nio resuelve. En vez de leer y escribir directamente a los streams de un socket, se manejan buffers que se le pasan a los sockets (llamados SocketChannels) y que el socket escribirá en su tiempo. También hay un mecanismo que no es precisamente de notificación pero que permite saber cuando un socket tiene datos en su buffer para que los leamos. Esto, en el ejemplo del servidor, nos permite implementar un solo ciclo donde vamos aceptando conexiones y además vamos leyendo datos de los sockets que ya tenemos abiertos.

Esto va a quedar más claro con un ejemplo. Primero vamos con el tradicional enfoque de java.io (en nombre de la brevedad, me voy a saltar algunos try/catch de IOException y ciertas declaraciones no tan relevantes):

public class Servidor implements Runnable {
  public void run() {
    ServerSocket server = new ServerSocket(puerto); //nos lo definen en otro lado
    while (true) {
      Socket = server.accept();
      Conexion conn = new Conexion(sock);
      new Thread(conn).start(); //arranca la conexion en otro thread
    }
  }
}

public class Conexion implements Runnable {
  private Socket socket;
  private Escritor escritor;
  public void run() {
    escritor = new Escritor(socket.getOutputStream());
    //Corremos el escritor en OTRO thread
    new Thread(escritor).start();
    while (sigue) { //socket abierto, esperando datos, etc
      //Leer del socket
      byte[] buf = new byte[1024];
      socket.read(buf);
      if (mensajeCompleto) {
        escritor.envia(mensaje);
      }
    }
  }
}

public class Escritor implements Runnable {
  OutputStream outs;
  LinkedBlockingQueue<Mensaje> cola = new LinkedBlockingQueue<Mensaje>();

  public void envia(Mensaje msg) {
    cola.add(msg); //solamente agregamos el mensaje a la cola
  }

  public void run() {
    //Este metodo bloquea hasta que haya algo que devolver
    Mensaje resp = cola.take();
    //codificamos mensaje
    byte[] buf = resp.codifica(); //la clase Mensaje me la salto por el momento
    outs.write(buf);
  }

}

public class Main {
  public static void main(String[] args) {
    new Thread(new Servidor()).run();
  }
}

Entonces tenemos que el Servidor corre en un thread, y solamente es un ciclo que va aceptando conexiones. El metodo accept() bloquea el thread hasta que alguien se conecta al puerto donde está escuchando; entonces crea una conexión y la corre en un thread separado. La conexión en su thread crea un objeto Escritor, que corre en otro thread, y luego entra en un ciclo donde lee del socket (el método socket.read() bloquea el thread hasta obtener datos, si no se definió un timeout de lectura en dicho socket). Cuando tiene listo un mensaje completo, simplemente lo pasa al Escritor, donde se encola para que su propio thread lo tome en cuanto haya un mensaje disponible y se escribe al socket.
Y aquí ni siquiera tenemos el paisaje completo; el procesamiento de los datos de entrada debería hacerse en otro thread, precisamente para no interrumpir la lectura de datos y que no vayamos a perder nada; dicho thread (que ya no incluí para no complicar más las cosas) posteriormente es quien debe pasar el mensaje al Escritor.

Bastante complicado, no? sobre todo para un sencillo ejemplo de servidorsito en Java... Ahora vamos a ver la versión usando java.nio:

public class Servidor implements Runnable {
  public void run() {
    ServerSocketChannel server = ServerSocketChannel.open();
    server.configureBlocking(false); //esto es importante
    Selector sel = Selector.open();
    server.register(sel, SelectionKey.OP_ACCEPT); //registramos para recibir aviso cuando lleguen conexiones nuevas
    server.socket().bind(new InetSocketAddress(puerto));

    while (true) {
      //Este metodo bloquea hasta que pase algo
      selector.select();
      //Llegamos aqui cuando ya entra una conexion o hay datos que leer
      for (Iterator<SelectionKey> iter = selector.selectedKeys().iterator(); iter.hasNext();) {
        //Esta llave nos dice que es lo que esta pasando
        SelectionKey skey = iter.next();
        if ((skey.readyOps() & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT) {
          //hay una conexion entrante, hay que aceptarla
          SocketChannel sock = server.accept();
          //Creamos la conexion y la registramos con el selector y el socket.
          //notese que la conexion no sabe nada del socket aqui
          Conexion conn = new Conexion();
          sock.configureBlocking(false);
          sock.register(sel, SelectionKey.OP_READ, conn);
        } else if ((skey.readyOps() & SelectionKey.OP_READ) == SelectionKey.OP_READ) {
          //El socket viene en el evento
          final SocketChannel sock = (SocketChannel)skey.channel();
          //Sacamos la conexion, que viene pegada al evento porque asi la registramos
          final Conexion conn = (Conexion)skey.attachment();
          //este metodo me devuelve true cuando ya se leyo un mensaje completo
          if (conn.readData(sock)) {
             //En la practica esto se podria correr en un thread pool
             procesador.procesa(conn, sock);
          }
        }
        //Hay que quitar el evento del iterador
        iter.remove();
      }
    }
  }
}

public class Conexion { //ya no tiene que ser Runnable
  //Aqui vamos a ir metiendo los datos que vengan del socket
  ByteBuffer buf = ByteBuffer.allocate(100);

  public boolean readData(SocketChannel sock) {
    //Aqui nada mas leemos lo que venga
    sock.read(buf);
    if (mensajeCompleto) { //de alguna manera hay que saber
      return true;
    }
    return false;
  }

  public Mensaje getMensaje() {
    buf.flip();//esto pasa el "cursor" del buffer al principio
    //aqui parseariamos los bytes para crear un objeto Mensaje
    //finalmente lo devolvemos
    return mensaje;
  }

}

public class Procesador {
  //Este es nuestro componente que procesa todas las peticiones

  public void procesa(Conexion conn, SocketChannel sock) {
    Mensaje msg = conn.getMensaje();
    //Aqui procesamos los datos, hacemos todo el trabajo
    //Finalmente necesitamos crear una respuesta
    //Y la tenemos que meter a un ByteBuffer
    ByteBuffer buf = ByteBuffer.allocate(100);
    //le metemos los datos al buffer y luego hay que prepararlo para escritura
    buf.flip();
    //OJO: este metodo no bloquea
    sock.write(buf);
  }

}

public class Main {
  public static void main(String[] args) {
    new Thread(new Servidor()).start();
  }
}

En este caso el código de la clase Servidor creció bastante, pero veamos el manejo de threads: no no podemos salvar de tener un thread dedicado, en este caso para el ciclo principal del Servidor. Sin embargo, el Servidor está haciendo más cosas ahora, todo dentro de ese mismo thread; cuando recibe una conexión nueva, crea un objeto para manejar los datos que van a llegar por dicha conexión (clase Conexion). Pero también, cuando llegan datos por una de las conexiones existentes, el Servidor va a pedir al objeto que la administra, que lea datos de ahí, y si ya se leyó un mensaje completo, se procesa (eso lo hace otro objeto, Procesador) y se escribe la respuesta.
De manera que tenemos un solo thread encargado de aceptar conexiones nuevas, leer datos de las conexiones existentes, e incluso procesar los datos y enviar las respuestas. Esto último no es lo óptimo; sin embargo es muy fácil incluir un thread pool en el servidor para realizar ese paso. Solamente hay que crear algo así en la clase Servidor:

//Esto crea un pool de 10 threads
ExecutorService threadPool = Executors.newFixedThreadPool(10);

y luego dentro del if de lectura de datos completa:

          if (conn.readData(sock)) {
             threadPool.execute(new Runnable(){
               public void run() {
                 procesador.procesa(conn, sock);
               }
             });
          }

Con eso ya el procesamiento se va a hacer dentro del thread pool.

Así que como pueden ver, el uso de java.nio aunque es un paradigma algo distinto de estar leyendo directo de los streams, simplifica el funcionamiento de las aplicaciones y no requiere tener threads dedicados para lectura/escritura de datos.

IMPORTANTE: Esta noticia salió en julio de 2010, donde se hicieron unos benchmarks y resulta que ahora IO es nuevamente más rápido que NIO, al menos en Linux, debido a los cambios en la implementación de threads a nivel sistema operativo; ahora usar java.io con threads dedicados resulta ser de 25% a 35% más rápido que NIO, porque usar threads separados ya es más rápido que los cambios de contexto dentro del mismo thread (que es lo que se hace en el ciclo de java.nio que describo en este post).

Comentarios

Opciones de visualización de comentarios

Seleccione la forma que prefiera para mostrar los comentarios y haga clic en «Guardar las opciones» para activar los cambios.
Imagen de 1a1iux

FileChannel

En el mismo paquete java.nio se incluye la clase FileChannel.

Una de las principales ventajas de hacer uso de un FileChannel es que las operaciones de lectura y escritura se realizarán muy rápido si el sistema operativo las puede optimizar. Se dice que se pueden mover datos directamente de y hacía la caché del sistema de archivos con el uso de estos canales.

A continuación un ejemplo de como se haría la copia de un archivo desde java.

public static void copy(String srcFile, String dstFile) {
    try {
        // Create channel on the source
        FileChannel srcChannel = new FileInputStream(srcFile).getChannel();

        // Create channel on the destination
        FileChannel dstChannel =
                new FileOutputStream(dstFile).getChannel();

         // Copy file contents from source to destination
         dstChannel.transferFrom(srcChannel, 0, srcChannel.size());

         // Close the channels
         srcChannel.close();
         dstChannel.close();
      } catch (Exception e) {
          System.out.println(e);
      }
 }

 

Sale y vale
Byte

Imagen de Nopalin

No pude

Hace tiempo realize un sencillo juego de mesa multiplayer utilizando java.io, el programa quedo funcionando pero todavia hace falta pulir muchas cosas, el proyecto para el que le interese esta en mi blog en este sitio, sin embargo como ya habia leido sobre esto quize implementarlo usando java.nio y simplemente no operó como esperaba o no supe como implementarlo que es lo más seguro jeje, si alguien puede tomar la idea e implementarlo en nio, pus estaria chido.

sobres