一文读懂多线程背后的实际应用场景

950 阅读18分钟

今天我来给大家介绍一下多线程的实际应用场景。相信很多同学都会有疑惑,就是知道多线程在概念上和实际的软件工程里是重要的,但是在实际上业务上的具体应用却完全找不到,但是在面试的时候呢,又会反复考察对于多线程的理解,然后通过面试后在工作的几年内,完全没有使用过多线程编程,大有”面试造飞机进来拧螺丝“的感觉。

那我今天以我在工作中碰到的实际案例来给大家解答一下什么是多线程以及多线程到底有什么具体应用场景,以及为什么要掌握这样一门底层技能。

语言的抽象

首先我解释一下,在不同的语言里面对硬件和软件掌控的层次是不一样的,比如说在偏底层的语言里面,C和C++需要直接来掌握手动的创建线程和管理内存。这就使得这种底层的语言能力非常强大,但是它的使用上手的成本就会越高,毕竟越底层越有很多物理概念。

因此为了考虑效率,编程语言会往更高的抽象和封装的程度发展,使得更高级的语言越来越方便使用,对开发者越来越友好,具体的实现方式,就是高级语言帮助开发者屏蔽了很多底层的细节,使得开发者更加专注自己的业务逻辑就可以了。简单说,高级语言会告诉使用者”你就这么简单的使用即可,无需关心实现“。

框架的抽象

但是高级语言相对一些简单业务来说,还是比较复杂,比如我就想实现一个对外提供服务的应用,如果自己实现的话,还需要自己实现一套RPC的调用,实际上我连RPC都不想关心,我只关心我对外提供什么样的数据。因此在这个层面上,又有大量现成的框架把底层功能也封装起来了,屏蔽了技术细节,使得开发者可以直接编写业务逻辑。

比如说我们的经常用到的一些RPC的框架(Dubbo),在客户发起请求的时候,这种RPC框架会自动创建线程,再结合Spring就可以直接进行请求流转。所以我们用SpringBoot这种框架来写代码的时候,我们全程都没有看到任何线程创建的例子,应用功能就生成好了,部署上线客户就能访问了。

这个就是框架的意义,帮我们屏蔽了很多偏底层的和业务不直接相关的逻辑和代码或者依赖。所以这种情况使得你完全不懂得线程是什么东西,你也可以快速地s写出业务代码并部署上线。

多线程的使用原则

然而这个只是区别于低级程序员和高级程序员的一个特点。虽然在大多数情况下如果做一些简单的工作,我们甚至不需要理解什么是多线程。但是如果我们是一个有自我更高追求的,并且我们要完成的业务逻辑特别复杂的,有着非常大的挑战的场景,就不得不了解什么是多线程,因为多线程实际上能从底层思维来解决复杂的业务问题。

因此对于这些基础知识的掌握,决定了一名程序员的天花板在哪里。

在业务访问量特别低的情况下的话,我们直接套用框架的代码并做快速的业务实现就可以了,比如现在如果要构建一个WEB应用,我们可以快速利用SpringBoot全家桶配置化的生成框架代码,然后自行实现里面的Controll方法就可以实现一个线上可用的系统,但是当我们面对极其复杂的、大流量的、高并发的情况下,那么就会对我们的业务逻辑提出了更高的挑战。也就是我们直接使用这种框架式的代码来支持百万级的吞吐量、几十万级的QPS就完全扛不住了。

比如我们如果只是用简单的一些框架自带的模板方法,它是没有针对我们具体的场景做过优化的,举一个简单例子,假如我们只是用一些RPC自带的逻辑里面,一般没有提供并发处理的能力的。举个简单例子,当客户发起请求的时候,会给客户分配到这唯一的一个线程,并且在这个请求线程里面调用我们实现的代码逻辑,当我们的代码特别的复杂,特别的耗时的时候,整个框架的调用也会变得更长,因此如果你不懂得优化的方式,那么就会把所有的请求拉长,耗时拉长,而最终使得整个性能的系统的吞吐量都会大规模的降低,进而引起超时或者雪崩故障。而此时如果你只是一个CRUD,鄙视学习多线程基础知识的同学,那这种故障来临的时候,你往往只能干瞪眼,悔不及当初为什么不多学点基础的知识。

