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: 0chrt命令可以修改进程的调度策略,注意修改时可能需要root权限。其中SCHED_RR、SCHED_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_RR3.6.2 nice设置
Linux也提供了nice命令可以干预进程的调度。给进程设置比较高的nice值的话,是尽量把CPU时间让给其它进程使用。如果想占用更多的CPU,那就把nice调为负数。这是和人类似的,如果一个人很nice,那他倾向于把资源给别人用,如果一个人不太nice,那他更倾向于抢夺别人的资源用。nice值的合法取值范围是[-20, 19]之间。至于为什么定这个范围,估计现在也没人能知道了。
进程启动的时候可以使用nice命令来设置进程的nice值:
# nice -n -20 vi进程已经启动之后可以使用renice:
# renice +5 {pid}nice和renice命令都会触发内核提供的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]
原始文本---