Todos os artigos

// Knowledge.log — 技術記事

Dois @Scheduled no Spring Boot 4.1: o segundo espera o primeiro acabar

Boot 4.1.2: o TaskScheduler default tem 1 thread. Dois @Scheduled, um sleep maior que o intervalo, e o segundo método só roda depois.

O cenário é este: uma aplicação tem @EnableScheduling e dois métodos @Scheduled. Um deles faz um trabalho pesado e demora mais que o próprio intervalo. O outro só incrementa um contador a cada 50 ms. Você escreve um teste, espera o método lento começar, olha o contador do rápido e ele está em zero.

Diante disso, quase todo mundo chega a uma de duas conclusões: o agendamento quebrou, ou falta um @Async. Nenhuma das duas está certa. O scheduler está funcionando. Ele só tem uma thread, e essa thread está ocupada dormindo dentro do método lento.

No fim do texto você vai ter uma fixture Maven sem web com três testes. O primeiro mostra os dois métodos serializados em scheduling-1. O segundo mostra o método rápido rodando em scheduling-2 depois de uma mudança de propriedade. O terceiro mede a diferença entre fixedRate e fixedDelay quando o corpo do método dura mais que o período. Também fica claro onde aumentar o pool deixa de ser a resposta.

O que rodou e com quais versões

A fixture rodou em 28/10/2026 com Spring Boot 4.1.2, Temurin 25.0.4 LTS (25.0.4+7-LTS) e Maven 3.9.12, com java.version=25 no POM. A página de requisitos do Spring Boot 4.1 diz que o 4.1.2 exige pelo menos Java 17, é compatível até o Java 27 e precisa do Spring Framework 7.0.10 ou superior. O JDK 25 está dentro da matriz.

O mvn -q test passou com Tests run: 3, Failures: 0, Errors: 0. As classes são PoolSizeOneTest, PoolSizeTwoTest e FixedRateVsFixedDelayTest. Não há servidor web nem Actuator, e o contexto sobe com webEnvironment = NONE.

Se você está vindo do Boot 3.x, a propriedade que aparece aqui já existia lá. O que pega de surpresa é o default, e não alguma API nova.

O default é uma thread

A documentação de task execution and scheduling do Boot 4.1 diz que, na ausência de um scheduler seu, o Boot auto-configura um ThreadPoolTaskScheduler com uma thread. O controle é spring.task.scheduling.pool.size, e as threads recebem o prefixo scheduling-, que pode ser trocado com spring.task.scheduling.thread-name-prefix.

Uma thread só para todos os @Scheduled da aplicação. Não é uma por método e não é uma por bean. Colocar os dois métodos em classes separadas não muda nada, porque os dois continuam sendo disparados pelo mesmo pool.

A fixture

O POM, sem o cabeçalho:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.2</version>
    <relativePath/>
</parent>

<properties>
    <java.version>25</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

A aplicação:

package devdojo.sched;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class SchedApp {

    public static void main(String[] args) {
        SpringApplication.run(SchedApp.class, args);
    }
}

Os dois métodos ficam no mesmo bean, ativado pelo profile slowfast. O slow() tem período de 100 ms e dorme 400 ms. O fast() tem período de 50 ms e não dorme:

package devdojo.sched;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;

import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
@Profile("slowfast")
public class SlowAndFastTasks {

    public final AtomicInteger slowRuns = new AtomicInteger();
    public final AtomicInteger fastRuns = new AtomicInteger();
    public final CountDownLatch slowStarted = new CountDownLatch(1);
    public volatile String slowThread = "<none>";
    public volatile String fastThread = "<none>";
    public final List<String> log = Collections.synchronizedList(new ArrayList<>());
    public final long t0 = System.nanoTime();

    @Scheduled(fixedRate = 100)
    public void slow() {
        long start = System.nanoTime();
        slowThread = Thread.currentThread().getName();
        slowRuns.incrementAndGet();
        slowStarted.countDown();
        log.add("slow-start@" + (start - t0) / 1_000_000 + "ms/" + slowThread);
        sleep(400);
        log.add("slow-end  @" + (System.nanoTime() - t0) / 1_000_000 + "ms/" + Thread.currentThread().getName());
    }

    @Scheduled(fixedRate = 50)
    public void fast() {
        fastThread = Thread.currentThread().getName();
        fastRuns.incrementAndGet();
        log.add("fast     @" + (System.nanoTime() - t0) / 1_000_000 + "ms/" + fastThread);
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
        }
    }
}

O primeiro teste fixa spring.task.scheduling.pool.size=1, que é o próprio default. Fica explícito para que o teste não dependa de alguém lembrar o default. Ele espera o primeiro slow() começar, observa por 1200 ms e imprime o log de eventos:

@SpringBootTest(classes = SchedApp.class,
        webEnvironment = SpringBootTest.WebEnvironment.NONE,
        properties = {
                "spring.task.scheduling.pool.size=1",
                "spring.profiles.active=slowfast"
        })
class PoolSizeOneTest {

    @Autowired
    SlowAndFastTasks tasks;

    @Autowired
    TaskScheduler taskScheduler;

    @Test
    void diagnose() throws Exception {
        ThreadPoolTaskScheduler scheduler = (ThreadPoolTaskScheduler) taskScheduler;
        System.out.println("[pool=1] core=" + scheduler.getScheduledThreadPoolExecutor().getCorePoolSize()
                + " poolSize=" + scheduler.getScheduledThreadPoolExecutor().getPoolSize());
        assertThat(tasks.slowStarted.await(5, TimeUnit.SECONDS)).isTrue();
        Thread.sleep(1200);
        ArrayList<String> snapshot;
        synchronized (tasks.log) {
            snapshot = new ArrayList<>(tasks.log);
        }
        System.out.println("[pool=1] events=" + snapshot.size() + " slowRuns=" + tasks.slowRuns.get()
                + " fastRuns=" + tasks.fastRuns.get() + " slowThread=" + tasks.slowThread
                + " fastThread=" + tasks.fastThread);
        snapshot.forEach(e -> System.out.println("[pool=1]   " + e));
        assertThat(scheduler.getScheduledThreadPoolExecutor().getCorePoolSize()).isEqualTo(1);
    }
}

O que o log mostrou com uma thread

[pool=1] core=1 poolSize=1
[pool=1] events=13 slowRuns=4 fastRuns=6 slowThread=scheduling-1 fastThread=scheduling-1
[pool=1]   slow-start@14ms/scheduling-1
[pool=1]   slow-end  @414ms/scheduling-1
[pool=1]   fast     @415ms/scheduling-1
[pool=1]   fast     @415ms/scheduling-1
[pool=1]   slow-start@415ms/scheduling-1
[pool=1]   slow-end  @815ms/scheduling-1
[pool=1]   fast     @815ms/scheduling-1
[pool=1]   fast     @815ms/scheduling-1
[pool=1]   slow-start@815ms/scheduling-1
[pool=1]   slow-end  @1215ms/scheduling-1
[pool=1]   fast     @1215ms/scheduling-1
[pool=1]   fast     @1215ms/scheduling-1
[pool=1]   slow-start@1215ms/scheduling-1

Esse log desfaz duas leituras erradas.

A primeira é achar que o fast() foi cancelado. Ele não foi, só ficou na fila. Entre 14 ms e 414 ms não existe nenhum evento fast, então um teste que olhasse o contador nessa janela veria fastRuns == 0. É daí que sai o diagnóstico de "o segundo método não dispara". Depois de cada slow-end, o fast() roda duas vezes no mesmo milissegundo e logo devolve a thread para o próximo slow-start, que já está atrasado.

A segunda é achar que os métodos estão "competindo". Na verdade eles se revezam numa thread só, e todos os eventos do log saíram de scheduling-1. Com fixedRate = 50, uma janela de 1,2 s daria umas duas dezenas de execuções do fast(). O log tem seis. O método rápido virou passageiro do lento.

Repare também que o slow() recomeça logo em seguida, em 415, 815 e 1215 ms. Com período de 100 ms e corpo de 400 ms, ele já está sempre atrasado, e a thread passa quase todo o tempo dentro dele. O fast() só entra nas brechas.

Isso não é o problema do @Async chamado pelo this, que está no post sobre @Async e self-invocation. Lá o interceptor não roda. Aqui não há proxy nenhum envolvido: o scheduler chama os dois métodos corretamente e só tem uma thread para isso. Colocar @Async no método lento só tiraria o trabalho do scheduler se você também tivesse @EnableAsync e a chamada passasse pelo proxy. Mesmo assim, o pool de scheduling continuaria com uma thread.

Com pool.size=2, o segundo método roda

O segundo teste usa o mesmo bean e o mesmo profile. Só a propriedade muda, e a observação é de 700 ms depois do primeiro slow():

@SpringBootTest(classes = SchedApp.class,
        webEnvironment = SpringBootTest.WebEnvironment.NONE,
        properties = {
                "spring.task.scheduling.pool.size=2",
                "spring.profiles.active=slowfast"
        })
class PoolSizeTwoTest {

    @Autowired
    SlowAndFastTasks tasks;

    @Autowired
    TaskScheduler taskScheduler;

    @Test
    void secondScheduledMethodRunsWithTwoThreads() throws Exception {
        ThreadPoolTaskScheduler scheduler = (ThreadPoolTaskScheduler) taskScheduler;

        assertThat(scheduler.getScheduledThreadPoolExecutor().getCorePoolSize()).isEqualTo(2);
        assertThat(tasks.slowStarted.await(5, TimeUnit.SECONDS)).isTrue();

        Thread.sleep(700);

        System.out.println("[pool=2] corePoolSize=" + scheduler.getScheduledThreadPoolExecutor().getCorePoolSize()
                + " slowRuns=" + tasks.slowRuns.get()
                + " fastRuns=" + tasks.fastRuns.get()
                + " slowThread=" + tasks.slowThread
                + " fastThread=" + tasks.fastThread);

        assertThat(tasks.fastRuns.get()).isGreaterThanOrEqualTo(5);
        assertThat(tasks.fastThread).isNotEqualTo(tasks.slowThread);
    }
}

Saída:

[pool=2] corePoolSize=2 slowRuns=2 fastRuns=15 slowThread=scheduling-1 fastThread=scheduling-2

Em 700 ms foram 15 execuções do fast(), contra 6 em 1200 ms no cenário anterior. Enquanto o slow() dorme em scheduling-1, o fast() usa scheduling-2. Os campos slowThread e fastThread guardam a última thread que cada método usou. A asserção de que são diferentes prova que, no momento da leitura, os dois não estavam presos à mesma thread.

A propriedade para colar amanhã:

spring.task.scheduling.pool.size=2

Duas threads resolvem este exemplo porque ele tem exatamente um método lento. Se você tiver três métodos lentos, o problema volta, só que com um número maior.

fixedRate e fixedDelay quando o corpo passa do período

Com o pool liberado, vale entender o que cada modo faz quando o corpo do método dura mais que o período. O javadoc de @Scheduled define fixedRate como o período entre os inícios das invocações e fixedDelay como o período entre o fim de uma invocação e o início da próxima.

A fixture coloca os dois lado a lado no profile ratedelay, com corpo de 250 ms, período de 100 ms e pool.size=2 para que um não atrase o outro:

@Component
@Profile("ratedelay")
public class RateVsDelayTasks {

    public final List<Long> rateStarts = Collections.synchronizedList(new ArrayList<>());
    public final List<Long> delayStarts = Collections.synchronizedList(new ArrayList<>());

    @Scheduled(fixedRate = 100)
    public void rate() {
        rateStarts.add(System.nanoTime());
        sleep(250);
    }

    @Scheduled(fixedDelay = 100)
    public void delay() {
        delayStarts.add(System.nanoTime());
        sleep(250);
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
        }
    }
}

O teste dorme 2800 ms, calcula a mediana dos intervalos entre inícios e afirma a diferença:

@SpringBootTest(classes = SchedApp.class,
        webEnvironment = SpringBootTest.WebEnvironment.NONE,
        properties = {
                "spring.task.scheduling.pool.size=2",
                "spring.profiles.active=ratedelay"
        })
class FixedRateVsFixedDelayTest {

    @Autowired
    RateVsDelayTasks tasks;

    @Test
    void fixedRateCatchesUpAndFixedDelayWaitsForTheEnd() throws Exception {
        Thread.sleep(2800);

        List<Long> rateGaps = gapsMillis(tasks.rateStarts);
        List<Long> delayGaps = gapsMillis(tasks.delayStarts);
        long rateMedian = median(rateGaps);
        long delayMedian = median(delayGaps);

        System.out.println("[rate-vs-delay] rateStarts=" + tasks.rateStarts.size()
                + " delayStarts=" + tasks.delayStarts.size()
                + " rateMedianGapMs=" + rateMedian
                + " delayMedianGapMs=" + delayMedian);

        assertThat(rateMedian).isLessThan(300);
        assertThat(delayMedian).isGreaterThan(320);
        assertThat(tasks.rateStarts.size()).isGreaterThanOrEqualTo(tasks.delayStarts.size() + 2);
    }

    // gapsMillis(...) e median(...) são helpers simples: diferenças em ms entre inícios
    // consecutivos e o elemento do meio da lista ordenada.
}

Saída:

[rate-vs-delay] rateStarts=14 delayStarts=10 rateMedianGapMs=250 delayMedianGapMs=350
modoperíodocorpomediana entre iníciosinícios em ~2,8 s
fixedRate = 100100 ms250 ms250 ms14
fixedDelay = 100100 ms250 ms350 ms10

No fixedRate, a próxima execução já está vencida quando o corpo termina, e por isso começa na hora. O intervalo real vira a duração do corpo, 250 ms, e não os 100 ms anotados. fixedRate = 100 declara uma intenção, e o relógio não tem obrigação de cumpri-la. No fixedDelay, a espera de 100 ms começa no fim do corpo, o que dá 250 + 100 = 350 ms.

Na prática:

  • fixedRate serve quando você quer manter a cadência e o corpo cabe com folga no período. Quando ele não cabe, o método passa a ocupar a thread sem pausa, que é exatamente o que o slow() fez com o fast() na seção anterior.
  • fixedDelay serve quando cada execução deve terminar antes de a próxima começar a contar. A cadência fica mais lenta e previsível, e o método devolve a thread ao pool entre uma execução e outra. Num pool compartilhado, é essa pausa que dá espaço para os outros métodos.

Quando o trabalho pesado não deveria estar no scheduler

Aumentar spring.task.scheduling.pool.size é a correção mínima para que um segundo método leve volte a rodar. Não é autorização para estacionar processamento em lote no scheduler. A função da thread de scheduling é disparar no horário. Se ela passa 400 ms dentro de um método, todos os outros @Scheduled da aplicação herdam esse atraso. Com fixedRate, as execuções vencidas ainda se acumulam. Com N threads, você só passou a ter N trabalhos pesados atrelados à latência do agendamento.

O padrão que a DevDojo adotaria: o @Scheduled fica curto e só entrega o trabalho, e o processamento roda num TaskExecutor próprio. Um esboço, que não fez parte da fixture:

@Configuration
class RelatorioConfig {

    @Bean
    ThreadPoolTaskExecutor relatorioExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(1);
        executor.setMaxPoolSize(1);
        executor.setQueueCapacity(1);
        executor.setThreadNamePrefix("relatorio-");
        return executor;
    }
}

@Component
class RelatorioTrigger {

    private final ThreadPoolTaskExecutor relatorioExecutor;
    private final RelatorioService relatorioService;

    RelatorioTrigger(ThreadPoolTaskExecutor relatorioExecutor, RelatorioService relatorioService) {
        this.relatorioExecutor = relatorioExecutor;
        this.relatorioService = relatorioService;
    }

    @Scheduled(fixedDelay = 60_000)
    public void disparar() {
        relatorioExecutor.execute(relatorioService::gerar);
    }
}

O método agendado devolve a thread de scheduling- em microssegundos, e o relatório roda em relatorio-1. Com fila de capacidade 1, um disparo que encontra o executor ocupado e a fila cheia é rejeitado com TaskRejectedException, e a exceção aparece dentro do próprio @Scheduled. Isso é melhor do que a fila crescer sem que ninguém perceba. Antes de declarar um Executor seu no contexto, confira na mesma página de task execution and scheduling como isso afeta o executor que o Boot auto-configura para o resto da aplicação.

Observar em produção

A forma mais barata é o nome da thread. O padrão de log do Boot já imprime a thread entre colchetes. Se o seu log mostra dois @Scheduled diferentes sempre em scheduling-1, o diagnóstico está pronto. Quase ninguém olha essa coluna, e ela costuma ser a única evidência de que você precisa.

Para confirmar o tamanho do pool, faça o que os testes fazem: converta o TaskScheduler injetado para ThreadPoolTaskScheduler e leia getScheduledThreadPoolExecutor().getCorePoolSize(). Registrar esse valor no startup custa uma linha. Se o projeto tiver Actuator, o endpoint scheduledtasks lista as tarefas registradas e seus triggers. A fixture não usou esse endpoint.

Virtual threads mudam as regras

Esta fixture rodou com platform threads. Segundo a documentação do Boot 4.1, com spring.threads.virtual.enabled=true o scheduler auto-configurado passa a ser um SimpleAsyncTaskScheduler, e as propriedades de pool são ignoradas. Nesse caso, spring.task.scheduling.pool.size=2 deixa de valer, e o javadoc de @Scheduled avisa que fixedDelay roda numa única thread de scheduler. Os números deste texto não se aplicam a esse modo, e a fixture não o testou.

Próximo passo

  1. Liste os @Scheduled do projeto (grep -rn "@Scheduled" src/main/java) e anote quanto cada um dura de verdade, não quanto deveria durar.
  2. Se mais de um método divide o pool default, coloque spring.task.scheduling.pool.size no application.properties com um valor consciente e escreva um teste como o PoolSizeTwoTest, que afirma getCorePoolSize() e threads diferentes.
  3. Para todo método cujo corpo passa do período, decida entre trocar fixedRate por fixedDelay e mover o trabalho para um executor próprio. Se ele faz I/O ou lote, mova.
javaspring-boot

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos