J'essaie d'utiliser Akka HTTP pour authentifier de base ma demande. Il se trouve que j'ai une ressource externe pour m'authentifier, donc je dois faire un appel de repos à cette ressource.
Cela prend un certain temps, et pendant le traitement, il semble que le reste de mon API soit bloqué, en attente de cet appel. Je l'ai reproduit avec un exemple très simple:
// used dispatcher:
implicit val system = ActorSystem()
implicit val executor = system.dispatcher
implicit val materializer = ActorMaterializer()
val routes =
(post & entity(as[String])) { e =>
complete {
Future{
Thread.sleep(5000)
e
}
}
} ~
(get & path(Segment)) { r =>
complete {
"get"
}
}
Si je poste sur le point de terminaison du journal, mon point de terminaison get est également bloqué en attendant les 5 secondes, dictées par le point de terminaison du journal.
S'agit-il d'un comportement attendu et, dans l'affirmative, comment puis-je effectuer des opérations de blocage sans bloquer l'intégralité de mon API?
Ce que vous observez est le comportement attendu - mais bien sûr, il est très mauvais. Heureusement que des solutions connues et les meilleures pratiques existent pour s'en prémunir. Dans cette réponse, j'aimerais passer un peu de temps à expliquer le problème court, long, puis en profondeur - bonne lecture!
Réponse courte : " ne bloquez pas l'infrastructure de routage !", utilisez toujours un répartiteur dédié pour bloquer les opérations!
Cause du symptôme observé: Le problème est que vous utilisez context.dispatcher
en tant que répartiteur sur lequel les futures bloquantes s'exécutent. Le même répartiteur (qui n'est en termes simples qu'un "groupe de threads") est utilisé par l'infrastructure de routage pour gérer réellement les demandes entrantes - donc si vous bloquez tous les threads disponibles, vous finissez par affamer l'infrastructure de routage. (Une chose à débattre et à comparer est si Akka HTTP pouvait protéger contre cela, j'ajouterai cela à ma liste de tâches de recherche).
Le blocage doit être traité avec un soin particulier pour ne pas avoir d'impact sur les autres utilisateurs du même répartiteur (c'est pourquoi nous simplifions la séparation de l'exécution sur différents), comme expliqué dans la section Akka docs: Le blocage nécessite une gestion minutieuse .
Quelque chose d'autre que je voulais attirer l'attention ici est que l'on devrait éviter de bloquer les API du tout si possible - si votre longue opération n'est pas vraiment une opération, mais une série de celles-ci, vous auriez pu les séparer ceux-ci sur différents acteurs, ou futurs séquencés. Quoi qu'il en soit, je voulais juste souligner - si possible, éviter de tels appels bloquants, mais si vous le devez -, ce qui suit explique comment les traiter correctement.
Analyse et solutions approfondies :
Maintenant que nous savons ce qui ne va pas, conceptuellement, regardons ce qui est exactement cassé dans le code ci-dessus et à quoi ressemble la bonne solution à ce problème:
Couleur = état du fil:
Examinons maintenant 3 morceaux de code et l'impact des répartiteurs et les performances de l'application. Pour forcer ce comportement, l'application a été placée sous la charge suivante:
1) [bad]
Comportement du répartiteur sur un mauvais code :
// BAD! (due to the blocking in Future):
implicit val defaultDispatcher = system.dispatcher
val routes: Route = post {
complete {
Future { // uses defaultDispatcher
Thread.sleep(5000) // will block on the default dispatcher,
System.currentTimeMillis().toString // starving the routing infra
}
}
}
Nous exposons donc notre application à [une] charge, et vous pouvez déjà voir un certain nombre de threads akka.actor.default-dispatcher - ils traitent les demandes - un petit extrait vert et orange signifiant que les autres y sont en fait inactifs.
Ensuite, nous commençons la charge [b], ce qui provoque le blocage de ces threads - vous pouvez voir un premier thread "default-dispatcher-2,3,4" entrer dans le blocage après avoir été inactif auparavant. Nous observons également que le pool s'agrandit - de nouveaux threads sont lancés "default-dispatcher-18,19,20,21 ..." mais ils s'endorment immédiatement (!) - nous gaspillons de précieuses ressources ici!
Le nombre de ces threads démarrés dépend de la configuration du répartiteur par défaut, mais ne dépassera probablement pas 50 environ. Puisque nous venons de tirer des opérations de blocage 2k, nous affamons tout le pool de threads - les opérations de blocage dominent de telle sorte que le routage infra n'a pas de thread disponible pour gérer les autres requêtes - très mauvais!
Faisons quelque chose (qui est une bonne pratique Akka - isolez toujours le comportement de blocage comme indiqué ci-dessous):
2) [good!]
Comportement du répartiteur bon code structuré/répartiteurs :
Dans votre application.conf
configurez ce répartiteur dédié au comportement de blocage:
my-blocking-dispatcher {
type = Dispatcher
executor = "thread-pool-executor"
thread-pool-executor {
// in Akka previous to 2.4.2:
core-pool-size-min = 16
core-pool-size-max = 16
max-pool-size-min = 16
max-pool-size-max = 16
// or in Akka 2.4.2+
fixed-pool-size = 16
}
throughput = 100
}
Vous devriez en lire plus dans la documentation Akka Dispatchers , pour comprendre les différentes options ici. Le point principal est que nous avons choisi un ThreadPoolExecutor
qui a une limite stricte de threads qu'il garde disponible pour les opérations de blocage. Les paramètres de taille dépendent de ce que fait votre application et du nombre de cœurs de votre serveur.
Ensuite, nous devons l'utiliser, au lieu de celui par défaut:
// GOOD (due to the blocking in Future):
implicit val blockingDispatcher = system.dispatchers.lookup("my-blocking-dispatcher")
val routes: Route = post {
complete {
Future { // uses the good "blocking dispatcher" that we configured,
// instead of the default dispatcher – the blocking is isolated.
Thread.sleep(5000)
System.currentTimeMillis().toString
}
}
}
Nous faisons pression sur l'application en utilisant la même charge, d'abord un peu de requêtes normales, puis nous ajoutons celles qui bloquent. Voici comment les ThreadPools se comporteront dans ce cas:
Donc, au départ, les requêtes normales sont facilement gérées par le répartiteur par défaut, vous pouvez y voir quelques lignes vertes - c'est une exécution réelle (je ne mets pas vraiment le serveur sous une lourde charge, il est donc principalement inactif).
Maintenant, lorsque nous commençons à émettre les opérations de blocage, le my-blocking-dispatcher-*
entre en action et démarre jusqu'au nombre de threads configurés. Il gère tout le sommeil là-dedans. De plus, après une certaine période où rien ne se produit sur ces threads, il les arrête. Si nous devions frapper le serveur avec un autre tas de blocage, le pool démarrerait de nouveaux threads qui prendront soin de sleep () - les, mais en attendant - nous ne gaspillons pas nos précieux threads "restons là et ne fais rien".
Lors de l'utilisation de cette configuration, le débit des demandes GET normales n'a pas été impacté, elles étaient toujours heureusement servies sur le répartiteur par défaut (toujours assez gratuit).
Il s'agit de la méthode recommandée pour traiter tout type de blocage dans les applications réactives. Il est souvent appelé "cloisonnement" (ou "isolement") les parties défectueuses d'une application, dans ce cas, le mauvais comportement est de dormir/bloquer.
3) [workaround-ish]
Comportement du répartiteur lorsque blocking
s'est appliqué correctement :
Dans cet exemple, nous utilisons scaladoc pour scala.concurrent.blocking
méthode qui peut aider en cas d'opérations de blocage. Il provoque généralement plus de threads pour survivre aux opérations de blocage.
// OK, default dispatcher but we'll use `blocking`
implicit val dispatcher = system.dispatcher
val routes: Route = post {
complete {
Future { // uses the default dispatcher (it's a Fork-Join Pool)
blocking { // will cause much more threads to be spun-up, avoiding starvation somewhat,
// but at the cost of exploding the number of threads (which eventually
// may also lead to starvation problems, but on a different layer)
Thread.sleep(5000)
System.currentTimeMillis().toString
}
}
}
}
L'application se comportera comme ceci:
Vous remarquerez que BEAUCOUP de nouveaux threads sont créés, c'est parce que le blocage fait allusion à "oh, ce sera le blocage, donc nous avons besoin de plus de threads". Cela fait que le temps total pendant lequel nous sommes bloqués est plus petit que dans l'exemple 1), mais nous avons ensuite des centaines de threads qui ne font rien après la fin des opérations de blocage ... Bien sûr, ils seront finalement arrêtés (le FJP le fait ), mais pendant un certain temps, nous aurons une grande quantité (non contrôlée) de threads en cours d'exécution, contrairement à la solution 2), où nous savons exactement combien de threads nous consacrons aux comportements de blocage.
Résumé : ne bloquez jamais le répartiteur par défaut :-)
La meilleure pratique consiste à utiliser le modèle indiqué dans 2)
, pour disposer d'un répartiteur pour les opérations de blocage disponibles, et les y exécuter.
J'espère que cela aide, happy hakking!
Discussion sur la version HTTP d'Akka : 2.0.1
Profiler utilisé: Beaucoup de gens m'ont demandé en réponse à cette réponse en privé quel profileur j'ai utilisé pour visualiser les états du fil dans les photos ci-dessus, donc en ajoutant ces informations ici: j'ai utilisé YourKit qui est un profileur commercial génial (gratuit pour OSS), bien que vous puissiez obtenir les mêmes résultats en utilisant le libre VisualVM d'OpenJDK .
Étrange, mais pour moi tout fonctionne bien (pas de blocage). Voici le code:
import akka.actor.ActorSystem
import akka.http.scaladsl.Http
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.stream.ActorMaterializer
import scala.concurrent.Future
object Main {
implicit val system = ActorSystem()
implicit val executor = system.dispatcher
implicit val materializer = ActorMaterializer()
val routes: Route = (post & entity(as[String])) { e =>
complete {
Future {
Thread.sleep(5000)
e
}
}
} ~
(get & path(Segment)) { r =>
complete {
"get"
}
}
def main(args: Array[String]) {
Http().bindAndHandle(routes, "0.0.0.0", 9000).onFailure {
case e =>
system.shutdown()
}
}
}
Vous pouvez également envelopper votre code asynchrone dans la directive onComplete
ou onSuccess
:
onComplete(Future{Thread.sleep(5000)}){e}
onSuccess(Future{Thread.sleep(5000)}){complete(e)}