多线程在工作台的应用

所以我所负责的业务工作台和协作两个场景为例,来阐述一下为什么多线程在这里面发挥着重要的作用。

先从工作台开始,我们知道工作台采用的是组件化的设计思路,也就是你访问的每一个板块都是一个一个的组件。因此在用户访问工作台的时候就需要全部加载这些组件。在正常情况下,一个非常简单的思路就是我们串行的请求,一个组织所有的组件列表,并且在内存里面做适当的渲染,最终返回给用户。在刚开始的时候,我们用单线程就能够顺利的完成这些任务,但随着用户量发展,随着整个业务逻辑的复杂,使得对整个性能提出了非常大的挑战。

比如有些客户的组件非常的多,几十个组件,并且有的组件里面还包含了大量的应用,这些应用的处理都需要非常耗时,因此综合起来一个客户的处理可能时间就超过了一两秒。但是我们想象一下客户的员工要到工作台使用考勤应用,但整个工作台却需要花上两秒钟的时间才能打开工作台,可想而知体感有多么的差。

但如果你不懂底层的线程技术的话,对性能优化的问题,可能是无解的。这个时候如果解决这样一类问题,就能够清晰地区别出来一名高级程序员和一名普通程序员的区别了。

当然做这样一类的优化有很多方法,比如说缓存技术等等。但是为了保证实时性,并且在架构上面又保持这样的一种优雅特性,那么用多线程的方式就是非常好的一种方式。

那使用到多线程的时候就必须要先对线程场景进行一个解构,也就是对单体请求如何合理的拆分成多线程请求,就放在这个场景来说,我们可以看到我们一个客户是有很多的组件的,同时这些组件又是相互独立的,并且在某些组件发生了异常情况下的话,我们希望是可以降级的,让客户能够尽可能看到更多的组件,能够使用到尽可能多的功能。这本身就是一种系统降级设计的思维。

基于这个场景上面的拆分,我们就可以考虑使用多线程的方式来执行任务,也就是在客户发起请求的时候,加载到工作台模块的时候,我们就用多线程的方式来并行处理多个任务,每个人任务单独处理一个多个组件,然后帮多个任务的结果汇总到一块,最终返回给客户。

所以如果要使用多线程的话,那么我们不仅仅明白了这样的核心骨架的设计,我们更要设计里面的一些细节,比如说我们对于一个客户的请求该分配多少个线程,如果线程数少了,导致客户性能依然无法更好的改善。但是如果线程多了,大量的客户请求都会创建线程,又使得整个线程会占用掉过多的资源,进而造成整个系统的崩溃。

所以在这里我们就要想办法去熟悉线程的知识,该怎么样去定义一个最佳的线程数量平衡。

在完成现实线程数量的评估之后,我们就要考虑我们到底是手动创建线程,还是利用线程池来创建。那我们也可以了解到线程池和线程的区别,如果手动创建线程的话,成本会非常的高,并且还要考虑到线程的释放同时还要去管理线程的数量,缺点非常明显,那么这里面我们就能够引入线程池,线程池就能够帮助我们更好的管理线程而且能够实现动态的创建和线程回收,帮我们屏蔽了很多细节。

现在线程数的设计我们有一个基本原则上的设计,线程池我们也引用了,那么我们就可以进一步了解现成的线程池的具体用法。在我们了解了线程池的具体用法以后,就可以开始定义任务。

  • 初始化线程池
// 初始化线程池
queue = new ArrayBlockingQueue<>(threadPoolConfig.getBlockQueueSize());
executorService = new ThreadPoolExecutor(threadPoolConfig.getCoreThreadNum(), threadPoolConfig.getMaxThreadNum(),
                KEEP_ALIVE_TIME, TimeUnit.SECONDS, queue, r
                    -> new Thread(r, "mutilThread_" + r.hashCode()), new ThreadPoolExecutor.DiscardPolicy());

首先第一步就是定义线程池,几个关键参数就是线程池内核心线程数量、线程的最大数量、线程任务队列、任务丢弃策略等等。

这里的情况可以根据经验值来确定,也可以根据自己的系统和业务情况来定义具体的参数值,我这里就不具体介绍了,有兴趣的可以参考相关线程池的文章。

  • 定义任务

