Introduction

在操作系统中,System Call 是用户程序进入内核态的主要接口,而 Context Switch 是操作系统调度多个进程运行的基础。

这一Homework来自OSTEP Mechanism:LimitedDirectExecution 章节末尾,要求包含两个方面:

  1. Measuringthe cost of a system call
  2. Measuring the cost of a context switch

对于 System Call 的测量,书中给出的参考方案是执行0字节读取( Performing a 0-byte read ),计算方法是总时长除以迭代次数。

对于 Context Switch 的测量,书中给出的参考方案是在一个 CPU 上运行两个进程(后面分别简称为进程0和进程1),并且在进程之间建立两个管道(后面分别简称为管道01和管道10),然后执行下面的操作

  1. 进程0向管道01写入数据,等待进程1读取
  2. 进程1从管道01读取数据,然后向管道10写入数据,等待进程0读取
  3. 循环反复

注意事项:

  1. 时间精度问题:书中建议使用 gettimeofday() 来获取时间信息,但如果需要更精确的时间测量可以考虑在 x86 的机器上使用 rdtsc 指令。一般而言采用多次迭代计算平均用时即可。
  2. 多核调度问题: Context Switch 测量时,要求任务在同一个处理器上完成,否则会引入误差。书中给出了 sched setaffinity() 来让进程绑定在同一个处理器上。

实验环境

本文中所有代码除特殊标注外,均运行在 x86 Linux 环境中

  • OS: Ubuntu 24.04.4 LTS
  • Kernel: Linux 6.8
  • Architecture: x86_64
  • CPU: AMD EPYC 7K62
  • Compiler: gcc 13

gettimeofday() 的分辨率测试以及 rdtsc 介绍

gettimeofday() 的基本用法

在 Linux 系统中,gettimeofday 函数是一个常用于获取当前时间的函数,它可以计算出从1970年1月1日00:00(UTC)到当前时间的时间跨度。这个函数的使用非常广泛,尤其是在需要进行时间计算或者性能测试时。

在 time.h 中其函数原型声明如下

/* Get the current time of day, putting it into *TV.
   If TZ is not null, *TZ must be a struct timezone, and both fields
   will be set to zero.
   Calling this function with a non-null TZ is obsolete;
   use localtime etc. instead.
   This function itself is semi-obsolete;
   most callers should use time or clock_gettime instead. */
#ifndef __USE_TIME_BITS64
extern int gettimeofday (struct timeval *__restrict __tv, void *__restrict __tz) __THROW __nonnull ((1));
#else
# ifdef __REDIRECT_NTH
extern int __REDIRECT_NTH (gettimeofday, (struct timeval *__restrict __tv,
                                          void *__restrict __tz),
                           __gettimeofday64) __nonnull ((1));
# else
#  define gettimeofday __gettimeofday64
# endif
#endif

由于 timezone 已经被废弃,第二个参数传 NULL 即可,时间数值会被传到第一个参数中,第一个参数涉及的 timeval 结构体定义如下

/* A time value that is accurate to the nearest
   microsecond but also has a range of years.  */
struct timeval
{
    #ifdef __USE_TIME_BITS64
    __time64_t tv_sec;          /* Seconds.  */
    __suseconds64_t tv_usec;    /* Microseconds.  */
    #else
    __time_t tv_sec;            /* Seconds.  */
    __suseconds_t tv_usec;      /* Microseconds.  */
    #endif
};
#endif

只需要知道 tv_sec 表示秒数, tv_usec 表示微秒数即可,简单用例如下

#include <sys/time.h>   // 提供 gettimeofday
#include <stdio.h>      // 提供 printf

int main(void) {
    struct timeval val;
    int ret = gettimeofday(&val, NULL); // 
    if (ret == -1) {
        printf("Error: gettimeofday()\n");
        return ret;
    }
    printf("sec: %ld, usec: %ld\n", val.tv_sec, val.tv_usec);
    return 0;
}
sec: 1786687539, usec: 798782

gettimeofday() 的分辨率测试

尝试多次调用 gettimeofday() 来初步判断其精度

#include <sys/time.h>   // 提供 gettimeofday
#include <stdio.h>      // 提供 printf

