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 = TRUE faz 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ão 100. Verificar dez vezes por segundo gasta ciclos à toa; meio segundo é imperceptível para o usuário.
  • tryCatch no get_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
Modelovocê controla o processoa reatividade se integra a promessas
Códigoobserve com invalidateLater%...>% encadeando no output
Progressomanual (arquivo, socket)progressr se integra melhor
Cancelar$kill(), diretomais indireto
Curvamais explícito, mais códigomais 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

  1. 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 callr sã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.

  2. Por que chamar processo$get_result() dentro do observeEvent que disparou a tarefa anula o benefício do callr?

    ✓ 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 num reactiveVal e criar um observe que, enquanto $is_alive() for TRUE, chama invalidateLater(500, session) e retorna; só quando o processo termina é que se chama get_result() — e nesse instante ele retorna imediatamente.

  3. O que supervise = TRUE e session$onSessionEnded previnem, 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 = TRUE age quando o processo pai morre (o app cai, o servidor reinicia o worker), garantindo que os filhos caiam junto. session$onSessionEnded age 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.

  4. 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 com writeLines() a cada iteração; o pai lê o arquivo num renderUI/observe com invalidateLater(500). A corrida a tratar é a leitura acontecer no meio de uma escrita — o arquivo pode estar vazio ou truncado naquele instante, e as.numeric() devolveria NA. Trata-se com tryCatch mais uma verificação de is.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.

  5. Dez usuários disparam ao mesmo tempo um treino que consome 4 GB cada. callr resolve? O que resolve?

    ✓ Resposta:

    Não resolve. O callr melhora 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.

Fontes e leituras recomendadas