3.4 任务切换开销与调度命令

3.5 任务切换开销实测

进程是操作系统的伟大发明,对应用程序屏蔽了CPU调度、内存管理等硬件细节,抽象出一个进程的概念,让应用程序专心于实现自己的业务逻辑。但是进程为用户带来方便的同时,也引入了一些额外的开销。如下图,在进程运行中间的时间里,虽然CPU也在忙于干活,但是却没有完成任何的用户工作,这就是进程机制带来的额外开销。

在3.4节我们介绍进程调度的时候,讲到了选出来待运行的新进程以后,接着就需要执行进程上下文切换,把新进程的运行状态给切换上来。这个上下文切换的主要函数是context_switch。我们通过它可以看到进程上下文切换都干了啥。

// file:kernel/sched/core.c
static inline void
context_switch(struct rq *rq, struct task_struct *prev,
       struct task_struct *next)
{
  prepare_task_switch(rq, prev, next);
  ......
    
  // 执行地址空间切换
  switch_mm_irqs_off(prev->active_mm, next->mm, next);
  // 执行栈和寄存器切换
  switch_to(prev, next, prev);
  ......
}

可见在切换过程中,首先要执行的是调用switch_mm完成新旧两个进程的地址空间的切换。然后再调用switch_to完成新旧两个进程的栈和寄存器的切换。

地址空间、栈、寄存器等都称之为进程的上下文。context_switch需要保存旧进程的上下文,以便等它下一次再重新获得CPU的时候知道自己上一次执行到哪儿了,下一条指令是啥。context_switch还需要加载新进程的上下文,将寄存器、栈、地址空间都切换上来,然后开始运行新进程。这个过程被称为进程上下文切换

进程上下文切换开销在进程不多、切换不频繁的应用场景下问题不大。但是现在Linux操作系统被用到了高并发的网络程序后端服务器。在单机支持成千上万个用户请求的时候,这个开销如果控制的不好的话,就可能会出现CPU一直疲于忙碌着进行切换,而没多少时间真正处理用户需求,导致服务器性能低下。

发生进程上下文的机会其实挺多的,比如时间片到,或者以同步阻塞的方式等待网络或磁盘IO的时候都会导致进程失去CPU运行权,导致切换的发生。例如用户进程在在请求Redis、MySQL数据等网络IO阻塞掉的时候,就会发生切换。

那么上下文切换的时候,CPU的开销都具体有哪些呢?开销分成两种,一种是直接开销、一种是间接开销。
直接开销就是在切换时,CPU必须做的事情,包括地址空间切换、栈、寄存器的切换。间接开销主要指的是虽然切换到一个新进程后,由于各种缓存并不热,速度运行会慢一些。如果进程始终都在一个CPU上调度还好一些,如果跨CPU的话,之前热起来的TLB、L1、L2、L3因为运行的进程已经变了,所以以局部性原理cache起来的代码、数据也都没有用了,导致新进程穿透到内存的IO会变多。

3.5.1 进程切换开销

在这里,我们采用两种方式进行进程上下文的切换。一种是自己写的测试代码,另一种是采用专业的lmbench工具

我们先以代码的方式来实验测试一下。实验方法是创建两个进程并在它们之间传送一个令牌。其中一个进程在读取令牌时就会引起阻塞。另一个进程发送令牌后等待其返回时也处于阻塞状态。如此往返传送一定的次数,然后统计他们的平均单次切换时间开销。

#define EXEC_COUNT 10000
int main()  
{  
  ......
  while ((x = fork()) == -1); 
  if (x==0) {  
    printf("开始测试时间:%u s, %u us\n", ...);  
    for (i = 0; i < EXEC_COUNT; i++) {  
      read(fd[0], &receive, 1);  
      write(p[1], &send, 1);  
    }  
    exit(0);  
  }  
  else {  
    for (i = 0; i < EXEC_COUNT; i++) {  
      write(fd[1], &send, 1);  
      read(p[0], &receive, 1);  
    }  
    printf("结束测试时间:%u s, %u us\n", ...);  
  }  
  return 0;  
}

更为完整的实验代码参见:https://github.com/yanfeizhang/deep_linux_process_tests/tree/main/chapter-09/test-02。编译运行之。

# gcc main.c -o main
# ./main
./main
开始测试时间:1565352257 s, 774767 us
结束测试时间:1565352257 s, 842852 us

实验中的代码测试了有10000轮,每轮是会在两个进程之间各发生一次上下文切换。则算的平均耗时等于 (842852 - 774767) / (10000 * 2) = 3.4 us。也就是说在我的机器上,平均每次上下文切换耗时3.4us左右。

前面我们测试系统调用的时候,最低值是200ns。系统调用只是在进程内将用户态切换到内核态,然后再切回来,而上下文切换可是直接从进程A切换到了进程B。显然这个上下文切换需要完成的工作量会更多一些。感兴趣你也可以用这段代码放在自己的机器上跑一下,看看耗时是多少。

NOTE

这里要注意的是我们的测试代码中使用的数据并不是很多,所以其实我们上面的实验并没有很好地测量到间接开销。