int main(void) {
    struct timeval prev, now;
    gettimeofday(&prev,NULL);
    while (1) {
        gettimeofday(&now,NULL);
        long diff =
            (now.tv_sec-prev.tv_sec)*1000000
            +(now.tv_usec-prev.tv_usec);
        if (diff != 0) {
            printf("%ld us\n", diff);
            break;
        }
        prev=now;
    }
}

输出结果是

1 us

尝试反复调取的代码及其运行结果如下

#include <sys/time.h> // 提供 gettimeofday
#include <stdio.h>    // 提供 printf

int main()
{
    struct timeval start, end;
    int interval[20];
    int top = 0;
    for (int i = 0; i < 500; i++)
    {
        gettimeofday(&start, NULL);
        gettimeofday(&end, NULL);
        long diff =
            (end.tv_sec - start.tv_sec) * 1000000 + (end.tv_usec - start.tv_usec);
        if (diff)
            interval[top++] = i;
    }
    for (int i = 0; i < top; i++)
        printf("%d ", interval[i]);
    printf("\n");
    return 0;
}
ubuntu@VM-0-6-ubuntu:~/workspace/c_test$ gcc -O2 testgtod.c -o tgtod
ubuntu@VM-0-6-ubuntu:~/workspace/c_test$ ./tgtod 
7 39 97 132 145 195 219 231 258 273 340 376 424 
ubuntu@VM-0-6-ubuntu:~/workspace/c_test$ ./tgtod 
14 61 77 115 131 210 226 321 337 448 464 
ubuntu@VM-0-6-ubuntu:~/workspace/c_test$ ./tgtod 
5 68 84 179 195 306 322 338 401 417 

可以看出 gettimeofday() 的可观察分辨率为 1 μs,而单次调用间隔远小于该尺度

rdtsc 简单介绍

rdtsc 的使用核心是:

  1. 在目标代码执行前读取一次 CPU 时间戳计数器
  2. 执行目标代码
  3. 再读取一次
  4. 两次相减得到消耗的 CPU cycles

基本形式:

start = rdtsc();
operation();
end = rdtsc();
cycles = end - start;

需要使用内联汇编来完成调用

#include <stdio.h>
#include <stdint.h>
static inline uint64_t rdtsc() {
    unsigned int lo, hi;
    __asm__ volatile(
        "rdtsc"
        : "=a"(lo), "=d"(hi)
    );
    return ((uint64_t)hi << 32) | lo;
}

int main() {
    uint64_t start, end;
    start = rdtsc();
    for (int i = 0; i < 1000; i++) {
        ;
    }
    end = rdtsc();
    printf("cycles: %lu\n", end-start);
    return 0;
}

由于 CPU 的乱序执行、频率变化等因素,通过测量 cycle 数量来测量时间的方法较为复杂。后面时间测量均采用 gettimeofday()。

System Call 开销测量

0-byte read

测试代码如下

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/time.h>

int main() {
    int N = 10000000;
    int fd = open("/dev/null", O_RDONLY); // null device, open read only
    char buf;
    struct timeval start, end;
    volatile ssize_t ret;
    gettimeofday(&start, NULL);
    for (int i = 0; i < N; i++) {
        ret = read(fd, &buf, 0); // read 0 byte
    }
    gettimeofday(&end, NULL);

    long time =
        (end.tv_sec - start.tv_sec) * 1000000 + (end.tv_usec - start.tv_usec);

    printf("total: %ld us\n", time);
    printf("per syscall: %.3f us\n",
           (double)time / N);
    return ret;
}

运行结果

ubuntu@VM-0-6-ubuntu:~/workspace/c_test$ ./sysc
total: 1536081 us
per syscall: 0.154 us
ubuntu@VM-0-6-ubuntu:~/workspace/c_test$ ./sysc
total: 1514027 us
per syscall: 0.151 us
ubuntu@VM-0-6-ubuntu:~/workspace/c_test$ ./sysc
total: 1496064 us
per syscall: 0.150 us

同样的程序在 MacBook Air M5 运行结果如下,仅供参考

