记录一次生产问题:当线程池打满,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,下班!


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

猜你喜欢

转载自blog.csdn.net/booynal/article/details/129150435