在我们完成通常意义的线程池定下来之后,我们就要考虑到怎么样去定义任务。既然聊到定义一个任务,我们就不能面向实现编程,而应该面向接口来编程,所以我们需要定义一个统一的任务接口,让所有需要实现任务的地方直接去实现任务接口,并且把任务扔到线程池里面就可以了。

/**
     * 接受执行任务
     *
     * @param task
     */
    public <T> void accept(Task<T> task, T t) {
        TaskRunable runable = new TaskRunable(task, t, log);
        try {
            executorService.execute(runable);
        } catch (RejectedExecutionException e) {
            LogUtils.error(log, e, "task_reject");
            throw new RpcException("10004", "server is busy");
        } catch (Exception e) {
            LogUtils.error(log, e, "accept exception");
            throw new RpcException("10005", "server is error");
        }
    }

线程池接受任务的接口是一个Runnable的接口,也就是任务只要实现Runnable接口即可作为任务提交给线程池来执行。

@AllArgsConstructor
public class TaskRunable<T> implements Runnable {

    private Task<T> task;

    private T t;

    private Logger logger;

    @Override
    public void run() {
        if (task.getCaller() == null) {
            return;
        }

        try {
            task.getCaller().apply(t);
        } catch (Exception e) {
            // 加上关键监控
            LogUtils.error(logger, e, "TaskRunableExcep");
        } finally {
            // 通知完成
            task.completeNotify(t);
        }

    }
}

这里我们定义了一个通用的任务类,任务类里面包含具体的任务接口

@Data
public abstract class Task<T> {

    private Caller<T> caller;

    /**
     * 通知任务执行完成
     *
     * @param t
     */
    public abstract void completeNotify(T t);
}
  • 线程通信

由于主线程需要等待所有子线程执行完毕后才能组装最终返回给客户,因此就需要采用线程通信的方式来使得主线程和子线程的协作。当然在Java8已经有了Future对象的方式来高效实现线程通信了,我们本案例采用更加底层的方式来实现线程通信。线程通信的详细文章见链接:redspider.gitbook.io/concurrent/…

这里我们采用了Java关键字Object类的wait()方法和notify(), notifyAll()的方式来实现进程间的通信。notify()方法会随机叫醒一个正在等待的线程,而notifyAll()会叫醒所有正在等待的线程。

Task<MainContext> task = new Task<MainContext>() {
		@Override
    public void completeNotify(ComponentSchemaContext context) {
    		// 回调实现,当任务完成的时候,将该任务标记置为完成
        context.getParallelContext().completeTask();
        // 如果不是全部完成,则返回
    		if (!context.getParallelContext().getFinish()) {
    				return;
    		}
				
        //如果是全部完成,则通知主线程继续
				synchronized (context.getParallelContext().getLock()) {
						context.getParallelContext().getLock().notifyAll();
				}
			}
    };
    // processImpl是具体的任务
    task.setCaller(t -> processImpl(t));
    // 这里只负责将任务丢进线程池,不需要等待所有任务完成
    multiThreadTaskExecutor.accept(task, context);
// 等待所有的组件任务处理完成
        synchronized(parallelContext.getLock()) {
            while (!parallelContext.getFinish()) {
                try {
                    // 最多等待一秒,避免LWP主线程在极端情况下被消耗殆尽
                    parallelContext.getLock().wait(1000);
                } catch (InterruptedException e) {
                    LogUtils.error(log, e, "waitTaskException", "corpId", context.getCorpId(),
                            "appUuid", context.getAppUuid());
                }

                // 最多等待一秒
                break;
            }
        }

        // 组装组件
        assembleComponents(context);

从上面的实现可以看到,我们先实现了一个任务的模板,该任务的模板就是在任务执行完毕后,执行一个回调类来判断是否通知主线程来收集所有数据。

  • 线程共享变量