sodium@nas-MacBook-Air-13 testcode % ./test
total: 2366329 us
per syscall: 0.237 us
sodium@nas-MacBook-Air-13 testcode % ./test
total: 2392746 us
per syscall: 0.239 us
sodium@nas-MacBook-Air-13 testcode % ./test
total: 2369268 us
per syscall: 0.237 us

注:为什么使用 read(fd, buf, 0)

这里测量的目标不是文件读取速度,而是系统调用本身的开销。

/dev/null 提供了一个简单的设备文件,不涉及磁盘 I/O。

读取 0 字节意味着:

  • 不需要复制用户数据
  • 不涉及实际数据传输
  • 主要测量 syscall entry/exit 和内核处理路径

get_pid 和 syscall(SYS_getpid)

测试代码如下

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/time.h>

int main() {
    int N = 10000000;
    struct timeval start, end;
    volatile pid_t ret;

    gettimeofday(&start, NULL);
    for (int i = 0; i < N; i++) {
        ret = getpid();
    }
    gettimeofday(&end, NULL);

    long time =
        (end.tv_sec - start.tv_sec) * 1000000 + (end.tv_usec - start.tv_usec);
    printf("pid: %d\n", ret);
    printf("total: %ld us\n", time);
    printf("per syscall: %.3f us\n",
           (double)time / N);
    return 0;
}

运行结果如下

ubuntu@VM-0-6-ubuntu:~/workspace/c_test$ ./sysc2
pid: 2607272
total: 975629 us
per syscall: 0.098 us
ubuntu@VM-0-6-ubuntu:~/workspace/c_test$ ./sysc2
pid: 2607968
total: 971518 us
per syscall: 0.097 us

同样附上 MacBook Air M5 的结果

sodium@nas-MacBook-Air-13 testcode % ./test
pid: 28018
total: 16077 us
per syscall: 0.002 us

考虑一个3GHz的CPU,其周期为0.33ns,这意味着M5的2ns用时仅仅只是几个周期,这一结果明显小于正常的用户态/内核态切换。同样的Ubuntu的结果也明显小于先前的 0-byte read 测试。说明不同平台中 getpid() 的实现方式可能不同,它并不一定通过传统意义上的 syscall 陷入内核。

将 getpid() 替换成了 syscall(SYS_getpid); 之后,运行结果如下

ubuntu@VM-0-6-ubuntu:~/workspace/c_test$ ./sysc2
pid: 2614724
total: 979699 us
per syscall: 0.098 us

同样的代码在 macOS 上触发了编译警告,需要注意的是,macOS 官方并不保证 syscall 编号 ABI 的稳定性,因此该测试仅用于实验观察,不推荐实际应用依赖。

sodium@nas-MacBook-Air-13 testcode % ./test
pid: 30601
total: 893413 us
per syscall: 0.089 us

这一现象说明再次先前 macOS 上的 getpid() 很可能并不是真的 syscall

现象汇总分析

函数UbuntuMac
read(/dev/null,0)151 ns237 ns
getpid()97 ns2 ns
syscall(SYS_getpid)98 ns89 ns

结论:

  1. 现代系统调用成本非常低:一次用户态到内核态再返回的过程,大约需要几百 CPU cycles。
  2. 不能根据函数名判断是否发生 syscall
    • getpid() 是 POSIX API,而不是一定对应一次 syscall。其具体实现过程可能因平台、系统而异。
  3. 性能测试必须考虑 timer 精度、编译器优化、系统调用路径等情况

Context Switch 开销测量

sched_setaffinity() 函数

在多CPU系统中,sched_setaffinity 函数允许开发者设置进程的CPU亲和力,即指定进程在特定的一个或多个CPU上运行。

// Function prototype
int sched_setaffinity(
    pid_t pid,
    size_t cpusetsize,
    const cpu_set_t *mask
);

参数作用

  1. pid: 表示要设置哪个进程的 CPU affinity
    • 特殊地,0 表示当前程序只能运行在指定 CPU 上
  2. cpusetsize: mask 指向的 CPU 集合占多少字节
  3. mask: 允许该进程运行在哪些 CPU 上

实际测试