lmbench工具是一个更为专业的测试工具。能够测试包括文档读写、内存操作、进程创建销毁开销、网络等性能。使用方法简单。

在进程上下文的测试上,这个工具的优势是进行了多组实验,每组2个进程、8个、16个。每个进程使用的数据大小也在变,充分模拟cache miss造成的影响。我用它测了一下结果如下:

-------------------------------------------------------------------------
Host                 OS  2p/0K 2p/16K 2p/64K 8p/16K 8p/64K 16p/16K 16p/64K  
                         ctxsw  ctxsw  ctxsw ctxsw  ctxsw   ctxsw   ctxsw  
--------- ------------- ------ ------ ------ ------ ------ ------- -------  
bjzw_46_7 Linux 2.6.32- 2.7800 2.7800 2.7000 4.3800 4.0400 4.75000 5.48000  

lmbench显示的进程上下文切换耗时从2.7us到5.48us之间。

3.5.2 线程切换开销

上面小节我们介绍了进程上下文切换时包含两种开销。一是直接开销,另一种是切换新进程后因为Cache Miss导致的间接开销。对于线程来讲,如果在发生上下文切换时,上一个任务和下一个线程如果同属一个进程的话,它们使用的是一个地址空间,因此Cache Miss的概率会相对低一些。因此我们再继续在Linux测试一下线程。看看究竟比进程能不能快一些。

在Linux下其实本并没有线程,只是为了迎合开发者口味,搞了个轻量级进程出来就叫做线程。轻量级进程和进程一样,都有自己独立的task_struct进程描述符,也有自己独立的pid。从操作系统视角看,调度上和进程没有什么区别,都是在等待队列里选择一个task_struct切到运行态而已。只不过轻量级进程和普通进程的区别是可以共享同一内存地址空间,包括代码段、全局变量、同一打开文件集合而已。

我们在配套源码中提供了一个实验,其原理和进程测试差不多,创建了20个线程,在线程之间通过管道来传递信号。接到信号就唤醒,然后再传递信号给下一个线程,自己睡眠。这个实验里单独考虑了给管道传递信号的额外开销,并在第一步就统计了出来。完整源码参见:https://github.com/yanfeizhang/deep_linux_process_tests/tree/main/chapter-09/test-03。

编译运行后输出测试结果。

double thread_switch_test()
{
  int i = 20;
  struct timeval start, end;
  pthread_t tid;
  while(--i)
  {
    pthread_create(&tid,NULL,thread_func,(void *)pipes[i]);
  }
  ......
}
void *thread_func(void *arg)
{
  int pos = ((int *)arg)[2];
  int in = pipes[pos][0];
  int to = pipes[(pos + 1)%20][1];
  while(running)
  {
    read(in,buffer,10);
    if(write(to,buffer,10)==-1)
      exit(1);
  }
}
# gcc -lpthread main.c -o main
0.508250  
4.363495  

根据上面的测试结果看,大约每次线程切换开销大约是3.8us左右(4.36us - 0.5us)。从上下文切换的耗时上来看,Linux线程其实和进程差别不太大。

3.6 Linux调度器相关命令

到现在为止,我们了解了Linux的调度算法。但我想大家一定会对如何查看和干预Linux的调度更感兴趣。在本小节中,我们来了解下和调度器相关的一些Linux命令。

3.6.1 调度策略

我们先来了解下进程调度策略相关的命令chrt,使用该命令可以查看和修改进程的调度策略。例如下面的例子展示的是查看了PID为12345这个进程的调度策略为SCHED_OTHER,对应的就是普通进程的完全公平调度器。

# sudo chrt -p 12345
pid 12345's current scheduling policy: SCHED_OTHER
pid 12345's current scheduling priority: 0

chrt命令可以修改进程的调度策略,注意修改时可能需要root权限。其中SCHED_RRSCHED_FIFO都是实时进程。

同时也可以设置进程的优先级,对于实时进程来说,优先级为1-99之间。

# chrt --help
 -b, --batch          set policy to SCHED_BATCH
 -d, --deadline       set policy to SCHED_DEADLINE
 -f, --fifo           set policy to SCHED_FIFO
 -i, --idle           set policy to SCHED_IDLE
 -o, --other          set policy to SCHED_OTHER
 -r, --rr             set policy to SCHED_RR (default)
# sudo chrt --max
SCHED_OTHER min/max priority  : 0/0
SCHED_FIFO min/max priority : 1/99
SCHED_RR min/max priority : 1/99
SCHED_BATCH min/max priority  : 0/0
SCHED_IDLE min/max priority : 0/0
SCHED_DEADLINE min/max priority : 0/0

假如我们现在有一个进程的PID为12345,我们把它修改为SCHED_RR类型的实时进程,而且设置其优先级为10。那么具体的命令就是如下:

# sudo chrt -p -r 10 12345

命令执行完后,再次查看进程的调度策略以及调度优先级。结果显示调度策略成功被设置为SCHED_RR、调度优先级也变成了10。

# sudo chrt -p 12345
pid 123456's current scheduling policy: SCHED_RR
pid 123456's current scheduling priority: 10

