Introdução ao Quasar em Kotlin
1. Introdução
Quasar is a Kotlin library that brings some asynchronous concepts to Kotlin in an easier to manage way. Isso inclui threads leves, canais, atores e muito mais.
2. Configurando o Build
To use the most recent version of Quasar, you need to run on a JDK version 11 or newer. Versões mais antigas são compatíveis com JDK 7, para situações em que você ainda não pode atualizar para o Java 11.
Quasar comes with four dependencies that we need, dependendo precisamente de qual funcionalidade você está usando. Ao combiná-los, é essencial que usemos a mesma versão para cada um deles.
-
co.paralleluniverse:quasar-core - O núcleo do Quasar.
-
co.paralleluniverse:quasar-kotlin - extensões Kotlin para Quasar
-
co.paralleluniverse:quasar-actors - Suporte para atores no Quasar. Nós os cobriremos em um artigo futuro.
-
co.paralleluniverse:quasar-reactive-streams - Suporte para fluxos reativos no Quasar. Nós os cobriremos em um artigo futuro.
To work correctly, Quasar needs to perform some bytecode instrumentation. Isso pode ser feito em tempo de execução usando um agente Java ou em tempo de compilação. O agente Java é a abordagem preferida, pois não possui requisitos de construção especiais e pode funcionar com qualquer configuração. No entanto, isso tem o lado negativo, já que o Java suporta apenas um único agente Java de cada vez.
2.1. Executando a partir da linha de comando
Ao executar um aplicativo usando Quasar, especificamos o agente Java usando o sinalizador-javaagent para a JVM. Isso leva o caminho completo para o arquivoquasar-core.jar como parâmetro:
$ java -javaagent:quasar-core.jar -cp quasar-core.jar:quasar-kotlin.jar:application.jar fully.qualified.main.Class
2.2. Executando nosso aplicativo do Maven
Se quisermos,we can also use Maven to add the Java agent.
Podemosaccomplish this with Maven em algumas etapas.
Primeiro, configuramos o plug-in de dependência para gerar uma propriedade apontando para o arquivoquasar-core.jar:
maven-dependency-plugin
3.1.1
getClasspathFilenames
properties
Em seguida, usamos o plug-in Exec para realmente iniciar nosso aplicativo:
org.codehaus.mojo
exec-maven-plugin
1.3.2
target/classes
echo
-javaagent:${co.paralleluniverse:quasar-core:jar}
-classpath
com.example.quasar.QuasarHelloWorldKt
Em seguida, precisamosrun Maven with the correct call para fazer uso disso:
mvn compile dependency:properties exec:exec
Isso garante que o código mais recente seja compilado e que a propriedade que aponta para o nosso agente Java esteja disponível antes de executar o aplicativo.
2.3. Execução de testes de unidade
Seria ótimo obter osame benefit in our unit tests that we get from the Quasar agent.
Podemos configurar o Surefire para usar essa mesma propriedade ao executar os testes:
org.apache.maven.plugins
maven-surefire-plugin
2.22.1
-javaagent:${co.paralleluniverse:quasar-core:jar}
Podemos fazer o mesmo para o Failsafe se o usarmos para nossos testes de integração também.
3. Fibras
The core functionality of Quasar is that of fibers. Eles têm conceito semelhante aos threads, mas servem a um propósito sutilmente diferente. As fibras são significativamente mais leves que os threads - consumindo muito menos memória e tempo de CPU do que os threads padrão exigem.
Fibers are not meant to be a direct replacement for threads. Eles são uma escolha melhor em algumas situações e pior em outras.
Especificamente,they are designed for scenarios where the executing code will spend a lot of time blocking em outras fibras, threads ou processos - por exemplo, esperando por um resultado de um banco de dados.
As fibras são semelhantes, mas não são iguais às linhas verdes. Os threads verdes foram projetados para funcionar da mesma maneira que os threads do SO, mas não são mapeados diretamente nos threads do SO. Isso significa que as linhas verdes são melhor usadas em situações em que estão sempre processando, em oposição às fibras projetadas para serem usadas em situações que normalmente bloqueiam.
Quando necessário,it’s possible to use fibers and threads together para atingir o resultado desejado.
3.1. Fibras de lançamento
We launch fibers in a very similar way to how we’d launch threads. Criamos uma instância da classeFiber<V> que envolve nosso código para execução - na forma deSuspendableRunnable - e então chamamos o métodostart:
class MyRunnable : SuspendableRunnable {
override fun run() {
println("Inside Fiber")
}
}
Fiber(MyRunnable()).start()
O Kotlin nos permite substituir a instânciaSuspendableRunnable por um lambda, se desejarmos:
val fiber = Fiber {
println("Inside Fiber Lambda")
}
fiber.start()
E ainda existe um DSL auxiliar especial que faz tudo isso de uma forma ainda mais simples:
fiber @Suspendable {
println("Inside Fiber DSL")
}
Isso cria a fibra, cria oSuspendableRunnable envolvendo o bloco fornecido e inicia sua execução.
O uso da DSL é muito preferido sobre o lambda, se você quiser fazê-lo no local. Com a opção lambda, podemos passar o lambda como uma variável, se necessário.
3.2. Retornando Valores de Fibras
O uso de aSuspendableRunnable com fibras é o equivalente direto deRunnable com fios. We can also use a SuspensableCallable<V> with fibers, which equates to Callable with threads.
Podemos fazer isso da mesma maneira que acima, com um tipo explícito, um lambda ou usando o DSL:
class MyCallable : SuspendableCallable {
override fun run(): String {
println("Inside Fiber")
return "Hello"
}
}
Fiber(MyCallable()).start()
fiber @Suspendable {
println("Inside Fiber DSL")
"Hello"
}
The use of a SuspendableCallable instead of a SuspendableRunnable means that our fiber now has a generic return type - acima, temosFiber<String> em vez deFiber<Unit>.
Once we’ve got a Fiber<V> in our hands, we can extract the value from it - que é o valor retornado peloSuspendableCallable - usando o métodoget() na fibra:
val pi = fiber @Suspendable {
computePi()
}.get()
O métodoget() funciona da mesma forma que em ajava.util.concurrent.Future - e funciona diretamente em termos de um. Isso significa queit will block until the value is present.
3.3. Esperando nas Fibras
Em outras ocasiões,we might want to wait for a fiber to have finished executing. Isso geralmente é contra o motivo de usarmos o código assíncrono, mas há ocasiões em que precisamos fazer isso.
Da mesma forma que os threads Java,we have a join() method that we can call on a Fiber<V> that will block until it has finished executing:
val fiber = Fiber(Runnable()).start()
fiber.join()
Também podemos fornecer um tempo limite, de modo que, se a fibra demorar mais para terminar do que o esperado, não bloqueemos indefinidamente:
fiber @Suspendable {
TimeUnit.SECONDS.sleep(5)
}.join(2, TimeUnit.SECONDS)
If the fiber does take too long, the join() method will throw a TimeoutException para indicar que isso aconteceu. Também podemos fornecer esses tempos limite para o métodoget() que vimos anteriormente da mesma maneira.
3.4. Agendamento de fibras
Fibers are all run on a scheduler. Especificamente, por algum exemplo deFiberScheduler ou uma subclasse deste. Se um não for especificado, um padrão será usado, que está diretamente disponível comoDefaultFiberScheduler.instance.
Existem várias propriedades do sistema que podemos usar para configurar nosso agendador:
-
co.paralleluniverse.fibers.DefaultFiberPool.parallelism - O número de threads a serem usados.
-
co.paralleluniverse.fibers.DefaultFiberPool.exceptionHandler - o manipulador de exceções a ser usado se uma fibra lançar uma exceção
-
co.paralleluniverse.fibers.DefaultFiberPool.monitor - Os meios para monitorar as fibras
-
co.paralleluniverse.fibers.DefaultFiberPool.detailedFiberInfo - Se o monitor obtém informações detalhadas ou não.
By default, this will be a FiberForkJoinScheduler which runs one thread per CPU core availablee fornece breves informações de monitoramento via JMX.
This is a good choice for most cases, mas ocasionalmente, você pode querer uma escolha diferente. The other standard choice is FiberExecutorScheduler which runs the fibers on a provided Java Executor to run on a thread pool, ou você pode fornecer o seu próprio se necessário - por exemplo, você pode precisar executá-los todos em um thread específico em um cenário AWT ou Swing.
3.5. Métodos Suspendíveis
Quasar works in terms of a concept known as Suspendable Methods. Estes são métodos especialmente marcados que podem ser suspensos e, portanto, executados dentro das fibras.
Typically these methods are any that declare that they throw a SuspendException. No entanto, como isso nem sempre é possível, temos outros casos especiais que podemos usar:
-
Qualquer método que anotamos com a anotação@Suspendable
-
Tudo o que acaba como um método lambda do Java 8 - eles não podem declarar exceções e, portanto, são tratados especialmente
-
Qualquer chamada feita por reflexão, uma vez que estas são calculadas em tempo de execução e não em tempo de compilação
Além disso,it’s not allowed to use a constructor or class initializer as a suspendable method.
We can also not use synchronized blocks along with suspendable methods. Isso significa que não podemos marcar o método em si comosynchronized, não podemos chamar métodossynchronized de dentro dele e não podemos usar blocossynchronized dentro do método.
In the same way that we can’t use synchronized within suspendable methods, they should not be directly blocking the thread of execution in other ways - por exemplo, usandoThread.sleep(). Isso levará a problemas de desempenho e, potencialmente, à instabilidade do sistema.
Qualquer uma dessas ações gerará um erro do agente java Quasar. No caso padrão, veremos a saída para o console indicando o que aconteceu:
WARNING: fiber [email protected]:fiber-10000004[task: [email protected]([email protected]), target: [email protected], scheduler: [email protected]] is blocking a thread (Thread[ForkJoinPool-default-fiber-pool-worker-3,5,main]).
at [email protected]/java.lang.Thread.sleep(Native Method)
at [email protected]/java.lang.Thread.sleep(Thread.java:339)
at [email protected]/java.util.concurrent.TimeUnit.sleep(TimeUnit.java:446)
at app//com.example.quasar.SimpleFiberTest$fiberTimeout$1.invoke(SimpleFiberTest.kt:43)
at app//com.example.quasar.SimpleFiberTest$fiberTimeout$1.invoke(SimpleFiberTest.kt:12)
at app//co.paralleluniverse.kotlin.KotlinKt$fiber$sc$1.invoke(Kotlin.kt:32)
at app//co.paralleluniverse.kotlin.KotlinKt$fiber$sc$1.run(Kotlin.kt:65535)
at app//co.paralleluniverse.fibers.Fiber.run(Fiber.java:1099)
4. Strands
Strands are a concept in Quasar that combines both fibers and threads. Eles nos permitem trocar fios e fibras, conforme necessário, sem que outras partes de nosso aplicativo se importem.
Criamos um Strand envolvendo o thread ou instância de fibra em uma classe Strand, usandoStrand.of():
val thread: Thread = ...
val strandThread = Strand.of(thread)
val fiber: Fiber = ...
val strandFiber = Strand.of(fiber)
Alternativamente,we can get a Strand instance for the currently executing thread or fiber using Strand.currentStrand():
val myFiber = fiber @Suspendable {
// Strand.of(myFiber) == Strand.currentStrand()
}
Uma vez feito isso,we can interact with both using the same API, permitindo-nos interrogar o fio, esperar até que ele termine de executar e assim por diante:
strand.id // Returns the ID of the Fiber or Thread
strand.name // Returns the Name of the Fiber or Thread
strand.priority // Returns the Priority of the Fiber or Thread
strand.isAlive // Returns if the Fiber or Thread is currently alive
strand.isFiber // Returns if the Strand is a Fiber
strand.join() // Block until the Fiber or Thread is completed
strand.get() // Returns the result of the Fiber or Thread
5. Envolvendo chamadas de retorno
Um dos principais usos das fibras é envolver o código assíncrono que usa retornos de chamada para retornar o status ao chamador.
O Quasar fornece uma classe chamadaFiberAsync<T, E> que podemos usar exatamente para este caso. Podemos estendê-lo para fornecer uma API baseada em fibra em vez de uma baseada em retorno de chamada para o mesmo código.
Isso é feito escrevendo uma classe que implementa nossa interface de retorno de chamada, estende o sclassFiberAsync e delega os métodos de retorno à classeFiberAsync para lidar com:
interface PiCallback {
fun success(result: BigDecimal)
fun failure(error: Exception)
}
class PiAsync : PiCallback, FiberAsync() {
override fun success(result: BigDecimal) {
asyncCompleted(result)
}
override fun failure(error: Exception) {
asyncFailed(error)
}
override fun requestAsync() {
computePi(this)
}
}
Agora temos uma classe que podemos usar para calcular nosso resultado, onde podemos tratar isso como se fosse uma chamada simples e não uma API baseada em retorno de chamada:
val result = PiAsync().run()
Isso retornará o valor de sucesso - o valor que passamos paraasyncCompleted() - ou então lançará a exceção de falha - aquele que passamos paraasyncFailed.
When we use this, Quasar will launch a new fiber that is directly tied to the current one and will suspend the current fiber until the result is available. Isso significa que devemos usá-lo de dentro de uma fibra e não de um fio. Isso também significa que a instância deFiberAsync deve ser criada e executada na mesma fibra para funcionar.
Além disso,they are not reusable - não podemos reiniciá-los depois de concluídos.
6. Canais
Quasar introduz o conceito de canais para permitir a passagem de mensagens entre diferentes vertentes. Eles são muito semelhantes aChannels in the Go programming language.
6.1. Criação de canais
Podemos criar canais usando o método estáticoChannels.newChannel.
Channels.newChannel(bufferSize, overflowPolicy, singleProducerOptimized, singleConsumerOptimized);
Portanto, um exemplo que bloqueia quando o buffer está cheio e tem como alvo um único produtor e consumidor seria:
Channels.newChannel(1024, Channels.OverflowPolicy.BLOCK, true, true);
There are also some special methods for creating channels of certain primitive types -newIntChannel,newLongChannel,newFloatChannelenewDoubleChannel. Podemos usá-los se estamos enviando mensagens desses tipos específicos e obtemos um fluxo mais eficiente entre as fibras. Observe quewe can never use these primitive channels from multiple consumers - isso é parte da eficiência que o Quasar dá com eles.
6.2. Usando canais
O objetoChannel resultante implementa duas interfaces diferentes -SendPorteReceivePort.
Podemos usar a interfaceReceivePort dos fios que estão consumindo mensagens:
fiber @Suspendable {
while (true) {
val message = channel.receive()
println("Received: $message")
}
}
We can then use the SendPort interface of the same channel to produce messages que será consumido pelo acima:
channel.send("Hello")
channel.send("World")
Por razões óbvias, não podemos usar ambos da mesma fita, maswe can share the same channel instance between different strands to allow message sharing between the two. Nesse caso, o fio pode ser uma fibra ou um fio.
6.3. Canais de fechamento
No exemplo acima, tivemos uma leitura infinita do loop do canal. Obviamente, isso não é o ideal.
What we should prefer doing is to loop all the while the channel is actively producing messages, and stop when then the channel is finished. Podemos fazer isso usandoclose() para marcar o canal como fechado e a propriedadeisClosed para ver se o canal está fechado:
fiber @Suspendable {
while (!channel.isClosed) {
val message = channel.receive()
println("Received: $message")
}
println("Stopped receiving messages")
}
channel.send("Hello")
channel.send("World")
channel.close()
6.4. Canais de bloqueio
Channels are, by their very nature, blocking concepts. OReceivePort bloqueará até que uma mensagem esteja disponível para processamento, e podemos configurar oSendPort para bloquear até que a mensagem possa ser armazenada em buffer.
Isso aproveita um conceito crucial de fibras - que são suspensas. When any of these blocking actions occur, Quasar will use very lightweight mechanisms to suspend the fiber until it can continue its work, em vez de pesquisar repetidamente o canal. Isso permite que os recursos do sistema sejam usados em outros lugares - para processar outras fibras, por exemplo.
6.5. Aguardando vários canais
Vimos que o Quasar pode bloquear em um único canal até que uma ação possa ser executada. Quasar also offers the ability to wait across multiple channels.
Fazemos isso usando a instruçãoSelector.select. Este conceito pode ser familiar de Go e deJava NIO.
O métodoselect() leva uma coleção de instânciasSelectAction e irá bloquear até que uma destas ações seja realizada:
fiber @Suspendable {
while (!channel1.isClosed && !channel2.isClosed) {
val received = Selector.select(
Selector.receive(channel1),
Selector.receive(channel2)
)
println("Received: $received")
}
}
Acima,we can then have multiple channels written to, and our fiber will read immediately on any of them that have a message available. O seletor consumirá apenas a primeira mensagem disponível, para que nenhuma mensagem seja descartada.
Também podemos usar isso para enviar a vários canais:
fiber @Suspendable {
for (i in 0..10) {
Selector.select(
Selector.send(channel1, "Channel 1: $i"),
Selector.send(channel2, "Channel 2: $i")
)
}
}
Como comreceive, isso será bloqueado até que a primeira ação possa ser realizada e, em seguida, executará essa ação. Isso tem o efeito colateral interessante dethe message will send to exactly one channel, mas o canal para o qual é enviado passa a ser o primeiro a ter espaço de buffer disponível para ele. This allows us to distribute messages across multiple channels com base exatamente na contrapressão das extremidades de recepção desses canais.
6.6. Canais de ticker
A special kind of channel that we can create is the ticker channel. Estes são semelhantes em conceito aos tickers da bolsa de valores - não é importante que o consumidor veja todas as mensagens, já que as mais novas substituem as mais antigas.
These are useful when we have a constant flow of status updates - por exemplo, preços de bolsa de valores ou porcentagem concluída.
We create these as normal channels, but we use the OverflowPolicy.DISPLACE setting. Nesse caso, se o buffer estiver cheio ao produzir uma nova mensagem, a mensagem mais antiga será eliminada silenciosamente para dar espaço a ela.
Só podemos consumir esses canais a partir de um único fio. No entanto,we can create a TickerChannelConsumer to read from this channel across multiple strands:
val channel = Channels.newChannel(3, Channels.OverflowPolicy.DISPLACE)
for (i in 0..10) {
val tickerConsumer = Channels.newTickerConsumerFor(channel)
fiber @Suspendable {
while (!tickerConsumer.isClosed) {
val message = tickerConsumer.receive()
println("Received on $i: $message")
}
println("Stopped receiving messages on $i")
}
}
for (i in 0..50) {
channel.send("Message $i")
}
channel.close()
Every instance of the TickerChannelConsumer will potentially receive all the messages sent to the wrapped channel - permitindo qualquer descartado pela política de estouro.
Sempre receberemos mensagens na ordem correta e podemos consumir cadaTickerChannelConsumer na taxa de que precisamos para trabalhar -one fiber running slowly will not affect any others.
We will also know when the wrapped channel is closed para que possamos parar de ler nossoTickerChannelConsumer. Isso permite que o produtor não se importe com a maneira como os consumidores estão lendo as mensagens, nem com o tipo de canal que está sendo usado.
6.7. Transformações funcionais para canais
Estamos todos acostumados com transformações funcionais em Java, usandostreams. We can apply these same standard transformations on channels - como variações de envio e recebimento.
Essas ações que podem ser aplicadas incluem:
-
filter - Filtre as mensagens que não cabem em um determinado lambda
-
map - Converte mensagens conforme elas fluem pelo canal
-
flatMap - O mesmo que map, mas convertendo uma mensagem em várias mensagens
-
reduce - Aplicar umreduction function a um canal
Por exemplo, podemos converter umReceivePort<String> em um que inverta todas as strings que fluem por ele usando o seguinte:
val transformOnReceive = Channels.map(channel, Function { msg: String? -> msg?.reversed() })
Isso não afetará as mensagens no canal original e elas ainda podem ser consumidas em outro lugar sem ver o efeito dessa transformação.
Como alternativa, podemos converter umSendPort<String> em um que force todas as strings para maiúsculas à medida que as gravamos no canal da seguinte maneira:
val transformOnSend = Channels.mapSend(channel, Function { msg: String? -> msg?.toUpperCase() })
This will affect messages as they are written, and in this case, the wrapped channel will only ever see the transformed message. No entanto, ainda podemos escrever diretamente no canal que está sendo encapsulado para ignorar essa transformação, se necessário.
7. Fluxo de dados
Quasar Core gives us a couple of tools to support reactive programming. Eles não são tão poderosos quanto algo comoRxJava, mas mais do que o suficiente para a maioria dos casos.
Temos acesso a dois conceitos -ValeVar. Val represents a constant value, and Var represents a varying one.
Ambos os tipos são construídos com nenhum valor ou umSuspendableCallable que será usado na fibra para calcular o valor:
val a = Var()
val b = Val()
val c = Var { a.get() + b.get() }
val d = Var { a.get() * b.get() }
// (a*b) - (a+b)
val initialResult = Val { d.get() - c.get() }
val currentResult = Var { d.get() - c.get() }
Inicialmente,initialResultecurrentResult não terão valores, e tentar obter valor deles bloqueará a vertente atual. As soon as we give a and b values, we can read values from both initialResult and currentResult.
Além disso,if we further change a then currentResult will update to reflect this but initialResult won’t:
a.set(2)
b.set(4)
Assert.assertEquals(2, initialResult.get())
Assert.assertEquals(2, currentResult.get())
a.set(3)
Assert.assertEquals(2, initialResult.get()) // Unchanged
Assert.assertEquals(5, currentResult.get()) // New Value
Se tentarmos alterarb, obteremos uma exceção lançada, porquea Val is can only have a single value assigned to it.
8. Conclusão
Este artigo forneceu uma introdução à biblioteca Quasar que podemos usar para programação assíncrona. O que vimos aqui é apenas o básico do que podemos alcançar com o Quasar. Por que não experimentá-lo no próximo projeto?
Exemplos de alguns dos conceitos que cobrimos aquican be found over on GitHub.