Linux任务调度(8): 任务越多调度就越频繁吗

Posted 2025-04-22 12:36 +0800 by ZhangJie ‐ 5 min read

分享:  

Linux任务调度(8): 任务越多调度就越频繁吗

本文将讲述一个曾经困扰在我们项目组心头的关于go进程混部时的担忧,以及由此引出的多进程混部时的隔离性问题。比如,个别程序不健壮创建大量进程,是否会推高上下文切换次数导致无谓的CPU开销的问题。我们将结合工具perf、bpftrace来深入观察并分析,以加深了对真实负载场景下任务调度的深层理解。

Go运行时引发的思考

一个线上问题

对CFS的深入思考,一个直接原因是因为go程序中GOMAXPROCS设置不合理,母机上有128个CPU核心,但是虚拟化技术下容器里分配的只有2个cpus。

此时go进程检测到GOMAXPROCS=128(go不会自动感知到实际上只分配了2个cpus),此时runtime会误认为最多可以创建128个P(GMP中的P,Processor),后果就是进程中最多会创建128个P。比如随着goroutines增多如果当前P处理不过来,就会激活更多的空闲P,对应的创建更多的线程M并轮询绑定的P上的的localrunq、全局的globalrunq以及定时器事件、网络IO事件就绪的goroutines并调度。这里的轮询操作就会导致较高的CPU开销,容易导致CPU throttling(节流)从而导致程序性能下降。

GMP调度是如何初始化的

go运行时是这样创建GMP的

  1. 进程启动的时候会根据GOMAXPROCS先创建出对应数量的P,详见 schedinit()->procresize(),但是还是没有创建M个这么多线程的;
  2. 上述创建出来的一堆P,除了当前g.m.p是在用状态,其他都是idle状态;M也不会预先创建出来,而是根据设计负载情况动态去创建、去激活P去执行的;
  3. 具体来说就是当创建一堆goroutines后,这些goroutine会先往 p.runq放,放不下了就会考虑 injectglist(...),这个其实就是放到全局队列 sched.runq,放的时候:
    • 如果当前M有关联一个P,就先放 npidle个G到 sched.runq,并且启动 npdile个M去激活 npdile个P,去尝试从goroutine抢G然后执行。然后剩下的放到 p.runq
    • 如果当前M没有关联一个P,这种情况下怎么会发生呢(有多种情况可能会发生,比如GC、系统调用阻塞、初始化阶段等)?这种情况下会全部放到 sched.runq,然后启动最多npidle个(即 min(goroutineQSize, npdile))个M去激活P并执行;

简单总结就是:“如果短时间内创建大量goroutines,当前p.runq full(或者M解绑了P)就会往sched.runq放。然后会启动最多npidle个M去抢P激活,然后workstealing的方式从sched.runq抢goroutines执行。

如果这种情况一旦出现了,这些大量创建出来的M,后续无goroutines执行时,也会不断地执行一些轮询 p.runq、sched.runq、netpoller、stealing、timer事件,这个无谓的轮询过程中就容易推高CPU占用。而实际的 --cpus 配额很少,就更容易达到CPU配额限制,进而被虚拟化管理软件给节流(CPU throttling),进而导致程序性能出现整体性的下降 (程序正常逻辑还没怎么执行,全被这些多出来的M轮询消耗掉了)。

一时负载高创建的M能退出吗

那有没有办法,让这些创建出来的大量M退出呢?创建出来的M退出只有一种办法,runtime.LockOSThread(),这种情况下,goroutine会和M绑定,goroutine执行完毕退出时,M也会被销毁。但是正常情况下是不会调用这个函数的(调试器tracer会调用该函数),所以多创建出来的M不会退出,进而就导致了这里的问题。

实际上,go程序中解决这个问题,很简单,读取下cgroups的cpu配额即可。可以直接 import _ "github.com/uber-go/automaxprocs" 来解决。

更多任务会导致更频繁上下文切换吗

