Consejo de Java: cuándo usar ForkJoinPool vs ExecutorService

La biblioteca Fork / Join introducida en Java 7 amplía el paquete de concurrencia de Java existente con soporte para paralelismo de hardware, una característica clave de los sistemas multinúcleo. En esta sugerencia de Java, Madalin Ilie demuestra el impacto en el rendimiento de reemplazar la ExecutorServiceclase Java 6 con Java 7 ForkJoinPoolen una aplicación de rastreo web.

Los rastreadores web, también conocidos como arañas web, son clave para el éxito de los motores de búsqueda. Estos programas escanean constantemente la web, recopilan millones de páginas de datos y los envían a las bases de datos de los motores de búsqueda. Luego, los datos se indexan y procesan algorítmicamente, lo que da como resultado resultados de búsqueda más rápidos y precisos. Si bien se utilizan principalmente para la optimización de la búsqueda, los rastreadores web también se pueden utilizar para tareas automatizadas como la validación de enlaces o la búsqueda y devolución de datos específicos (como direcciones de correo electrónico) en una colección de páginas web.

Arquitectónicamente, la mayoría de los rastreadores web son programas multiproceso de alto rendimiento, aunque con una funcionalidad y requisitos relativamente simples. Por lo tanto, la construcción de un rastreador web es una forma interesante de practicar, así como de comparar técnicas de programación simultáneas o multiproceso.

¡El regreso de Java Tips!

Los Java Tips son artículos breves basados ​​en código que invitan a los lectores de JavaWorld a compartir sus habilidades y descubrimientos en programación. Háganos saber si tiene un consejo para compartir con la comunidad JavaWorld. Consulte también el archivo de consejos de Java para obtener más consejos de programación de sus compañeros.

En este artículo, analizaré dos enfoques para escribir un rastreador web: uno usando Java 6 ExecutorService y el otro Java 7, ForkJoinPool. Para seguir los ejemplos, deberá tener (al momento de escribir este artículo) Java 7 update 2 instalado en su entorno de desarrollo, así como la biblioteca de terceros HtmlParser.

Dos enfoques de la concurrencia de Java

La ExecutorServiceclase es parte de la java.util.concurrentrevolución introducida en Java 5 (y parte de Java 6, por supuesto), que simplificó el manejo de subprocesos en la plataforma Java. ExecutorServicees un ejecutor que proporciona métodos para gestionar el seguimiento del progreso y la terminación de tareas asincrónicas. Antes de la introducción de java.util.concurrent, los desarrolladores de Java confiaban en bibliotecas de terceros o escribían sus propias clases para administrar la concurrencia en sus programas.

Fork / Join, introducido en Java 7, no pretende reemplazar ni competir con las clases de utilidad de concurrencia existentes; en su lugar, las actualiza y las completa. Fork / Join aborda la necesidad de dividir y conquistar, o procesamiento de tareas recursivo en programas Java (ver Recursos).

La lógica de Fork / Join es muy simple: (1) separe (bifurque) cada tarea grande en tareas más pequeñas; (2) procesar cada tarea en un hilo separado (separándolas en tareas aún más pequeñas si es necesario); (3) une los resultados.

Las dos implementaciones de rastreadores web que siguen son programas simples que demuestran las características y la funcionalidad de Java 6 ExecutorServicey Java 7 ForkJoinPool.

Creación y evaluación comparativa del rastreador web

La tarea de nuestro rastreador web será buscar y seguir enlaces. Su propósito podría ser la validación de enlaces o podría ser la recopilación de datos. (Por ejemplo, podría indicarle al programa que busque en la web imágenes de Angelina Jolie o Brad Pitt).

La arquitectura de la aplicación consta de lo siguiente:

  1. Una interfaz que expone operaciones básicas para interactuar con enlaces; es decir, obtener el número de enlaces visitados, agregar nuevos enlaces para ser visitados en la cola, marcar un enlace como visitado
  2. Una implementación para esta interfaz que también será el punto de partida de la aplicación
  3. Un hilo / acción recursiva que mantendrá la lógica empresarial para comprobar si ya se ha visitado un enlace. De lo contrario, recopilará todos los enlaces en la página correspondiente, creará un nuevo hilo / tarea recursiva y lo enviará al ExecutorServiceoForkJoinPool
  4. An ExecutorServiceo ForkJoinPoolpara manejar tareas en espera