假如我们想把它修改为SCHED_FIFO,优先级为10,则:

# sudo chrt -p -f 10 12345

也可以设置成完全公平调度SCHED_RR策略。

# sudo chrt -p 12345
pid 123456's current scheduling policy: SCHED_OTHER
pid 123456's current scheduling priority: 10
# sudo chrt -p -o 12345
pid 123456's current scheduling policy: SCHED_RR

3.6.2 nice设置

Linux也提供了nice命令可以干预进程的调度。给进程设置比较高的nice值的话,是尽量把CPU时间让给其它进程使用。如果想占用更多的CPU,那就把nice调为负数。这是和人类似的,如果一个人很nice,那他倾向于把资源给别人用,如果一个人不太nice,那他更倾向于抢夺别人的资源用。nice值的合法取值范围是[-20, 19]之间。至于为什么定这个范围,估计现在也没人能知道了。

进程启动的时候可以使用nice命令来设置进程的nice值:

# nice -n -20 vi

进程已经启动之后可以使用renice

# renice +5 {pid}

nicerenice命令都会触发内核提供的nice系统调用。该系统调用的核心操作在set_user_nice函数中,我把它的两个关键操作抽了出来。

//file:kernel/sched/core.c
SYSCALL_DEFINE1(nice, int, increment)
{
  ...
  set_user_nice(current, nice);
}
//file:kernel/sched/core.c
void set_user_nice(struct task_struct *p, long nice)
{
  ...
  // 1.将nice转化为优先级
  p->static_prio = NICE_TO_PRIO(nice);
  
  // 2.将nice转化为weight,存储到p->se.load.weight上
  set_load_weight(p, true);
  ...
}

其中NICE_TO_PRIO是将nice值转化为优先级并保存到static_prio上。转化过程就是将nice + DEFAULT_PRIO就可以了,DEFAULT_PRIO是100。

// file:include/linux/sched/prio.h
#define NICE_TO_PRIO(nice)  ((nice) + DEFAULT_PRIO)
#define PRIO_TO_NICE(prio)  ((prio) - DEFAULT_PRIO)

其中DEFAULT_PRIO对应的是nice为0的进程的prio值,其源码的宏定义经过各种展开后,值最终为120:

// file:include/linux/sched/prio.h
#define DEFAULT_PRIO    (MAX_RT_PRIO + NICE_WIDTH / 2)
#define MAX_NICE  19
#define MIN_NICE  -20
#define NICE_WIDTH  (MAX_NICE - MIN_NICE + 1)

另外的set_load_weight中是将nice值根据预定义的权重数组转化为实际的权重。

// file:kernel/sched/core.c
static void set_load_weight(struct task_struct *p, bool update_load)
{
  // 假设 nice 为 0,上面提到过 p->static_prio = 120
  // 则计算出的 prio 为 20
  int prio = p->static_prio - MAX_RT_PRIO;
  struct load_weight *load = &p->se.load;
  ...
  load->weight = scale_load(sched_prio_to_weight[prio]);
  load->inv_weight = sched_prio_to_wmult[prio];
}

其中sched_prio_to_weight是我们在vruntime计算小节提到过。是一个给每个nice值静态预定义的数组。之所以不动态地算,而是用一个数组预置好,是为了性能。

const int sched_prio_to_weight[40] = {
 /* -20 */     88761,     71755,     56483,     46273,     36291,
 /* -15 */     29154,     23254,     18705,     14949,     11916,
 /* -10 */      9548,      7620,      6100,      4904,      3906,
 /*  -5 */      3121,      2501,      1991,      1586,      1277,
 /*   0 */      1024,       820,       655,       526,       423,
 /*   5 */       335,       272,       215,       172,       137,
 /*  10 */       110,        87,        70,        56,        45,
 /*  15 */        36,        29,        23,        18,        15,
};

为了方便理解。我们举一个例子。假设某进程的nice为默认的0,上面提到过经过NICE_TO_PRIO计算后,其p->static_prio为120。则set_load_weight函数中的int prio = p->static_prio - MAX_RT_PRIO这句最终计算的结果是20。用该值当下标到sched_prio_to_weight数组中,直接查询到其weight为1024。

另外的inv_weight的设置目的只是为了加速计算性能时用到的一个辅助值。

3.6.3 taskset命令

完全公平调度器有个负载均衡器,可以在各个核之间迁移任务,进而使得各个核都均匀地处理任务,不至于忙的忙死闲的闲死。但是在某些情况下我们可能并不希望这个特性生效。例如在高性能要求的场景,每一次任务在CPU之间的迁移,都极有可能导致需要重新加载数据到CPU缓存中,导致性能损失。

taskset命令允许我们为进程设置一个调度亲和性,这样进程在调度的时候就不会出现在预期之外的核上运行了。假如我们想为进程12345设置固定使用1号核,那用法如下:

# sudo taskset -pc 1 12345
pid 12345's current affinity list: 0-7
pid 12345's new affinity list: 1

设置完成后,还可以继续使用该命令查看一下是否设置成功。

NOTE

Image Context: [Image 550 on Page 120]
Image Context: [Image 573 on Page 125]
原始文本---