Introduction
在操作系统中,System Call 是用户程序进入内核态的主要接口,而 Context Switch 是操作系统调度多个进程运行的基础。
这一Homework来自OSTEP Mechanism:LimitedDirectExecution 章节末尾,要求包含两个方面:
- Measuringthe cost of a system call
- Measuring the cost of a context switch
对于 System Call 的测量,书中给出的参考方案是执行0字节读取( Performing a 0-byte read ),计算方法是总时长除以迭代次数。
对于 Context Switch 的测量,书中给出的参考方案是在一个 CPU 上运行两个进程(后面分别简称为进程0和进程1),并且在进程之间建立两个管道(后面分别简称为管道01和管道10),然后执行下面的操作
- 进程0向管道01写入数据,等待进程1读取
- 进程1从管道01读取数据,然后向管道10写入数据,等待进程0读取
- 循环反复
注意事项:
- 时间精度问题:书中建议使用
gettimeofday()来获取时间信息,但如果需要更精确的时间测量可以考虑在 x86 的机器上使用rdtsc指令。一般而言采用多次迭代计算平均用时即可。 - 多核调度问题: 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 的使用核心是:
- 在目标代码执行前读取一次 CPU 时间戳计数器
- 执行目标代码
- 再读取一次
- 两次相减得到消耗的 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
现象汇总分析
| 函数 | Ubuntu | Mac |
|---|---|---|
read(/dev/null,0) | 151 ns | 237 ns |
getpid() | 97 ns | 2 ns |
syscall(SYS_getpid) | 98 ns | 89 ns |
结论:
- 现代系统调用成本非常低:一次用户态到内核态再返回的过程,大约需要几百 CPU cycles。
- 不能根据函数名判断是否发生 syscall
getpid()是 POSIX API,而不是一定对应一次 syscall。其具体实现过程可能因平台、系统而异。
- 性能测试必须考虑 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
);
参数作用
pid: 表示要设置哪个进程的 CPU affinity- 特殊地,0 表示当前程序只能运行在指定 CPU 上
cpusetsize: mask 指向的 CPU 集合占多少字节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 的比较
| Test | Ubuntu | macOS M5 |
|---|---|---|
| read(/dev/null,0) | 0.15 μs | 0.237 μs |
| getpid() | 0.098 μs | 0.002 μs |
| syscall(SYS_getpid) | 0.098 μs | 0.089 μs |
| context switch | 2.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)
通过实验可以观察到:
- System Call 本身只需要数百个 CPU cycle。
- Context Switch 的开销明显高于 System Call,因为它需要保存和恢复不同进程的执行状态。
- 操作系统提供的 API 和底层机制并不是一一对应的,现代操作系统会通过缓存、vDSO、快速路径等方式优化常用操作。
这些实验结果也说明,操作系统抽象背后存在大量实现细节。
实验局限
gettimeofday精度有限- CPU 频率动态变化会影响时间
- 虚拟机环境会引入调度噪声
- 不同操作系统 syscall ABI 不同
- 测量结果不是理论最小成本,而是当前环境下的平均成本