Pour autant que j'avais compris ForkJoinPool
, ce pool crée un nombre fixe de threads (par défaut: nombre de cœurs) et ne créera jamais plus de threads (sauf si l'application indique un besoin pour ceux-ci en utilisant managedBlock
).
Cependant, en utilisant ForkJoinPool.getPoolSize()
j'ai découvert que dans un programme qui crée 30 000 tâches (RecursiveAction
), le ForkJoinPool
exécutant ces tâches utilise 700 threads en moyenne (threads comptés chaque fois qu'un est créée). Les tâches ne font pas d'E/S, mais du calcul pur; la seule synchronisation entre tâches appelle ForkJoinTask.join()
et accède à AtomicBoolean
s, c'est-à-dire qu'il n'y a pas d'opérations de blocage de threads.
Étant donné que join()
ne bloque pas le thread appelant tel que je le comprends, il n'y a aucune raison pour qu'un thread du pool soit bloqué, et donc (je l'avais supposé) il ne devrait y avoir aucune raison de créer d'autres threads (ce qui se passe évidemment quand même).
Alors, pourquoi ForkJoinPool
crée-t-il autant de threads? Quels facteurs déterminent le nombre de threads créés?
J'avais espéré que cette question pourrait être répondu sans code de publication, mais ici, il vient sur demande. Ce code est un extrait d'un programme de quatre fois la taille, réduit aux parties essentielles; il ne compile pas tel quel. Si vous le souhaitez, je peux bien sûr également publier le programme complet.
Le programme recherche dans un labyrinthe un chemin d'un point de départ donné à un point final donné en utilisant la recherche en profondeur d'abord. Une solution est garantie d'exister. La logique principale est dans la méthode compute()
de SolverTask
: Un RecursiveAction
qui commence à un point donné et continue avec tous les points voisins accessibles à partir du point actuel. Plutôt que de créer un nouveau SolverTask
à chaque point de branchement (ce qui créerait beaucoup trop de tâches), il pousse tous les voisins sauf un sur une pile de backtracking pour être traité plus tard et continue avec le seul voisin non poussé vers le empiler. Une fois qu'il atteint une impasse de cette façon, le point le plus récemment poussé vers la pile de retour en arrière est sauté, et la recherche se poursuit à partir de là (réduisant en conséquence le chemin construit à partir du point de départ du taks). Une nouvelle tâche est créée une fois qu'une tâche trouve sa pile de retour en arrière supérieure à un certain seuil; à partir de ce moment, la tâche, tout en continuant à sauter de sa pile de retour en arrière jusqu'à ce qu'elle soit épuisée, ne poussera plus de points vers sa pile lorsqu'elle atteindra un point de branchement, mais créera une nouvelle tâche pour chacun de ces points. Ainsi, la taille des tâches peut être ajustée à l'aide du seuil de limite de pile.
Les chiffres que j'ai cités ci-dessus ("30 000 tâches, 700 threads en moyenne") proviennent de la recherche d'un labyrinthe de 5 000 x 5 000 cellules. Voici donc le code essentiel:
class SolverTask extends RecursiveTask<ArrayDeque<Point>> {
// Once the backtrack stack has reached this size, the current task
// will never add another cell to it, but create a new task for each
// newly discovered branch:
private static final int MAX_BACKTRACK_CELLS = 100*1000;
/**
* @return Tries to compute a path through the maze from local start to end
* and returns that (or null if no such path found)
*/
@Override
public ArrayDeque<Point> compute() {
// Is this task still accepting new branches for processing on its own,
// or will it create new tasks to handle those?
boolean stillAcceptingNewBranches = true;
Point current = localStart;
ArrayDeque<Point> pathFromLocalStart = new ArrayDeque<Point>(); // Path from localStart to (including) current
ArrayDeque<PointAndDirection> backtrackStack = new ArrayDeque<PointAndDirection>();
// Used as a stack: Branches not yet taken; solver will backtrack to these branching points later
Direction[] allDirections = Direction.values();
while (!current.equals(end)) {
pathFromLocalStart.addLast(current);
// Collect current's unvisited neighbors in random order:
ArrayDeque<PointAndDirection> neighborsToVisit = new ArrayDeque<PointAndDirection>(allDirections.length);
for (Direction directionToNeighbor: allDirections) {
Point neighbor = current.getNeighbor(directionToNeighbor);
// contains() and hasPassage() are read-only methods and thus need no synchronization
if (maze.contains(neighbor) && maze.hasPassage(current, neighbor) && maze.visit(neighbor))
neighborsToVisit.add(new PointAndDirection(neighbor, directionToNeighbor.opposite));
}
// Process unvisited neighbors
if (neighborsToVisit.size() == 1) {
// Current node is no branch: Continue with that neighbor
current = neighborsToVisit.getFirst().getPoint();
continue;
}
if (neighborsToVisit.size() >= 2) {
// Current node is a branch
if (stillAcceptingNewBranches) {
current = neighborsToVisit.removeLast().getPoint();
// Push all neighbors except one on the backtrack stack for later processing
for(PointAndDirection neighborAndDirection: neighborsToVisit)
backtrackStack.Push(neighborAndDirection);
if (backtrackStack.size() > MAX_BACKTRACK_CELLS)
stillAcceptingNewBranches = false;
// Continue with the one neighbor that was not pushed onto the backtrack stack
continue;
} else {
// Current node is a branch point, but this task does not accept new branches any more:
// Create new task for each neighbor to visit and wait for the end of those tasks
SolverTask[] subTasks = new SolverTask[neighborsToVisit.size()];
int t = 0;
for(PointAndDirection neighborAndDirection: neighborsToVisit) {
SolverTask task = new SolverTask(neighborAndDirection.getPoint(), end, maze);
task.fork();
subTasks[t++] = task;
}
for (SolverTask task: subTasks) {
ArrayDeque<Point> subTaskResult = null;
try {
subTaskResult = task.join();
} catch (CancellationException e) {
// Nothing to do here: Another task has found the solution and cancelled all other tasks
}
catch (Exception e) {
e.printStackTrace();
}
if (subTaskResult != null) { // subtask found solution
pathFromLocalStart.addAll(subTaskResult);
// No need to wait for the other subtasks once a solution has been found
return pathFromLocalStart;
}
} // for subTasks
} // else (not accepting any more branches)
} // if (current node is a branch)
// Current node is dead end or all its neighbors lead to dead ends:
// Continue with a node from the backtracking stack, if any is left:
if (backtrackStack.isEmpty()) {
return null; // No more backtracking avaible: No solution exists => end of this task
}
// Backtrack: Continue with cell saved at latest branching point:
PointAndDirection pd = backtrackStack.pop();
current = pd.getPoint();
Point branchingPoint = current.getNeighbor(pd.getDirectionToBranchingPoint());
// DEBUG System.out.println("Backtracking to " + branchingPoint);
// Remove the dead end from the top of pathSoFar, i.e. all cells after branchingPoint:
while (!pathFromLocalStart.peekLast().equals(branchingPoint)) {
// DEBUG System.out.println(" Going back before " + pathSoFar.peekLast());
pathFromLocalStart.removeLast();
}
// continue while loop with newly popped current
} // while (current ...
if (!current.equals(end)) {
// this task was interrupted by another one that already found the solution
// and should end now therefore:
return null;
} else {
// Found the solution path:
pathFromLocalStart.addLast(current);
return pathFromLocalStart;
}
} // compute()
} // class SolverTask
@SuppressWarnings("serial")
public class ParallelMaze {
// for each cell in the maze: Has the solver visited it yet?
private final AtomicBoolean[][] visited;
/**
* Atomically marks this point as visited unless visited before
* @return whether the point was visited for the first time, i.e. whether it could be marked
*/
boolean visit(Point p) {
return visited[p.getX()][p.getY()].compareAndSet(false, true);
}
public static void main(String[] args) {
ForkJoinPool pool = new ForkJoinPool();
ParallelMaze maze = new ParallelMaze(width, height, new Point(width-1, 0), new Point(0, height-1));
// Start initial task
long startTime = System.currentTimeMillis();
// since SolverTask.compute() expects its starting point already visited,
// must do that explicitly for the global starting point:
maze.visit(maze.start);
maze.solution = pool.invoke(new SolverTask(maze.start, maze.end, maze));
// One solution is enough: Stop all tasks that are still running
pool.shutdownNow();
pool.awaitTermination(Integer.MAX_VALUE, TimeUnit.DAYS);
long endTime = System.currentTimeMillis();
System.out.println("Computed solution of length " + maze.solution.size() + " to maze of size " +
width + "x" + height + " in " + ((float)(endTime - startTime))/1000 + "s.");
}
Il y a des questions connexes sur stackoverflow:
ForkJoinPool se bloque pendant invokeAll/join
ForkJoinPool semble gaspiller un fil
J'ai fait une version dépouillée de ce qui se passe (arguments jvm que j'ai utilisés: -Xms256m -Xmx1024m -Xss8m):
import Java.util.ArrayList;
import Java.util.List;
import Java.util.concurrent.ForkJoinPool;
import Java.util.concurrent.RecursiveAction;
import Java.util.concurrent.RecursiveTask;
import Java.util.concurrent.TimeUnit;
public class Test1 {
private static ForkJoinPool pool = new ForkJoinPool(2);
private static class SomeAction extends RecursiveAction {
private int counter; //recursive counter
private int childrenCount=80;//amount of children to spawn
private int idx; // just for displaying
private SomeAction(int counter, int idx) {
this.counter = counter;
this.idx = idx;
}
@Override
protected void compute() {
System.out.println(
"counter=" + counter + "." + idx +
" activeThreads=" + pool.getActiveThreadCount() +
" runningThreads=" + pool.getRunningThreadCount() +
" poolSize=" + pool.getPoolSize() +
" queuedTasks=" + pool.getQueuedTaskCount() +
" queuedSubmissions=" + pool.getQueuedSubmissionCount() +
" parallelism=" + pool.getParallelism() +
" stealCount=" + pool.getStealCount());
if (counter <= 0) return;
List<SomeAction> list = new ArrayList<>(childrenCount);
for (int i=0;i<childrenCount;i++){
SomeAction next = new SomeAction(counter-1,i);
list.add(next);
next.fork();
}
for (SomeAction action:list){
action.join();
}
}
}
public static void main(String[] args) throws Exception{
pool.invoke(new SomeAction(2,0));
}
}
Apparemment, lorsque vous effectuez une jointure, le thread actuel voit que la tâche requise n'est pas encore terminée et prend une autre tâche pour lui-même.
Cela se passe dans Java.util.concurrent.ForkJoinWorkerThread#joinTask
.
Cependant, cette nouvelle tâche génère plusieurs des mêmes tâches, mais ils ne peuvent pas trouver de threads dans le pool, car les threads sont verrouillés dans la jointure. Et comme il n'a aucun moyen de savoir combien de temps il leur faudra pour être libéré (le thread peut être en boucle infinie ou bloqué pour toujours), de nouveaux threads sont générés (Compenser les threads joints comme Louis Wasserman mentionné): Java.util.concurrent.ForkJoinPool#signalWork
Donc, pour éviter un tel scénario, vous devez éviter le frai récursif des tâches.
Par exemple, si dans le code ci-dessus, vous définissez le paramètre initial sur 1, le nombre de threads actifs sera 2, même si vous multipliez par dix le nombre d'enfants.
Notez également que, tandis que la quantité de threads actifs augmente, la quantité de threads en cours d'exécution est inférieure ou égale à parallélisme .
D'après les commentaires de la source:
Compensation: à moins qu'il y ait déjà suffisamment de threads actifs, la méthode tryPreBlock () peut créer ou réactiver un thread de rechange pour compenser les jointures bloquées jusqu'à ce qu'elles se débloquent.
Je pense que ce qui se passe, c'est que vous ne terminez aucune des tâches très rapidement, et comme il n'y a pas de threads de travail disponibles lorsque vous soumettez une nouvelle tâche, un nouveau thread est créé.
strict, full-strict et terminal-strict ont à voir avec le traitement d'un graphe acyclique dirigé (DAG). Vous pouvez rechercher ces termes sur Google pour les comprendre pleinement. C'est le type de traitement que le framework a été conçu pour traiter. Regardez le code dans l'API pour Recursive ..., le framework s'appuie sur votre code compute () pour faire d'autres liens compute () puis faire un join (). Chaque tâche effectue une jointure unique () tout comme le traitement d'un DAG.
Vous ne faites pas de traitement DAG. Vous bifurquez de nombreuses nouvelles tâches et attendez (join ()) chacune. Lisez le code source. C'est horriblement complexe, mais vous pourrez peut-être le comprendre. Le cadre ne fait pas une bonne gestion des tâches. Où va-t-il placer la tâche en attente lors d'une jointure ()? Il n'y a pas de file d'attente suspendue, ce qui nécessiterait un thread de surveillance pour regarder constamment la file d'attente pour voir ce qui est terminé. C'est pourquoi le framework utilise des "threads de continuation". Lorsqu'une tâche rejoint (), le framework suppose qu'il attend la fin d'une seule tâche inférieure. Lorsque de nombreuses méthodes join () sont présentes, le thread ne peut pas continuer, donc un thread d'assistance ou de continuation doit exister.
Comme indiqué ci-dessus, vous avez besoin d'un processus de jointure en fourche de type diffusion-collecte. Là, vous pouvez créer autant de tâches
Les deux extraits de code postés par Holger Peine et elusive-code ne suivent pas réellement la pratique recommandée qui est apparue dans javadoc pour la version 1.8 :
Dans les utilisations les plus courantes, une paire fork-join agit comme un appel (fork) et retourne (join) à partir d'une fonction récursive parallèle. Comme c'est le cas avec d'autres formes d'appels récursifs, les retours (jointures) doivent être effectués en premier lieu. Par exemple, a.fork (); b.fork (); b.join (); a.join (); est probablement beaucoup plus efficace que la jonction de code a avant le code b .
Dans les deux cas, FJPool a été instancié via le constructeur par défaut. Cela conduit à la construction du pool avec asyncMode = false , qui est par défaut:
@param asyncMode si true,
établit le mode de planification local premier entré, premier sorti pour les tâches bifurquées qui ne sont jamais jointes. Ce mode peut être plus approprié que le mode basé sur la pile localement par défaut dans les applications dans lesquelles les threads de travail traitent uniquement les tâches asynchrones de style événement. Pour la valeur par défaut, utilisez false.
de cette façon, la file d'attente de travail est en fait lifo:
tête -> | t4 | t3 | t2 | t1 | ... | <- queue
Donc, dans les extraits, ils fork () toutes les tâches les poussant sur la pile et que join () dans le même ordre, c'est-à-dire de la tâche la plus profonde (t1) au plus haut (t4) bloquant efficacement jusqu'à ce qu'un autre thread vole (t1), puis (t2) et ainsi de suite. Puisqu'il y a des tâches enouth pour bloquer tous les threads de pool (task_count >> pool.getParallelism ()), la compensation entre en jeu comme Louis Wasserman décrit.
Il est à noter que la sortie du code posté par elusive-code dépend de la version de Java. Exécuter le code dans le Java 8 Je vois la sortie:
...
counter=0.73 activeThreads=45 runningThreads=5 poolSize=49 queuedTasks=105 queuedSubmissions=0 parallelism=2 stealCount=3056
counter=0.75 activeThreads=46 runningThreads=1 poolSize=51 queuedTasks=0 queuedSubmissions=0 parallelism=2 stealCount=3158
counter=0.77 activeThreads=47 runningThreads=3 poolSize=51 queuedTasks=0 queuedSubmissions=0 parallelism=2 stealCount=3157
counter=0.74 activeThreads=45 runningThreads=3 poolSize=51 queuedTasks=5 queuedSubmissions=0 parallelism=2 stealCount=3153
Mais en exécutant le même code dans le Java 11 la sortie est différente:
...
counter=0.75 activeThreads=1 runningThreads=1 poolSize=2 queuedTasks=4 queuedSubmissions=0 parallelism=2 stealCount=0
counter=0.76 activeThreads=1 runningThreads=1 poolSize=2 queuedTasks=3 queuedSubmissions=0 parallelism=2 stealCount=0
counter=0.77 activeThreads=1 runningThreads=1 poolSize=2 queuedTasks=2 queuedSubmissions=0 parallelism=2 stealCount=0
counter=0.78 activeThreads=1 runningThreads=1 poolSize=2 queuedTasks=1 queuedSubmissions=0 parallelism=2 stealCount=0
counter=0.79 activeThreads=1 runningThreads=1 poolSize=2 queuedTasks=0 queuedSubmissions=0 parallelism=2 stealCount=0