上下文切换:多任务处理的隐性成本

📖 7min read

如果服务器慢,增加线程?

当我刚加入公司时,我负责的 Spring Boot API 服务器每当流量很大时就会变慢。由于我不知道原因,我开始通过谷歌搜索。

《Spring Boot服务器响应慢》《Tomcat性能调优》

搜索的结果是,在博客和社区中找到的最常见的建议很简单。 ‘增加Tomcat的线程池大小。由于没有足够的工作人员,您的请求正在等待。’

我想,“啊哈,我们缺工人!”我想的很简单。我立即打开 application.yml 设置并将线程数从默认的 200 增加到 2,000。根据我的计算,worker数量增加了10倍,所以处理速度必须更快。

但是看到布防后的监控画面,我愣住了。服务器实际上移动得更慢,CPU 使用率飙升,但正在处理的请求数量实际上减少了。看起来工人们只是在空中铲土,没有做任何工作。

到底为什么工人增加了,工厂却变慢了?在深入探究原因时,我发现了操作系统最昂贵的成本,“上下文切换”。

拥有更多的工人并不是一件好事。轮换他们的成本可能会更高。

回顾:线程,还记得吗?

对于那些第一次阅读本文的人,或者对于那些不熟悉上一篇文章(第 4 部分:进程和线程)内容的人,让我们回顾一下。

在我们的“数字物流中心”世界观中:

Spring Boot 基本上是多线程。每次收到请求时,都会分配一个工作人员(线程)来完成该工作。所以我只是想,“如果有更多的工作人员,就会同时处理更多的请求,对吧?”

但是有一点我忽略了。它是我们工厂的关键工人CPU 核心的数量。

数字配送中心轮班工作

事实上,CPU 核心(计算机的核心工作人员)一次只能执行一项任务。 (基于单核)但是,我们同时听歌、编码、使用KakaoTalk。这怎么可能?

这是因为工厂经理(OS)命令工人(CPU)以极快的速度“轮班”。 “播放歌曲 0.001 秒,然后停止!在接下来的 0.001 秒发送 KakaoTalk,然后停止!”

这就是“分时”,而工人放下工具并拿起新工具的过程就是“上下文切换”。

任务切换的代价:换衣服的时间

这就是我将线程数增加到 2,000 时发生的情况。

CPU是一个整体,有2000个线程工作者在喊:“摆脱它!” CPU连续与2000人轮流会面,公平地处理工作。

问题在于,从工人 A 的工作转到工人 B 的工作时,需要“准备时间”。

这个“记录、存放和读取的时间”被称为“上下文切换成本(开销)”。当有足够的工人时,这个成本可以忽略不计。但如果工人太多怎么办? CPU陷入了整天整理worker账本,却无法做任何‘实际工作(计算)’的情况。这就是我的服务器速度慢的真正原因。

任务变更期间,工厂停止运行。

【代码验证】线程多了就一定会更快吗?

眼见为实。我们用代码来证明一下。我们来比较一下使用一个线程执行相同数量的加法运算并将其划分为100万个线程的速度。常识表明 100 万台应该更快,但现实情况不同。

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class ContextSwitchingTest {
    private static final int TASK_COUNT = 1_000_000;

    public static void main(String[] args) throws InterruptedException {
        // 1. 以單執行緒處理(無輪班)
        long start = System.currentTimeMillis();
        for (int i = 0; i < TASK_COUNT; i++) {
            simpleTask();
        }
        System.out.println("單執行緒耗時:" + (System.currentTimeMillis() - start) + "ms");

        // 2. 以大量執行緒處理(誘發脈絡切換)
        // 建立無上限的執行緒池(注意:電腦可能凍結)
        ExecutorService executor = Executors.newCachedThreadPool();
        start = System.currentTimeMillis();
        for (int i = 0; i < TASK_COUNT; i++) {
            executor.submit(() -> simpleTask());
        }
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.HOURS);
        System.out.println("多執行緒耗時:" + (System.currentTimeMillis() - start) + "ms");
    }

    private static void simpleTask() {
        int a = 1 + 1; // 非常輕量的工作
    }
}

示例结果(因环境而异):

分析:任务本身(1+1)非常简单,眨眼间即可完成。然而,在多线程方法中,创建一百万个线程并让操作系统在它们之间来回切换的成本比操作时间要昂贵数千倍。 肚脐比胃大

实践经验教训:找到最佳点

那么 Spring Boot 服务器适合多少个线程呢?答案取决于“服务器做什么”。

但是,就像我经历的情况一样,盲目地将数量增加到2000就太多了。这是因为随着线程数量的增加,会消耗更多的内存(堆栈),并且由于上下文切换成本而导致 CPU 过载。

最近,非阻塞技术(例如Node.js和Spring的WebFlux)正在引起人们的关注,以解决这个问题。他们采用的策略是“不增加线程数,让一个人不停地快速处理。”

没有无条件的答案。您应该根据您的服务是否需要大量“轮班”或“加急”来进行选择。

结束语:天下没有免费的午餐

我们经常错误地认为“同时处理事物会更快”。然而,在计算机的世界里,“同时”实际上只是高速轮班工作,这几乎是一个技巧。

一旦您理解了上下文切换,您就会明白为什么服务器调整不仅仅是“增加数字”。乱加线程实际上会成为服务器窒息的毒药。

现在,计算机的内部工厂(CPU、RAM、进程)似乎运行得很好。现在,我们打开工厂大门,出去吧。我们如何将我们工厂中创建的数据发送到远方的另一个工厂(客户)?

下次,我们将讨论网络、HTTP,以及那个看不见的道路网络。

發佈留言