测量 Linux 系统调用和进程切换开销
系统调用开销
操作系统中存在各类系统调用,用于让用户态的程序陷入内核态,从而执行特权指令,操作硬件、读取系统信息和创建进程等。如何测量它们的开销呢?
一个简单的办法是,只要执行足够多次系统调用,再用总消耗时间除以调用次数,就可以得到一个系统调用的平均消耗时间。
当然,这一开销在不同硬件和操作系统上会有所区别,不过可以大致估计一下其数量级。
代码
以下代码给出了 Linux 上的一些系统调用的平均开销时间测量方法:
#include <err.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
#define MEASURE(tip, n, call) do { \
struct timespec ts1; \
struct timespec ts2; \
clock_gettime(CLOCK_MONOTONIC, &ts1); \
for (int i = 0; i < (n); i++) { \
call; \
} \
clock_gettime(CLOCK_MONOTONIC, &ts2); \
double average = (double) ((ts2.tv_sec - ts1.tv_sec) * 1000000000 + \
ts2.tv_nsec - ts1.tv_nsec) / (n); \
printf("%s: %f ns\n", (tip), average); \
} while (0)
int main(int argc, char *argv[])
{
if (argc != 2) {
errx(1, "invalid argument");
}
int n = atoi(argv[1]);
// 获取当前进程 ID
MEASURE("getpid", n, getpid());
// 0 字节读取
int fd = open("measure.c", O_RDONLY);
if (fd == -1)
err(1, "open");
char buf[1];
MEASURE("read 0 byte", n, read(fd, buf, 0));
// 获取文件状态信息
struct stat stat;
MEASURE("lstat", n, lstat("measure.c", &stat));
return 0;
}
我写了个宏方便使用。用 do { ... } while (0) 块包裹一个块,让内部变量有独立作用域。注意多行的宏,每行结尾都要有反斜杠,用来向编译器说明这是一个多行宏。
结果示例
$ gcc -o measure measure.c
$ ./measure 1000
getpid: 146.415000 ns
read 0 byte: 164.010000 ns
lstat: 221.023000 ns
$ ./measure 1000000
getpid: 60.134272 ns
read 0 byte: 75.495297 ns
lstat: 200.536937 ns
注意到,当调用次数上升之后,各个系统调用的消耗时间会降低,这可能是 CPU 分支预测和缓存等机制作用的结果。
进程切换开销
在同一个 CPU 核心上,同一时间只能有一个进程在运行,当其执行了 IO 操作或者时间片用完后,操作系统会根据调度策略,执行其他进程。而这需要操作系统保存当前进程的状态到内存,切换另一个进程并运行。进程切换过程也有开销。如何测量进程切换的开销呢?
我们可以使用 lmbench 基准测试:
lmbench 基准测试的实现方法,是在单个 CPU 上运行两个进程并在它们之间设置两个 UNIX 管道。管道只是 UNIX 系统中的进程可以相互通信的许多方式之一。第一个进程向第一个管道写入数据,然后等待第二个数据的读取。由于看到第一个进程等待从第二个管道读取内容,OS 将第一个进程置于阻塞状态,并切换到另一个进程,该进程从第一个管道读取数据,然后写入第二个管道。当第二个进程再次尝试从第一个管道读取时,它会阻塞,从而继续进行通信的往返循环。通过反复测量这种通信的成本,lmbench 可以很好地估计上下文切换的成本。
代码
在 Linux 上,我们可以使用这样的代码测量进程切换的开销:
#define _GNU_SOURCE
#include <err.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
if (argc != 3) {
exit(1);
}
int cpu = atoi(argv[1]);
int nloops = atoi(argv[2]);
// 绑定进程到指定 CPU 核心
cpu_set_t cpu_set;
CPU_ZERO(&cpu_set);
CPU_SET(cpu, &cpu_set);
if (sched_setaffinity(0, sizeof(cpu_set), &cpu_set) == -1)
err(1, "sched_setaffinity");
// 创建管道,pipefd[0] 是读端,pipefd[1] 是写端
int pipefd1[2];
int pipefd2[2];
if (pipe(pipefd1) == -1)
err(1, "pipefd1");
if (pipe(pipefd2) == -1)
err(1, "pipefd2");
// 读写缓冲区顺便作为计数器
int buf[1];
buf[0] = 1;
int cpid = fork();
if (cpid == -1) {
err(1, "fork");
} else if (cpid == 0) {
// 子进程
if (close(pipefd1[1]) == -1)
err(1, "child close pipefd1[1]");
if (close(pipefd2[0]) == -1)
err(1, "child close pipefd2[0]");
while (buf[0] < nloops) {
if (read(pipefd1[0], buf, sizeof(buf)) == -1)
err(1, "child read %d", buf[0]);
buf[0] += 1;
if (write(pipefd2[1], buf, sizeof(buf)) == -1)
err(1, "child write %d", buf[0]);
}
} else {
// 父进程
if (close(pipefd1[0]) == -1)
err(1, "parent close pipefd1[0]");
if (close(pipefd2[1]) == -1)
err(1, "parent close pipefd2[1]");
struct timespec ts1;
struct timespec ts2;
if (clock_gettime(CLOCK_MONOTONIC, &ts1) == -1)
err(1, "clock_gettime on start");
while (buf[0] < nloops) {
if (write(pipefd1[1], buf, sizeof(buf)) == -1)
err(1, "parent write %d", buf[0]);
if (read(pipefd2[0], buf, sizeof(buf)) == -1)
err(1, "parent read %d", buf[0]);
buf[0] += 1;
}
if (clock_gettime(CLOCK_MONOTONIC, &ts2) == -1)
err(1, "clock_gettime on end");
double average = (double) ((ts2.tv_sec - ts1.tv_sec) * 1000000000 +
ts2.tv_nsec - ts1.tv_nsec) / nloops;
printf("switch time: %f ns", average);
}
return 0;
}
fork 出的子进程会继承和父进程同样的 CPU 绑定状态,所以只需要设置一次。
必须确保两个进程运行在同一个 CPU 核心上,不然结果会偏大。我猜测是因为两个进程不在同一 CPU 核心上的话,当父进程执行到 read,等待子进程 write 时,运行子进程的另一个 CPU 核心可能在运行其他进程,不能马上切换到子进程,反过来也是同理。
必须创建两个管道,确保每个进程都只能在一个管道写,在另一个管道读。如果只有一个管道,一个进程在同一管道的两端读写的话,父进程因为先 write,所以 read 的时候不会阻塞,从而父进程自己会跑完所有循环,抢占所有 CPU 时间,就没有进程来回切换的时间了,导致结果偏小。
结果示例
$ gcc -o swtch swtch.c
$ ./swtch 0 1000
switch time: 906.945000 ns
$ ./swtch 31 1000
switch time: 1709.777000 ns
$ ./swtch 0 1000000
switch time: 887.263488 ns
我的 CPU 是 13 代酷睿,有大小核的区分。可以看到,不同性能的 CPU 核心对进程切换时间影响较大。
调用次数越多,运行结果也会被分支预测和缓存等机制优化,不过相对来说没有系统调用那么明显的优化。
使用管道应该也有开销,不过这应该接近真实消耗时间了。我还写了个用 Unix socket 测量的版本,消耗时间稍微多点。