进程和线程的切换开销

进程以及进程切换

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

进程切换

在进程A切换到进程B的过程中,先保存A进程的上下文,以便于等A恢复运行的时候,能够知道A进程的下一条指令是啥。然后将要运行的B进程的上下文恢复到寄存器中。这个过程被称为上下文切换。上下文切换开销在进程不多、切换不频繁的应用场景下问题不大。但是现在Linux操作系统被用到了高并发的网络程序后端服务器。在单机支持成千上万个用户请求的时候,这个开销影响较大。因为用户进程在请求Redis、Mysql数据等网络IO阻塞掉的时候,或者在进程时间片到了,都会引发上下文切换。

进程状态变化

测试

实验方法为创建两个进程并在它们之间传送一个令牌。其中一个进程在读取令牌时就会引起阻塞。另一个进程发送令牌后等待其返回时也处于阻塞状态。如此往返传送一定的次数,然后统计他们的平均单次切换时间开销。编译、运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <stdio.h>  
#include <stdlib.h>
#include <sys/time.h>
#include <time.h>
#include <sched.h>
#include <sys/types.h>
#include <unistd.h> //pipe()

int main()
{
int x, i, fd[2], p[2];
char send = 's';
char receive;
pipe(fd);
pipe(p);
struct timeval tv;
struct sched_param param;
param.sched_priority = 0;

while ((x = fork()) == -1);
if (x==0) {
sched_setscheduler(getpid(), SCHED_FIFO, &param);
gettimeofday(&tv, NULL);
printf("Before Context Switch Time%u s, %u us\n", tv.tv_sec, tv.tv_usec);
for (i = 0; i < 10000; i++) {
read(fd[0], &receive, 1);
write(p[1], &send, 1);
}
exit(0);
}
else {
sched_setscheduler(getpid(), SCHED_FIFO, &param);
for (i = 0; i < 10000; i++) {
write(fd[1], &send, 1);
read(p[0], &receive, 1);
}
gettimeofday(&tv, NULL);
printf("After Context SWitch Time%u s, %u us\n", tv.tv_sec, tv.tv_usec);
}
return 0;
}

//gcc 编译运行
//Before Context Switch Time1617894469 s, 453169 us
//After Context SWitch Time1617894469 s, 506257 us

每次执行的时间会有差异,多次运行后平均每次上下文切换耗时3.5us左右。当然这个数字因机器而异。

测试系统调用的时候,最低值是200ns。可见,上下文切换开销要比系统调用的开销要大。系统调用只是在进程内将用户态切换到内核态,然后再切回来,而上下文切换可是直接从进程A切换到了进程B。显然这个上下文切换需要完成的工作量更大。

进程切换开销

上下文切换的时候,CPU的开销都具体有哪些呢?开销分成两种,一种是直接开销、一种是间接开销。

直接开销就是在切换时,cpu必须做的事情,包括:

1、切换页表全局目录

2、切换内核态堆栈

3、切换硬件上下文(进程恢复前,必须装入寄存器的数据统称为硬件上下文)

  • ip(instruction pointer):指向当前执行指令的下一条指令
  • bp(base pointer): 用于存放执行中的函数对应的栈帧的栈底地址
  • sp(stack poinger): 用于存放执行中的函数对应的栈帧的栈顶地址
  • cr3:页目录基址寄存器,保存页目录表的物理地址
  • ……

4、刷新TLB

5、系统调度器的代码执行

间接开销主要指的是虽然切换到一个新进程后,由于各种缓存并不热,速度运行会慢一些如果进程始终都在一个CPU上调度还好一些,如果跨CPU的话,之前热起来的TLB、L1、L2、L3因为运行的进程已经变了,所以以局部性原理cache起来的代码、数据也都没有用了,导致新进程穿透到内存的IO会变多。上面的实验并没有很好地测量到这种情况,所以实际的上下文切换开销可能比3.5us要大。

测试工具

lmbench是用于评价系统综合性能的多平台开源benchmark,能够测试包括文档读写、内存操作、进程创建销毁开销、网络等性能。这个工具的优势是是进行了多组实验,每组2个进程、8个、16个。每个进程使用的数据大小也在变,充分模拟cache miss造成的影响。lmbench显示的进程上下文切换耗时从2.7us到5.48之间。

线程上下文切换耗时

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

同一进程下的线程getpid()看到的pid是一样的,其实task_struct里还有一个tgid字段。对于多线程程序来说,getpid()系统调用获取的实际上是这个tgid,因此隶属同一进程的多线程看起来PID相同。

实际测试:

线程和进程测试差不多,创建20个线程,在线程之间通过管道来传递信号。接到信号就唤醒,然后再传递信号给下一个线程,自己睡眠。这个实验里单独考虑了给管道传递信号的额外开销,并在第一步就统计了出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
#include <stdio.h>
#include <unistd.h>
#include <sys/time.h>
#include<pthread.h>

int pipes[20][3];
char buffer[10];
int running = 1;

void inti()
{
int i =20;
while(i--)
{
if(pipe(pipes[i])<0)
exit(1);
pipes[i][2] = i;
}
}

void distroy()
{
int i =20;
while(i--)
{
close(pipes[i][0]);
close(pipes[i][1]);
}
}

double self_test()
{
int i =20000;
struct timeval start, end;
gettimeofday(&start, NULL);
while(i--)
{
if(write(pipes[0][1],buffer,10)==-1)
exit(1);
read(pipes[0][0],buffer,10);
}
gettimeofday(&end, NULL);
return (double)(1000000*(end.tv_sec-start.tv_sec)+ end.tv_usec-start.tv_usec)/20000;
}

void *_test(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);
}
}

double threading_test()
{
int i = 20;
struct timeval start, end;
pthread_t tid;
while(--i)
{
pthread_create(&tid,NULL,_test,(void *)pipes[i]);
}
i = 10000;
gettimeofday(&start, NULL);
while(i--)
{
if(write(pipes[1][1],buffer,10)==-1)
exit(1);
read(pipes[0][0],buffer,10);
}
gettimeofday(&end, NULL);
running = 0;
if(write(pipes[1][1],buffer,10)==-1)
exit(1);
return (double)(1000000*(end.tv_sec-start.tv_sec)+ end.tv_usec-start.tv_usec)/10000/20;
}

int main()
{
inti();
printf("%6.6f\n",self_test());
printf("%6.6f\n",threading_test());
distroy();
exit(0);
}
1
2
3
4
$ gcc xiancheng.c -o xiancheng -pthread
dongshifu@dong:~/test$ ./xiancheng
1.332800
3.168695

参考

https://mp.weixin.qq.com/s/uq5s5vwk5vtPOZ30sfNsOg