Tenga en cuenta que un enlace se considera "visitado" después de que se hayan devuelto todos los enlaces de la página correspondiente.

Además de comparar la facilidad de desarrollo con las herramientas de concurrencia disponibles en Java 6 y Java 7, compararemos el rendimiento de la aplicación en función de dos puntos de referencia:

  • Cobertura de búsqueda : mide el tiempo necesario para visitar 1500 enlaces distintos
  • Potencia de procesamiento : mide el tiempo en segundos necesario para visitar 3000 enlaces no distintos ; esto es como medir cuántos kilobits por segundo procesa su conexión a Internet.

Si bien son relativamente simples, estos puntos de referencia proporcionarán al menos una pequeña ventana sobre el rendimiento de la concurrencia de Java en Java 6 frente a Java 7 para ciertos requisitos de la aplicación.

Un rastreador web Java 6 creado con ExecutorService

Para la implementación del rastreador web Java 6 usaremos un grupo de subprocesos fijos de 64 subprocesos, que creamos llamando al Executors.newFixedThreadPool(int)método de fábrica. El Listado 1 muestra la implementación de la clase principal.

Listado 1. Construyendo un WebCrawler

package insidecoding.webcrawler; import java.util.Collection; import java.util.Collections; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import insidecoding.webcrawler.net.LinkFinder; import java.util.HashSet; /** * * @author Madalin Ilie */ public class WebCrawler6 implements LinkHandler { private final Collection visitedLinks = Collections.synchronizedSet(new HashSet()); // private final Collection visitedLinks = Collections.synchronizedList(new ArrayList()); private String url; private ExecutorService execService; public WebCrawler6(String startingURL, int maxThreads) { this.url = startingURL; execService = Executors.newFixedThreadPool(maxThreads); } @Override public void queueLink(String link) throws Exception { startNewThread(link); } @Override public int size() { return visitedLinks.size(); } @Override public void addVisited(String s) { visitedLinks.add(s); } @Override public boolean visited(String s) { return visitedLinks.contains(s); } private void startNewThread(String link) throws Exception { execService.execute(new LinkFinder(link, this)); } private void startCrawling() throws Exception { startNewThread(this.url); } /** * @param args the command line arguments */ public static void main(String[] args) throws Exception { new WebCrawler("//www.javaworld.com", 64).startCrawling(); } }

En el WebCrawler6constructor anterior , creamos un grupo de subprocesos de tamaño fijo de 64 subprocesos. Luego iniciamos el programa llamando al startCrawlingmétodo, que crea el primer hilo y lo envía al archivo ExecutorService.

A continuación, creamos una LinkHandlerinterfaz que expone métodos auxiliares para interactuar con las URL. Los requisitos son los siguientes: (1) marcar una URL como visitada utilizando el addVisited()método; (2) obtenga el número de URL visitadas a través del size()método; (3) determinar si ya se ha visitado una URL utilizando el visited()método; y (4) agregue una nueva URL en la cola a través del queueLink()método.

Listado 2. La interfaz LinkHandler

package insidecoding.webcrawler; /** * * @author Madalin Ilie */ public interface LinkHandler { /** * Places the link in the queue * @param link * @throws Exception */ void queueLink(String link) throws Exception; /** * Returns the number of visited links * @return */ int size(); /** * Checks if the link was already visited * @param link * @return */ boolean visited(String link); /** * Marks this link as visited * @param link */ void addVisited(String link); }

Ahora, a medida que rastreamos páginas, necesitamos iniciar el resto de los hilos, lo que hacemos a través de la LinkFinderinterfaz, como se muestra en el Listado 3. Observe la linkHandler.queueLink(l)línea.

Listado 3. LinkFinder

package insidecoding.webcrawler.net; import java.net.URL; import org.htmlparser.Parser; import org.htmlparser.filters.NodeClassFilter; import org.htmlparser.tags.LinkTag; import org.htmlparser.util.NodeList; import insidecoding.webcrawler.LinkHandler; /** * * @author Madalin Ilie */ public class LinkFinder implements Runnable { private String url; private LinkHandler linkHandler; /** * Used fot statistics */ private static final long t0 = System.nanoTime(); public LinkFinder(String url, LinkHandler handler) { this.url = url; this.linkHandler = handler; } @Override public void run() { getSimpleLinks(url); } private void getSimpleLinks(String url) { //if not already visited if (!linkHandler.visited(url)) { try { URL uriLink = new URL(url); Parser parser = new Parser(uriLink.openConnection()); NodeList list = parser.extractAllNodesThatMatch(new NodeClassFilter(LinkTag.class)); List urls = new ArrayList(); for (int i = 0; i < list.size(); i++) { LinkTag extracted = (LinkTag) list.elementAt(i); if (!extracted.getLink().isEmpty() && !linkHandler.visited(extracted.getLink())) { urls.add(extracted.getLink()); } } //we visited this url linkHandler.addVisited(url); if (linkHandler.size() == 1500) { System.out.println("Time to visit 1500 distinct links = " + (System.nanoTime() - t0)); } for (String l : urls) { linkHandler.queueLink(l); } } catch (Exception e) { //ignore all errors for now } } } }

La lógica del LinkFinderes simple: (1) comenzamos a analizar una URL; (2) después de recopilar todos los enlaces dentro de la página correspondiente, marcamos la página como visitada; y (3) enviamos cada enlace encontrado a una cola llamando al queueLink()método. Este método creará un nuevo hilo y lo enviará al archivo ExecutorService. Si hay subprocesos "libres" disponibles en el grupo, el subproceso se ejecutará; de lo contrario, se colocará en una cola de espera. Después de llegar a 1.500 enlaces distintos visitados, imprimimos las estadísticas y el programa continúa ejecutándose.

Un rastreador web Java 7 con ForkJoinPool

El marco Fork / Join introducido en Java 7 es en realidad una implementación del algoritmo Divide and Conquer (ver Recursos), en el que una central ForkJoinPoolejecuta bifurcaciones ForkJoinTask. Para este ejemplo usaremos un ForkJoinPool"respaldado" por 64 hilos. Digo respaldado porque los ForkJoinTasks son más ligeros que los hilos. En Fork / Join, una gran cantidad de tareas se pueden alojar en una menor cantidad de subprocesos.

De manera similar a la implementación de Java 6, comenzamos instanciando en el WebCrawler7constructor un ForkJoinPoolobjeto respaldado por 64 subprocesos.

Listado 4. Implementación de Java 7 LinkHandler

package insidecoding.webcrawler7; import java.util.Collection; import java.util.Collections; import java.util.concurrent.ForkJoinPool; import insidecoding.webcrawler7.net.LinkFinderAction; import java.util.HashSet; /** * * @author Madalin Ilie */ public class WebCrawler7 implements LinkHandler { private final Collection visitedLinks = Collections.synchronizedSet(new HashSet()); // private final Collection visitedLinks = Collections.synchronizedList(new ArrayList()); private String url; private ForkJoinPool mainPool; public WebCrawler7(String startingURL, int maxThreads) { this.url = startingURL; mainPool = new ForkJoinPool(maxThreads); } private void startCrawling() { mainPool.invoke(new LinkFinderAction(this.url, this)); } @Override public int size() { return visitedLinks.size(); } @Override public void addVisited(String s) { visitedLinks.add(s); } @Override public boolean visited(String s) { return visitedLinks.contains(s); } /** * @param args the command line arguments */ public static void main(String[] args) throws Exception { new WebCrawler7("//www.javaworld.com", 64).startCrawling(); } }

Tenga en cuenta que la LinkHandlerinterfaz del Listado 4 es casi la misma que la implementación de Java 6 del Listado 2. Solo falta el queueLink()método. Los métodos más importantes a considerar son el constructor y el startCrawling()método. En el constructor, creamos un nuevo ForkJoinPoolrespaldado por 64 hilos. (Elegí 64 subprocesos en lugar de 50 o algún otro número redondo porque en el ForkJoinPoolJavadoc establece que el número de subprocesos debe ser una potencia de dos). El grupo invoca un nuevo LinkFinderAction, que invocará más de forma recursiva ForkJoinTasks. El Listado 5 muestra la LinkFinderActionclase: