记录一次生产问题:当线程池打满,CallerRunsPolicy这个策略导致主调线程ThreadLocal变量丢失

用户信息丢失的事故总结

复现事故场景

微服务架构,用户在ThreadLocal变量中存储,每次请求进服务的时候都需要将传递过来的用户放进ThreadLocal变量,某些请求的步骤较多,导致耗时很长。
为了加快响应速度,将某些步骤并发执行。于是创建了一个线程池,参数给了核心5,最大20,队列100,拒绝策略用了自带的CallerRunsPolicy(即调用者自己执行任务)。

那么线程池如何解决ThreadLocal透传的问题呢,这里用了一个wrap包装,自己实现的,很简单:

public static Runnable wrap(Runnable r, String user) {
    return () -> {
        setUser(user);
        try {
            callable.run();
        } finally {
            setUser(null);
        }
    };
}

业务日志发现某些请求在跨越线程之后,用户信息丢失了。
同时发现另一个重要线索:用户丢失的时候线程池的使用频率很高。
于是很自然的想到是不是因为线程池被打满了,让主调线程自己执行任务的时候导致了用户信息丢失。正常情况下,任务在线程池中执行完毕了就释放用户信息,释放的是子线程的啊,是不会释放到主调线程的用户的啊。但如果主调线程亲自执行任务,会不会也释放了主掉线程的用户信息呢?
为了验证这个问题,我写了一段代码来验证:

public class Test {

    @Test
    public void test_拒绝策略() throws InterruptedException {
        AtomicInteger count = new AtomicInteger();
        ThreadPoolExecutorWithUser executor = new ThreadPoolExecutorWithUser(
                1, 2, // 1个核心线程,2个总线程数
                1, TimeUnit.HOURS,
                new LinkedBlockingQueue<>(2), // 队列长度为2
                r -> new Thread(r, "t-" + count.getAndIncrement()),
                // 拒绝策略:JDK自带的调用者自己执行任务的策略
                new ThreadPoolExecutor.CallerRunsPolicy()
        );

        setUser("张三"); // 在提交给线程池之前,先在主调线程中设置user信息
        for (int i = 0; i < 7; i++) {
            int taskId = i;
            executor.submit(() -> {
                try {
                    // 睡眠,是为了模拟真是业务场景,否则线程执行太快,还没提交完任务呢,前面提交的任务就结束了,这样不能打满线程池
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                String user = getUser(); // 获取线程本地中的用户信息
                String name = Thread.currentThread().getName();
                System.out.printf("taskId: '%s', thread: '%s', user: '%s'\n", taskId, name, user);
                if (name.startsWith("t-") == false) {
                    // 识别线程池的名称,如果不是线程池的线程执行的任务,则打印出来
                    System.err.printf("taskId: '%s', 我是拒绝策略执行的\n", taskId);
                }
                if (null == user) {
                    // 当用户信息为null的时候,打印出来
                    System.err.printf("taskId: '%s', thread: '%s' is null\n", taskId, name);
                }
            });
        }
        System.out.println(executor);
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);
        System.out.println(executor);
        String user = getUser();
        System.out.println("main user = " + user);
        System.out.println("done. ");
    }

    public static class ThreadPoolExecutorWithUser extends ThreadPoolExecutor {

        public ThreadPoolExecutorWithUser(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) {
            super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler);
        }

        @Override
        public void execute(Runnable command) {
            super.execute(wrap(command, getUser()));
        }
    }

    public static Runnable wrap(Runnable r, String user) {
        return () -> {
            setUser(user);
            try {
                r.run();
            } finally {
                setUser(null);
            }
        };
    }

	// 线程本地变量,用来储存用户信息,每个线程有独立的存储空间
    private static ThreadLocal<String> tlUser = new ThreadLocal<>();
    private static void setUser(String user) {        tlUser.set(user);    }
    private static String getUser() {        return tlUser.get();    }

}

输出结果:

taskId: '0', thread: 't-0', user: '张三'
taskId: '4', thread: 'main', user: '张三'
taskId: '3', thread: 't-1', user: '张三'
taskId: '4', 我是拒绝策略执行的 				-- 红色
[Running, pool size = 2, active threads = 2, queued tasks = 1, completed tasks = 2]
taskId: '2', thread: 't-1', user: '张三'
taskId: '1', thread: 't-0', user: '张三'
taskId: '5', thread: 't-1', user: 'null'
taskId: '5', thread: 't-1' is null 				-- 红色
[Terminated, pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 5]
user = null
done. 

线程池的容量为2,队列的容量为2,也就是说线程池最大能够同时吃下的任务数量是线程池容量2+队列容量2 = 4个任务。
但是for循环给了6个任务,为了防止任务执行太快在任务里面加了睡眠100ms,以保证可以打满线程池。

  • 0号任务,由于线程池是空的,所以分配一个核心线程去执行,于是0号任务给了0号线程
  • 1号任务,由于线程池的核心线程数已经满了,所以加入队列等待,从结果来看1号任务在中后部的位置出被执行
  • 2号任务,由于核心线程满了,但队列还有一个位置,所以同样加入队列等待,此时队列满了
  • 3号任务,由于核心和队列都已满了,但还未达到线程池的最大数量,于是开启了一个新的线程(1号线程)来执行任务3。从结果来看任务3是在任务0之后就被执行了
  • 4号任务,由于核心线程数、等待队列已满,并且线程池活动的线程数量也达到了上限(2个),所以走线程池的拒绝策略,拒绝策略的逻辑如下:
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    	if (!e.isShutdown()) {
        	r.run();
        }
    }
    
    看源码其实就是判断线程池未关闭就直接调用run方法,也就是主调线程自己执行任务。
    所以4号任务的线程名称是“main”,而不是t-1 这样的线程池名称。
    由于main线程都亲自上战场执行任务了,所以for循环也被阻塞了,直到4号任务执行完毕后,main线程才能继续执行for循环。但是,就在4号任务睡眠了100ms醒来即将结束任务之际,由于这个任务被包装过,在原始任务执行完毕后,就进入了包装任务的finally代码块,在这里将执行代码:setUser(null); 。这句话将在main线程中将用户信息设置为null。于是下一个任务就拿不到用户信息了。
  • 5号任务,等到4号任务执行结束后,main线程才将流程恢复for循环,于是到了5号任务提交到线程池,此时发现线程池有空余资源来处理了,应该放入了队列,因为此时两个线程正在执行此前放入队列的1号和2号任务,这两个任务此刻应该还在睡眠中,但等到1号和2号任务都处理完毕了,就会将5号任务开始执行。由于5号任务是在main线程清空了user信息之后才提交的,所以在线程池里也没有用户信息,可以看打打印了用户为null

以上就是6个任务的执行过程
另外,我还多打印了一些变量,两次打印了线程池的状态信息,第一次是刚提交完任务的时候:

[Running, pool size = 2, active threads = 2, queued tasks = 1, completed tasks = 2]

可以看到线程池的线程数量是2,已经最大了,计划线程数量也是2,而队列数量是1,为什么队列没有满,别着急,看看已完成任务数是2,加起来已经5个任务了,再加上main线程还执行了一个任务,总共是6个任务,齐了。所以队列没有满,并不是什么问题,而是因为曾经满过,但被线程消费了一个任务,所以还剩余1个任务。此时线程池的状态是运行状态

等到关闭线程池后,再次打印线程池的状态信息:

[Terminated, pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 5]

首先可以看到线程池的状态是Terminated,表示线程池已经终止了,已完成任务从上一次的2变为5个,从这里也能证明线程池总共只执行了5个任务,确实有一个任务是被main线程执行了。

在往下面,还打印了user,这是main线程提交完所有的任务后,再次查看main线程的用户信息,结果发现被清空了,这就是生产环境出现的场景。原本的设计是让主掉线程的用户信息能够透传进线程池的,结果没想到一行finally块的清理代码却引发了这么大个生产事故,罪过罪过!

但还是要自我鼓励一下,毕竟一个高逼格的程序员就是在不停的犯错和解决问题中茁壮成长的。发现问题就已经解决了一半的问题了,剩下的就是写一个应对策略

自定义拒绝策略

自己实现接口RejectedExecutionHandler,在内部直接调用任务的run方法。不同的是,在调用run方法的前后增加一个读取user和设置user的动作:

public static class CallerRunsWithUser implements RejectedExecutionHandler {

    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        if (!executor.isShutdown()) {
            // 调用执行之前先保留线程本地变量,因为run方法之后会清理线程本地变量
            String oldUser = getUser();
            try {
                r.run();
            } finally {
                // 将执行任务之前的变量存回去
                setUser(oldUser);
            }
        }
    }
}

将这个自定义的拒绝策略带入之前的代码中:

AtomicInteger count = new AtomicInteger();
ThreadPoolExecutorWithUser executor = new ThreadPoolExecutorWithUser(
        1, 2,
        1, TimeUnit.HOURS,
        new LinkedBlockingQueue<>(2),
        r -> new Thread(r, "t-" + count.getAndIncrement()),
//        new ThreadPoolExecutor.CallerRunsPolicy()
        new CallerRunsWithUser()
);

运行结果如下:

taskId: '3', thread: 't-1', user: '张三'
taskId: '4', thread: 'main', user: '张三'
taskId: '4', 我是拒绝策略执行的 			-- 红色
[Running, pool size = 2, active threads = 2, queued tasks = 2, completed tasks = 1]
taskId: '0', thread: 't-0', user: '张三'
taskId: '1', thread: 't-1', user: '张三'
taskId: '2', thread: 't-0', user: '张三'
taskId: '5', thread: 't-1', user: '张三'
[Terminated, pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 5]
main user = 张三
done. 

同样的任务数量,同样的线程数量,同样的队列容量,但不同的拒绝策略。从结果可以看到,4号任务已经是被拒绝策略执行的,是用err流打印,在console是红色,但在这里就丢失了颜色信息,我在后面追加了红色的备注。

与上次执行不同的是,5号任务中的用户信息不再是null了,并且提交完所有任务后的main线程中的用户信息也不再是null了。

至此,成功修复一个bug,下班!


你有更多多线程的问题欢迎撩我。

<think>嗯,用户想了解线程池的拒绝策略CallerRunsPolicy。首先,我得回忆一下线程池的基本概念,然后重点放在拒绝策略上。线程池在处理任务时,如果队列了并且线程数达到最大值,这时候就需要拒绝策略来决定如何处理新提交的任务。 CallerRunsPolicy是其中一种策略,它的作用是什么呢?我记得当线程池无法处理任务时,这个策略会让调用者线程来执行任务。比如,如果是主线程提交的任务,当线程池了,主线程就会自己去执行这个任务。这样可能会降低任务提交的速度,因为主线程在忙着执行任务,而不是继续提交新的任务,从而避免资源耗尽。 接下来需要确认这个策略的应用场景。比如,当系统不能丢弃任务,但又希望减缓任务提交的速度时,使用CallerRunsPolicy比较合适。不过要注意,如果任务提交速度一直很高,调用者线程可能会被长时间占用,影响整体性能。 用户可能需要知道这个策略与其他策略的区别,比如AbortPolicy直接抛出异常,DiscardPolicy静默丢弃,DiscardOldestPolicy丢弃队列最旧的任务。而CallerRunsPolicy则让调用者自己执行,这样既不会丢失任务,又能起到反馈调节的作用。 还要考虑如何在实际代码中设置这个策略。比如,通过ThreadPoolExecutor的构造函数或者setRejectedExecutionHandler方法。给出一个简单的示例代码可能会有帮助,让用户更直观地理解。 另外,需要提醒用户使用这个策略时要注意的问题,比如可能导致线程阻塞或性能下降,特别是在高负载情况下,调用者线程如果处理任务时间过长,可能会影响整个应用的响应速度。 总结一下,回答的结构应该包括:线程池拒绝策略的作用,CallerRunsPolicy的定义,具体行为,适用场景,与其他策略的对比,代码示例,以及注意事项。确保内容清晰,层次分明,帮助用户全面理解这个策略的优缺点和使用方法。</think>**线程池拒绝策略 CallerRunsPolicy 介绍** 在多线程编程中,**线程池**(如 Java 的 `ThreadPoolExecutor`)通过复用线程提高效率,但需处理任务过载问题。当线程池的**工作队列已**且**线程数达到最大值**时,新提交的任务将触发**拒绝策略(Rejection Policy)**,而 `CallerRunsPolicy` 是其中一种常用策略。 --- ### **CallerRunsPolicy 的核心行为** - **定义**:当线程池无法处理新任务时,由**提交任务的调用者线程**(如主线程)直接执行该任务。 - **效果**:调用者线程会被阻塞,直到任务完成。这会间接**降低新任务提交速度**(因为调用者忙于执行任务),形成一种“反馈调节”机制,避免系统过载。 --- ### **具体流程** 1. 线程池(所有线程忙碌 + 队列已)。 2. 新任务到达,触发拒绝策略。 3. **不丢弃任务**,而是将任务回退给调用者线程执行。 4. 调用者线程执行任务期间,无法继续提交新任务,缓解系统压力。 --- ### **适用场景** - 需保证所有任务最终被执行(不允许丢弃)。 - 希望**平滑任务提交速度**,避免突发流量压垮系统。 - 任务本身可接受**短暂延迟**(调用者线程执行需时间)。 --- ### **与其他策略对比** | 策略 | 行为 | 适用场景 | |------|------|----------| | **CallerRunsPolicy** | 调用者线程执行任务 | 不允许丢任务,需自动限流 | | **AbortPolicy**(默认) | 直接抛出异常 | 需快速失败,由调用者处理异常 | | **DiscardPolicy** | 静默丢弃新任务 | 允许丢任务,追求高吞吐 | | **DiscardOldestPolicy** | 丢弃队列中最旧任务,重试新任务 | 允许丢旧任务,关注新任务实时性 | --- ### **代码示例(Java)** ```java ThreadPoolExecutor executor = new ThreadPoolExecutor( 2, // 核心线程数 5, // 最大线程数 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(10), // 容量为10的队列 new ThreadPoolExecutor.CallerRunsPolicy() // 设置拒绝策略 ); // 提交任务(当线程池时,主线程将执行任务) executor.submit(() -> { // 任务逻辑 }); ``` --- ### **注意事项** 1. **潜在性能风险**:若调用者线程是主线程或关键线程,长时间执行任务可能阻塞核心逻辑(如 UI 线程卡顿)。 2. **任务依赖问题**:如果任务之间有关联,调用者线程执行任务可能导致死锁或逻辑混乱。 3. **需监控反馈**:触发此策略表明系统压力较大,需结合监控优化线程池参数或扩容。 --- ### **总结** `CallerRunsPolicy` 是一种温和的拒绝策略,通过“调用者执行任务”实现限流与任务保底,适用于对任务完整性要求较高且能容忍短暂延迟的场景。实际使用时需权衡性能与可靠性,结合具体业务调整线程池参数。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值