根据前面的思路,可以给出下面的代码

#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/time.h>
#include <stdlib.h>

#define ITERATIONS 1000000

void bind_cpu() {
    cpu_set_t set;
    CPU_ZERO(&set);     // 清空 CPU mask
    CPU_SET(0, &set);   // 添加 CPU0
    if (sched_setaffinity(  // 当前程序只能运行在 CPU0 上
            0,
            sizeof(set),
            &set) == -1) {
        perror("sched_setaffinity");
        exit(1);
    }
}

int main() {
    int pipe1[2];
    int pipe2[2];
    pipe(pipe1);
    pipe(pipe2);
    bind_cpu();

    pid_t pid = fork();
    char buf = 'a';

    if (pid == 0) {
        // child
        bind_cpu();
        for (int i = 0; i < ITERATIONS; i++) {
            read(pipe1[0], &buf, 1);
            write(pipe2[1], &buf, 1);
        }
    }
    else {
        // parent
        struct timeval start, end;
        gettimeofday(&start, NULL);
        for (int i = 0; i < ITERATIONS; i++) {
            write(pipe1[1], &buf, 1);
            read(pipe2[0], &buf, 1);
        }
        gettimeofday(&end, NULL);

        long elapsed =
            (end.tv_sec - start.tv_sec) * 1000000 
            + (end.tv_usec - start.tv_usec);
        printf("total: %ld us\n", elapsed);
        double per_switch =
            (double)elapsed /
            (ITERATIONS * 2);
        printf(
            "context switch: %.3f us\n",
            per_switch);
    }
    return 0;
}

一次循环中包含了两次上下文切换:

Parent → Child
Child → Parent

因此总切换次数为:

$$ 2 \times ITERATIONS $$

运行结果如下:

ubuntu@VM-0-6-ubuntu:~/workspace/c_test$ ./cst
total: 5187095 us
context switch: 2.594 us
ubuntu@VM-0-6-ubuntu:~/workspace/c_test$ ./cst
total: 4963219 us
context switch: 2.482 us
ubuntu@VM-0-6-ubuntu:~/workspace/c_test$ ./cst
total: 5136505 us
context switch: 2.568 us

macOS 没有提供 Linux sched_setaffinity() 接口。 虽然 Darwin 提供 thread_affinity_policy_set 等 Mach API, 但其语义与 Linux CPU mask 不同,因此本文不进行 macOS context switch 测试。

结论:context switch 与 syscall 的比较

TestUbuntumacOS M5
read(/dev/null,0)0.15 μs0.237 μs
getpid()0.098 μs0.002 μs
syscall(SYS_getpid)0.098 μs0.089 μs
context switch2.5 μs-

注意到 context switch 的开销明显大于一般的 syscall

Syscall 的流程大致如下,整个过程仍然是同一个进程、同一个地址空间,最终回到同一个进程

用户进程 A

User Mode
    |
    | syscall instruction
    |
    v

Kernel Mode
    |
    | 执行 read handler
    |
    v

User Mode

Context Switch 的流程大致如下,中间不仅经过用户态/内核态的切换,还要经过 scheduler 之后等待切换成另一个进程,中间发生了多次寄存器的保存和恢复,还有修改进程状态、切换内核栈和页表等操作。因此 Context Switch 的开销要明显更大。

Process A

write(pipe)
    |
    v
Kernel
    |
    | A blocks
    |
scheduler
    |
    v
Process B

read(pipe)

通过实验可以观察到:

  1. System Call 本身只需要数百个 CPU cycle。
  2. Context Switch 的开销明显高于 System Call,因为它需要保存和恢复不同进程的执行状态。
  3. 操作系统提供的 API 和底层机制并不是一一对应的,现代操作系统会通过缓存、vDSO、快速路径等方式优化常用操作。

这些实验结果也说明,操作系统抽象背后存在大量实现细节。

实验局限

  1. gettimeofday 精度有限
  2. CPU 频率动态变化会影响时间
  3. 虚拟机环境会引入调度噪声
  4. 不同操作系统 syscall ABI 不同
  5. 测量结果不是理论最小成本,而是当前环境下的平均成本