Existe um limite que nenhuma técnica de reatividade contorna: numa sessão Shiny, o R é single-threaded. Enquanto uma operação roda, aquela sessão está bloqueada — nenhum outro output atualiza, nenhum botão responde, e a barra de progresso que você programou com tanto capricho fica congelada porque não há linha de execução sobrando para desenhá-la.
Para operações de dois ou três segundos isso é tolerável. Para um modelo que leva um minuto, é inaceitável — e num app com vários usuários, se o processo do R é compartilhado, o travamento de um pode virar travamento de todos.
O callr resolve isso da maneira mais direta possível: ele roda o código em outro processo R. A sessão que atende o usuário continua livre, apenas verificando de tempos em tempos se o trabalho terminou.
Executar trabalho pesado fora da sessão Shiny com callr, acompanhar o andamento sem bloquear a interface, cancelar uma tarefa em curso, e saber quando essa abordagem é preferível às alternativas (future/promises, fila externa).
O modelo mental: outro processo, não outra thread
O callr inicia um processo R separado, com sua própria memória. Isso tem três consequências que determinam como o código precisa ser escrito:
- Nada é compartilhado automaticamente. O processo filho não enxerga suas variáveis, seus pacotes carregados nem sua conexão de banco. Tudo que ele precisa tem que ser passado explicitamente.
- Só dados atravessam. Os argumentos e o retorno são serializados. Objetos que não sobrevivem à serialização — conexões de banco, ponteiros de C++, ambientes com referência ao estado da sessão — não podem atravessar.
- Custo de inicialização. Subir um processo R leva algo entre 0,3 e 1 segundo. Para tarefas curtas, o custo supera o ganho.
Essa última é o critério de decisão: use callr quando a tarefa dura muito mais que o tempo de subir o processo.
O básico: r_bg()
library(callr)
processo <- r_bg(
func = function(n, semente) {
set.seed(semente)
# este ambiente é limpo: carregue o que precisar aqui dentro
library(dplyr)
dados <- data.frame(x = rnorm(n), g = sample(letters[1:5], n, TRUE))
dados |> group_by(g) |> summarise(media = mean(x), .groups = "drop")
},
args = list(n = 5e6, semente = 42)
)
processo$is_alive() # TRUE enquanto roda
resultado <- processo$get_result() # bloqueia até terminar — cuidado no Shiny
Note o library(dplyr) dentro da função. É o erro número um com callr: o processo filho começa do zero, e um pacote carregado na sua sessão não está carregado lá.
E note que get_result() bloqueia. Chamá-lo direto num observeEvent anula todo o propósito — seria o mesmo que rodar na sessão.
Integrando ao Shiny sem bloquear
O padrão correto é: dispara, guarda o processo, e verifica periodicamente com invalidateLater().
server <- function(input, output, session) {
tarefa <- reactiveVal(NULL) # o objeto do processo
resultado <- reactiveVal(NULL)
rodando <- reactiveVal(FALSE)
observeEvent(input$rodar, {
req(!rodando())
rodando(TRUE)
resultado(NULL)
tarefa(callr::r_bg(
func = function(parametros) {
library(dplyr)
treinar_modelo(parametros) # a função pesada
},
args = list(parametros = input$parametros),
supervise = TRUE # morre junto com o processo pai
))
})
# o coração do padrão: enquanto roda, reavalia a cada meio segundo
observe({
req(rodando(), tarefa())
if (tarefa()$is_alive()) {
invalidateLater(500, session)
return()
}
rodando(FALSE)
saida <- tryCatch(tarefa()$get_result(),
error = function(e) {
showNotification(paste("Falhou:", conditionMessage(e)),
type = "error", duration = NULL)
NULL
})
resultado(saida)
})
output$status <- renderText(if (rodando()) "Processando…" else "Pronto")
}
Três pontos que a experiência ensina:
supervise = TRUEfaz o processo filho morrer junto com o pai. Sem isso, fechar o app pode deixar processos R órfãos consumindo CPU no servidor — e eles se acumulam.invalidateLater(500), não100. Verificar dez vezes por segundo gasta ciclos à toa; meio segundo é imperceptível para o usuário.tryCatchnoget_result(). Se o código do filho deu erro,get_result()relança a exceção na sessão. Sem tratamento, derruba o app inteiro no momento em que o usuário menos espera.
Progresso real vindo do processo filho
Um spinner diz "estou trabalhando". Melhor é dizer quanto falta — e para isso o filho precisa se comunicar. O caminho mais simples e robusto é um arquivo temporário:
observeEvent(input$rodar, {
arquivo_progresso <- tempfile(fileext = ".txt")
caminho_progresso(arquivo_progresso)
tarefa(callr::r_bg(
func = function(itens, arquivo) {
total <- length(itens)
for (i in seq_along(itens)) {
processar(itens[[i]])
writeLines(as.character(i / total), arquivo)
}
"concluido"
},
args = list(itens = lista_de_itens, arquivo = arquivo_progresso),
supervise = TRUE
))
})
output$barra <- renderUI({
req(rodando(), caminho_progresso())
invalidateLater(500, session)
fracao <- tryCatch(as.numeric(readLines(caminho_progresso(), warn = FALSE)[1]),
error = function(e) 0)
req(!is.na(fracao))
div(class = "progress",
div(class = "progress-bar", role = "progressbar",
style = sprintf("width: %.0f%%", 100 * fracao),
sprintf("%.0f%%", 100 * fracao)))
})
Arquivo é deselegante, mas é simples, funciona entre processos e não exige infraestrutura. O tryCatch cobre a corrida óbvia: o leitor pode chegar no instante em que o escritor está reescrevendo o arquivo.
Cancelar
Uma operação longa que não pode ser cancelada é uma operação que o usuário vai cancelar recarregando a página — e aí você tem um processo órfão rodando sem ninguém para receber o resultado.
observeEvent(input$cancelar, {
req(tarefa())
if (tarefa()$is_alive()) tarefa()$kill()
rodando(FALSE)
showNotification("Operação cancelada.", type = "warning")
})
# e no fim da sessão, por garantia
session$onSessionEnded(function() {
p <- isolate(tarefa())
if (!is.null(p) && p$is_alive()) p$kill()
})
O onSessionEnded é o que separa um app de laboratório de um app de produção. Sem ele, cada usuário que fecha a aba no meio de um processamento deixa para trás um processo R vivo, consumindo memória e CPU do servidor até o reinício.
callr ou future/promises?
As duas abordagens resolvem o mesmo problema por caminhos diferentes:
callr |
future + promises |
|
|---|---|---|
| Modelo | você controla o processo | a reatividade se integra a promessas |
| Código | observe com invalidateLater | %...>% encadeando no output |
| Progresso | manual (arquivo, socket) | progressr se integra melhor |
| Cancelar | $kill(), direto | mais indireto |
| Curva | mais explícito, mais código | mais elegante, mais sutilezas |
future/promises produz um código mais limpo e se integra melhor ao fluxo reativo. callr dá controle explícito — você tem o objeto do processo na mão, mata quando quiser, inspeciona o que quiser. Para tarefas que o usuário precisa poder cancelar, o controle direto costuma compensar a verbosidade.
Vale registrar o limite comum às duas: elas melhoram a responsividade, não multiplicam a capacidade. Se dez usuários disparam simultaneamente um treino que consome 4 GB, não há biblioteca que resolva — o gargalo é a máquina. A partir daí a resposta é arquitetural: uma fila de tarefas com trabalhadores dedicados, fora do Shiny, com o app apenas enfileirando e consultando o estado.
Quando não usar
Três casos em que callr piora as coisas:
- Tarefa curta. Abaixo de uns 2 segundos, o tempo de subir o processo domina.
- Objeto que não serializa. Conexões de banco, ponteiros externos, ambientes com referência à sessão. Passe os parâmetros e abra a conexão dentro do filho.
- Dado enorme de entrada ou saída. Serializar 3 GB para ida e volta pode custar mais que o processamento. Nesse caso, passe um caminho de arquivo e deixe o filho ler e escrever em disco.
Numa sessão Shiny o R é single-threaded, então trabalho pesado bloqueia a sessão inteira — e o callr resolve isso rodando esse trabalho em outro processo R. O processo filho começa do zero: o library() vai dentro da função, e nada da sua sessão está disponível lá. Só dados serializáveis atravessam a fronteira, o que exclui conexão de banco — passe os parâmetros e conecte do outro lado. Nunca chame $get_result() direto, porque ele bloqueia; o padrão é observe com invalidateLater(500) verificando $is_alive(). Para higiene de processos, supervise = TRUE evita órfãos e session$onSessionEnded com $kill() fecha o ciclo quando o usuário abandona a página. Envolva o get_result() num tryCatch: erro no filho é relançado no pai e derruba o app. Para progresso real, o filho escreve a fração num arquivo temporário e o pai lê a cada meio segundo. Entre as opções, future com promises é mais elegante, enquanto o callr dá controle explícito e cancelamento direto — mas nenhum dos dois multiplica a capacidade da máquina, e para isso é preciso fila com trabalhadores dedicados. E não use nada disso para tarefas curtas, objetos não serializáveis ou volumes enormes de entrada e saída.
Exercícios
-
Um colega passou uma conexão DBI como argumento para
r_bg()e recebeu um erro de ponteiro inválido. Explique e corrija.✓ Resposta:Uma conexão de banco é um ponteiro para um recurso do sistema operacional (um socket, um handle de driver), válido apenas dentro do processo que a abriu. Os argumentos do
callrsão serializados e reconstruídos no processo filho, e um ponteiro serializado vira um endereço sem sentido no outro processo. A correção é passar os parâmetros de conexão, não a conexão, e abrir dentro da função:r_bg(function(cfg) { con <- DBI::dbConnect(RMariaDB::MariaDB(), host = cfg$host, dbname = cfg$db, user = cfg$user, password = cfg$pass) on.exit(DBI::dbDisconnect(con)) DBI::dbGetQuery(con, "SELECT ...") }, args = list(cfg = config))O mesmo raciocínio vale para qualquer ponteiro externo: handles de arquivo, objetos de pacotes com backend em C++ e ambientes que referenciem a sessão Shiny.
-
Por que chamar
processo$get_result()dentro doobserveEventque disparou a tarefa anula o benefício docallr?✓ Resposta:Porque
get_result()bloqueia até o processo filho terminar. A sessão Shiny fica parada esperando exatamente como ficaria se o cálculo rodasse nela — nenhum output atualiza, nenhum botão responde. O único efeito líquido é ter pago o custo extra de subir um processo R para obter o mesmo travamento. O padrão correto é guardar o objeto do processo numreactiveVale criar umobserveque, enquanto$is_alive()forTRUE, chamainvalidateLater(500, session)e retorna; só quando o processo termina é que se chamaget_result()— e nesse instante ele retorna imediatamente. -
O que
supervise = TRUEesession$onSessionEndedprevinem, e por que os dois são necessários?✓ Resposta:Ambos previnem processos R órfãos — filhos que continuam rodando sem ninguém para receber o resultado, consumindo memória e CPU do servidor até um reinício. Eles cobrem situações diferentes:
supervise = TRUEage quando o processo pai morre (o app cai, o servidor reinicia o worker), garantindo que os filhos caiam junto.session$onSessionEndedage quando o usuário abandona a sessão — fecha a aba, perde a conexão — enquanto o processo pai continua vivo e feliz; sem ele, nada mataria aquele filho. Num app de produção com muitos usuários, a ausência do segundo é a que se acumula mais rápido. -
Descreva uma forma de o processo filho reportar progresso ao pai e a condição de corrida a tratar.
✓ Resposta:A forma mais simples é um arquivo temporário: o pai cria o caminho com
tempfile()e o passa como argumento; o filho escreve a fração concluída comwriteLines()a cada iteração; o pai lê o arquivo numrenderUI/observecominvalidateLater(500). A corrida a tratar é a leitura acontecer no meio de uma escrita — o arquivo pode estar vazio ou truncado naquele instante, eas.numeric()devolveriaNA. Trata-se comtryCatchmais uma verificação deis.na(), tratando a falha como "mantém o último valor conhecido". Como a leitura se repete meio segundo depois, uma falha ocasional é invisível ao usuário. -
Dez usuários disparam ao mesmo tempo um treino que consome 4 GB cada.
callrresolve? O que resolve?✓ Resposta:Não resolve. O
callrmelhora a responsividade — a interface de cada usuário continua funcionando durante o processamento —, mas não cria capacidade: dez processos de 4 GB exigem 40 GB de RAM, e a máquina vai para swap ou o sistema começa a matar processos. O problema aqui é de arquitetura, não de biblioteca. A resposta é tirar o processamento do app: uma fila de tarefas (Redis, um banco, um broker) com um número fixo de trabalhadores dedicados, dimensionado para a máquina. O Shiny apenas enfileira a solicitação e consulta o estado; a fila regula a concorrência, e o usuário recebe uma posição na fila em vez de um servidor derrubado.