Escolar Documentos
Profissional Documentos
Cultura Documentos
atores
Nissocapítulo
Nos capítulos anteriores, você aprendeu muitas técnicas para ajudá-lo a escrever
programas mais seguros. A maioria dessas técnicas vem da programação funcio-
nal. Uma dessas técnicas consiste em usar dados imutáveis para evitar mutações
de estado. Programas sem estados mutáveis são mais seguros, confiáveis e fáceis
de projetar e dimensionar.
Imagine que você queira contar quantas vezes uma função é chamada. Em um
aplicativo de thread único, você pode fazer isso adicionando o contador ao argu-
mento da função e retornando o contador incrementado como parte do resultado.
Mas a maioria dos programadores imperativos prefere incrementar o contador
como um efeito colateral. Isso funcionaria perfeitamente porque há apenas um
único thread, portanto, nenhum bloqueio é necessário para impedir o possível
acesso simultâneo. Isso é o mesmo que viver em uma ilha deserta. Se você é o
único habitante, não há necessidade de trancar as portas. Mas em um programa
multithreaded, como incrementar o contador de forma segura, evitando o acesso
concorrente? A resposta geralmente é usar bloqueios ou tornar as operações atô-
micas, ou ambos.
Neste capítulo, você não desenvolverá uma estrutura de ator real e completa.
Criar uma estrutura de ator completa é um trabalho tão grande que você deve
usar uma já existente. Aqui você desenvolverá uma estrutura de ator mínima que
lhe dará a sensação do que uma estrutura de ator traz para a programação
funcional.
Atores se comunicam com outros atores por meio de efeitos, como se essa comu-
nicação fosse a E/S de mensagens. Isso significa que os atores contam com um me-
canismo para serializar as mensagens que recebem. (Aqui, serialização significali-
dando com uma mensagem após a outra. Isso não deve ser confundido com a seri-
alização de objetos.) Devido a esse mecanismo, os atores podem processar mensa-
gens uma por vez sem ter que se preocupar com o acesso simultâneoaos seus re-
cursos internos. Como resultado, um sistema de atores pode ser visto como uma
série de programas funcionais que se comunicam entre si por meio de efeitos.
Cada ator pode ser de thread único, portanto, não há acesso simultâneo aos recur-
sos internos. A simultaneidade é abstraída dentro da estrutura.
13.1.1 Noções básicas sobre mensagens assíncronas
oO modelo de ator permite que as tarefas sejam paralelizadas usando um ator ge-
rente que é responsável por dividir a tarefa em subtarefas e distribuí-las a vários
atores trabalhadores. Cada vez que um ator trabalhador retorna um resultado ao
gerente, ele recebe uma nova subtarefa. Esse modelo oferece uma vantagem so-
bre outros modelos de paralelização, pois nenhum ator trabalhador fica ocioso
até que a lista de subtarefas esteja vazia. A desvantagem é que o ator gerente não
participa da computação. Mas em um aplicativo real, isso geralmente não faz ne-
nhuma diferença perceptível.
Para algumas tarefas, os resultados das subtarefas podem precisar ser reordena-
dos ao serem recebidos. Nesse caso, o ator gerente provavelmente enviará os re-
sultados para um ator específico responsável por esse trabalho. Você verá um
exemplo disso na seção 13.3. Em programas pequenos, o próprio gerenciador
pode cuidar dessa tarefa. Na figura 13.1 , esse ator é chamado de Receptorator.
atorespodem ser sem estado (imutáveis) ou com estado, o que significa que eles
devem mudar de estado de acordo com as mensagens que recebem. Por exemplo,
um ator sincronizador pode receber os resultados de cálculos que devem ser reor-
denados antes de serem usados.
Imagine, por exemplo, que você tenha uma lista de dados que devem passar por
uma computação pesada para fornecer uma lista de resultados. Em suma, este é
um mapeamento. Ele pode ser paralelizado dividindo a lista em várias sublistas e
fornecendo essas sublistas aos agentes de trabalho para processamento. Mas não
há garantia de que os atores trabalhadores terminarão seus trabalhos na mesma
ordem em que esses trabalhos foram dados a eles.
Figura 13.1 O ator principal produz a tarefa principal e a envia ao gerente, que a divide em subtarefas que
são processadas em paralelo por vários atores trabalhadores. Subresultadossão enviados de volta ao gerente,
que os repassa ao destinatário. Depois de agrupar os subresultados, o receptor envia o resultado final para o
ator principal.
DentroNesta seção, você aprenderá como construir uma estrutura de ator mí-
nima, mas totalmente funcional. Ao criar essa estrutura, você aprenderá como
uma estrutura de ator permite o compartilhamento seguro de estado mutável, pa-
ralelização e reserialização fáceis e seguras e arquitetura modular de aplicativos.
No final deste capítulo, você verá algumas coisas gerais que pode fazer com as es-
truturas de ator.
Este componente não é necessário em uma implementação tão pequena, mas im-
plementações mais sérias usam tal componente. Este contexto permite, por exem-
plo, pesquisar atores disponíveis.
a MessageProcessor interfaceserá a interface que você implementará para
qualquer componente que tenha que lidar com uma mensagem recebida.
Outra limitação de sua implementação será em relação aos atores remotos. A mai-
oria dos frameworks de atores permite que atores remotos sejam manipulados de
forma transparente, o que significa que você pode usar atores que estão rodando
em máquinas diferentes sem ter que se preocupar com a comunicação. Isso torna
as estruturas de ator uma maneira ideal de criar aplicativos escaláveis. eu não
vou lidar com issoaspecto neste livro.
Esta função é usada para enviar uma mensagem ao this ator (ou seja, o ator que
detém a função). Isso significa que, para enviar uma mensagem a um ator, você
deve ter uma referência a ele. (Isto é diferente de estruturas de atores reais em
que as mensagens não são enviadas para atores, mas para referências de atores,
proxies ou algum outro substituto. Sem esse aprimoramento, não seria possível
enviar mensagens para atores remotos.) Esta função ( que na verdade é um efeito)
recebe a Result<Actor> como segundo parâmetro. É suposto representar o re-
metente, mas às vezes é definido como ninguém (o resultado vazio) ou para um
ator diferente.
Outras funções são usadas para gerenciar o ciclo de vida do ator para facilitar o
uso de atores, mostradas na Listagem 13.1 . Este código não pretende usar os re-
sultados dos exercícios dos capítulos anteriores, mas sim o fpinkotlin-com-
mon móduloque está disponível no código que acompanha este livro (
https://github.com/pysaumont/fpinkotlin ). Este é basicamente o mesmo código
das soluções dos exercícios, mas com algumas funções adicionais.
interface Ator<T> {
desligamento divertido() ④
objeto complementar {
fun <T> noSender(): Resultado<Ator<T>> = Resultado() ⑥
}
}
⑤ Esta é uma função de conveniência que envia uma mensagem com uma referên-
cia de ator em vez de um Result<Actor>.
interface AtorContext<T> {
@Sincronizado
sobrepor
fun torne-se(comportamento: MessageProcessor<T>) { ③
this.behavior = comportamento
}
@Sincronizado
sobrescrever fun tell(mensagem: T,
remetente: Resultado<Ator<T>>) { ⑥
executor.execute {
tentar {
context.behavior()
.process(mensagem, remetente) ⑦
} catch (e: RejectedExecutionException) {
/*
* Isso provavelmente é normal e significa todas as tarefas pendentes
* foram cancelados porque o ator foi parado.
*/
} catch (e: Exceção) {
lance RuntimeException(e)
}
}
}
}
⑥ A função tell é como um ator recebe uma mensagem. É sincronizado para garantir
que as mensagens sejam processadas uma de cada vez.
Sua estrutura de ator agora está completa, embora, como mencionei antes, este
não seja um código de produção. Este é um exemplo mínimo para mostrar como
uma estrutura de ator podetrabalhar.
Agora que você tem uma estrutura de ator à sua disposição, é hora de aplicá-la a
alguns problemas concretos. Os atores são úteis quando vários threads devem
compartilhar algum estado mutável, como quando um thread produz o resultado
de uma computação e esse resultado deve ser passado para outro thread para
processamento posterior.
Primeirovocê vai implementar o árbitro. Tudo o que você precisa fazer é criar um
ator, implementando sua onReceive função. Nesta função, você exibirá uma
mensagem conforme a listagem a seguir.
Em seguida, você criará os dois jogadores. Como há duas instâncias, você tem
duas opções. A abordagem de objeto é criar Player classecomo mostra a listagem
a seguir.
① A classe é privada porque é definida no nível do pacote. Você também pode defini-
la como uma classe local, dentro da função principal do programa.
② A sequência de som é uma mensagem exibida pelos jogadores quando eles rece-
bem a bola (seja Ping ou Pong).
③ Cada jogador é criado com uma referência ao árbitro para que um jogador possa
devolver a bola ao árbitro quando o jogo terminar. Isso não seria necessário se a
classe Player fosse definida localmente na mesma função do árbitro (por exem-
plo, na main função).
④ Esta é a parte comercial do ator, ou seja, a parte que faz o que o usuário espera
ver.
Se preferir o modo funcional, você pode criar uma função retornando um Ac-
tor conforme mostrado na listagem a seguir.
① A sequência de som é uma mensagem exibida pelos jogadores quando eles rece-
bem a bola (seja Ping ou Pong).
② Cria cada jogador com uma referência ao árbitro para que um jogador possa de-
volver a bola ao árbitro quando o jogo terminar
Como você pode ver, essas duas soluções são quase idênticas, mostrando que os
objetos são de fato funções.
Com a player função criada (ou a Player classe), você pode finalizar seu pro-
grama. Mas você precisa de uma maneira de manter o aplicativo em execução até
que o jogo termine. Sem isso, o thread principal do aplicativo é encerrado assim
que o jogo é iniciado e os jogadores não terão a oportunidade de jogar. Isso pode
ser feito através do uso de um semáforo, conforme mostrado na listagem a seguir.
val jogador1 =
jogador("Jogador1", "Ping", árbitro) ③
val jogador2 = jogador("Jogador2", "Pong", árbitro)
semaphore.acquire() ④
player1.tell(1, Resultado(player2))
semaphore.acquire() ⑤
// thread principal termina ⑥
}
③ Se você preferir definir uma Player classe, a única diferença será maiúscula
Player em vez de player .
⑤ A thread principal tenta adquirir uma nova permissão. Como não há nenhum dis-
ponível, ele bloqueia até que o semáforo seja liberado.
⑥ Ao retomar, o thread principal termina. Todos os encadeamentos de atores são da-
emons, então eles também param automaticamente.
Ping - 1
Pong - 2
Ping - 3
Pong - 4
Ping - 5
Pong - 6
Ping - 7
Pong - 8
Ping - 9
Pong - 10
Jogo terminou após 10 tiros
Como você pode ver, este ator é apátrida. Ele calcula o resultado e o envia de volta
ao remetente ao qual recebeu uma referência. Isso pode ser um ator diferente do
chamador.
iniciar {
val splitLists = list.splitAt(this.workers) ⑦
this.inicial =
splitLists.first.zipWithPosition() ⑧
this.workList = splitLists.second ⑨
this.resultList = List() ⑩
③ A lista inicial é uma lista de um par de inteiros, contendo tanto o número a proces-
sar (.first) quanto a posição na lista (.second).
④ A workList é a lista de tarefas restantes a serem executadas uma vez que todos os
atores trabalhadores tenham recebido sua primeira tarefa.
⑧ A lista de tarefas iniciais (números para os quais o valor de Fibonacci será calcu-
lado) é compactada com a posição de seus elementos. A posição (números de 0 a n
) são usados apenas para nomear os atores trabalhadores de 0 a n .
⑨ A workList é definida para as tarefas restantes.
Como você pode ver, se o cálculo for concluído, o resultado será adicionado à lista
de resultados e enviado ao cliente. Caso contrário, o resultado é adicionado à lista
de resultados atual. Na programação tradicional, isso seria feito alterando a lista
de resultados que seria mantida pelo arquivo Manager . Isso é exatamente o que
acontece aqui, exceto por duas diferenças:
① O Behavior é construído com a workList (da qual a cabeça foi removida antes de
chamar o construtor) e a resultList (à qual um resultado foi adicionado).
. . .
começo divertido() {
onReceive(0, self()) ①
sequence(initial.map { this.initWorker(it) })
.forEach(onSuccess = { this.initWorkers(it) },
onFailure = ②
{ this.tellClientEmptyResult(
it.message ?: "Erro desconhecido") })
}
privado
fun tellClientEmptyResult(string: String) { ⑤
client.tell(Result.failure("$string causada por lista de entrada vazia."))
}
. . .
}
① Para iniciar, o Gerente envia uma mensagem para si mesmo. Qual é a mensagem
não faz diferença, porque o comportamento ainda não foi inicializado.
③ Esta função cria uma função do tipo () -> Unidade criando um ator trabalhador.
import com.fpinkotlin.common.List
import com.fpinkotlin.common.Result
import com.fpinkotlin.common.range
importar java.util.concurrent.Semaphore
① Um semáforo é criado para permitir que o thread principal espere que os atores
concluam seu trabalho.
Você pode executar este programa com vários comprimentos para a lista de tare-
fas e vários números de agentes de trabalho. Na minha caixa Linux de oito nú-
cleos, a execução com uma duração de tarefa de 20.000 fornece os seguintes
resultados:
Esses números não são precisos, mas mostram que usar um número de threads
maior que o número de núcleos disponíveis é inútil. O resultado exibido pelo pro-
grama é o seguinte (somente os primeiros 11 resultados são exibidos):
Entrada: [5, 23, 4, 2, 25, 28, 16, 1, 34, 9, 22, ..., NIL]
Resultado: [8, 5, 2, 1597, 46368, 121393, 2, 55, 28657, 1, 2, ..., NIL]
Tempo total: 12558
Comovocê deve ter notado, o resultado não está correto. Isso é óbvio quando se
olha para o segundo valor aleatório (23) e para o resultado correspondente (5).
Você também pode comparar os seguintes valores e resultados. Se você executar o
programa em seu computador, obterá resultados diferentes para cada execução.
O que está acontecendo aqui é que nem todas as tarefas levam o mesmo tempo
para serem executadas. Eu deliberadamente defino a computação para ser execu-
tada dessa maneira, para que algumas tarefas (cálculos para valores de argu-
mento baixos) retornem rapidamente, enquanto outras (cálculos para valores
mais altos) demorem muito mais. Como resultado, os valores retornados não es-
tão na ordem correta.
Para corrigir esse problema, você precisa classificar os resultados na mesma or-
dem dos argumentos correspondentes. Uma solução é usar o Heap tipo de dados
desenvolvido no capítulo 11. Você pode numerar cada tarefa e usar esse número
como a prioridade em uma fila de prioridade.
A primeira coisa que você precisa mudar é o tipo dos atores trabalhadores. Em
vez de trabalhar com inteiros, eles precisarão trabalhar com tuplas de inteiros:
um inteiro representando o argumento da computação e outro representando o
número da tarefa. A listagem a seguir mostra as alterações correspondentes na
Worker classe.
O número da tarefa é o segundo elemento da tupla. Isso não é fácil de ler, visto
que o número da tarefa e o argumento da computação são do mesmo tipo ( Int ).
Na vida real, isso não deveria acontecer porque você deveria usar um tipo especí-
fico para a tarefa. Mas se preferir, você também pode usar um tipo específico ao
invés de Pair para agrupar a tarefa e o número da tarefa, como um Task tipo
com uma propriedade de número.
iniciar {
val splitLists = list.zipWithPosition().splitAt(this.workers)
this.initial = splitLists.first
this.workList = splitLists.second
this.resultHeap = Heap(Comparator {
p1: Pair<Int, Int>, p2: Pair<Int, Int> ->
p1.second.compareTo(p2.second)
})
O workList agora contém pares (como era o caso da initial lista no exemplo
anterior) e o resultado é uma fila de prioridade ( Heap ) de pares. Este Heap é ini-
cializado com um Comparator baseado na comparação do segundo elemento dos
pares.
Usar um Task tipo que envolva a tarefa e o número da tarefa permitiria que você
criasse esse tipo Comparable , de modo que a Comparator seria inútil. (Deixo
essa otimização como um exercício para você.) O managerFunction também é
diferente:
② Após a conclusão do cálculo, o Heap é convertido em uma lista antes de ser devol-
vido ao cliente.
A Behavior classe interna deve ser alterada para refletir a alteração do tipo de
ator:
sobrepor
fun process(mensagem: Pair<Int, Int>,
remetente: Resultado<Ator<Par<Int, Int>>>) { ④
managerFunction(this@Manager)(this@Behavior)(message)
sender.forEach(onSuccess = { a: Actor<Pair<Int, Int>> ->
workList.headSafe()
.forEach({ a.tell(it, self()) }) { a.shutdown() }
})
}
}
começo divertido() {
onReceive(Pair(0, 0), self()) ①
sequence(initial.map { this.initWorker(it) })
.forEach({ this.initWorkers(it) },
{ this.tellClientEmptyResult(
it.message ?: "Erro desconhecido") })
}
Apesarfunciona bem, este exemplo está longe de ser ideal. O principal motivo é
que todos os resultados são colocados na fila de prioridade resultante para que se-
jam classificados. Como eu disse no capítulo 11, este não é o caso de uso correto
para uma fila de prioridade.
Uma fila de prioridade é projetada para colocar elementos que devem ser proces-
sados em uma determinada ordem (de acordo com sua prioridade). Os elementos
devem ser consumidos à medida que são produzidos, garantindo que a fila nunca
retenha mais do que alguns elementos por vez. No presente caso, os elementos de-
vem ser armazenados apenas enquanto existirem elementos de maior prioridade
que ainda não foram processados. Este não é o único caso de uso de uma fila de
prioridade, mas é perfeito.
Para ver o problema na prática, tente substituir a slowFibonnacci funçãona
Worker classecom um eficiente, como
Você pode notar uma coisa interessante aqui: quando as tarefas são curtas, o be-
nefício de ter vários atores simultâneos é muito menor. Normalmente, há um ga-
nho ao passar de um para dois atores. Colocar mais atores não traz mais desem-
penho. Pode não ser óbvio porque esses números dependem do computador
usado, mas são bastante lentos. Como comparação, você pode usar o código a se-
guir, reutilizando o testList do WorkersExample programa:
Isso é executado em 700 ms, o que é cerca de 30 vezes mais rápido que a versão
de ator usando 2, 4 ou 8 atores. Uma razão para isso é o gargalo causado pelo
Heap . os Heap dadosestrutura não se destina à classificação. Ele fornece bom de-
sempenho desde que o número de elementos seja mantido baixo, mas aqui esta-
mos inserindo todos os 200.000 resultados na pilha, classificando o conjunto de
dados completo em cada inserção. Isso não é eficiente.
Mas, às vezes, o melhor caminho a seguir é usar o trabalho certo para a ferra-
menta. O fato de você estar consumindo o resultado de forma síncrona pode não
ser um requisito. Se não for, você está adicionando um requisito implícito que
torna o problema mais difícil de resolver. Uma possibilidade seria passar os resul-
tados individualmente para o cliente. Dessa forma, o Heap seria usado apenas
quando os resultados estiverem fora de ordem, evitando que fique muito grande.
Esse tipo de uso é como uma fila de prioridade deve ser usada. Para levar isso em
consideração, você pode adicionar um Receiver atorao seu programa. O Recei-
ver ator é mostrado na listagem a seguir.
Listagem 13.14 O Receiver ator responsável por receber os resultados de forma
assíncrona
import com.fpinkotlin.common.List
import com.fpinkotlin.common.Result
private val receiverFunction: (Receptor) -> (Comportamento) -> (Int) -> Unidade
iniciar {
receiverFunction = { receiver -> ③
{ comportamento ->
{ eu ->
se (i == -1) {
this.client.tell(behavior.resultList.reverse())
desligar()
} outro {
receptor.contexto ④
.become(Behavior(behavior.resultList.cons(i)))
}
}
}
}
}
② A classe Receiver é um ator parametrizado pelo tipo de dado que pretende rece-
ber: Int.
③ A função Receiver recebe um Int. Se for –1, significando que a computação está
completa, ele envia o resultado para seu cliente e se desliga.
o Worker atoré exatamente igual ao exemplo anterior. Isso nos deixa com a Ma-
nager classesegurando as mudanças mais importantes. A primeira mudança é
que o Manager terá um cliente do tipo Actor<Int> e acompanhará o compri-
mento da lista de tarefas:
gerente de classe(
id: String, lista: List<Int>,
cliente val privado: Ator<Int>, ①
trabalhadores val privados: Int): AbstractActor<Pair<Int, Int>>(id) {
...
começo divertido() {
onReceive(Pair(0, 0), self())
sequence(initial.map { this.initWorker(it) })
.forEach({ this.initWorkers(it) },
{client.tell(-1)})
}
Com essas modificações, o aplicativo é muito mais rápido. Por exemplo, nas mes-
mas condições do exemplo anterior, aqui estão os tempos necessários para pro-
cessar 1.000.000 números com um, dois, quatro e oito atores trabalhadores:
1 ator: 40567 ms
2 atores: 12251 ms
4 atores: 11055 ms
8 atores: 11043 ms
Obviamente, esse processo não é tão rápido quanto mapear a fibonacci função
para a lista de números. Mas lembre-se de que você mudou para a versão rápida
da função. Paralelizar tarefas não ajuda com tarefas curtas. Mas se você voltar
para a versão lenta da função, os resultados serão bem diferentes (usando uma
lista de 200.000 números):
Agora você pode ver que a paralelização usando atores pode aumentar drastica-
mente as performances assim que as tarefas a serem executadas em paralelo fo-
rem duradouras. Este foi apenas um exemplo para mostrar como os atores podem
ser usados. A solução desse tipo de problema é muito mais fácil por outros meios,
como a paralelização automática de listas (como você viu no capítulo 8).
O principal uso de atores não é para paralelização, mas para a abstração de com-
partilhar um estado mutável. Nesses exemplos, você usou listas compartilhadas
entre tarefas. Sem atores, você teria que sincronizar o acesso ao workList e re-
sultHeap paralidar com a simultaneidade. Os atores permitem que você abstraia
a sincronização e a mutação na estrutura.
Sua estrutura de ator é mínima e não se destina a ser usada em nenhum código
sério. Para tais usos com Kotlin, você pode usar uma das estruturas de ator dispo-
níveis para Java, particularmente Akka. Embora o Akka seja escrito em Scala, ele
também pode ser usado em programas Kotlin. Ao usar o Akka, você nunca verá
uma linha de código Scala, a menos que queira. Para saber mais sobre atores, e
Akka em particular, consulte o livro de Raymond Roestenburg, Rob Bakker e Rob
Williams, Akka em ação (Manning, 2016).
Resumo