上面go运行时错误设置GOMAXPROCS导致过多P、M创建出来导致了轮询的CPU开销,这个点我们已经明确了,并且了解到了对应的解决方案。

我们还有一个顾虑:

1)同一个机器上,有多个进程,其中一个go进程因为上述原因创建了大量的线程,CFS调度器任务切换频率会不会也被推高?我们都知道上下文切换有开销。 2)同一个机器上,如果有多个进程,如果我想避免某个进程对其他进程的影响,或者某个用户下的所有进程对其他用户下的进程的影响?该如何做。

这几个问题,其实就是我深入研究CFS调度器的根本原因,因为我像搞明白混部的影响及问题边界,这对保证服务乃至系统的可用性至关重要。当然你可以不混部来绕过这些弯弯绕绕的细节。

让我来尝试会大下上面两个问题,其中2)我们已经知道了,CFS可以通过组调度来解决这类问题,但是不会自动构建不同用户的任务组,一个进程包括多个线程也不会作为一个任务组进行限制,可以理解成系统默认有更多线程有更多处理能力,除非你们的系统管理员显示设置。

OK, 那现在,我们只需要搞清楚1),如果任务数增多会导致上下文切换更频率吗

假设CFS的设计实现果真如此,那这就是个巨大的风险点。现代Linux系统可以创建非常多的任务出来。现代Linux系统不是早些年的时候由CS 13bits索引范围限制了GDT/LDT表长度了,2^13/2=4096个进程(每个进程占GDT表的2项),早期版本最多支持这么多个任务。但是后面Linux版本对此做了修改,解除了这里的限制。每个处理器核心只在GDT中记录它当前运行的任务的表项信息,而任务队列则交给每个处理器核心的cfs_rq,可以创建的任务数量不再受CS 13bits索引、GDT/LDT表长度限制了。Linux系统可以支持的任务数只受限于pid_max、内核配置项、系统资源了。

而如果随着任务数增多,上下文切换频率就变高,这样大量的CPU资源会被浪费在上下文切换上。所以调度器是绝对不会这样实现的,这种设计太蠢了。如果任务数很多,我们可以接受不饿死的前提下、允许一定的调度延时、允许降低一定的交互性,但是不能降低系统调度的吞吐量、不能导致CPU资源巨大浪费、完全不可用。

所以我们的判断应该是,No!更多任务不会导致更频繁的上下文切换!这里的更多任务是指的非常多任务,而不是说从1到2,从2到4,从4到8,从8到16,从16到32这种程度,我们讨论的是从128到256,从1024到2048,从2048到4096这种程度。

谨慎评估下上下文切换频率

根据前面的介绍,任务切换 __schedule(preempt)的时机有3个,任务阻塞主动让出CPU、任务抢占、任务唤醒被重新加入run-queue。结合我们下面的测试用例,任务阻塞到被唤醒,我们创建的线程不会主动阻塞,只会被抢占,所以我们只需要分析任务抢占这个路径即可,scheduler_tick()->task_tick()->check_preempt_tick(),这里面会检查当前任务是否应该被抢占,发生抢占才会发生上下文切换。

但是: 1)其他任务可能会涉及到阻塞、唤醒,也会涉及到奖励、惩罚导致的动态优先级、动态时间片调整。 2)我们创建的线程也是系统的一部分,它的时间片也会因为其他进程动态优先级变化而变化。 3)而且即使我们确定了任务的执行时间片,抢占检测时,只要有vruntime比它小一个时间片的,就可以被抢占,不一定执行完自己的时间片。

所以要说我们的程序一秒钟会上下文切换多少次?因为整个系统是动态的,真的没那么好推算。

那我们能否先简化下这个量化模型,姑且认为: 1)所有任务的静态优先级(nice值)相同,也都不是交互式任务(动态优先级都是0),最终他们优先级一样; 2)最终从优先级转换为的权重也应该一样; 3)那么这样计算出的动态时间片也应该一样; Ok,那时间片长度是如何计算的?sched_slice来计算动态时间片,大致计算方式是:

   u64 slice = __sched_period(cfs_rq->nr_running + !se->on_rq);
   slice = __calc_delta(slice, se->load.weight, load);

第1步__sched_period计算的是调度周期:1)nr_running<=8时,固定6ms;2)nr_running>8时,等于nr_running0.75ms; 第2步 __calc_delta按当前任务贡献的全局权重来瓜分调度周期,作为该任务的时间片; 每个任务的时间片 = nr_running3ms * (1/nr_running)=3ms,对吗? 那上下文切换频率 = 1000ms/3ms = 333.3 次/s,对吗?

这个值,可能过于理想了,如果是写个cfs调度算法,输入是一堆优先级完全相同的任务,可能抛出来结果是这样的,但是真实系统中存在各种IO任务(交互式任务)、不同优先级任务、任务创建销毁等情况,这些都会反过来影响调度,所以实际测试跑出来的结果可能与这里的分析差的非常远。我们还是实测下,然后从测试结果来反推、来理解下吧。

实际测试下

测试环境说明:

注意在Linux v5.13版本,调度器内核参数位置作了修改,sysctl -a看不到调度器相关的参数了。实际上是做了调整,以前的 kernel.sched_xxx 相关参数被移动到了 /sys/kernel/debug/sched/ 下面,比如 kernel.sched_latency_ns 对应的就是 /sys/kernel/debug/sched/latency_ns

另外几个关键配置的默认值也做了修改,在内核版本 v5.12中:

kernel.sched_latency_ns = 6000000        // 6ms
kernel.sched_min_granularity_ns = 750000 // 0.75ms

从v5.13开始:

kernel.sched_latency_ns = 24000000        // 24ms
kernel.sched_min_granularity_ns = 3000000 // 3ms

我用来测试的版本是v5.15,配置值同v5.13:

$ uname -r
5.15.90.1-microsoft-standard-WSL2+

测试步骤:

  1. 我们写个工具测试下,thread_test.c:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>

// Thread function
void *thread_func(void *arg) {
    long long i = 0;
    while (1) {
        i++;  // Simple increment operation
    }
    return NULL;
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        printf("Usage: %s -n <thread_count>\n", argv[0]);
        return 1;
    }

    int thread_count = atoi(argv[1] + 2);  // Skip "-n"
    if (thread_count <= 0) {
        printf("Invalid thread count: %d\n", thread_count);
        return 1;
    }

    pthread_t *threads = malloc(thread_count * sizeof(pthread_t));
    if (!threads) {
        perror("Failed to allocate memory for threads");
        return 1;
    }

    printf("Creating %d threads...\n", thread_count);

    // Create threads
    for (int i = 0; i < thread_count; i++) {
        if (pthread_create(&threads[i], NULL, thread_func, NULL) != 0) {
            perror("Failed to create thread");
            free(threads);
            return 1;
        }
    }

    printf("Threads created. Press Ctrl+C to exit...\n");

    // Wait indefinitely (or until Ctrl+C)
    while (1) {
        sleep(1);
    }

    // This code is unreachable but included for completeness
    free(threads);
    return 0;
}
  1. 编译构建:gcc -o thread_test thread_test.c -lpthread
  2. 然后为了避免其他机器进程的影响,我们使用docker来隔离下环境,然后在docker容器里观察该进程下所有线程的上下文切换次数:

shell1:

# 先创建容器,分配一个cpu减少多核负载均衡影响
docker run --name linux101 --rm -it -v .:/workspace --cpus=1 --cap-add SYS_ADMIN hitzhangjie/linux101:latest /bin/bash

# 启动进程
cd /workspace
./thread_test -n1 #逐渐增大到2,3,4,5,8,16,32,64,128,256,512,1024,2048,4096等分别观察

shell2:

# 先进入容器
docker exec linux101 -it /bin/bash` 

# perf观察,每1s输出一次结果
yum install perf
perf stat -e context-switches -I 1000 -p `pidof thread_test`
  1. 逐渐增大thread_test -n<?>的参数值,观察线程数增大时,perf观察到的上下文切换频率的变化。

预期结果:

我推测单处理器核心上下文切换频率最高=1000ms/3ms=333.3次/s,而且我判断这个频率可能与创建的线程数没有太大关系,因为我前面做了两个重要的问题简化:

  • 简化1:假定系统中所有任务的优先级都相同
  • 简化2:任务数nr_running超过1,那么调度周期sched_latency=nr_running*3ms,假定所有任务权重相同,那么权重占总权重的比例相同,那么每个任务得到的动态时间片相同,恒为3ms左右;
  • 简化3:当任务执行抢占逻辑检查时,vruntime更小的任务继续等待,直到当前任务运行完时间片,实际上不用等到执行完就可以切换。

OK,带着这个预期的结果,我们跑下测试看看,看看是不是与我们想象中一样。

 9.001905387                168      context-switches      <= 16 threads, 168 次/s
10.099542009                184      context-switches
11.099730077                164      context-switches
...

 4.299515329                372      context-switches      <= 64 threads, 372 次/s
 5.399359483                387      context-switches
 6.499338791                380      context-switches
...

 7.699145120                458      context-switches      <= 256 threads, 458 次/s
 8.798950683                418      context-switches
 9.899027530                447      context-switches
...

157.899086597               551      context-switches      <= 512 threads,598/(159-157) = 299 次/s
159.000687239               598      context-switches
160.100710621               507      context-switches
...

56.603350232              6,854      context-switches       <= 2048 threads, 6991/(71-56)=466 次/s
71.400023708              6,991      context-switches
83.700729604              6,418      context-switches
...

实际测试结果,我们预测的333次/s和真实情况有较大偏差,说明我们之前的一些判断是有问题的,真实系统中不能忽略的因素就不能忽略。我们之前试图简化系统中的任务优先级、交互式任务的奖励与惩罚、任务抢占时执行时间小于任务时间片等的一系列做法,在真实负载的系统中是错误的,是违背真实情况的。如果我们是是写一个cfs的单测,输入是优先级相同的任务数量,那结果可能会和我们的分析接近,但是真实系统中完全不一样

但是这里的测试结果表明,尽管随着任务数增加,上下文切换次数也增加(从16个线程涨到2k个,上下文切换次数多了2倍),但是好的结果是,有上涨,但并不是线性上涨的,更不是数量级上的变化。这样其实是可以接受的。

使用bpf来跟踪下任务执行时间

但是我们不满足于上述测试,我们想通过bpftrace跟踪下随着线程数增加,我们测试程序中创建出来的线程参与调度时获得的实际执行时间是多少,从而更好帮助我们理解,真实负载系统中的调度是什么样子的,我们忽略那些任务优先级、交互式任务的奖励与惩罚、任务抢占时的执行时间小于时间片等的一系列做法,是有多么“粗暴” :)

ps: 注意:

1)这里分析的是任务的实际执行时间,非动态时间片sched_slice,抢占发生时不一定用完时间片。

2)bpftrace跟踪sched_switch统计执行时间比较方便,比跟踪sched_slice算时间片方便。

bpftrace收集sched_switch事件然后统计可以做到这点,我们现在写一个bpftrace脚本,sched_trace.bt:

#!/usr/bin/env bpftrace

BEGIN
{
    printf("Tracing CFS scheduler... Hit Ctrl-C to end.\n");
    @last_switch = nsecs;
}

// 跟踪进程切换事件
tracepoint:sched:sched_switch
{
    $prev_pid = args->prev_pid;
    $next_pid = args->next_pid;
    $prev_prio = args->prev_prio;
    $next_prio = args->next_prio;
    $prev_comm = args->prev_comm;
    $next_comm = args->next_comm;

    // 计算两次切换之间的时间间隔(实际运行时间)
    $delta = nsecs - @last_switch;
    @last_switch = nsecs;

    // 只关注 thread_test 相关的线程
    if (strncmp($prev_comm, "thread_test", 10) == 0 && strncmp($next_comm, "thread_test", 10) == 0) {
        // 记录运行时间分布(单位:微秒)
        @runtime_us = hist($delta / 1000);
    
        // 记录超过理论时间片(3ms)的次数
        if ($delta > 3000000) {
            @long_runtime++;
        }
    
        // 打印详细信息
        printf("switch: %s(%d) -> %s(%d), runtime: %d us\n", 
               $prev_comm, $prev_pid, $next_comm, $next_pid, $delta / 1000);
    }
}

// 跟踪唤醒事件
tracepoint:sched:sched_wakeup
{
    $pid = args->pid;
    $comm = args->comm;
  
    if (strncmp($comm, "thread_test", 10) == 0) {
        @wakeups[$comm]++;
    }
}

END
{
    clear(@last_switch);
    printf("\nRuntime distribution (microseconds):\n");
    print(@runtime_us);
    printf("\nLong runtime (>3ms) count: %d\n", @long_runtime);
    printf("\nWakeup counts per thread:\n");
    print(@wakeups);
}

然后在docker宿主机上执行 bpftrace sched_trace.bt,注意使用root权限。

128 threads时:

[0]                  281 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[1]                  128 |@@@@@@@@@@@@@@@@@@@@@@@                             |
[2, 4)                88 |@@@@@@@@@@@@@@@@                                    |
[4, 8)                33 |@@@@@@                                              |
[8, 16)               71 |@@@@@@@@@@@@@                                       |
[16, 32)              79 |@@@@@@@@@@@@@@                                      |
[32, 64)              80 |@@@@@@@@@@@@@@                                      |
[64, 128)            165 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@                      |
[128, 256)           161 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@                       |
[256, 512)           121 |@@@@@@@@@@@@@@@@@@@@@@                              |
[512, 1K)             28 |@@@@@                                               |
...

256 ~ 512 threads:

<skip>

1024 threads时:

@runtime_us:
[0]                  605 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[1]                  206 |@@@@@@@@@@@@@@@@@                                   |
[2, 4)                29 |@@                                                  |
[4, 8)                15 |@                                                   |
[8, 16)               15 |@                                                   |
[16, 32)              18 |@                                                   |
[32, 64)              29 |@@                                                  |
[64, 128)             29 |@@                                                  |
[128, 256)            26 |@@                                                  |
[256, 512)            43 |@@@                                                 |
[512, 1K)              6 |                                                    |
[1K, 2K)               0 |                                                    |
...

2048threads:

@runtime_us:
[0]                  591 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[1]                  216 |@@@@@@@@@@@@@@@@@@@                                 |
[2, 4)                37 |@@@                                                 |
[4, 8)                20 |@                                                   |
[8, 16)                8 |                                                    |
[16, 32)              12 |@                                                   |
[32, 64)              25 |@@                                                  |
[64, 128)             38 |@@@                                                 |
[128, 256)            15 |@                                                   |
[256, 512)            22 |@                                                   |
[512, 1K)              2 |                                                    |

4096threads:

[0]                  718 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[1]                  263 |@@@@@@@@@@@@@@@@@@@                                 |
[2, 4)                60 |@@@@                                                |
[4, 8)                17 |@                                                   |
[8, 16)               12 |                                                    |
[16, 32)              16 |@                                                   |
[32, 64)              43 |@@@                                                 |
[64, 128)             56 |@@@@                                                |
[128, 256)            16 |@                                                   |
[256, 512)            49 |@@@                                                 |
[512, 1K)              4 |                                                    |
[1K, 2K)               0 |                                                    |

So … 实际上算出来的动态时间片,跟我们想象的完全不一样:

  1. 它并没有尽可能逼近那个所谓的最小值3ms,实际上时间片要小的多;
  2. 从几十个任务增加到几百个任务,每个任务的动态时间片确实是减少的趋势。128个任务时甚至还有1ms的时间片,1k个任务时大部分任务的时间片缩到了10微秒以下;
  3. 从1k个任务增加到4k个任务,每个任务的动态时间片并没有继续明显减少了。1k个任务到4k个任务,任务的时间片没有明显减少了,大部分都是10微秒以下;

我们通过bpf工具观察到了这个现象,并没第一时间从源码层面分析出,呃呃任务的时间片还可以这么短。有可能变量kernel.sched_min_granularity_ns将我们的思路引入歧途了。

分析误区

我们分析下推测严重失误的原因,因为我们前面做了几个重要的问题简化,这里的简化在真实系统负载中是不可以简化的:

  • 简化1:假定系统中所有任务的优先级都相同,实际上不可能 误区1:对动态优先级认识不足 1)即使我们top中看到有些进程的nice值相同,也不能认为后面运行中它们的优先级一直相同。 2)nice只是确定了一个静态优先级,运行时调度器会根据进程是否是交互式任务进行奖励和触发,动态优先级会不同。 3)静态优先级相同,动态优先级不同,最终优先级还是不同。 4)优先级不同,导致权重不同,会影响任务分得的时间片大小。 简化1会错误估计时间片大小,进而错误估计上下文切换频率。
  • 简化2:任务数nr_running超过1,那么调度周期sched_latency=nr_running3ms,假定所有任务权重相同,那么权重占总权重的比例相同,那么每个任务得到的动态时间片相同,恒为3ms左右; 误区3:低估了系统中高优先级进程的影响 1)系统中存在其他高优先级进程 2)高优先级进程获得的权重要大,对于的vruntime可能小的多 3)实际执行后,其他高优先级进程贡献的负载,要比当前测试进程多的多 4)实际上我们这里创建的线程的时间片=nr_runningload/totalload,实际上分得的时间片可能会少的可怜,甚至连kernel.sched_min_granularity_ns=3ms都不到,可能是微妙级别的,后面的bpf跟踪证明了这点。 简化2导致低估了优先级的影响,高估了测试线程时间片长度,而高优先级进程时间片可能很长,上下文切换次数不一定高。 比如就不能只拿微妙级别的时间片来做除法,1000ms/1us=10^6,很可能高优先级进程的存在降低了整体的上下文切换次数。
  • 简化3:当任务执行抢占逻辑检查时,vruntime更小的任务继续等待,直到当前任务运行完时间片,实际上不用等到执行完就可以切换。 误区3:这个假设不准确, 1)假定此时cfs_rq上存在vruntime更小的任务t 2)且此时当前任务vruntime-min_vruntime > 当前任务的ideal_time 3)那么当前任务此时没有用光时间片,也需要进行任务抢占 简化3会导致低估了上下文切换的次数。

经验教训

OK,那我们最后来总结下,其实我们想知道的无非就是当创建大量任务时(上1000之后),调度器层面会不会随着任务数增加导致更加频繁的上下文切换,过于频繁的上下文切换会浪费CPU资源,程序也不能得到很好的执行。对,我们担心的主要是这个。其实从前面perf、bpftrace的跟踪结果显示,当任务数量达到一定数量后,继续增加的话,动态时间片、上下文切换次数,都不会有明显的上涨了,这是一个可以接受的结果,恰恰说明了Linux CFS调度器的吞吐能力。

本文总结

本文讲述了困扰在我们项目心头的关于go进程混部时的一些担忧,以及由此引出思考。通过对CFS调度器的内部工作原理的深入学习,以及结合perf、bpftrace对真实负载下任务调度的观测,我们分析了为什么实际测试结果与我们预期相去甚远的原因,加深了对真实负载场景下任务调度的理解。

OK,希望大家读完后,能够有所感悟吧!