/**
     * 任务数目
     */
    private Integer threadTaskNum;

    /**
     * 原子计数器(完成的任务数目)
     */
    private volatile AtomicInteger completeCounter = new AtomicInteger(0);

    /**
     * 并发锁,用于控制线程之间的等待和通知机制
     */
    private volatile Object lock = new Object();

    /**
     * 是否全量完成
     */
    private volatile Boolean finish = false;

    public ParallelContext(Integer threadTaskNum) {
        this.threadTaskNum = threadTaskNum;
    }

    /**
     * 不允许默认构造函数
     */
    private ParallelContext() {}

    /**
     * 任务完成
     */
    public void completeTask() {
        if (completeCounter.incrementAndGet() >= threadTaskNum) {
            finish = true;
        }
    }

我们需要完成线程间的通信,首先要一定一个lock,用来作为线程间通信的机制,即主线程执行到需要组装子线程数据的时候,就调用lock.wait来阻塞住,然后子线程在全部执行完毕后调用lock.notifyAll来通知主线程继续。其次,为了保障我们所有的子线程都处理完毕后再通知主线程,我们需要统计任务的完成情况,即当任务完成一个的时候,就把任务加1,最终当任务完成数和初始化的线程数量相同的时候,我们通知主线程。

于是我们定义了AtomicInteger completeCounter作为完成任务的计数器,当完成一个任务时,就调用completeCounter原子方法加1,直到所有任务都完成。

在所有子任务执行完毕之后,我们主线程是要获得返回值,并最终把返回值聚合在主线程里面返回给客户的。所以有了上面的线程通知机制,我们就可以实现在主线程里等待子线程执行任务,当任务执行完毕后再聚合子线程的结果最终返回给客户。

经过压测,我们发现整体性能相比改造前提升了约一倍。

多线程在”协作“的应用

在说完了第一个例子之后,我们再说一下第二个例子。第二个例子也是一个实际生产上面的例子,产品能力就是”协作“,”协作“主要是把一个员工在钉钉上所有相关的事件进行聚合,并且分成几类,比如可以分成”最近代办的事“以及”最近关注的事情“以此来提高效率。

后续的产品还有更多其他方式的演变,可能除了这两类之外,还会扩展其他的类别,所以它是一个可以横向扩展的分类聚合。

在刚刚开始的时候,这样的事件是比较少的,所以我们有个单线程就可以完成所有的事件的查询,但是随着业务的变更,会发现事件越来越多,分类可能也越来越多。因此在性能上就开始变得越来越慢,碰到一些长尾数据的情况下(比如某人一天有几百个事件),当事件数变得过多的情况下的话,整个产品功能就变得缓慢了甚至卡顿导致手机Crash。

所以根据我们这种场景,用多线程来处理就变得非常的适合了。那首先我们这里第一步要考虑的就是场景的解构。我们可以在事件维度上面进行并发处理,我们也可以在分类上面进行并发处理。那结合这个情况,我们认为按照分类的方法来做并行的线程处理会非常相对来说会比较简单,因为每一个类的事件是相互独立的,所以用线程并发能够最快和最简单的对业务进行拆解。

因此我们就可以考虑用并发的方式来实现,在最初的代码里面我们只需要用两个线程来完成任务就可以了,因为只有两个分类。和我们上面所说的一样,为了高效利用线程,降低编码的复杂度,我们也引用了线程池。于是我们就有下面这一部分的框架代码。

  • 创建线程池
    /**
     * 创建线程池
     *
     * @param name          线程名称
     * @param maxPoolSize   线程池最大值
     * @param corePoolSize  线程池core大小
     * @param queueCapacity 队列长度
     */
    protected static void createThreadPool(String name, int maxPoolSize, int corePoolSize,
        int queueCapacity) {
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(corePoolSize, maxPoolSize, 60,
            TimeUnit.SECONDS, new LinkedBlockingQueue<>(queueCapacity), namedThreadFactory,
            new ThreadPoolExecutor.AbortPolicy());
        threadPools.put(name, threadPool);

    }

线程池的本质,就是一种”池化技术“。为了解决手动使用线程时需要频繁的创建和销毁的场景,使用线程池可以大幅度提升线程”复用“带来的效率,以一种更优雅的方式来使用和管理线程。类似的技术有数据库连接池和HTTP连接池技术。

  • 提交任务
for (Zone zone : response.allZone) {
            Future<Boolean> future = executorService.submit(() -> {
                // 3. 子线程内进行信息流查询
                taskExecutor.query(uid, orgId, zone, queryTime);
                return Boolean.TRUE;
            });
            futureList.add(future);
        }

线程池设计的最大特点就是解耦了线程和任务。使用者只要定义和实现任务即可,在需要线程执行的地方直接把任务传递给线程池即可。

  • 结果聚合
// 4. 主线程等待子线程结束后再返回
        futureList.forEach(x -> {
            try {
                x.get(TimeOut, TimeUnit.MILLISECONDS);
            } catch (ExecutionException ee) {
                // 获取线程执行异常,打error日志
                LogUtil.error(ee);
            } catch (Exception e) {
                // 对接口超时的情况,打印warn日志
                LogUtil.warn(e);
            }
        });

对于上述的例子相对来说比较简单,因为不同的任务无需共享写变量,从而没有多线程的并发问题。只要每个任务根据当前传入的区域获取到对应的数据并对数据做相关的操作即可。

经过压测,我们发现整体性能相比改造前提升了约一倍。

并发获取网页内容案例应用

  • 多线程主流程
//一个并发请求网页HTML的多线程程序
public class MainTask {

    public static void main(String[] args) throws ExecutionException, 
    InterruptedException {

        //任务入参
        List<String> urls = Arrays.asList("http://www.javaer.com.cn/",
        "https://www.baidu.com/");

        //创建线程池
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(5, 10, 60,
                TimeUnit.SECONDS, new LinkedBlockingQueue<>(100), 
                new ThreadFactoryBuilder()
                .setNameFormat("thread-pool-%d").build(),
                new ThreadPoolExecutor.AbortPolicy());

        //提交任务
        List<Future<String>> result = new ArrayList<>();
        for(String url : urls){
            result.add(threadPool.submit(new UrlFetchTask(url)));
        }

        //获取任务结果
        for(Future<String> future : result){
            String urlData = future.get();
            System.out.println(urlData);
        }

        Thread.sleep(Integer.MAX_VALUE);

    }
}
  • 多线程子任务
/**
 * 任务,需要实现Callable接口
 */
public class UrlFetchTask implements Callable<String> {

    //入参,外部传入
    private String url;

    public  UrlFetchTask(String url){
        this.url = url;
    }

    @Override
    public String call() throws Exception {
        return UrlFetchTask.httpClientFetch(url,DEFAULT_CHARSET);
    }

    /**
     * HttpClient Get工具,获取页面或Json
     * @param url
     * @param charset
     * @return
     * @throws Exception
     */
    public static String httpClientFetch(String url, 
    String charset) throws Exception {
        // GET
        HttpClient httpClient = new HttpClient();
        httpClient.getParams().setContentCharset(charset);
        HttpMethod method = new GetMethod(url);
        httpClient.executeMethod(method);
        return method.getResponseBodyAsString();
    }
}

上述就是一个简单的多线程并发的例子,大家可以根据自己的业务情况,按照多线程框架的方式进行场景解构、定义线程池、定义线程通信方式等步骤进行多线程并发编程。

总结

所以综上所述,线程池在实际业务代码应用的场景里面确实比较少,也是由于方便开发者使用的原因,把线程池封装到各个框架里面,对普通开发者的感知不强。但是开发者还是要根据具体的场景来说明是否需要引用多线程和线程池,因为多线程的引入势必会引入编程的复杂度,如果不是对线程理解透彻的话,很容易带来失控的问题。

但我们正好看到,其实在一些特别复杂的场景里面,需要用到线程池来解决问题,也就是在初级程序员无法系统化解决这些性能问题的时候,可能多线程就是一个很好的手段,这个就要求高级程序员需要非常了解这么一套技术能力。而且通过实际的监控,我们发现使用了多线程技术后,产品的性能确实能够翻倍的提高,这种优化结果是非常明显的。

总结起来,一方面了解了多线程这种底层技术,对于程序员加深对整个应用的底层结构的理解是非常有帮助的,另外一方面,在解决一些特别复杂特别需要性能优化的场景里,多线程本身就是一种很好的处理策略。

所以对了解一些技术底层的复杂的技术还是非常的有用的,这也是一个程序员的自我修养,不断追求更优秀的技术,并且在实践中灵活运用。

更多原创内容,关注公众号:ali老蒋 或访问网站:www.javaer.com.cn/