Linux任务调度(7)

Posted 2024-06-27 12:36 +0800 by ZhangJie ‐ 1 min read

分享:  

线程数多了之后,线程切换会更频繁吗

讨论一个问题,进程数、线程数多了之后,CFS调度器下线程的切换频率是否会升高呢?

会的!直观理解就是:

  • 线程数少:vruntime分布稀疏,切换概率低,当前线程可能继续执行。
  • 线程数多:vruntime分布密集,切换概率高,当前线程更容易被切换出去。

注意,尽管CFS调度器会为每个cpu维护独立的任务调度数据结构(rbtree),但是CFS调度器确实有多cpu的负载均衡机制。

假设有两个CPU(CPU1和CPU2),每个CPU有自己的调度队列和红黑树:

  • CPU1上的任务P1的vruntime较大,暂时不被CPU1调度。
  • CPU2上的任务P2的vruntime最小,但P1的vruntime比P2更小。

在这种情况下,是否会将P1迁移到CPU2取决于负载均衡机制的具体实现和当前系统的负载情况:

  • 如果CPU1的负载较高,而CPU2的负载较低,负载均衡机制可能会将P1迁移到CPU2,以平衡负载。
  • 如果CPU1和CPU2的负载相对均衡,调度器可能不会进行任务迁移,因为任务迁移本身也有一定的开销。

cfs调度器负载均衡的时机,以及考虑因素

负载均衡的时机: 负载均衡通常在以下几种情况下触发:

  1. 周期性负载均衡:调度器会定期检查各个CPU的负载,并在必要时进行任务迁移。
  2. 任务唤醒:当一个任务从睡眠状态被唤醒时,调度器会检查当前CPU的负载情况,并可能将任务分配到负载较轻的CPU。
  3. 任务创建:当一个新任务被创建时,调度器会选择一个负载较轻的CPU来运行该任务。

任务迁移的考虑因素: 在决定是否迁移任务时,调度器会考虑多个因素,包括:

  1. CPU负载:调度器会比较各个CPU的负载,选择负载较轻的CPU进行任务迁移。
  2. 任务的vruntime:调度器会比较任务的vruntime,选择合适的任务进行迁移。
  3. 任务的亲和性:某些任务可能对特定的CPU有亲和性(例如,缓存亲和性),调度器会尽量避免迁移这些任务。

为何会想到这个问题呢

一个直接原因时因为go程序中GOMAXPROCS设置不合理,母机上有128 cores,但是虚拟化技术下容器里分配的只有2个cpus。

此时go进程里看到GOMAXPROCS=128(go不会自动感知到实际上只分配了2个cpus),此时runtime会误认为最多可以创建128个P(GMP中的P,Processor),后果就是程序启动时会创建128个P,负载一升高,goruntime负载均衡就会为每个P分配goroutines执行,对应的M就要创建出来并轮询P localrunq、globalrunq等处理。

ps: 严格来说,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并执行;

有些细节就过分展开细说,大家知道这一点就好了,“如果短时间内创建大量goroutine,当前p.runq full就会往sched.runq放,并且会启动最多npidle个M去抢P执行。”

如果这种情况出现了,并且GOMAXPROCS设置的不合理(如远大于虚拟化技术分配的cpu配合,如docker run –cpus=2,GOMAXPROCS=128),那么这些创建出来的众多的M在执行一些轮询p.runq,sched.runq,netpoller,stealing,contextswitch过程中就容易推高cpu占用,如果GOMAXPROCS,–cpus差的离谱,那么这个开销就很明显,而且很容易达到配额限制,很更容易被虚拟化管理软件给限制导致出现cpu throttling(节流),进而导致性能出现整体性的下降。

ok, 这样的话就会两个不好的影响:

  • M多了线程切换频率也会高,也会导致开销。当然128个M肯定是不够多的,但是由这个问题联想到了CFS调度器这里的工作机制,顺便提下而已;
  • 更主要原因还是在于,M关联P后,会轮询P的localrunq,,以及globalrunq,这些自旋等待会导致cpu开销升高;

由于此时cpu配额实际上是有限制的,所以无意义的操作空耗cpu占用了可以执行业务代码的时间。而且更容易触发cpu throttling(节流),进一步导致整体性能变差。

go程序中解决这个问题,可以直接 import _ "github.com/uber-go/automaxprocs" 来解决。这不是一个新问题,只是思考了下和CFS调度器、goruntime调度的一点联系。