快乐的春饼

测量 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 测量的版本,消耗时间稍微多点。

所有代码片段